Compare commits

..

11 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 20:45:56 +09:00
42 changed files with 1369 additions and 414 deletions

View File

@@ -16,12 +16,66 @@ go test ./entity/ -run TestName # Run a specific test
go vet ./... # Lint go vet ./... # Lint
``` ```
Docker: Docker (local):
```bash ```bash
docker build -t catacombs . docker build -t catacombs .
docker-compose up # SSH on :2222, HTTP on :8080 docker-compose up # SSH on :2222, HTTP on :8080
``` ```
## Deployment (tolelom.xyz)
The game runs as a Docker container on the Mac Mini server, behind Caddy reverse proxy.
**Server structure:**
```
~/server/
├── docker-compose.yml # All services (caddy, gitea, catacombs, etc.)
├── caddy/Caddyfile # Reverse proxy config
└── apps/catacombs/ # Git clone of this repo
```
**First-time setup:**
```bash
cd ~/server
git clone https://git.tolelom.xyz/tolelom/Catacombs.git apps/catacombs
```
Add to `docker-compose.yml` services:
```yaml
catacombs:
build: ./apps/catacombs
restart: always
logging: *default-logging
volumes:
- catacombs_data:/app/data
networks:
- web
deploy:
resources:
limits:
memory: 64m
pids: 200
```
Add `catacombs_data:` to the `volumes:` section.
Add to `caddy/Caddyfile`:
```
catacombs.tolelom.xyz {
encode gzip zstd
reverse_proxy catacombs:8080
}
```
**Deploy / Update:**
```bash
cd ~/server/apps/catacombs && git pull
cd ~/server && docker compose up -d --build catacombs
docker restart server-caddy-1 # only needed if Caddyfile changed
```
**Access:** https://catacombs.tolelom.xyz/static/
## Architecture ## Architecture
**Package dependency flow:** `main``server`/`web`/`store``game``dungeon``entity``combat` **Package dependency flow:** `main``server`/`web`/`store``game``dungeon``entity``combat`

View File

@@ -57,6 +57,7 @@ func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster, coopBonu
results[i] = AttackResult{TargetIdx: -1, Damage: totalDmg, IsAoE: true} results[i] = AttackResult{TargetIdx: -1, Damage: totalDmg, IsAoE: true}
} else { } else {
if intent.TargetIdx < 0 || intent.TargetIdx >= len(monsters) { if intent.TargetIdx < 0 || intent.TargetIdx >= len(monsters) {
results[i] = AttackResult{TargetIdx: -1} // mark as invalid
continue continue
} }
m := monsters[intent.TargetIdx] m := monsters[intent.TargetIdx]

View File

@@ -27,7 +27,7 @@ var comboDefs = []ComboDef{
{Class: entity.ClassMage, ActionType: "skill"}, {Class: entity.ClassMage, ActionType: "skill"},
{Class: entity.ClassWarrior, ActionType: "attack"}, {Class: entity.ClassWarrior, ActionType: "attack"},
}, },
Effect: ComboEffect{DamageMultiplier: 1.5, Message: "💥 ICE SHATTER! Frozen enemies shatter!"}, Effect: ComboEffect{DamageMultiplier: 1.5, Message: "💥 ICE SHATTER! 동결된 적이 산산조각!"},
}, },
{ {
Name: "Holy Assault", Name: "Holy Assault",
@@ -35,7 +35,7 @@ var comboDefs = []ComboDef{
{Class: entity.ClassHealer, ActionType: "skill"}, {Class: entity.ClassHealer, ActionType: "skill"},
{Class: entity.ClassWarrior, ActionType: "attack"}, {Class: entity.ClassWarrior, ActionType: "attack"},
}, },
Effect: ComboEffect{DamageMultiplier: 1.3, HealAll: 10, Message: "✨ HOLY ASSAULT! Blessed strikes heal the party!"}, Effect: ComboEffect{DamageMultiplier: 1.3, HealAll: 10, Message: "✨ HOLY ASSAULT! 축복받은 공격이 파티를 치유!"},
}, },
{ {
Name: "Shadow Strike", Name: "Shadow Strike",
@@ -43,7 +43,7 @@ var comboDefs = []ComboDef{
{Class: entity.ClassRogue, ActionType: "skill"}, {Class: entity.ClassRogue, ActionType: "skill"},
{Class: entity.ClassMage, ActionType: "attack"}, {Class: entity.ClassMage, ActionType: "attack"},
}, },
Effect: ComboEffect{DamageMultiplier: 1.4, Message: "🗡️ SHADOW STRIKE! Magical shadows amplify the attack!"}, Effect: ComboEffect{DamageMultiplier: 1.4, Message: "🗡️ SHADOW STRIKE! 마법의 그림자가 공격을 증폭!"},
}, },
{ {
Name: "Full Assault", Name: "Full Assault",
@@ -52,7 +52,7 @@ var comboDefs = []ComboDef{
{Class: entity.ClassMage, ActionType: "attack"}, {Class: entity.ClassMage, ActionType: "attack"},
{Class: entity.ClassRogue, ActionType: "attack"}, {Class: entity.ClassRogue, ActionType: "attack"},
}, },
Effect: ComboEffect{DamageMultiplier: 1.3, BonusDamage: 5, Message: "⚔️ FULL ASSAULT! Combined attack overwhelms!"}, Effect: ComboEffect{DamageMultiplier: 1.3, BonusDamage: 5, Message: "⚔️ FULL ASSAULT! 합동 공격으로 압도!"},
}, },
{ {
Name: "Restoration", Name: "Restoration",
@@ -60,7 +60,7 @@ var comboDefs = []ComboDef{
{Class: entity.ClassHealer, ActionType: "skill"}, {Class: entity.ClassHealer, ActionType: "skill"},
{Class: entity.ClassRogue, ActionType: "item"}, {Class: entity.ClassRogue, ActionType: "item"},
}, },
Effect: ComboEffect{HealAll: 20, Message: "💚 RESTORATION! Combined healing surges!"}, Effect: ComboEffect{HealAll: 20, Message: "💚 RESTORATION! 합동 치유가 폭발적으로 발동!"},
}, },
} }
@@ -75,10 +75,12 @@ func DetectCombos(actions map[string]ComboAction) []ComboDef {
} }
func matchesCombo(required []ComboAction, actions map[string]ComboAction) bool { func matchesCombo(required []ComboAction, actions map[string]ComboAction) bool {
used := make(map[string]bool)
for _, req := range required { for _, req := range required {
found := false found := false
for _, act := range actions { for id, act := range actions {
if act.Class == req.Class && act.ActionType == req.ActionType { if !used[id] && act.Class == req.Class && act.ActionType == req.ActionType {
used[id] = true
found = true found = true
break break
} }

View File

@@ -63,7 +63,7 @@ func defaults() Config {
return Config{ return Config{
Server: ServerConfig{SSHPort: 2222, HTTPPort: 8080}, Server: ServerConfig{SSHPort: 2222, HTTPPort: 8080},
Game: GameConfig{ Game: GameConfig{
TurnTimeoutSec: 5, MaxPlayers: 4, MaxFloors: 20, TurnTimeoutSec: 10, MaxPlayers: 4, MaxFloors: 20,
CoopBonus: 0.10, InventoryLimit: 10, SkillUses: 3, CoopBonus: 0.10, InventoryLimit: 10, SkillUses: 3,
}, },
Combat: CombatConfig{FleeChance: 0.50, MonsterScaling: 1.15, SoloHPReduction: 0.50}, Combat: CombatConfig{FleeChance: 0.50, MonsterScaling: 1.15, SoloHPReduction: 0.50},

View File

@@ -16,8 +16,8 @@ func TestLoadDefaults(t *testing.T) {
if cfg.Server.HTTPPort != 8080 { if cfg.Server.HTTPPort != 8080 {
t.Errorf("expected HTTP port 8080, got %d", cfg.Server.HTTPPort) t.Errorf("expected HTTP port 8080, got %d", cfg.Server.HTTPPort)
} }
if cfg.Game.TurnTimeoutSec != 5 { if cfg.Game.TurnTimeoutSec != 10 {
t.Errorf("expected turn timeout 5, got %d", cfg.Game.TurnTimeoutSec) t.Errorf("expected turn timeout 10, got %d", cfg.Game.TurnTimeoutSec)
} }
if cfg.Game.MaxPlayers != 4 { if cfg.Game.MaxPlayers != 4 {
t.Errorf("expected max players 4, got %d", cfg.Game.MaxPlayers) t.Errorf("expected max players 4, got %d", cfg.Game.MaxPlayers)

View File

@@ -1,6 +1,9 @@
package dungeon package dungeon
import "math/rand" import (
"math/rand"
"sort"
)
const ( const (
MapWidth = 60 MapWidth = 60
@@ -35,18 +38,25 @@ func GenerateFloor(floorNum int, rng *rand.Rand) *Floor {
var leaves []*bspNode var leaves []*bspNode
collectLeaves(root, &leaves) collectLeaves(root, &leaves)
// Shuffle leaves so room assignment is varied // We want 5-8 rooms. If we have more leaves, shuffle and trim.
rng.Shuffle(len(leaves), func(i, j int) {
leaves[i], leaves[j] = leaves[j], leaves[i]
})
// We want 5-8 rooms. If we have more leaves, merge some; if fewer, accept it.
// Ensure at least 5 leaves by re-generating if needed (BSP should produce enough).
// Cap at 8 rooms.
targetRooms := 5 + rng.Intn(4) // 5..8 targetRooms := 5 + rng.Intn(4) // 5..8
if len(leaves) > targetRooms { if len(leaves) > targetRooms {
rng.Shuffle(len(leaves), func(i, j int) {
leaves[i], leaves[j] = leaves[j], leaves[i]
})
leaves = leaves[:targetRooms] leaves = leaves[:targetRooms]
} }
// Sort leaves by physical position (left-to-right, top-to-bottom)
// so room indices match their map positions
sort.Slice(leaves, func(i, j int) bool {
ci := leaves[i].y + leaves[i].h/2
cj := leaves[j].y + leaves[j].h/2
if ci != cj {
return ci < cj
}
return leaves[i].x+leaves[i].w/2 < leaves[j].x+leaves[j].w/2
})
// If we somehow have fewer than 5, that's fine — the BSP with 60x20 and min 12x8 gives ~5-8 naturally. // If we somehow have fewer than 5, that's fine — the BSP with 60x20 and min 12x8 gives ~5-8 naturally.
// Place rooms inside each leaf // Place rooms inside each leaf
@@ -108,6 +118,9 @@ func GenerateFloor(floorNum int, rng *rand.Rand) *Floor {
leaf.roomIdx = i leaf.roomIdx = i
} }
// First room is always empty (safe starting area)
rooms[0].Type = RoomEmpty
// Last room is boss // Last room is boss
rooms[len(rooms)-1].Type = RoomBoss rooms[len(rooms)-1].Type = RoomBoss

View File

@@ -59,33 +59,66 @@ func roomOwnership(floor *Floor) [][]int {
return owner return owner
} }
// corridorVisibility determines if a corridor tile should be visible. // buildCorridorVisibility flood-fills corridor visibility from visible/visited rooms.
// A corridor is visible if it's adjacent to a visible or visited room. // Returns a map of (y,x) → Visibility for all corridor tiles.
func corridorVisible(floor *Floor, owner [][]int, x, y int) Visibility { func buildCorridorVisibility(floor *Floor, owner [][]int) [][]Visibility {
best := Hidden vis := make([][]Visibility, floor.Height)
// Check neighboring tiles for room ownership for y := 0; y < floor.Height; y++ {
for dy := -1; dy <= 1; dy++ { vis[y] = make([]Visibility, floor.Width)
for dx := -1; dx <= 1; dx++ { }
ny, nx := y+dy, x+dx
if ny >= 0 && ny < floor.Height && nx >= 0 && nx < floor.Width { // Seed: corridor tiles adjacent to visible/visited rooms
ri := owner[ny][nx] type pos struct{ y, x int }
if ri >= 0 { queue := []pos{}
v := GetRoomVisibility(floor, ri)
if v > best { for y := 0; y < floor.Height; y++ {
best = v for x := 0; x < floor.Width; x++ {
if floor.Tiles[y][x] != TileCorridor {
continue
}
best := Hidden
for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ {
ny, nx := y+dy, x+dx
if ny >= 0 && ny < floor.Height && nx >= 0 && nx < floor.Width {
ri := owner[ny][nx]
if ri >= 0 {
v := GetRoomVisibility(floor, ri)
if v > best {
best = v
}
}
} }
} }
} }
if best > Hidden {
vis[y][x] = best
queue = append(queue, pos{y, x})
}
}
}
// Flood-fill along corridor tiles (4-directional)
dirs := [4][2]int{{-1, 0}, {1, 0}, {0, -1}, {0, 1}}
for len(queue) > 0 {
cur := queue[0]
queue = queue[1:]
for _, d := range dirs {
ny, nx := cur.y+d[0], cur.x+d[1]
if ny >= 0 && ny < floor.Height && nx >= 0 && nx < floor.Width {
if floor.Tiles[ny][nx] == TileCorridor && vis[ny][nx] < vis[cur.y][cur.x] {
vis[ny][nx] = vis[cur.y][cur.x]
queue = append(queue, pos{ny, nx})
}
}
} }
} }
// Also check along the corridor path: if this corridor connects two rooms,
// it should be visible if either room is visible/visited. return vis
// The adjacency check above handles most cases.
return best
} }
// wallVisibility determines if a wall tile should be shown based on adjacent rooms. // wallVisibility determines if a wall tile should be shown based on adjacent rooms/corridors.
func wallVisible(floor *Floor, owner [][]int, x, y int) Visibility { func wallVisible(floor *Floor, owner [][]int, corrVis [][]Visibility, x, y int) Visibility {
best := Hidden best := Hidden
for dy := -1; dy <= 1; dy++ { for dy := -1; dy <= 1; dy++ {
for dx := -1; dx <= 1; dx++ { for dx := -1; dx <= 1; dx++ {
@@ -101,9 +134,8 @@ func wallVisible(floor *Floor, owner [][]int, x, y int) Visibility {
} }
} }
if floor.Tiles[ny][nx] == TileCorridor { if floor.Tiles[ny][nx] == TileCorridor {
cv := corridorVisible(floor, owner, nx, ny) if corrVis[ny][nx] > best {
if cv > best { best = corrVis[ny][nx]
best = cv
} }
} }
} }
@@ -155,6 +187,9 @@ func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
playerPos = [2]int{r.Y + r.H/2, r.X + r.W/2} playerPos = [2]int{r.Y + r.H/2, r.X + r.W/2}
} }
// Pre-compute corridor visibility via flood-fill
corrVis := buildCorridorVisibility(floor, owner)
buf := make([]byte, 0, floor.Width*floor.Height*4) buf := make([]byte, 0, floor.Width*floor.Height*4)
for y := 0; y < floor.Height; y++ { for y := 0; y < floor.Height; y++ {
@@ -173,9 +208,9 @@ func RenderFloor(floor *Floor, currentRoom int, showFog bool) string {
vis = Hidden vis = Hidden
} }
case TileCorridor: case TileCorridor:
vis = corridorVisible(floor, owner, x, y) vis = corrVis[y][x]
case TileWall: case TileWall:
vis = wallVisible(floor, owner, x, y) vis = wallVisible(floor, owner, corrVis, x, y)
default: default:
vis = Hidden vis = Hidden
} }

View File

@@ -16,7 +16,7 @@ const (
) )
func (r RoomType) String() string { func (r RoomType) String() string {
return [...]string{"Combat", "Treasure", "Shop", "Event", "Empty", "Boss", "Secret", "MiniBoss"}[r] return [...]string{"전투", "보물", "상점", "이벤트", "빈 방", "보스", "비밀", "미니보스"}[r]
} }
type Tile int type Tile int

View File

@@ -179,22 +179,22 @@ func (p *Player) TickEffects() []string {
if p.HP <= 0 { if p.HP <= 0 {
p.HP = 1 // Poison can't kill, leaves at 1 HP p.HP = 1 // Poison can't kill, leaves at 1 HP
} }
msgs = append(msgs, fmt.Sprintf("%s takes %d poison damage", p.Name, e.Value)) msgs = append(msgs, fmt.Sprintf("%s 독 피해 %d", p.Name, e.Value))
case StatusBurn: case StatusBurn:
p.HP -= e.Value p.HP -= e.Value
if p.HP <= 0 { if p.HP <= 0 {
p.HP = 0 p.HP = 0
p.Dead = true p.Dead = true
} }
msgs = append(msgs, fmt.Sprintf("%s takes %d burn damage", p.Name, e.Value)) msgs = append(msgs, fmt.Sprintf("%s 화상 피해 %d", p.Name, e.Value))
case StatusFreeze: case StatusFreeze:
msgs = append(msgs, fmt.Sprintf("%s is frozen!", p.Name)) msgs = append(msgs, fmt.Sprintf("%s 동결됨!", p.Name))
case StatusBleed: case StatusBleed:
p.HP -= e.Value p.HP -= e.Value
msgs = append(msgs, fmt.Sprintf("%s takes %d bleed damage", p.Name, e.Value)) msgs = append(msgs, fmt.Sprintf("%s 출혈 피해 %d", p.Name, e.Value))
e.Value++ // Bleed intensifies each turn e.Value++ // Bleed intensifies each turn
case StatusCurse: case StatusCurse:
msgs = append(msgs, fmt.Sprintf("%s is cursed! Healing reduced", p.Name)) msgs = append(msgs, fmt.Sprintf("%s 저주 상태! 회복량 감소", p.Name))
} }
if p.HP < 0 { if p.HP < 0 {
p.HP = 0 p.HP = 0

View File

@@ -200,7 +200,7 @@ func TestBleedEffect(t *testing.T) {
p.AddEffect(ActiveEffect{Type: StatusBleed, Duration: 3, Value: 2}) p.AddEffect(ActiveEffect{Type: StatusBleed, Duration: 3, Value: 2})
msgs := p.TickEffects() msgs := p.TickEffects()
if len(msgs) == 0 || !strings.Contains(msgs[0], "bleed") { if len(msgs) == 0 || !strings.Contains(msgs[0], "출혈") {
t.Error("expected bleed damage message") t.Error("expected bleed damage message")
} }
if p.HP != startHP-2 { if p.HP != startHP-2 {
@@ -231,7 +231,7 @@ func TestFreezeTickMessage(t *testing.T) {
p := NewPlayer("Test", ClassMage) p := NewPlayer("Test", ClassMage)
p.AddEffect(ActiveEffect{Type: StatusFreeze, Duration: 1, Value: 0}) p.AddEffect(ActiveEffect{Type: StatusFreeze, Duration: 1, Value: 0})
msgs := p.TickEffects() msgs := p.TickEffects()
if len(msgs) == 0 || !strings.Contains(msgs[0], "frozen") { if len(msgs) == 0 || !strings.Contains(msgs[0], "동결") {
t.Error("expected freeze message") t.Error("expected freeze message")
} }
// Freeze duration 1 -> removed after tick // Freeze duration 1 -> removed after tick

View File

@@ -1,11 +1,11 @@
package game package game
var emotes = map[string]string{ var emotes = map[string]string{
"/hi": "👋 waves hello!", "/hi": "👋 인사합니다!",
"/gg": "🎉 says GG!", "/gg": "🎉 GG!",
"/go": "⚔️ says Let's go!", "/go": "⚔️ 가자!",
"/wait": "✋ says Wait!", "/wait": "✋ 기다려!",
"/help": "🆘 calls for help!", "/help": "🆘 도움 요청!",
} }
func ParseEmote(input string) (string, bool) { func ParseEmote(input string) (string, bool) {

View File

@@ -8,11 +8,11 @@ func TestParseEmote(t *testing.T) {
isEmote bool isEmote bool
expected string expected string
}{ }{
{"/hi", true, "👋 waves hello!"}, {"/hi", true, "👋 인사합니다!"},
{"/gg", true, "🎉 says GG!"}, {"/gg", true, "🎉 GG!"},
{"/go", true, "⚔️ says Let's go!"}, {"/go", true, "⚔️ 가자!"},
{"/wait", true, "✋ says Wait!"}, {"/wait", true, "✋ 기다려!"},
{"/help", true, "🆘 calls for help!"}, {"/help", true, "🆘 도움 요청!"},
{"/unknown", false, ""}, {"/unknown", false, ""},
{"hello", false, ""}, {"hello", false, ""},
{"", false, ""}, {"", false, ""},

View File

@@ -20,6 +20,11 @@ func (s *GameSession) EnterRoom(roomIdx int) {
} }
} }
s.enterRoomLocked(roomIdx)
}
// enterRoomLocked performs room entry logic. Caller must hold s.mu.
func (s *GameSession) enterRoomLocked(roomIdx int) {
s.state.Floor.CurrentRoom = roomIdx s.state.Floor.CurrentRoom = roomIdx
dungeon.UpdateVisibility(s.state.Floor) dungeon.UpdateVisibility(s.state.Floor)
room := s.state.Floor.Rooms[roomIdx] room := s.state.Floor.Rooms[roomIdx]
@@ -40,6 +45,11 @@ func (s *GameSession) EnterRoom(roomIdx int) {
s.state.CombatTurn = 0 s.state.CombatTurn = 0
s.signalCombat() s.signalCombat()
case dungeon.RoomShop: case dungeon.RoomShop:
if s.hasMutation("no_shop") {
s.addLog("상점이 닫혔습니다! (주간 변이)")
room.Cleared = true
return
}
s.generateShopItems() s.generateShopItems()
s.state.Phase = PhaseShop s.state.Phase = PhaseShop
case dungeon.RoomTreasure: case dungeon.RoomTreasure:
@@ -98,9 +108,15 @@ func (s *GameSession) spawnMonsters() {
m.MaxHP = m.HP m.MaxHP = m.HP
m.DEF = int(float64(m.DEF) * s.cfg.Combat.SoloHPReduction) m.DEF = int(float64(m.DEF) * s.cfg.Combat.SoloHPReduction)
} }
if rand.Float64() < 0.20 { if s.hasMutation("elite_flood") || rand.Float64() < 0.20 {
entity.ApplyPrefix(m, entity.RandomPrefix()) entity.ApplyPrefix(m, entity.RandomPrefix())
} }
if s.HardMode {
mult := s.cfg.Difficulty.HardModeMonsterMult
m.HP = int(float64(m.HP) * mult)
m.MaxHP = m.HP
m.ATK = int(float64(m.ATK) * mult)
}
s.state.Monsters[i] = m s.state.Monsters[i] = m
} }
@@ -140,6 +156,12 @@ func (s *GameSession) spawnBoss() {
boss.MaxHP = boss.HP boss.MaxHP = boss.HP
boss.DEF = int(float64(boss.DEF) * s.cfg.Combat.SoloHPReduction) boss.DEF = int(float64(boss.DEF) * s.cfg.Combat.SoloHPReduction)
} }
if s.HardMode {
mult := s.cfg.Difficulty.HardModeMonsterMult
boss.HP = int(float64(boss.HP) * mult)
boss.MaxHP = boss.HP
boss.ATK = int(float64(boss.ATK) * mult)
}
s.state.Monsters = []*entity.Monster{boss} s.state.Monsters = []*entity.Monster{boss}
// Reset skill uses for all players at combat start // Reset skill uses for all players at combat start
@@ -152,7 +174,7 @@ func (s *GameSession) grantTreasure() {
floor := s.state.FloorNum floor := s.state.FloorNum
for _, p := range s.state.Players { for _, p := range s.state.Players {
if len(p.Inventory) >= s.cfg.Game.InventoryLimit { if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
s.addLog(fmt.Sprintf("%s's inventory is full!", p.Name)) s.addLog(fmt.Sprintf("%s의 인벤토리가 가득 찼습니다!", p.Name))
continue continue
} }
if rand.Float64() < 0.5 { if rand.Float64() < 0.5 {
@@ -161,14 +183,14 @@ func (s *GameSession) grantTreasure() {
Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: bonus, Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: bonus,
} }
p.Inventory = append(p.Inventory, item) p.Inventory = append(p.Inventory, item)
s.addLog(fmt.Sprintf("%s found %s (ATK+%d)", p.Name, item.Name, item.Bonus)) s.addLog(fmt.Sprintf("%s %s 발견 (ATK+%d)", p.Name, item.Name, item.Bonus))
} else { } else {
bonus := 2 + rand.Intn(4) + floor/4 bonus := 2 + rand.Intn(4) + floor/4
item := entity.Item{ item := entity.Item{
Name: armorName(floor), Type: entity.ItemArmor, Bonus: bonus, Name: armorName(floor), Type: entity.ItemArmor, Bonus: bonus,
} }
p.Inventory = append(p.Inventory, item) p.Inventory = append(p.Inventory, item)
s.addLog(fmt.Sprintf("%s found %s (DEF+%d)", p.Name, item.Name, item.Bonus)) s.addLog(fmt.Sprintf("%s %s 발견 (DEF+%d)", p.Name, item.Name, item.Bonus))
} }
} }
} }
@@ -186,6 +208,12 @@ func (s *GameSession) generateShopItems() {
potionHeal := 30 + floor potionHeal := 30 + floor
potionPrice := 20 + floor/2 potionPrice := 20 + floor/2
if s.HardMode {
mult := s.cfg.Difficulty.HardModeShopMult
potionPrice = int(float64(potionPrice) * mult)
weaponPrice = int(float64(weaponPrice) * mult)
armorPrice = int(float64(armorPrice) * mult)
}
s.state.ShopItems = []entity.Item{ s.state.ShopItems = []entity.Item{
{Name: "HP Potion", Type: entity.ItemConsumable, Bonus: potionHeal, Price: potionPrice}, {Name: "HP Potion", Type: entity.ItemConsumable, Bonus: potionHeal, Price: potionPrice},
{Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: weaponBonus, Price: weaponPrice}, {Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: weaponBonus, Price: weaponPrice},
@@ -221,7 +249,8 @@ func armorName(floor int) string {
func (s *GameSession) triggerEvent() { func (s *GameSession) triggerEvent() {
event := PickRandomEvent() event := PickRandomEvent()
s.addLog(fmt.Sprintf("Event: %s — %s", event.Name, event.Description)) s.state.LastEventName = event.Name
s.addLog(fmt.Sprintf("이벤트: %s — %s", event.Name, event.Description))
// Auto-resolve with a random choice // Auto-resolve with a random choice
choice := event.Choices[rand.Intn(len(event.Choices))] choice := event.Choices[rand.Intn(len(event.Choices))]
@@ -243,10 +272,10 @@ func (s *GameSession) triggerEvent() {
if outcome.HPChange > 0 { if outcome.HPChange > 0 {
before := target.HP before := target.HP
target.Heal(outcome.HPChange) target.Heal(outcome.HPChange)
s.addLog(fmt.Sprintf(" %s heals %d HP", target.Name, target.HP-before)) s.addLog(fmt.Sprintf(" %s HP %d 회복", target.Name, target.HP-before))
} else if outcome.HPChange < 0 { } else if outcome.HPChange < 0 {
target.TakeDamage(-outcome.HPChange) target.TakeDamage(-outcome.HPChange)
s.addLog(fmt.Sprintf(" %s takes %d damage", target.Name, -outcome.HPChange)) s.addLog(fmt.Sprintf(" %s %d 피해를 받음", target.Name, -outcome.HPChange))
} }
if outcome.GoldChange != 0 { if outcome.GoldChange != 0 {
@@ -255,9 +284,9 @@ func (s *GameSession) triggerEvent() {
target.Gold = 0 target.Gold = 0
} }
if outcome.GoldChange > 0 { if outcome.GoldChange > 0 {
s.addLog(fmt.Sprintf(" %s gains %d gold", target.Name, outcome.GoldChange)) s.addLog(fmt.Sprintf(" %s 골드 %d 획득", target.Name, outcome.GoldChange))
} else { } else {
s.addLog(fmt.Sprintf(" %s loses %d gold", target.Name, -outcome.GoldChange)) s.addLog(fmt.Sprintf(" %s 골드 %d 잃음", target.Name, -outcome.GoldChange))
} }
} }
@@ -268,27 +297,27 @@ func (s *GameSession) triggerEvent() {
bonus := 3 + rand.Intn(6) + floor/3 bonus := 3 + rand.Intn(6) + floor/3
item := entity.Item{Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: bonus} item := entity.Item{Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: bonus}
target.Inventory = append(target.Inventory, item) target.Inventory = append(target.Inventory, item)
s.addLog(fmt.Sprintf(" %s found %s (ATK+%d)", target.Name, item.Name, item.Bonus)) s.addLog(fmt.Sprintf(" %s %s 발견 (ATK+%d)", target.Name, item.Name, item.Bonus))
} else { } else {
bonus := 2 + rand.Intn(4) + floor/4 bonus := 2 + rand.Intn(4) + floor/4
item := entity.Item{Name: armorName(floor), Type: entity.ItemArmor, Bonus: bonus} item := entity.Item{Name: armorName(floor), Type: entity.ItemArmor, Bonus: bonus}
target.Inventory = append(target.Inventory, item) target.Inventory = append(target.Inventory, item)
s.addLog(fmt.Sprintf(" %s found %s (DEF+%d)", target.Name, item.Name, item.Bonus)) s.addLog(fmt.Sprintf(" %s %s 발견 (DEF+%d)", target.Name, item.Name, item.Bonus))
} }
} else { } else {
s.addLog(fmt.Sprintf(" %s's inventory is full!", target.Name)) s.addLog(fmt.Sprintf(" %s의 인벤토리가 가득 찼습니다!", target.Name))
} }
} }
} }
func (s *GameSession) grantSecretTreasure() { func (s *GameSession) grantSecretTreasure() {
s.addLog("You discovered a secret room filled with treasure!") s.addLog("보물로 가득 찬 비밀의 방을 발견했습니다!")
floor := s.state.FloorNum floor := s.state.FloorNum
// Double treasure: grant two items per player // Double treasure: grant two items per player
for _, p := range s.state.Players { for _, p := range s.state.Players {
for i := 0; i < 2; i++ { for i := 0; i < 2; i++ {
if len(p.Inventory) >= s.cfg.Game.InventoryLimit { if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
s.addLog(fmt.Sprintf("%s's inventory is full!", p.Name)) s.addLog(fmt.Sprintf("%s의 인벤토리가 가득 찼습니다!", p.Name))
break break
} }
if rand.Float64() < 0.5 { if rand.Float64() < 0.5 {
@@ -297,14 +326,14 @@ func (s *GameSession) grantSecretTreasure() {
Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: bonus, Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: bonus,
} }
p.Inventory = append(p.Inventory, item) p.Inventory = append(p.Inventory, item)
s.addLog(fmt.Sprintf("%s found %s (ATK+%d)", p.Name, item.Name, item.Bonus)) s.addLog(fmt.Sprintf("%s %s 발견 (ATK+%d)", p.Name, item.Name, item.Bonus))
} else { } else {
bonus := 2 + rand.Intn(4) + floor/4 bonus := 2 + rand.Intn(4) + floor/4
item := entity.Item{ item := entity.Item{
Name: armorName(floor), Type: entity.ItemArmor, Bonus: bonus, Name: armorName(floor), Type: entity.ItemArmor, Bonus: bonus,
} }
p.Inventory = append(p.Inventory, item) p.Inventory = append(p.Inventory, item)
s.addLog(fmt.Sprintf("%s found %s (DEF+%d)", p.Name, item.Name, item.Bonus)) s.addLog(fmt.Sprintf("%s %s 발견 (DEF+%d)", p.Name, item.Name, item.Bonus))
} }
} }
} }
@@ -345,8 +374,14 @@ func (s *GameSession) spawnMiniBoss() {
miniBoss.MaxHP = miniBoss.HP miniBoss.MaxHP = miniBoss.HP
miniBoss.DEF = int(float64(miniBoss.DEF) * s.cfg.Combat.SoloHPReduction) miniBoss.DEF = int(float64(miniBoss.DEF) * s.cfg.Combat.SoloHPReduction)
} }
if s.HardMode {
mult := s.cfg.Difficulty.HardModeMonsterMult
miniBoss.HP = int(float64(miniBoss.HP) * mult)
miniBoss.MaxHP = miniBoss.HP
miniBoss.ATK = int(float64(miniBoss.ATK) * mult)
}
s.state.Monsters = []*entity.Monster{miniBoss} s.state.Monsters = []*entity.Monster{miniBoss}
s.addLog(fmt.Sprintf("A mini-boss appears: %s!", miniBoss.Name)) s.addLog(fmt.Sprintf("미니보스 등장: %s!", miniBoss.Name))
// Reset skill uses for all players at combat start // Reset skill uses for all players at combat start
for _, p := range s.state.Players { for _, p := range s.state.Players {

View File

@@ -117,10 +117,10 @@ func (l *Lobby) InvitePlayer(roomCode, fingerprint string) error {
defer l.mu.Unlock() defer l.mu.Unlock()
p, ok := l.online[fingerprint] p, ok := l.online[fingerprint]
if !ok { if !ok {
return fmt.Errorf("player not online") return fmt.Errorf("플레이어가 온라인이 아닙니다")
} }
if p.InRoom != "" { if p.InRoom != "" {
return fmt.Errorf("player already in a room") return fmt.Errorf("플레이어가 이미 방에 있습니다")
} }
// Store the invite as a pending field // Store the invite as a pending field
p.InRoom = "invited:" + roomCode p.InRoom = "invited:" + roomCode
@@ -148,19 +148,38 @@ func (l *Lobby) JoinRoom(code, playerName, fingerprint string) error {
defer l.mu.Unlock() defer l.mu.Unlock()
room, ok := l.rooms[code] room, ok := l.rooms[code]
if !ok { if !ok {
return fmt.Errorf("room %s not found", code) return fmt.Errorf("방 %s을(를) 찾을 수 없습니다", code)
} }
if len(room.Players) >= l.cfg.Game.MaxPlayers { if len(room.Players) >= l.cfg.Game.MaxPlayers {
return fmt.Errorf("room %s is full", code) return fmt.Errorf("방 %s이(가) 가득 찼습니다", code)
} }
if room.Status != RoomWaiting { if room.Status != RoomWaiting {
return fmt.Errorf("room %s already in progress", code) return fmt.Errorf("방 %s이(가) 이미 진행 중입니다", code)
} }
room.Players = append(room.Players, LobbyPlayer{Name: playerName, Fingerprint: fingerprint}) room.Players = append(room.Players, LobbyPlayer{Name: playerName, Fingerprint: fingerprint})
slog.Info("player joined", "room", code, "player", playerName) slog.Info("player joined", "room", code, "player", playerName)
return nil return nil
} }
func (l *Lobby) LeaveRoom(code, fingerprint string) {
l.mu.Lock()
defer l.mu.Unlock()
room, ok := l.rooms[code]
if !ok {
return
}
for i, p := range room.Players {
if p.Fingerprint == fingerprint {
room.Players = append(room.Players[:i], room.Players[i+1:]...)
break
}
}
// Remove empty waiting rooms
if len(room.Players) == 0 && room.Status == RoomWaiting {
delete(l.rooms, code)
}
}
func (l *Lobby) SetPlayerClass(code, fingerprint, class string) { func (l *Lobby) SetPlayerClass(code, fingerprint, class string) {
l.mu.Lock() l.mu.Lock()
defer l.mu.Unlock() defer l.mu.Unlock()

View File

@@ -19,16 +19,16 @@ type Mutation struct {
// Mutations is the list of all available mutations. // Mutations is the list of all available mutations.
var Mutations = []Mutation{ var Mutations = []Mutation{
{ID: "no_skills", Name: "Skill Lockout", Description: "Class skills are disabled", {ID: "no_skills", Name: "스킬 봉인", Description: "직업 스킬 사용 불가",
Apply: func(cfg *config.GameConfig) { cfg.SkillUses = 0 }}, Apply: func(cfg *config.GameConfig) { cfg.SkillUses = 0 }},
{ID: "speed_run", Name: "Speed Run", Description: "Turn timeout halved", {ID: "speed_run", Name: "스피드 런", Description: "턴 제한 시간 절반",
Apply: func(cfg *config.GameConfig) { cfg.TurnTimeoutSec = max(cfg.TurnTimeoutSec/2, 2) }}, Apply: func(cfg *config.GameConfig) { cfg.TurnTimeoutSec = max(cfg.TurnTimeoutSec/2, 2) }},
{ID: "no_shop", Name: "Shop Closed", Description: "Shops are unavailable", {ID: "no_shop", Name: "상점 폐쇄", Description: "상점 이용 불가",
Apply: func(cfg *config.GameConfig) {}}, Apply: func(cfg *config.GameConfig) {}}, // handled at runtime in EnterRoom
{ID: "glass_cannon", Name: "Glass Cannon", Description: "Double damage, half HP", {ID: "glass_cannon", Name: "유리 대포", Description: "피해 2배, HP 절반",
Apply: func(cfg *config.GameConfig) {}}, Apply: func(cfg *config.GameConfig) {}}, // handled at runtime in AddPlayer/spawnMonsters
{ID: "elite_flood", Name: "Elite Flood", Description: "All monsters are elite", {ID: "elite_flood", Name: "엘리트 범람", Description: "모든 몬스터가 엘리트",
Apply: func(cfg *config.GameConfig) {}}, Apply: func(cfg *config.GameConfig) {}}, // handled at runtime in spawnMonsters
} }
// GetWeeklyMutation returns the mutation for the current week, // GetWeeklyMutation returns the mutation for the current week,

View File

@@ -28,211 +28,211 @@ func GetRandomEvents() []RandomEvent {
return []RandomEvent{ return []RandomEvent{
{ {
Name: "altar", Name: "altar",
Description: "You discover an ancient altar glowing with strange energy.", Description: "이상한 에너지로 빛나는 고대 제단을 발견했습니다.",
Choices: []EventChoice{ Choices: []EventChoice{
{ {
Label: "Pray at the altar", Label: "제단에서 기도하기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
if rand.Float64() < 0.6 { if rand.Float64() < 0.6 {
heal := 15 + floor*2 heal := 15 + floor*2
return EventOutcome{HPChange: heal, Description: "The altar blesses you with healing light."} return EventOutcome{HPChange: heal, Description: "제단이 치유의 빛으로 축복합니다."}
} }
dmg := 10 + floor dmg := 10 + floor
return EventOutcome{HPChange: -dmg, Description: "The altar's energy lashes out at you!"} return EventOutcome{HPChange: -dmg, Description: "제단의 에너지가 당신을 공격합니다!"}
}, },
}, },
{ {
Label: "Offer gold", Label: "골드 바치기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
cost := 10 + floor cost := 10 + floor
return EventOutcome{GoldChange: -cost, ItemDrop: true, Description: "You offer gold and receive a divine gift."} return EventOutcome{GoldChange: -cost, ItemDrop: true, Description: "골드를 바치고 신성한 선물을 받았습니다."}
}, },
}, },
{ {
Label: "Walk away", Label: "그냥 지나가기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
return EventOutcome{Description: "You leave the altar undisturbed."} return EventOutcome{Description: "제단을 건드리지 않고 떠납니다."}
}, },
}, },
}, },
}, },
{ {
Name: "fountain", Name: "fountain",
Description: "A shimmering fountain bubbles in the center of the room.", Description: "방 중앙에서 빛나는 분수가 솟아오릅니다.",
Choices: []EventChoice{ Choices: []EventChoice{
{ {
Label: "Drink from the fountain", Label: "분수의 물 마시기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
heal := 20 + floor*2 heal := 20 + floor*2
return EventOutcome{HPChange: heal, Description: "The water rejuvenates you!"} return EventOutcome{HPChange: heal, Description: "물이 당신을 활기차게 합니다!"}
}, },
}, },
{ {
Label: "Toss a coin", Label: "동전 던지기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
if rand.Float64() < 0.5 { if rand.Float64() < 0.5 {
gold := 15 + floor*3 gold := 15 + floor*3
return EventOutcome{GoldChange: gold, Description: "The fountain rewards your generosity!"} return EventOutcome{GoldChange: gold, Description: "분수가 당신의 관대함에 보답합니다!"}
} }
return EventOutcome{GoldChange: -5, Description: "The coin sinks and nothing happens."} return EventOutcome{GoldChange: -5, Description: "동전이 가라앉고 아무 일도 일어나지 않습니다."}
}, },
}, },
}, },
}, },
{ {
Name: "merchant", Name: "merchant",
Description: "A hooded merchant appears from the shadows.", Description: "두건을 쓴 상인이 어둠 속에서 나타납니다.",
Choices: []EventChoice{ Choices: []EventChoice{
{ {
Label: "Trade gold for healing", Label: "골드로 치료 거래",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
cost := 15 + floor cost := 15 + floor
heal := 25 + floor*2 heal := 25 + floor*2
return EventOutcome{HPChange: heal, GoldChange: -cost, Description: "The merchant sells you a healing draught."} return EventOutcome{HPChange: heal, GoldChange: -cost, Description: "상인이 치유의 물약을 팝니다."}
}, },
}, },
{ {
Label: "Buy a mystery item", Label: "미스터리 아이템 구매",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
cost := 20 + floor*2 cost := 20 + floor*2
return EventOutcome{GoldChange: -cost, ItemDrop: true, Description: "The merchant hands you a wrapped package."} return EventOutcome{GoldChange: -cost, ItemDrop: true, Description: "상인이 포장된 꾸러미를 건넵니다."}
}, },
}, },
{ {
Label: "Decline", Label: "거절하기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
return EventOutcome{Description: "The merchant vanishes into the shadows."} return EventOutcome{Description: "상인이 어둠 속으로 사라집니다."}
}, },
}, },
}, },
}, },
{ {
Name: "trap_room", Name: "trap_room",
Description: "The floor is covered with suspicious pressure plates.", Description: "바닥이 수상한 압력판으로 덮여 있습니다.",
Choices: []EventChoice{ Choices: []EventChoice{
{ {
Label: "Carefully navigate", Label: "조심히 지나가기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
if rand.Float64() < 0.5 { if rand.Float64() < 0.5 {
return EventOutcome{Description: "You skillfully avoid all the traps!"} return EventOutcome{Description: "능숙하게 모든 함정을 피했습니다!"}
} }
dmg := 8 + floor dmg := 8 + floor
return EventOutcome{HPChange: -dmg, Description: "You trigger a trap and take damage!"} return EventOutcome{HPChange: -dmg, Description: "함정을 밟아 피해를 입었습니다!"}
}, },
}, },
{ {
Label: "Rush through", Label: "돌진하기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
dmg := 5 + floor/2 dmg := 5 + floor/2
gold := 10 + floor*2 gold := 10 + floor*2
return EventOutcome{HPChange: -dmg, GoldChange: gold, Description: "You take minor damage but find hidden gold!"} return EventOutcome{HPChange: -dmg, GoldChange: gold, Description: "약간의 피해를 입었지만 숨겨진 골드를 발견했습니다!"}
}, },
}, },
}, },
}, },
{ {
Name: "shrine", Name: "shrine",
Description: "A glowing shrine hums with divine power.", Description: "신성한 힘으로 울리는 빛나는 성소가 있습니다.",
Choices: []EventChoice{ Choices: []EventChoice{
{ {
Label: "Kneel and pray", Label: "무릎 꿇고 기도하기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
heal := 30 + floor*2 heal := 30 + floor*2
return EventOutcome{HPChange: heal, Description: "The shrine fills you with renewed vigor!"} return EventOutcome{HPChange: heal, Description: "성소가 새로운 활력으로 가득 채워줍니다!"}
}, },
}, },
{ {
Label: "Take the offering", Label: "제물 가져가기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
gold := 20 + floor*3 gold := 20 + floor*3
dmg := 15 + floor dmg := 15 + floor
return EventOutcome{HPChange: -dmg, GoldChange: gold, Description: "You steal the offering but anger the spirits!"} return EventOutcome{HPChange: -dmg, GoldChange: gold, Description: "제물을 훔쳤지만 영혼들이 분노합니다!"}
}, },
}, },
}, },
}, },
{ {
Name: "chest", Name: "chest",
Description: "An ornate chest sits in the corner of the room.", Description: "방 구석에 화려한 상자가 놓여 있습니다.",
Choices: []EventChoice{ Choices: []EventChoice{
{ {
Label: "Open carefully", Label: "조심히 열기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
if rand.Float64() < 0.7 { if rand.Float64() < 0.7 {
gold := 15 + floor*2 gold := 15 + floor*2
return EventOutcome{GoldChange: gold, Description: "The chest contains a pile of gold!"} return EventOutcome{GoldChange: gold, Description: "상자 안에 골드 더미가 있습니다!"}
} }
dmg := 12 + floor dmg := 12 + floor
return EventOutcome{HPChange: -dmg, Description: "The chest was a mimic! It bites you!"} return EventOutcome{HPChange: -dmg, Description: "상자가 미믹이었습니다! 물어뜯깁니다!"}
}, },
}, },
{ {
Label: "Smash it open", Label: "부수어 열기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
return EventOutcome{ItemDrop: true, Description: "You smash the chest and find equipment inside!"} return EventOutcome{ItemDrop: true, Description: "상자를 부수고 안에서 장비를 발견했습니다!"}
}, },
}, },
{ {
Label: "Leave it", Label: "그냥 두기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
return EventOutcome{Description: "Better safe than sorry."} return EventOutcome{Description: "안전한 게 최고입니다."}
}, },
}, },
}, },
}, },
{ {
Name: "ghost", Name: "ghost",
Description: "A spectral figure materializes before you.", Description: "유령 같은 형체가 눈앞에 나타납니다.",
Choices: []EventChoice{ Choices: []EventChoice{
{ {
Label: "Speak with the ghost", Label: "유령과 대화하기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
gold := 10 + floor*2 gold := 10 + floor*2
return EventOutcome{GoldChange: gold, Description: "The ghost thanks you for listening and rewards you."} return EventOutcome{GoldChange: gold, Description: "유령이 들어줘서 감사하며 보상합니다."}
}, },
}, },
{ {
Label: "Attack the ghost", Label: "유령 공격하기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
if rand.Float64() < 0.4 { if rand.Float64() < 0.4 {
return EventOutcome{ItemDrop: true, Description: "The ghost drops a spectral weapon as it fades!"} return EventOutcome{ItemDrop: true, Description: "유령이 사라지며 유령 무기를 떨어뜨립니다!"}
} }
dmg := 15 + floor dmg := 15 + floor
return EventOutcome{HPChange: -dmg, Description: "The ghost retaliates with ghostly fury!"} return EventOutcome{HPChange: -dmg, Description: "유령이 분노하여 반격합니다!"}
}, },
}, },
}, },
}, },
{ {
Name: "mushroom", Name: "mushroom",
Description: "Strange glowing mushrooms grow in clusters here.", Description: "이상하게 빛나는 버섯들이 무리 지어 자라고 있습니다.",
Choices: []EventChoice{ Choices: []EventChoice{
{ {
Label: "Eat a mushroom", Label: "버섯 먹기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
r := rand.Float64() r := rand.Float64()
if r < 0.33 { if r < 0.33 {
heal := 20 + floor*2 heal := 20 + floor*2
return EventOutcome{HPChange: heal, Description: "The mushroom tastes great and heals you!"} return EventOutcome{HPChange: heal, Description: "버섯이 맛있고 치유 효과가 있습니다!"}
} else if r < 0.66 { } else if r < 0.66 {
dmg := 10 + floor dmg := 10 + floor
return EventOutcome{HPChange: -dmg, Description: "The mushroom was poisonous!"} return EventOutcome{HPChange: -dmg, Description: "독버섯이었습니다!"}
} }
gold := 10 + floor gold := 10 + floor
return EventOutcome{GoldChange: gold, Description: "The mushroom gives you strange visions... and gold falls from above!"} return EventOutcome{GoldChange: gold, Description: "버섯이 이상한 환각을 보여주고... 위에서 골드가 떨어집니다!"}
}, },
}, },
{ {
Label: "Collect and sell", Label: "채집하여 팔기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
gold := 8 + floor gold := 8 + floor
return EventOutcome{GoldChange: gold, Description: "You carefully harvest the mushrooms for sale."} return EventOutcome{GoldChange: gold, Description: "조심히 버섯을 채집하여 판매합니다."}
}, },
}, },
{ {
Label: "Ignore them", Label: "무시하기",
Resolve: func(floor int) EventOutcome { Resolve: func(floor int) EventOutcome {
return EventOutcome{Description: "You wisely avoid the mysterious fungi."} return EventOutcome{Description: "의문의 균류를 현명하게 피합니다."}
}, },
}, },
}, },

View File

@@ -55,6 +55,8 @@ type GameState struct {
TurnResolving bool // true while logs are being replayed TurnResolving bool // true while logs are being replayed
BossKilled bool BossKilled bool
FleeSucceeded bool FleeSucceeded bool
LastEventName string // name of the most recent random event (for codex)
MoveVotes map[string]int // fingerprint -> voted room index (exploration)
} }
func (s *GameSession) addLog(msg string) { func (s *GameSession) addLog(msg string) {
@@ -82,6 +84,7 @@ type GameSession struct {
combatSignal chan struct{} combatSignal chan struct{}
done chan struct{} done chan struct{}
lastActivity map[string]time.Time // fingerprint -> last activity time lastActivity map[string]time.Time // fingerprint -> last activity time
moveVotes map[string]int // fingerprint -> voted room index
HardMode bool HardMode bool
ActiveMutation *Mutation ActiveMutation *Mutation
DailyMode bool DailyMode bool
@@ -93,6 +96,11 @@ type playerActionMsg struct {
Action PlayerAction Action PlayerAction
} }
// hasMutation returns true if the session has the given mutation active.
func (s *GameSession) hasMutation(id string) bool {
return s.ActiveMutation != nil && s.ActiveMutation.ID == id
}
func NewGameSession(cfg *config.Config) *GameSession { func NewGameSession(cfg *config.Config) *GameSession {
return &GameSession{ return &GameSession{
cfg: cfg, cfg: cfg,
@@ -107,6 +115,13 @@ func NewGameSession(cfg *config.Config) *GameSession {
} }
} }
// ApplyWeeklyMutation sets the current week's mutation on this session.
func (s *GameSession) ApplyWeeklyMutation() {
mut := GetWeeklyMutation()
s.ActiveMutation = &mut
mut.Apply(&s.cfg.Game)
}
func (s *GameSession) Stop() { func (s *GameSession) Stop() {
select { select {
case <-s.done: case <-s.done:
@@ -163,7 +178,7 @@ func (s *GameSession) combatLoop() {
if last, ok := s.lastActivity[p.Fingerprint]; ok { if last, ok := s.lastActivity[p.Fingerprint]; ok {
if now.Sub(last) > 60*time.Second { if now.Sub(last) > 60*time.Second {
slog.Warn("player inactive removed", "fingerprint", p.Fingerprint, "name", p.Name) slog.Warn("player inactive removed", "fingerprint", p.Fingerprint, "name", p.Name)
s.addLog(fmt.Sprintf("%s removed (disconnected)", p.Name)) s.addLog(fmt.Sprintf("%s 제거됨 (접속 끊김)", p.Name))
changed = true changed = true
continue continue
} }
@@ -206,6 +221,14 @@ func (s *GameSession) AddPlayer(p *entity.Player) {
if p.Skills == nil { if p.Skills == nil {
p.Skills = &entity.PlayerSkills{BranchIndex: -1} p.Skills = &entity.PlayerSkills{BranchIndex: -1}
} }
if s.hasMutation("glass_cannon") {
p.ATK *= 2
p.MaxHP /= 2
if p.MaxHP < 1 {
p.MaxHP = 1
}
p.HP = p.MaxHP
}
s.state.Players = append(s.state.Players, p) s.state.Players = append(s.state.Players, p)
} }
@@ -281,6 +304,15 @@ func (s *GameSession) GetState() GameState {
submittedCopy[k] = v submittedCopy[k] = v
} }
// Copy move votes
var moveVotesCopy map[string]int
if s.state.MoveVotes != nil {
moveVotesCopy = make(map[string]int, len(s.state.MoveVotes))
for k, v := range s.state.MoveVotes {
moveVotesCopy[k] = v
}
}
// Copy pending logs // Copy pending logs
pendingCopy := make([]string, len(s.state.PendingLogs)) pendingCopy := make([]string, len(s.state.PendingLogs))
copy(pendingCopy, s.state.PendingLogs) copy(pendingCopy, s.state.PendingLogs)
@@ -304,24 +336,41 @@ func (s *GameSession) GetState() GameState {
TurnResolving: s.state.TurnResolving, TurnResolving: s.state.TurnResolving,
BossKilled: s.state.BossKilled, BossKilled: s.state.BossKilled,
FleeSucceeded: s.state.FleeSucceeded, FleeSucceeded: s.state.FleeSucceeded,
LastEventName: s.state.LastEventName,
MoveVotes: moveVotesCopy,
} }
} }
func (s *GameSession) SubmitAction(playerID string, action PlayerAction) { func (s *GameSession) SubmitAction(playerID string, action PlayerAction) {
s.mu.Lock() s.mu.Lock()
s.lastActivity[playerID] = time.Now() s.lastActivity[playerID] = time.Now()
// Block dead/out players from submitting
for _, p := range s.state.Players {
if p.Fingerprint == playerID && p.IsOut() {
s.mu.Unlock()
return
}
}
// Prevent duplicate submissions in the same turn
if _, already := s.state.SubmittedActions[playerID]; already {
s.mu.Unlock()
return
}
desc := "" desc := ""
switch action.Type { switch action.Type {
case ActionAttack: case ActionAttack:
desc = "Attacking" desc = "공격"
case ActionSkill: case ActionSkill:
desc = "Using Skill" desc = "스킬 사용"
case ActionItem: case ActionItem:
desc = "Using Item" desc = "아이템 사용"
case ActionFlee: case ActionFlee:
desc = "Fleeing" desc = "도주"
case ActionWait: case ActionWait:
desc = "Defending" desc = "방어"
} }
if s.state.SubmittedActions == nil { if s.state.SubmittedActions == nil {
s.state.SubmittedActions = make(map[string]string) s.state.SubmittedActions = make(map[string]string)
@@ -360,33 +409,46 @@ func (s *GameSession) AllocateSkillPoint(fingerprint string, branchIdx int) erro
for _, p := range s.state.Players { for _, p := range s.state.Players {
if p.Fingerprint == fingerprint { if p.Fingerprint == fingerprint {
if p.Skills == nil || p.Skills.Points <= p.Skills.Allocated { if p.Skills == nil || p.Skills.Points <= p.Skills.Allocated {
return fmt.Errorf("no skill points available") return fmt.Errorf("스킬 포인트가 없습니다")
} }
return p.Skills.Allocate(branchIdx, p.Class) return p.Skills.Allocate(branchIdx, p.Class)
} }
} }
return fmt.Errorf("player not found") return fmt.Errorf("플레이어를 찾을 수 없습니다")
} }
// BuyResult describes the outcome of a shop purchase attempt.
type BuyResult int
const (
BuyOK BuyResult = iota
BuyNoGold
BuyInventoryFull
BuyFailed
)
// BuyItem handles shop purchases // BuyItem handles shop purchases
func (s *GameSession) BuyItem(playerID string, itemIdx int) bool { func (s *GameSession) BuyItem(playerID string, itemIdx int) BuyResult {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
if s.state.Phase != PhaseShop || itemIdx < 0 || itemIdx >= len(s.state.ShopItems) { if s.state.Phase != PhaseShop || itemIdx < 0 || itemIdx >= len(s.state.ShopItems) {
return false return BuyFailed
} }
item := s.state.ShopItems[itemIdx] item := s.state.ShopItems[itemIdx]
for _, p := range s.state.Players { for _, p := range s.state.Players {
if p.Fingerprint == playerID && p.Gold >= item.Price { if p.Fingerprint == playerID {
if p.Gold < item.Price {
return BuyNoGold
}
if len(p.Inventory) >= s.cfg.Game.InventoryLimit { if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
return false return BuyInventoryFull
} }
p.Gold -= item.Price p.Gold -= item.Price
p.Inventory = append(p.Inventory, item) p.Inventory = append(p.Inventory, item)
return true return BuyOK
} }
} }
return false return BuyFailed
} }
// SendChat appends a chat message to the combat log // SendChat appends a chat message to the combat log
@@ -407,3 +469,82 @@ func (s *GameSession) LeaveShop() {
s.state.Phase = PhaseExploring s.state.Phase = PhaseExploring
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
} }
// SubmitMoveVote records a player's room choice during exploration.
// When all alive players have voted, the majority choice wins and the party moves.
// Returns true if the vote triggered a move (all votes collected).
func (s *GameSession) SubmitMoveVote(fingerprint string, roomIdx int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.state.Phase != PhaseExploring {
return false
}
s.lastActivity[fingerprint] = time.Now()
if s.moveVotes == nil {
s.moveVotes = make(map[string]int)
}
s.moveVotes[fingerprint] = roomIdx
// Copy votes to state for UI display
s.state.MoveVotes = make(map[string]int, len(s.moveVotes))
for k, v := range s.moveVotes {
s.state.MoveVotes[k] = v
}
// Check if all alive players have voted
aliveCount := 0
for _, p := range s.state.Players {
if !p.IsOut() {
aliveCount++
}
}
voteCount := 0
for _, p := range s.state.Players {
if !p.IsOut() {
if _, ok := s.moveVotes[p.Fingerprint]; ok {
voteCount++
}
}
}
if voteCount < aliveCount {
return false
}
// All voted — resolve by majority
tally := make(map[int]int)
for _, p := range s.state.Players {
if !p.IsOut() {
if room, ok := s.moveVotes[p.Fingerprint]; ok {
tally[room]++
}
}
}
bestRoom := -1
bestCount := 0
for room, count := range tally {
if count > bestCount || (count == bestCount && room < bestRoom) {
bestRoom = room
bestCount = count
}
}
// Clear votes
s.moveVotes = nil
s.state.MoveVotes = nil
// Execute the move (inline EnterRoom logic since we already hold the lock)
s.enterRoomLocked(bestRoom)
return true
}
// ClearMoveVotes resets any pending move votes (e.g. when phase changes).
func (s *GameSession) ClearMoveVotes() {
s.mu.Lock()
defer s.mu.Unlock()
s.moveVotes = nil
s.state.MoveVotes = nil
}

View File

@@ -63,8 +63,8 @@ func TestSessionTurnTimeout(t *testing.T) {
select { select {
case <-done: case <-done:
// Turn completed via timeout // Turn completed via timeout
case <-time.After(7 * time.Second): case <-time.After(12 * time.Second):
t.Error("Turn did not timeout within 7 seconds") t.Error("Turn did not timeout within 12 seconds")
} }
} }
@@ -142,8 +142,8 @@ func TestBuyItemInventoryFull(t *testing.T) {
} }
s.mu.Unlock() s.mu.Unlock()
if s.BuyItem("fp-buyer", 0) { if result := s.BuyItem("fp-buyer", 0); result != BuyInventoryFull {
t.Error("should not buy when inventory is full") t.Errorf("expected BuyInventoryFull, got %d", result)
} }
} }

View File

@@ -25,6 +25,16 @@ func (s *GameSession) RunTurn() {
} }
s.mu.Unlock() s.mu.Unlock()
// Drain stale actions from previous turn
draining:
for {
select {
case <-s.actionCh:
default:
break draining
}
}
// Collect actions with timeout // Collect actions with timeout
turnTimeout := time.Duration(s.cfg.Game.TurnTimeoutSec) * time.Second turnTimeout := time.Duration(s.cfg.Game.TurnTimeoutSec) * time.Second
timer := time.NewTimer(turnTimeout) timer := time.NewTimer(turnTimeout)
@@ -72,6 +82,14 @@ collecting:
} }
func (s *GameSession) resolvePlayerActions() { func (s *GameSession) resolvePlayerActions() {
// Record frozen players BEFORE ticking effects (freeze expires on tick)
frozenPlayers := make(map[string]bool)
for _, p := range s.state.Players {
if !p.IsOut() && p.HasEffect(entity.StatusFreeze) {
frozenPlayers[p.Fingerprint] = true
}
}
// Tick status effects with floor theme damage bonus // Tick status effects with floor theme damage bonus
theme := dungeon.GetTheme(s.state.FloorNum) theme := dungeon.GetTheme(s.state.FloorNum)
for _, p := range s.state.Players { for _, p := range s.state.Players {
@@ -91,13 +109,13 @@ func (s *GameSession) resolvePlayerActions() {
bonus := int(float64(e.Value) * (theme.DamageMult - 1.0)) bonus := int(float64(e.Value) * (theme.DamageMult - 1.0))
if bonus > 0 { if bonus > 0 {
p.TakeDamage(bonus) p.TakeDamage(bonus)
s.addLog(fmt.Sprintf(" (%s theme: +%d damage)", theme.Name, bonus)) s.addLog(fmt.Sprintf(" (%s 테마: +%d 피해)", theme.Name, bonus))
} }
} }
} }
if p.IsDead() { if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name)) s.addLog(fmt.Sprintf("☠ %s 쓰러졌습니다!", p.Name))
} }
} }
} }
@@ -117,6 +135,11 @@ func (s *GameSession) resolvePlayerActions() {
if p.IsOut() { if p.IsOut() {
continue continue
} }
// Frozen players skip their action
if frozenPlayers[p.Fingerprint] {
s.addLog(fmt.Sprintf("%s 동결되어 행동할 수 없습니다!", p.Name))
continue
}
action, ok := s.actions[p.Fingerprint] action, ok := s.actions[p.Fingerprint]
if !ok { if !ok {
continue continue
@@ -133,7 +156,7 @@ func (s *GameSession) resolvePlayerActions() {
intentOwners = append(intentOwners, p.Name) intentOwners = append(intentOwners, p.Name)
case ActionSkill: case ActionSkill:
if p.SkillUses <= 0 { if p.SkillUses <= 0 {
s.addLog(fmt.Sprintf("%s has no skill uses left!", p.Name)) s.addLog(fmt.Sprintf("%s 스킬 사용 횟수가 없습니다!", p.Name))
break break
} }
p.SkillUses-- p.SkillUses--
@@ -145,7 +168,7 @@ func (s *GameSession) resolvePlayerActions() {
m.TauntTurns = 2 m.TauntTurns = 2
} }
} }
s.addLog(fmt.Sprintf("%s used Taunt! Enemies focus on %s for 2 turns", p.Name, p.Name)) s.addLog(fmt.Sprintf("%s Taunt 사용! 적들이 2턴간 %s를 집중 공격합니다", p.Name, p.Name))
case entity.ClassMage: case entity.ClassMage:
skillPower := 0 skillPower := 0
if p.Skills != nil { if p.Skills != nil {
@@ -179,34 +202,41 @@ func (s *GameSession) resolvePlayerActions() {
if p.Skills != nil { if p.Skills != nil {
healAmount += p.Skills.GetSkillPower(p.Class) / 2 healAmount += p.Skills.GetSkillPower(p.Class) / 2
} }
if s.HardMode {
healAmount = int(float64(healAmount) * s.cfg.Difficulty.HardModeHealMult)
}
before := target.HP before := target.HP
target.Heal(healAmount) target.Heal(healAmount)
s.addLog(fmt.Sprintf("%s healed %s for %d HP", p.Name, target.Name, target.HP-before)) s.addLog(fmt.Sprintf("%s이(가) %s에게 HP %d 회복", p.Name, target.Name, target.HP-before))
case entity.ClassRogue: case entity.ClassRogue:
currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom] currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom]
for _, neighborIdx := range currentRoom.Neighbors { for _, neighborIdx := range currentRoom.Neighbors {
s.state.Floor.Rooms[neighborIdx].Visited = true s.state.Floor.Rooms[neighborIdx].Visited = true
} }
s.addLog(fmt.Sprintf("%s scouted nearby rooms!", p.Name)) s.addLog(fmt.Sprintf("%s 주변 방을 정찰했습니다!", p.Name))
} }
case ActionItem: case ActionItem:
found := false found := false
for i, item := range p.Inventory { for i, item := range p.Inventory {
if item.Type == entity.ItemConsumable { if item.Type == entity.ItemConsumable {
before := p.HP before := p.HP
p.Heal(item.Bonus) healAmt := item.Bonus
if s.HardMode {
healAmt = int(float64(healAmt) * s.cfg.Difficulty.HardModeHealMult)
}
p.Heal(healAmt)
p.Inventory = append(p.Inventory[:i], p.Inventory[i+1:]...) p.Inventory = append(p.Inventory[:i], p.Inventory[i+1:]...)
s.addLog(fmt.Sprintf("%s used %s, restored %d HP", p.Name, item.Name, p.HP-before)) s.addLog(fmt.Sprintf("%s %s 사용, HP %d 회복", p.Name, item.Name, p.HP-before))
found = true found = true
break break
} }
} }
if !found { if !found {
s.addLog(fmt.Sprintf("%s has no items to use!", p.Name)) s.addLog(fmt.Sprintf("%s 사용할 아이템이 없습니다!", p.Name))
} }
case ActionFlee: case ActionFlee:
if combat.AttemptFlee(s.cfg.Combat.FleeChance) { if combat.AttemptFlee(s.cfg.Combat.FleeChance) {
s.addLog(fmt.Sprintf("%s fled from battle!", p.Name)) s.addLog(fmt.Sprintf("%s 전투에서 도주했습니다!", p.Name))
s.state.FleeSucceeded = true s.state.FleeSucceeded = true
if s.state.SoloMode { if s.state.SoloMode {
s.state.Phase = PhaseExploring s.state.Phase = PhaseExploring
@@ -214,10 +244,10 @@ func (s *GameSession) resolvePlayerActions() {
} }
p.Fled = true p.Fled = true
} else { } else {
s.addLog(fmt.Sprintf("%s failed to flee!", p.Name)) s.addLog(fmt.Sprintf("%s 도주에 실패했습니다!", p.Name))
} }
case ActionWait: case ActionWait:
s.addLog(fmt.Sprintf("%s is defending", p.Name)) s.addLog(fmt.Sprintf("%s 방어 중", p.Name))
} }
} }
@@ -232,7 +262,7 @@ func (s *GameSession) resolvePlayerActions() {
if allFled && !s.state.SoloMode { if allFled && !s.state.SoloMode {
s.state.Phase = PhaseExploring s.state.Phase = PhaseExploring
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
s.addLog("All players fled!") s.addLog("모든 플레이어가 도주했습니다!")
for _, p := range s.state.Players { for _, p := range s.state.Players {
p.Fled = false p.Fled = false
} }
@@ -274,6 +304,12 @@ func (s *GameSession) resolvePlayerActions() {
} }
} }
// Build name→player map for relic effects
playerByName := make(map[string]*entity.Player)
for _, p := range s.state.Players {
playerByName[p.Name] = p
}
if len(intents) > 0 && len(s.state.Monsters) > 0 { if len(intents) > 0 && len(s.state.Monsters) > 0 {
results := combat.ResolveAttacks(intents, s.state.Monsters, s.cfg.Game.CoopBonus) results := combat.ResolveAttacks(intents, s.state.Monsters, s.cfg.Game.CoopBonus)
for i, r := range results { for i, r := range results {
@@ -281,16 +317,30 @@ func (s *GameSession) resolvePlayerActions() {
if r.IsAoE { if r.IsAoE {
coopStr := "" coopStr := ""
if r.CoopApplied { if r.CoopApplied {
coopStr = " (co-op!)" coopStr = " (협동!)"
} }
s.addLog(fmt.Sprintf("%s hit all enemies for %d total dmg%s", owner, r.Damage, coopStr)) s.addLog(fmt.Sprintf("%s 전체 적에게 총 %d 피해%s", owner, r.Damage, coopStr))
} else if r.TargetIdx >= 0 && r.TargetIdx < len(s.state.Monsters) { } else if r.TargetIdx >= 0 && r.TargetIdx < len(s.state.Monsters) {
target := s.state.Monsters[r.TargetIdx] target := s.state.Monsters[r.TargetIdx]
coopStr := "" coopStr := ""
if r.CoopApplied { if r.CoopApplied {
coopStr = " (co-op!)" coopStr = " (협동!)"
}
s.addLog(fmt.Sprintf("%s이(가) %s에게 %d 피해%s", owner, target.Name, r.Damage, coopStr))
}
// Apply Life Siphon relic: heal percentage of damage dealt
if r.Damage > 0 {
if p := playerByName[owner]; p != nil && !p.IsOut() {
for _, rel := range p.Relics {
if rel.Effect == entity.RelicLifeSteal {
heal := r.Damage * rel.Value / 100
if heal > 0 {
p.Heal(heal)
s.addLog(fmt.Sprintf(" %s의 Life Siphon으로 HP %d 회복", p.Name, heal))
}
}
}
} }
s.addLog(fmt.Sprintf("%s hit %s for %d dmg%s", owner, target.Name, r.Damage, coopStr))
} }
} }
} }
@@ -319,13 +369,13 @@ func (s *GameSession) resolvePlayerActions() {
} }
if r.Effect == entity.RelicHealOnKill { if r.Effect == entity.RelicHealOnKill {
p.Heal(r.Value) p.Heal(r.Value)
s.addLog(fmt.Sprintf("%s's relic heals %d HP", p.Name, r.Value)) s.addLog(fmt.Sprintf("%s의 유물로 HP %d 회복", p.Name, r.Value))
} }
} }
p.Gold += goldReward + bonus p.Gold += goldReward + bonus
} }
} }
s.addLog(fmt.Sprintf("%s defeated! +%d gold", m.Name, goldReward)) s.addLog(fmt.Sprintf("%s 처치! +%d 골드", m.Name, goldReward))
if m.IsBoss { if m.IsBoss {
s.state.BossKilled = true s.state.BossKilled = true
s.grantBossRelic() s.grantBossRelic()
@@ -345,7 +395,7 @@ func (s *GameSession) resolvePlayerActions() {
// Check if combat is over // Check if combat is over
if len(s.state.Monsters) == 0 { if len(s.state.Monsters) == 0 {
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
s.addLog("Room cleared!") s.addLog("방 클리어!")
for _, p := range s.state.Players { for _, p := range s.state.Players {
p.Fled = false p.Fled = false
} }
@@ -362,7 +412,7 @@ func (s *GameSession) advanceFloor() {
s.state.Phase = PhaseResult s.state.Phase = PhaseResult
s.state.Victory = true s.state.Victory = true
s.state.GameOver = true s.state.GameOver = true
s.addLog("You conquered the Catacombs!") s.addLog("카타콤을 정복했습니다!")
return return
} }
// Grant 1 skill point per floor clear // Grant 1 skill point per floor clear
@@ -381,11 +431,11 @@ func (s *GameSession) advanceFloor() {
} }
s.state.Phase = PhaseExploring s.state.Phase = PhaseExploring
s.state.CombatTurn = 0 s.state.CombatTurn = 0
s.addLog(fmt.Sprintf("Descending to B%d...", s.state.FloorNum)) s.addLog(fmt.Sprintf("B%d층으로 내려갑니다...", s.state.FloorNum))
for _, p := range s.state.Players { for _, p := range s.state.Players {
if p.IsDead() { if p.IsDead() {
p.Revive(0.30) p.Revive(0.30)
s.addLog(fmt.Sprintf("✦ %s revived at %d HP!", p.Name, p.HP)) s.addLog(fmt.Sprintf("✦ %s HP %d로 부활!", p.Name, p.HP))
} }
p.Fled = false p.Fled = false
} }
@@ -405,7 +455,7 @@ func (s *GameSession) grantBossRelic() {
if !p.IsOut() { if !p.IsOut() {
r := relics[rand.Intn(len(relics))] r := relics[rand.Intn(len(relics))]
p.Relics = append(p.Relics, r) p.Relics = append(p.Relics, r)
s.addLog(fmt.Sprintf("%s obtained relic: %s", p.Name, r.Name)) s.addLog(fmt.Sprintf("%s 유물 획득: %s", p.Name, r.Name))
} }
} }
} }
@@ -424,9 +474,9 @@ func (s *GameSession) resolveMonsterActions() {
if !p.IsOut() { if !p.IsOut() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5) dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5)
p.TakeDamage(dmg) p.TakeDamage(dmg)
s.addLog(fmt.Sprintf("%s AoE hits %s for %d dmg", m.Name, p.Name, dmg)) s.addLog(fmt.Sprintf("%s 광역 공격으로 %s에게 %d 피해", m.Name, p.Name, dmg))
if p.IsDead() { if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name)) s.addLog(fmt.Sprintf("☠ %s 쓰러졌습니다!", p.Name))
} }
} }
} }
@@ -437,21 +487,21 @@ func (s *GameSession) resolveMonsterActions() {
for _, p := range s.state.Players { for _, p := range s.state.Players {
if !p.IsOut() { if !p.IsOut() {
p.AddEffect(entity.ActiveEffect{Type: entity.StatusPoison, Duration: 3, Value: 5}) p.AddEffect(entity.ActiveEffect{Type: entity.StatusPoison, Duration: 3, Value: 5})
s.addLog(fmt.Sprintf("%s poisons %s!", m.Name, p.Name)) s.addLog(fmt.Sprintf("%s이(가) %s에게 독을 걸었습니다!", m.Name, p.Name))
} }
} }
case entity.PatternBurn: case entity.PatternBurn:
for _, p := range s.state.Players { for _, p := range s.state.Players {
if !p.IsOut() { if !p.IsOut() {
p.AddEffect(entity.ActiveEffect{Type: entity.StatusBurn, Duration: 2, Value: 8}) p.AddEffect(entity.ActiveEffect{Type: entity.StatusBurn, Duration: 2, Value: 8})
s.addLog(fmt.Sprintf("%s burns %s!", m.Name, p.Name)) s.addLog(fmt.Sprintf("%s이(가) %s을(를) 불태웠습니다!", m.Name, p.Name))
} }
} }
case entity.PatternFreeze: case entity.PatternFreeze:
for _, p := range s.state.Players { for _, p := range s.state.Players {
if !p.IsOut() { if !p.IsOut() {
p.AddEffect(entity.ActiveEffect{Type: entity.StatusFreeze, Duration: 1, Value: 0}) p.AddEffect(entity.ActiveEffect{Type: entity.StatusFreeze, Duration: 1, Value: 0})
s.addLog(fmt.Sprintf("%s freezes %s!", m.Name, p.Name)) s.addLog(fmt.Sprintf("%s이(가) %s을(를) 동결시켰습니다!", m.Name, p.Name))
} }
} }
case entity.PatternHeal: case entity.PatternHeal:
@@ -460,7 +510,7 @@ func (s *GameSession) resolveMonsterActions() {
if m.HP > m.MaxHP { if m.HP > m.MaxHP {
m.HP = m.MaxHP m.HP = m.MaxHP
} }
s.addLog(fmt.Sprintf("%s regenerates %d HP!", m.Name, healAmt)) s.addLog(fmt.Sprintf("%s HP %d 재생!", m.Name, healAmt))
} }
} }
} else { } else {
@@ -469,20 +519,20 @@ func (s *GameSession) resolveMonsterActions() {
if !p.IsOut() { if !p.IsOut() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0) dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
p.TakeDamage(dmg) p.TakeDamage(dmg)
s.addLog(fmt.Sprintf("%s attacks %s for %d dmg", m.Name, p.Name, dmg)) s.addLog(fmt.Sprintf("%s이(가) %s을(를) 공격하여 %d 피해", m.Name, p.Name, dmg))
if m.IsElite { if m.IsElite {
def := entity.ElitePrefixDefs[m.ElitePrefix] def := entity.ElitePrefixDefs[m.ElitePrefix]
if def.OnHit >= 0 { if def.OnHit >= 0 {
p.AddEffect(entity.ActiveEffect{Type: def.OnHit, Duration: 2, Value: 3}) p.AddEffect(entity.ActiveEffect{Type: def.OnHit, Duration: 2, Value: 3})
s.addLog(fmt.Sprintf("%s's %s effect afflicts %s!", m.Name, def.Name, p.Name)) s.addLog(fmt.Sprintf("%s %s 효과가 %s에게 적용!", m.Name, def.Name, p.Name))
} else if m.ElitePrefix == entity.PrefixVampiric { } else if m.ElitePrefix == entity.PrefixVampiric {
heal := dmg / 4 heal := dmg / 4
m.HP = min(m.HP+heal, m.MaxHP) m.HP = min(m.HP+heal, m.MaxHP)
s.addLog(fmt.Sprintf("%s drains life from %s! (+%d HP)", m.Name, p.Name, heal)) s.addLog(fmt.Sprintf("%s이(가) %s의 생명력을 흡수! (+%d HP)", m.Name, p.Name, heal))
} }
} }
if p.IsDead() { if p.IsDead() {
s.addLog(fmt.Sprintf("☠ %s has fallen!", p.Name)) s.addLog(fmt.Sprintf("☠ %s 쓰러졌습니다!", p.Name))
} }
} }
} }
@@ -500,6 +550,6 @@ func (s *GameSession) resolveMonsterActions() {
if allPlayersDead { if allPlayersDead {
s.state.Phase = PhaseResult s.state.Phase = PhaseResult
s.state.GameOver = true s.state.GameOver = true
s.addLog("Party wiped!") s.addLog("파티가 전멸했습니다!")
} }
} }

View File

@@ -14,16 +14,16 @@ type Achievement struct {
} }
var AchievementDefs = []Achievement{ var AchievementDefs = []Achievement{
{ID: "first_clear", Name: "Dungeon Delver", Description: "Clear floor 5 for the first time"}, {ID: "first_clear", Name: "던전 탐험가", Description: "처음으로 5층 클리어"},
{ID: "boss_slayer", Name: "Boss Slayer", Description: "Defeat any boss"}, {ID: "boss_slayer", Name: "보스 슬레이어", Description: "보스 처치"},
{ID: "floor10", Name: "Deep Explorer", Description: "Reach floor 10"}, {ID: "floor10", Name: "심층 탐험가", Description: "10층 도달"},
{ID: "floor20", Name: "Conqueror", Description: "Conquer the Catacombs (floor 20)"}, {ID: "floor20", Name: "정복자", Description: "카타콤 정복 (20)"},
{ID: "solo_clear", Name: "Lone Wolf", Description: "Clear floor 5 solo"}, {ID: "solo_clear", Name: "외로운 늑대", Description: "솔로로 5층 클리어"},
{ID: "gold_hoarder", Name: "Gold Hoarder", Description: "Accumulate 200+ gold in one run"}, {ID: "gold_hoarder", Name: "골드 수집가", Description: "한 번의 플레이에서 골드 200 이상 모으기"},
{ID: "no_death", Name: "Untouchable", Description: "Complete a floor without anyone dying"}, {ID: "no_death", Name: "무적", Description: "아무도 죽지 않고 층 클리어"},
{ID: "full_party", Name: "Fellowship", Description: "Start a game with 4 players"}, {ID: "full_party", Name: "동료들", Description: "4명으로 게임 시작"},
{ID: "relic_collector", Name: "Relic Collector", Description: "Collect 3+ relics in one run"}, {ID: "relic_collector", Name: "유물 수집가", Description: "한 번의 플레이에서 유물 3개 이상 수집"},
{ID: "flee_master", Name: "Tactical Retreat", Description: "Successfully flee from combat"}, {ID: "flee_master", Name: "전략적 후퇴", Description: "전투에서 도주 성공"},
} }
func (d *DB) initAchievements() error { func (d *DB) initAchievements() error {

View File

@@ -18,10 +18,11 @@ type DB struct {
} }
type RunRecord struct { type RunRecord struct {
Player string `json:"player"` Player string `json:"player"`
Floor int `json:"floor"` Floor int `json:"floor"`
Score int `json:"score"` Score int `json:"score"`
Class string `json:"class,omitempty"` Class string `json:"class,omitempty"`
Members []string `json:"members,omitempty"` // party member names (empty for solo)
} }
func Open(path string) (*DB, error) { func Open(path string) (*DB, error) {
@@ -51,6 +52,9 @@ func Open(path string) (*DB, error) {
if _, err := tx.CreateBucketIfNotExists(bucketCodex); err != nil { if _, err := tx.CreateBucketIfNotExists(bucketCodex); err != nil {
return err return err
} }
if _, err := tx.CreateBucketIfNotExists(bucketPasswords); err != nil {
return err
}
return nil return nil
}) })
return &DB{db: db}, err return &DB{db: db}, err
@@ -79,11 +83,11 @@ func (d *DB) GetProfile(fingerprint string) (string, error) {
return name, err return name, err
} }
func (d *DB) SaveRun(player string, floor, score int, class string) error { func (d *DB) SaveRun(player string, floor, score int, class string, members []string) error {
return d.db.Update(func(tx *bolt.Tx) error { return d.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket(bucketRankings) b := tx.Bucket(bucketRankings)
id, _ := b.NextSequence() id, _ := b.NextSequence()
record := RunRecord{Player: player, Floor: floor, Score: score, Class: class} record := RunRecord{Player: player, Floor: floor, Score: score, Class: class, Members: members}
data, err := json.Marshal(record) data, err := json.Marshal(record)
if err != nil { if err != nil {
return err return err

View File

@@ -38,9 +38,9 @@ func TestRanking(t *testing.T) {
os.Remove("test_rank.db") os.Remove("test_rank.db")
}() }()
db.SaveRun("Alice", 20, 1500, "Warrior") db.SaveRun("Alice", 20, 1500, "Warrior", nil)
db.SaveRun("Bob", 15, 1000, "Mage") db.SaveRun("Bob", 15, 1000, "Mage", nil)
db.SaveRun("Charlie", 20, 2000, "Rogue") db.SaveRun("Charlie", 20, 2000, "Rogue", nil)
rankings, err := db.TopRuns(10) rankings, err := db.TopRuns(10)
if err != nil { if err != nil {
@@ -63,10 +63,10 @@ func TestGetStats(t *testing.T) {
defer db.Close() defer db.Close()
// Save some runs // Save some runs
db.SaveRun("Alice", 5, 100, "Warrior") db.SaveRun("Alice", 5, 100, "Warrior", nil)
db.SaveRun("Alice", 10, 250, "Warrior") db.SaveRun("Alice", 10, 250, "Warrior", nil)
db.SaveRun("Alice", 20, 500, "Warrior") // victory (floor >= 20) db.SaveRun("Alice", 20, 500, "Warrior", nil) // victory (floor >= 20)
db.SaveRun("Bob", 3, 50, "") db.SaveRun("Bob", 3, 50, "", nil)
stats, err := db.GetStats("Alice") stats, err := db.GetStats("Alice")
if err != nil { if err != nil {

51
store/passwords.go Normal file
View File

@@ -0,0 +1,51 @@
package store
import (
bolt "go.etcd.io/bbolt"
"golang.org/x/crypto/bcrypt"
)
var bucketPasswords = []byte("passwords")
// SavePassword stores a bcrypt-hashed password for the given nickname.
func (d *DB) SavePassword(nickname, password string) error {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return err
}
return d.db.Update(func(tx *bolt.Tx) error {
return tx.Bucket(bucketPasswords).Put([]byte(nickname), hash)
})
}
// CheckPassword verifies a password against the stored bcrypt hash.
func (d *DB) CheckPassword(nickname, password string) (bool, error) {
var hash []byte
err := d.db.View(func(tx *bolt.Tx) error {
v := tx.Bucket(bucketPasswords).Get([]byte(nickname))
if v != nil {
hash = make([]byte, len(v))
copy(hash, v)
}
return nil
})
if err != nil {
return false, err
}
if hash == nil {
return false, nil
}
err = bcrypt.CompareHashAndPassword(hash, []byte(password))
return err == nil, nil
}
// HasPassword checks whether an account with a password exists for the nickname.
func (d *DB) HasPassword(nickname string) bool {
found := false
d.db.View(func(tx *bolt.Tx) error {
v := tx.Bucket(bucketPasswords).Get([]byte(nickname))
found = v != nil
return nil
})
return found
}

52
store/passwords_test.go Normal file
View File

@@ -0,0 +1,52 @@
package store
import (
"testing"
)
func TestPasswordRoundTrip(t *testing.T) {
dir := t.TempDir()
db, err := Open(dir + "/test.db")
if err != nil {
t.Fatal(err)
}
defer db.Close()
// New account should not have a password.
if db.HasPassword("alice") {
t.Fatal("expected no password for alice")
}
// Save and check password.
if err := db.SavePassword("alice", "secret123"); err != nil {
t.Fatal(err)
}
if !db.HasPassword("alice") {
t.Fatal("expected alice to have a password")
}
ok, err := db.CheckPassword("alice", "secret123")
if err != nil {
t.Fatal(err)
}
if !ok {
t.Fatal("expected correct password to pass")
}
ok, err = db.CheckPassword("alice", "wrong")
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected wrong password to fail")
}
// Non-existent user returns false, no error.
ok, err = db.CheckPassword("bob", "anything")
if err != nil {
t.Fatal(err)
}
if ok {
t.Fatal("expected non-existent user to fail")
}
}

View File

@@ -33,7 +33,7 @@ func (s *AchievementsScreen) View(ctx *Context) string {
} }
func renderAchievements(playerName string, achievements []store.Achievement, width, height int) string { func renderAchievements(playerName string, achievements []store.Achievement, width, height int) string {
title := styleHeader.Render("── Achievements ──") title := styleHeader.Render("── 업적 ──")
var content string var content string
unlocked := 0 unlocked := 0
@@ -49,9 +49,9 @@ func renderAchievements(playerName string, achievements []store.Achievement, wid
content += styleSystem.Render(" "+a.Description) + "\n" content += styleSystem.Render(" "+a.Description) + "\n"
} }
progress := fmt.Sprintf("\n %s", styleGold.Render(fmt.Sprintf("%d/%d Unlocked", unlocked, len(achievements)))) progress := fmt.Sprintf("\n %s", styleGold.Render(fmt.Sprintf("%d/%d 해금", unlocked, len(achievements))))
footer := styleSystem.Render("\n[A] Back") footer := styleSystem.Render("\n[A] 뒤로")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", content, progress, footer)) lipgloss.JoinVertical(lipgloss.Center, title, "", content, progress, footer))

View File

@@ -36,6 +36,8 @@ func (s *ClassSelectScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd)
if room != nil { if room != nil {
if room.Session == nil { if room.Session == nil {
room.Session = game.NewGameSession(ctx.Lobby.Cfg()) room.Session = game.NewGameSession(ctx.Lobby.Cfg())
room.Session.HardMode = ctx.HardMode
room.Session.ApplyWeeklyMutation()
} }
ctx.Session = room.Session ctx.Session = room.Session
player := entity.NewPlayer(ctx.PlayerName, selectedClass) player := entity.NewPlayer(ctx.PlayerName, selectedClass)
@@ -44,11 +46,8 @@ func (s *ClassSelectScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd)
if ctx.Lobby != nil { if ctx.Lobby != nil {
ctx.Lobby.RegisterSession(ctx.Fingerprint, ctx.RoomCode) ctx.Lobby.RegisterSession(ctx.Fingerprint, ctx.RoomCode)
} }
ctx.Session.StartGame() ws := NewWaitingScreen()
ctx.Lobby.StartRoom(ctx.RoomCode) return ws, ws.pollWaiting()
gs := NewGameScreen()
gs.gameState = ctx.Session.GetState()
return gs, gs.pollState()
} }
} }
} }
@@ -70,10 +69,10 @@ var classOptions = []struct {
name string name string
desc string desc string
}{ }{
{entity.ClassWarrior, "Warrior", "HP:120 ATK:12 DEF:8 Skill: Taunt (draw enemy fire)"}, {entity.ClassWarrior, "Warrior", "HP:120 ATK:12 DEF:8 스킬: Taunt (적의 공격을 끌어옴)"},
{entity.ClassMage, "Mage", "HP:70 ATK:20 DEF:3 Skill: Fireball (AoE damage)"}, {entity.ClassMage, "Mage", "HP:70 ATK:20 DEF:3 스킬: Fireball (광역 피해)"},
{entity.ClassHealer, "Healer", "HP:90 ATK:8 DEF:5 Skill: Heal (restore 30 HP)"}, {entity.ClassHealer, "Healer", "HP:90 ATK:8 DEF:5 스킬: Heal (HP 30 회복)"},
{entity.ClassRogue, "Rogue", "HP:85 ATK:15 DEF:4 Skill: Scout (reveal rooms)"}, {entity.ClassRogue, "Rogue", "HP:85 ATK:15 DEF:4 스킬: Scout (주변 방 탐색)"},
} }
func renderClassSelect(state classSelectState, width, height int) string { func renderClassSelect(state classSelectState, width, height int) string {
@@ -91,7 +90,7 @@ func renderClassSelect(state classSelectState, width, height int) string {
descStyle := lipgloss.NewStyle(). descStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240")) Foreground(lipgloss.Color("240"))
header := headerStyle.Render("── Choose Your Class ──") header := headerStyle.Render("── 직업을 선택하세요 ──")
list := "" list := ""
for i, opt := range classOptions { for i, opt := range classOptions {
marker := " " marker := " "
@@ -104,7 +103,7 @@ func renderClassSelect(state classSelectState, width, height int) string {
marker, style.Render(opt.name), descStyle.Render(opt.desc)) marker, style.Render(opt.name), descStyle.Render(opt.desc))
} }
menu := "[Up/Down] Select [Enter] Confirm" menu := "[Up/Down] 선택 [Enter] 확인"
return lipgloss.JoinVertical(lipgloss.Left, return lipgloss.JoinVertical(lipgloss.Left,
header, header,

View File

@@ -71,10 +71,10 @@ func (s *CodexScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
} }
func (s *CodexScreen) View(ctx *Context) string { func (s *CodexScreen) View(ctx *Context) string {
title := styleHeader.Render("-- Codex --") title := styleHeader.Render("-- 도감 --")
// Tab headers // Tab headers
tabNames := []string{"Monsters", "Items", "Events"} tabNames := []string{"몬스터", "아이템", "이벤트"}
var tabs []string var tabs []string
for i, name := range tabNames { for i, name := range tabNames {
if i == s.tab { if i == s.tab {
@@ -117,7 +117,7 @@ func (s *CodexScreen) View(ctx *Context) string {
} }
completion := lipgloss.NewStyle().Foreground(colorCyan). completion := lipgloss.NewStyle().Foreground(colorCyan).
Render(fmt.Sprintf("Discovered: %d/%d (%.0f%%)", count, total, pct)) Render(fmt.Sprintf("발견: %d/%d (%.0f%%)", count, total, pct))
// Sort discovered keys for consistent display // Sort discovered keys for consistent display
discoveredKeys := make([]string, 0, len(discovered)) discoveredKeys := make([]string, 0, len(discovered))
@@ -151,7 +151,7 @@ func (s *CodexScreen) View(ctx *Context) string {
} }
} }
footer := styleSystem.Render("[Tab/Left/Right] Switch Tab [Esc] Back") footer := styleSystem.Render("[Tab/Left/Right] 탭 전환 [Esc] 뒤로")
content := lipgloss.JoinVertical(lipgloss.Center, content := lipgloss.JoinVertical(lipgloss.Center,
title, title,

View File

@@ -16,4 +16,5 @@ type Context struct {
Store *store.DB Store *store.DB
Session *game.GameSession Session *game.GameSession
RoomCode string RoomCode string
HardMode bool
} }

View File

@@ -17,6 +17,7 @@ import (
type GameScreen struct { type GameScreen struct {
gameState game.GameState gameState game.GameState
targetCursor int targetCursor int
allyCursor int // for Healer skill targeting allies
moveCursor int moveCursor int
chatting bool chatting bool
chatInput string chatInput string
@@ -31,6 +32,23 @@ func NewGameScreen() *GameScreen {
} }
} }
func (s *GameScreen) leaveGame(ctx *Context) (Screen, tea.Cmd) {
if ctx.Lobby != nil && ctx.Fingerprint != "" {
ctx.Lobby.UnregisterSession(ctx.Fingerprint)
}
if ctx.Session != nil {
ctx.Session.Stop()
ctx.Session = nil
}
if ctx.Lobby != nil && ctx.RoomCode != "" {
ctx.Lobby.RemoveRoom(ctx.RoomCode)
}
ctx.RoomCode = ""
ls := NewLobbyScreen()
ls.refreshLobby(ctx)
return ls, ls.pollLobby()
}
func (s *GameScreen) pollState() tea.Cmd { func (s *GameScreen) pollState() tea.Cmd {
return tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg { return tea.Tick(time.Millisecond*200, func(t time.Time) tea.Msg {
return tickMsg{} return tickMsg{}
@@ -86,6 +104,15 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
} }
} }
// Record codex entries for events
if ctx.Store != nil && s.gameState.LastEventName != "" {
key := "event:" + s.gameState.LastEventName
if !s.codexRecorded[key] {
ctx.Store.RecordCodexEntry(ctx.Fingerprint, "event", s.gameState.LastEventName)
s.codexRecorded[key] = true
}
}
s.prevPhase = s.gameState.Phase s.prevPhase = s.gameState.Phase
} }
@@ -96,13 +123,14 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
score += p.Gold score += p.Gold
} }
playerClass := "" playerClass := ""
var members []string
for _, p := range s.gameState.Players { for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint { if p.Fingerprint == ctx.Fingerprint {
playerClass = p.Class.String() playerClass = p.Class.String()
break
} }
members = append(members, p.Name)
} }
ctx.Store.SaveRun(ctx.PlayerName, s.gameState.FloorNum, score, playerClass) ctx.Store.SaveRun(ctx.PlayerName, s.gameState.FloorNum, score, playerClass, members)
// Check achievements // Check achievements
if s.gameState.FloorNum >= 5 { if s.gameState.FloorNum >= 5 {
ctx.Store.UnlockAchievement(ctx.PlayerName, "first_clear") ctx.Store.UnlockAchievement(ctx.PlayerName, "first_clear")
@@ -238,10 +266,13 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
switch s.gameState.Phase { switch s.gameState.Phase {
case game.PhaseExploring: case game.PhaseExploring:
if isForceQuit(key) {
return s, tea.Quit
}
for _, p := range s.gameState.Players { for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint && p.IsDead() { if p.Fingerprint == ctx.Fingerprint && p.IsDead() {
if isQuit(key) { if isKey(key, "q") {
return s, tea.Quit return s.leaveGame(ctx)
} }
return s, nil return s, nil
} }
@@ -259,26 +290,37 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
return s, nil return s, nil
} }
neighbors := s.getNeighbors() neighbors := s.getNeighbors()
// Block input if this player already voted in multiplayer
alreadyVoted := false
if !s.gameState.SoloMode && s.gameState.MoveVotes != nil {
_, alreadyVoted = s.gameState.MoveVotes[ctx.Fingerprint]
}
if isUp(key) { if isUp(key) {
if s.moveCursor > 0 { if !alreadyVoted && s.moveCursor > 0 {
s.moveCursor-- s.moveCursor--
} }
} else if isDown(key) { } else if isDown(key) {
if s.moveCursor < len(neighbors)-1 { if !alreadyVoted && s.moveCursor < len(neighbors)-1 {
s.moveCursor++ s.moveCursor++
} }
} else if isEnter(key) { } else if isEnter(key) {
if ctx.Session != nil && len(neighbors) > 0 { if ctx.Session != nil && len(neighbors) > 0 && !alreadyVoted {
roomIdx := neighbors[s.moveCursor] roomIdx := neighbors[s.moveCursor]
ctx.Session.EnterRoom(roomIdx) if s.gameState.SoloMode {
ctx.Session.EnterRoom(roomIdx)
} else {
ctx.Session.SubmitMoveVote(ctx.Fingerprint, roomIdx)
}
s.gameState = ctx.Session.GetState() s.gameState = ctx.Session.GetState()
s.moveCursor = 0 s.moveCursor = 0
if s.gameState.Phase == game.PhaseCombat { if s.gameState.Phase == game.PhaseCombat {
return s, s.pollState() return s, s.pollState()
} }
} }
} else if isQuit(key) { } else if isForceQuit(key) {
return s, tea.Quit return s, tea.Quit
} else if isKey(key, "q") {
return s.leaveGame(ctx)
} }
case game.PhaseCombat: case game.PhaseCombat:
isPlayerDead := false isPlayerDead := false
@@ -292,17 +334,37 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
return s, s.pollState() return s, s.pollState()
} }
if isKey(key, "tab") || key.Type == tea.KeyTab { if isKey(key, "tab") || key.Type == tea.KeyTab {
if len(s.gameState.Monsters) > 0 { if key.Type == tea.KeyShiftTab {
s.targetCursor = (s.targetCursor + 1) % len(s.gameState.Monsters) // Shift+Tab: cycle ally target (for Healer)
if len(s.gameState.Players) > 0 {
s.allyCursor = (s.allyCursor + 1) % len(s.gameState.Players)
}
} else {
// Tab: cycle enemy target
if len(s.gameState.Monsters) > 0 {
s.targetCursor = (s.targetCursor + 1) % len(s.gameState.Monsters)
}
} }
return s, s.pollState() return s, s.pollState()
} }
if ctx.Session != nil { if ctx.Session != nil {
// Determine current player's class for skill targeting
myClass := entity.ClassWarrior
for _, p := range s.gameState.Players {
if p.Fingerprint == ctx.Fingerprint {
myClass = p.Class
break
}
}
switch key.String() { switch key.String() {
case "1": case "1":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: s.targetCursor}) ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionAttack, TargetIdx: s.targetCursor})
case "2": case "2":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: s.targetCursor}) skillTarget := s.targetCursor
if myClass == entity.ClassHealer {
skillTarget = s.allyCursor
}
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionSkill, TargetIdx: skillTarget})
case "3": case "3":
ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionItem}) ctx.Session.SubmitAction(ctx.Fingerprint, game.PlayerAction{Type: game.ActionItem})
case "4": case "4":
@@ -318,12 +380,12 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
} }
func (s *GameScreen) View(ctx *Context) string { func (s *GameScreen) View(ctx *Context) string {
return renderGame(s.gameState, ctx.Width, ctx.Height, s.targetCursor, s.moveCursor, s.chatting, s.chatInput, ctx.Fingerprint) return renderGame(s.gameState, ctx.Width, ctx.Height, s.targetCursor, s.allyCursor, s.moveCursor, s.chatting, s.chatInput, ctx.Fingerprint)
} }
func renderGame(state game.GameState, width, height int, targetCursor int, moveCursor int, chatting bool, chatInput string, fingerprint string) string { func renderGame(state game.GameState, width, height int, targetCursor int, allyCursor int, moveCursor int, chatting bool, chatInput string, fingerprint string) string {
mapView := renderMap(state.Floor) mapView := renderMap(state.Floor)
hudView := renderHUD(state, targetCursor, moveCursor, fingerprint) hudView := renderHUD(state, targetCursor, allyCursor, moveCursor, fingerprint)
logView := renderCombatLog(state.CombatLog) logView := renderCombatLog(state.CombatLog)
if chatting { if chatting {
@@ -355,11 +417,11 @@ func renderMap(floor *dungeon.Floor) string {
} }
total := len(floor.Rooms) total := len(floor.Rooms)
header := headerStyle.Render(fmt.Sprintf("── Catacombs B%d: %s ── %d/%d Rooms ──", floor.Number, theme.Name, explored, total)) header := headerStyle.Render(fmt.Sprintf("── 카타콤 B%d: %s ── %d/%d ──", floor.Number, theme.Name, explored, total))
return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true) return header + "\n" + dungeon.RenderFloor(floor, floor.CurrentRoom, true)
} }
func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerprint string) string { func renderHUD(state game.GameState, targetCursor int, allyCursor int, moveCursor int, fingerprint string) string {
var sb strings.Builder var sb strings.Builder
border := lipgloss.NewStyle(). border := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
@@ -370,23 +432,31 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
hpBar := renderHPBar(p.HP, p.MaxHP, 20) hpBar := renderHPBar(p.HP, p.MaxHP, 20)
status := "" status := ""
if p.IsDead() { if p.IsDead() {
status = " [DEAD]" status = " [사망]"
} }
sb.WriteString(fmt.Sprintf("%s (%s) %s %d/%d%s Gold: %d", sb.WriteString(fmt.Sprintf("%s (%s) %s %d/%d%s 골드: %d",
p.Name, p.Class, hpBar, p.HP, p.MaxHP, status, p.Gold)) p.Name, p.Class, hpBar, p.HP, p.MaxHP, status, p.Gold))
// Show inventory count // Show inventory count
itemCount := len(p.Inventory) itemCount := len(p.Inventory)
relicCount := len(p.Relics) relicCount := len(p.Relics)
if itemCount > 0 || relicCount > 0 { if itemCount > 0 || relicCount > 0 {
sb.WriteString(fmt.Sprintf(" Items:%d Relics:%d", itemCount, relicCount)) sb.WriteString(fmt.Sprintf(" 아이템:%d 유물:%d", itemCount, relicCount))
} }
sb.WriteString("\n") sb.WriteString("\n")
} }
if state.Phase == game.PhaseCombat { if state.Phase == game.PhaseCombat {
// Two-panel layout: PARTY | ENEMIES // Two-panel layout: PARTY | ENEMIES
partyContent := renderPartyPanel(state.Players, state.SubmittedActions) // Determine if current player is Healer for ally targeting display
isHealer := false
for _, p := range state.Players {
if p.Fingerprint == fingerprint && p.Class == entity.ClassHealer {
isHealer = true
break
}
}
partyContent := renderPartyPanel(state.Players, state.SubmittedActions, isHealer, allyCursor)
enemyContent := renderEnemyPanel(state.Monsters, targetCursor) enemyContent := renderEnemyPanel(state.Monsters, targetCursor)
partyPanel := lipgloss.NewStyle(). partyPanel := lipgloss.NewStyle().
@@ -408,7 +478,11 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
sb.WriteString("\n") sb.WriteString("\n")
// Action bar // Action bar
sb.WriteString(styleAction.Render("[1]Attack [2]Skill [3]Item [4]Flee [5]Wait [Tab]Target [/]Chat")) if isHealer {
sb.WriteString(styleAction.Render("[1]공격 [2]스킬 [3]아이템 [4]도주 [5]대기 [Tab]적 [Shift+Tab]아군 [/]채팅"))
} else {
sb.WriteString(styleAction.Render("[1]공격 [2]스킬 [3]아이템 [4]도주 [5]대기 [Tab]대상 [/]채팅"))
}
sb.WriteString("\n") sb.WriteString("\n")
// Timer // Timer
@@ -417,7 +491,7 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
if remaining < 0 { if remaining < 0 {
remaining = 0 remaining = 0
} }
sb.WriteString(styleTimer.Render(fmt.Sprintf(" Timer: %.1fs", remaining.Seconds()))) sb.WriteString(styleTimer.Render(fmt.Sprintf(" 타이머: %.1f", remaining.Seconds())))
sb.WriteString("\n") sb.WriteString("\n")
} }
@@ -427,25 +501,59 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
var skillDesc string var skillDesc string
switch p.Class { switch p.Class {
case entity.ClassWarrior: case entity.ClassWarrior:
skillDesc = "Skill: Taunt — enemies attack you for 2 turns" skillDesc = "스킬: Taunt — 2턴간 적의 공격을 끌어옴"
case entity.ClassMage: case entity.ClassMage:
skillDesc = "Skill: Fireball — AoE 0.8x dmg to all enemies" skillDesc = "스킬: Fireball — 전체 적에게 0.8배 피해"
case entity.ClassHealer: case entity.ClassHealer:
skillDesc = "Skill: Heal — restore 30 HP to an ally" skillDesc = "스킬: Heal — 아군 HP 30 회복"
case entity.ClassRogue: case entity.ClassRogue:
skillDesc = "Skill: Scout — reveal neighboring rooms" skillDesc = "스킬: Scout — 주변 방 공개"
} }
skillDesc += fmt.Sprintf(" (%d uses left)", p.SkillUses) skillDesc += fmt.Sprintf(" (남은 횟수: %d)", p.SkillUses)
sb.WriteString(styleSystem.Render(skillDesc)) sb.WriteString(styleSystem.Render(skillDesc))
sb.WriteString("\n") sb.WriteString("\n")
break break
} }
} }
} else if state.Phase == game.PhaseExploring { } else if state.Phase == game.PhaseExploring {
// Count votes per room for display
votesPerRoom := make(map[int]int)
if state.MoveVotes != nil {
for _, room := range state.MoveVotes {
votesPerRoom[room]++
}
}
myVoted := false
if !state.SoloMode && state.MoveVotes != nil {
_, myVoted = state.MoveVotes[fingerprint]
}
if state.Floor != nil && state.Floor.CurrentRoom >= 0 && state.Floor.CurrentRoom < len(state.Floor.Rooms) { if state.Floor != nil && state.Floor.CurrentRoom >= 0 && state.Floor.CurrentRoom < len(state.Floor.Rooms) {
current := state.Floor.Rooms[state.Floor.CurrentRoom] current := state.Floor.Rooms[state.Floor.CurrentRoom]
if len(current.Neighbors) > 0 { if len(current.Neighbors) > 0 {
sb.WriteString("\n") sb.WriteString("\n")
// Show vote status in multiplayer
if !state.SoloMode {
aliveCount := 0
votedCount := 0
for _, p := range state.Players {
if !p.IsDead() {
aliveCount++
if state.MoveVotes != nil {
if _, ok := state.MoveVotes[p.Fingerprint]; ok {
votedCount++
}
}
}
}
voteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("214")).Bold(true)
if myVoted {
sb.WriteString(voteStyle.Render(fmt.Sprintf("투표 완료! 대기 중... (%d/%d)", votedCount, aliveCount)))
} else {
sb.WriteString(voteStyle.Render(fmt.Sprintf("이동할 방을 선택하세요 (%d/%d 투표)", votedCount, aliveCount)))
}
sb.WriteString("\n")
}
selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true) selectedStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("46")).Bold(true)
normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255")) normalStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("255"))
for i, n := range current.Neighbors { for i, n := range current.Neighbors {
@@ -453,7 +561,7 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
r := state.Floor.Rooms[n] r := state.Floor.Rooms[n]
status := r.Type.String() status := r.Type.String()
if r.Cleared { if r.Cleared {
status = "Cleared" status = "클리어"
} }
marker := " " marker := " "
style := normalStyle style := normalStyle
@@ -461,7 +569,13 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
marker = "> " marker = "> "
style = selectedStyle style = selectedStyle
} }
sb.WriteString(style.Render(fmt.Sprintf("%sRoom %d: %s", marker, n, status))) voteInfo := ""
if !state.SoloMode {
if count, ok := votesPerRoom[n]; ok {
voteInfo = fmt.Sprintf(" [%d표]", count)
}
}
sb.WriteString(style.Render(fmt.Sprintf("%s방 %d: %s%s", marker, n, status, voteInfo)))
sb.WriteString("\n") sb.WriteString("\n")
} }
} }
@@ -473,7 +587,7 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
branches := entity.GetBranches(p.Class) branches := entity.GetBranches(p.Class)
sb.WriteString("\n") sb.WriteString("\n")
skillStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("213")).Bold(true) skillStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("213")).Bold(true)
sb.WriteString(skillStyle.Render(fmt.Sprintf(" Skill Point Available! (%d unspent)", p.Skills.Points-p.Skills.Allocated))) sb.WriteString(skillStyle.Render(fmt.Sprintf(" 스킬 포인트 사용 가능! (미사용: %d)", p.Skills.Points-p.Skills.Allocated)))
sb.WriteString("\n") sb.WriteString("\n")
for i, branch := range branches { for i, branch := range branches {
key := "[" key := "["
@@ -482,7 +596,7 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
} }
nextNode := p.Skills.Allocated nextNode := p.Skills.Allocated
if p.Skills.BranchIndex >= 0 && p.Skills.BranchIndex != i { if p.Skills.BranchIndex >= 0 && p.Skills.BranchIndex != i {
sb.WriteString(fmt.Sprintf(" [%s] %s (locked)\n", key, branch.Name)) sb.WriteString(fmt.Sprintf(" [%s] %s (잠김)\n", key, branch.Name))
} else if nextNode < 3 { } else if nextNode < 3 {
node := branch.Nodes[nextNode] node := branch.Nodes[nextNode]
sb.WriteString(fmt.Sprintf(" [%s] %s -> %s\n", key, branch.Name, node.Name)) sb.WriteString(fmt.Sprintf(" [%s] %s -> %s\n", key, branch.Name, node.Name))
@@ -491,7 +605,11 @@ func renderHUD(state game.GameState, targetCursor int, moveCursor int, fingerpri
break break
} }
} }
sb.WriteString("[Up/Down] Select [Enter] Move [Q] Quit") if !state.SoloMode && myVoted {
sb.WriteString("[Q] 종료 — 다른 파티원의 투표를 기다리는 중...")
} else {
sb.WriteString("[Up/Down] 선택 [Enter] 이동 [Q] 종료")
}
} }
if state.Phase == game.PhaseCombat { if state.Phase == game.PhaseCombat {
@@ -519,19 +637,19 @@ func renderCombatLog(log []string) string {
func colorizeLog(msg string) string { func colorizeLog(msg string) string {
switch { switch {
case strings.Contains(msg, "fled"): case strings.Contains(msg, "도주"):
return styleFlee.Render(msg) return styleFlee.Render(msg)
case strings.Contains(msg, "co-op"): case strings.Contains(msg, "협동"):
return styleCoop.Render(msg) return styleCoop.Render(msg)
case strings.Contains(msg, "healed") || strings.Contains(msg, "Heal") || strings.Contains(msg, "Blessing"): case strings.Contains(msg, "회복") || strings.Contains(msg, "Heal") || strings.Contains(msg, "치유") || strings.Contains(msg, "부활"):
return styleHeal.Render(msg) return styleHeal.Render(msg)
case strings.Contains(msg, "dmg") || strings.Contains(msg, "hit") || strings.Contains(msg, "attacks") || strings.Contains(msg, "Trap"): case strings.Contains(msg, "피해") || strings.Contains(msg, "공격") || strings.Contains(msg, "Trap") || strings.Contains(msg, "함정"):
return styleDamage.Render(msg) return styleDamage.Render(msg)
case strings.Contains(msg, "Taunt") || strings.Contains(msg, "scouted"): case strings.Contains(msg, "Taunt") || strings.Contains(msg, "정찰"):
return styleStatus.Render(msg) return styleStatus.Render(msg)
case strings.Contains(msg, "gold") || strings.Contains(msg, "Gold") || strings.Contains(msg, "found"): case strings.Contains(msg, "골드") || strings.Contains(msg, "Gold") || strings.Contains(msg, "발견"):
return styleGold.Render(msg) return styleGold.Render(msg)
case strings.Contains(msg, "defeated") || strings.Contains(msg, "cleared") || strings.Contains(msg, "Descending"): case strings.Contains(msg, "처치") || strings.Contains(msg, "클리어") || strings.Contains(msg, "내려갑니다") || strings.Contains(msg, "정복"):
return styleSystem.Render(msg) return styleSystem.Render(msg)
default: default:
return msg return msg
@@ -567,16 +685,20 @@ func renderHPBar(current, max, width int) string {
emptyStyle.Render(strings.Repeat("░", empty)) emptyStyle.Render(strings.Repeat("░", empty))
} }
func renderPartyPanel(players []*entity.Player, submittedActions map[string]string) string { func renderPartyPanel(players []*entity.Player, submittedActions map[string]string, showAllyCursor bool, allyCursor int) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(styleHeader.Render(" PARTY") + "\n\n") sb.WriteString(styleHeader.Render(" 아군") + "\n\n")
for _, p := range players { for i, p := range players {
nameStr := stylePlayer.Render(fmt.Sprintf(" ♦ %s", p.Name)) marker := " ♦"
if showAllyCursor && i == allyCursor {
marker = " >♦"
}
nameStr := stylePlayer.Render(fmt.Sprintf("%s %s", marker, p.Name))
classStr := styleSystem.Render(fmt.Sprintf(" (%s)", p.Class)) classStr := styleSystem.Render(fmt.Sprintf(" (%s)", p.Class))
status := "" status := ""
if p.IsDead() { if p.IsDead() {
status = styleDamage.Render(" [DEAD]") status = styleDamage.Render(" [사망]")
} }
sb.WriteString(nameStr + classStr + status + "\n") sb.WriteString(nameStr + classStr + status + "\n")
@@ -606,7 +728,7 @@ func renderPartyPanel(players []*entity.Player, submittedActions map[string]stri
sb.WriteString(styleHeal.Render(fmt.Sprintf(" ✓ %s", action))) sb.WriteString(styleHeal.Render(fmt.Sprintf(" ✓ %s", action)))
sb.WriteString("\n") sb.WriteString("\n")
} else if !p.IsOut() { } else if !p.IsOut() {
sb.WriteString(styleSystem.Render(" ... Waiting")) sb.WriteString(styleSystem.Render(" ... 대기중"))
sb.WriteString("\n") sb.WriteString("\n")
} }
sb.WriteString("\n") sb.WriteString("\n")
@@ -616,7 +738,7 @@ func renderPartyPanel(players []*entity.Player, submittedActions map[string]stri
func renderEnemyPanel(monsters []*entity.Monster, targetCursor int) string { func renderEnemyPanel(monsters []*entity.Monster, targetCursor int) string {
var sb strings.Builder var sb strings.Builder
sb.WriteString(styleHeader.Render(" ENEMIES") + "\n\n") sb.WriteString(styleHeader.Render(" ") + "\n\n")
for i, m := range monsters { for i, m := range monsters {
if m.IsDead() { if m.IsDead() {
@@ -636,7 +758,7 @@ func renderEnemyPanel(monsters []*entity.Monster, targetCursor int) string {
hpBar := renderHPBar(m.HP, m.MaxHP, 12) hpBar := renderHPBar(m.HP, m.MaxHP, 12)
taunt := "" taunt := ""
if m.TauntTarget { if m.TauntTarget {
taunt = styleStatus.Render(fmt.Sprintf(" [TAUNTED %dt]", m.TauntTurns)) taunt = styleStatus.Render(fmt.Sprintf(" [도발됨 %d]", m.TauntTurns))
} }
sb.WriteString(fmt.Sprintf(" %s[%d] %s %s %d/%d%s\n\n", 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)) marker, i, styleEnemy.Render(m.Name), hpBar, m.HP, m.MaxHP, taunt))

View File

@@ -26,27 +26,38 @@ func (s *HelpScreen) View(ctx *Context) string {
} }
func renderHelp(width, height int) string { func renderHelp(width, height int) string {
title := styleHeader.Render("── Controls ──") title := styleHeader.Render("── 조작법 ──")
sections := []struct{ header, body string }{ sections := []struct{ header, body string }{
{"Exploration", ` [Up/Down] Select room {"로비", ` [C] 방 만들기 [J] 코드로 참가
[Enter] Move to room [Enter] 선택한 방 참가
[/] Chat [D] 일일 도전 [H] 하드 모드 전환
[Q] Quit`}, [Q] 타이틀로 돌아가기`},
{"Combat", ` [1] Attack [2] Skill {"탐험", ` [Up/Down] 방 선택
[3] Use Item [4] Flee [Enter] 방으로 이동
[5] Defend [Tab] Switch Target [[] / []] 스킬 포인트 배분 (분기 1/2)
[/] Chat`}, [/] 채팅
{"Shop", ` [1-3] Buy item [Q] 종료`},
[Q] Leave shop`}, {"전투 (턴당 10초)", ` [1] 공격 [2] 스킬
{"Classes", ` Warrior 120HP 12ATK 8DEF Taunt (draw fire 2t) [3] 아이템 사용 [4] 도주
Mage 70HP 20ATK 3DEF Fireball (AoE 0.8x) [5] 방어 [Tab] 대상 변경
Healer 90HP 8ATK 5DEF Heal (restore 30HP) [/] 채팅`},
Rogue 85HP 15ATK 4DEF Scout (reveal rooms)`}, {"상점", ` [1-3] 아이템 구매
{"Tips", ` • Skills have 3 uses per combat [Q] 상점 나가기`},
• Co-op bonus: 10% extra when 2+ attack same target {"직업", ` Warrior 120HP 12ATK 8DEF Taunt (2턴간 적 공격 유도)
• Items are limited to 10 per player Mage 70HP 20ATK 3DEF Fireball (광역 0.8배)
• Dead players revive next floor at 30% HP`}, Healer 90HP 8ATK 5DEF Heal (HP 30 회복)
Rogue 85HP 15ATK 4DEF Scout (주변 방 공개)`},
{"멀티플레이", ` • 방당 최대 4명
• 협동 보너스: 2명 이상이 같은 적 공격 시 피해 +10%
• 직업 콤보로 추가 효과 발동
• 모든 플레이어 준비 완료 시 게임 시작`},
{"팁", ` • 스킬은 전투당 3회 사용 가능
• 아이템은 플레이어당 10개 제한
• 사망한 플레이어는 다음 층에서 HP 30%로 부활
• 보스는 5, 10, 15, 20층에 등장
• 스킬 포인트: 층 클리어당 1포인트 (최대 3)
• 주간 변이가 게임플레이를 변경`},
} }
var content string var content string
@@ -58,7 +69,7 @@ func renderHelp(width, height int) string {
content += bodyStyle.Render(s.body) + "\n\n" content += bodyStyle.Render(s.body) + "\n\n"
} }
footer := styleSystem.Render("[H] Back") footer := styleSystem.Render("[H] 뒤로")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", content, footer)) lipgloss.JoinVertical(lipgloss.Center, title, "", content, footer))

View File

@@ -2,6 +2,7 @@ package ui
import ( import (
"fmt" "fmt"
"strings"
"time" "time"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
@@ -43,10 +44,10 @@ func (s *LeaderboardScreen) View(ctx *Context) string {
} }
func renderLeaderboard(byFloor, byGold []store.RunRecord, daily []store.DailyRecord, tab, width, height int) string { func renderLeaderboard(byFloor, byGold []store.RunRecord, daily []store.DailyRecord, tab, width, height int) string {
title := styleHeader.Render("── Leaderboard ──") title := styleHeader.Render("── 리더보드 ──")
// Tab header // Tab header
tabs := []string{"Floor", "Gold", "Daily"} tabs := []string{"층수", "골드", "일일"}
var tabLine string var tabLine string
for i, t := range tabs { for i, t := range tabs {
if i == tab { if i == tab {
@@ -60,7 +61,7 @@ func renderLeaderboard(byFloor, byGold []store.RunRecord, daily []store.DailyRec
switch tab { switch tab {
case 0: // By Floor case 0: // By Floor
content += styleCoop.Render(" Top by Floor") + "\n" content += styleCoop.Render(" 층수 순위") + "\n"
for i, r := range byFloor { for i, r := range byFloor {
if i >= 10 { if i >= 10 {
break break
@@ -70,12 +71,16 @@ func renderLeaderboard(byFloor, byGold []store.RunRecord, daily []store.DailyRec
if r.Class != "" { if r.Class != "" {
cls = fmt.Sprintf(" [%s]", r.Class) cls = fmt.Sprintf(" [%s]", r.Class)
} }
content += fmt.Sprintf(" %s %s%s B%d %s\n", party := ""
if len(r.Members) > 1 {
party = styleSystem.Render(fmt.Sprintf(" (%s)", strings.Join(r.Members, ", ")))
}
content += fmt.Sprintf(" %s %s%s B%d %s%s\n",
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls), medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score))) r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)), party)
} }
case 1: // By Gold case 1: // By Gold
content += styleCoop.Render(" Top by Gold") + "\n" content += styleCoop.Render(" 골드 순위") + "\n"
for i, r := range byGold { for i, r := range byGold {
if i >= 10 { if i >= 10 {
break break
@@ -85,14 +90,18 @@ func renderLeaderboard(byFloor, byGold []store.RunRecord, daily []store.DailyRec
if r.Class != "" { if r.Class != "" {
cls = fmt.Sprintf(" [%s]", r.Class) cls = fmt.Sprintf(" [%s]", r.Class)
} }
content += fmt.Sprintf(" %s %s%s B%d %s\n", party := ""
if len(r.Members) > 1 {
party = styleSystem.Render(fmt.Sprintf(" (%s)", strings.Join(r.Members, ", ")))
}
content += fmt.Sprintf(" %s %s%s B%d %s%s\n",
medal, stylePlayer.Render(r.Player), styleSystem.Render(cls), medal, stylePlayer.Render(r.Player), styleSystem.Render(cls),
r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score))) r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score)), party)
} }
case 2: // Daily case 2: // Daily
content += styleCoop.Render(fmt.Sprintf(" Daily Challenge — %s", time.Now().Format("2006-01-02"))) + "\n" content += styleCoop.Render(fmt.Sprintf(" 일일 도전 — %s", time.Now().Format("2006-01-02"))) + "\n"
if len(daily) == 0 { if len(daily) == 0 {
content += " No daily runs yet today.\n" content += " 오늘 일일 도전 기록이 없습니다.\n"
} }
for i, r := range daily { for i, r := range daily {
if i >= 20 { if i >= 20 {
@@ -105,7 +114,7 @@ func renderLeaderboard(byFloor, byGold []store.RunRecord, daily []store.DailyRec
} }
} }
footer := styleSystem.Render("\n[Tab] Switch Tab [L] Back") footer := styleSystem.Render("\n[Tab] 탭 전환 [L] 뒤로")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, tabLine, "", content, footer)) lipgloss.JoinVertical(lipgloss.Center, title, tabLine, "", content, footer))

View File

@@ -41,6 +41,12 @@ func NewLobbyScreen() *LobbyScreen {
return &LobbyScreen{} return &LobbyScreen{}
} }
func (s *LobbyScreen) pollLobby() tea.Cmd {
return tea.Tick(time.Second*2, func(t time.Time) tea.Msg {
return tickMsg{}
})
}
func (s *LobbyScreen) refreshLobby(ctx *Context) { func (s *LobbyScreen) refreshLobby(ctx *Context) {
if ctx.Lobby == nil { if ctx.Lobby == nil {
return return
@@ -48,9 +54,9 @@ func (s *LobbyScreen) refreshLobby(ctx *Context) {
rooms := ctx.Lobby.ListRooms() rooms := ctx.Lobby.ListRooms()
s.rooms = make([]roomInfo, len(rooms)) s.rooms = make([]roomInfo, len(rooms))
for i, r := range rooms { for i, r := range rooms {
status := "Waiting" status := "대기중"
if r.Status == game.RoomPlaying { if r.Status == game.RoomPlaying {
status = "Playing" status = "진행중"
} }
players := make([]playerInfo, len(r.Players)) players := make([]playerInfo, len(r.Players))
for j, p := range r.Players { for j, p := range r.Players {
@@ -71,6 +77,11 @@ func (s *LobbyScreen) refreshLobby(ctx *Context) {
} }
func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
switch msg.(type) {
case tickMsg:
s.refreshLobby(ctx)
return s, s.pollLobby()
}
if key, ok := msg.(tea.KeyMsg); ok { if key, ok := msg.(tea.KeyMsg); ok {
// Join-by-code input mode // Join-by-code input mode
if s.joining { if s.joining {
@@ -97,7 +108,7 @@ func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
// Normal lobby key handling // Normal lobby key handling
if isKey(key, "c") { if isKey(key, "c") {
if ctx.Lobby != nil { if ctx.Lobby != nil {
code := ctx.Lobby.CreateRoom(ctx.PlayerName + "'s Room") code := ctx.Lobby.CreateRoom(ctx.PlayerName + "의 방")
ctx.Lobby.JoinRoom(code, ctx.PlayerName, ctx.Fingerprint) ctx.Lobby.JoinRoom(code, ctx.PlayerName, ctx.Fingerprint)
ctx.RoomCode = code ctx.RoomCode = code
return NewClassSelectScreen(), nil return NewClassSelectScreen(), nil
@@ -124,7 +135,7 @@ func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
} else if isKey(key, "d") { } else if isKey(key, "d") {
// Daily Challenge: create a private solo daily session // Daily Challenge: create a private solo daily session
if ctx.Lobby != nil { if ctx.Lobby != nil {
code := ctx.Lobby.CreateRoom(ctx.PlayerName + "'s Daily") code := ctx.Lobby.CreateRoom(ctx.PlayerName + "의 일일 도전")
if err := ctx.Lobby.JoinRoom(code, ctx.PlayerName, ctx.Fingerprint); err == nil { if err := ctx.Lobby.JoinRoom(code, ctx.PlayerName, ctx.Fingerprint); err == nil {
ctx.RoomCode = code ctx.RoomCode = code
room := ctx.Lobby.GetRoom(code) room := ctx.Lobby.GetRoom(code)
@@ -132,6 +143,7 @@ func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
room.Session = game.NewGameSession(ctx.Lobby.Cfg()) room.Session = game.NewGameSession(ctx.Lobby.Cfg())
room.Session.DailyMode = true room.Session.DailyMode = true
room.Session.DailyDate = time.Now().Format("2006-01-02") room.Session.DailyDate = time.Now().Format("2006-01-02")
room.Session.ApplyWeeklyMutation()
ctx.Session = room.Session ctx.Session = room.Session
} }
return NewClassSelectScreen(), nil return NewClassSelectScreen(), nil
@@ -139,6 +151,7 @@ func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
} }
} else if isKey(key, "h") && s.hardUnlocked { } else if isKey(key, "h") && s.hardUnlocked {
s.hardMode = !s.hardMode s.hardMode = !s.hardMode
ctx.HardMode = s.hardMode
} else if isKey(key, "q") { } else if isKey(key, "q") {
if ctx.Lobby != nil { if ctx.Lobby != nil {
ctx.Lobby.PlayerOffline(ctx.Fingerprint) ctx.Lobby.PlayerOffline(ctx.Fingerprint)
@@ -187,14 +200,14 @@ func renderLobby(state lobbyState, width, height int) string {
Border(lipgloss.RoundedBorder()). Border(lipgloss.RoundedBorder()).
Padding(0, 1) Padding(0, 1)
header := headerStyle.Render(fmt.Sprintf("── Lobby ── %d online ──", state.online)) header := headerStyle.Render(fmt.Sprintf("── 로비 ── %d명 접속중 ──", state.online))
menu := "[C] Create Room [J] Join by Code [D] Daily Challenge [Up/Down] Select [Enter] Join [Q] Back" menu := "[C] 방 만들기 [J] 코드로 참가 [D] 일일 도전 [Up/Down] 선택 [Enter] 참가 [Q] 뒤로"
if state.hardUnlocked { if state.hardUnlocked {
hardStatus := "OFF" hardStatus := "OFF"
if state.hardMode { if state.hardMode {
hardStatus = "ON" hardStatus = "ON"
} }
menu += fmt.Sprintf(" [H] Hard Mode: %s", hardStatus) menu += fmt.Sprintf(" [H] 하드 모드: %s", hardStatus)
} }
roomList := "" roomList := ""
@@ -221,11 +234,11 @@ func renderLobby(state lobbyState, width, height int) string {
} }
} }
if roomList == "" { if roomList == "" {
roomList = " No rooms available. Create one!" roomList = " 방이 없습니다. 새로 만드세요!"
} }
if state.joining { if state.joining {
inputStr := state.codeInput + strings.Repeat("_", 4-len(state.codeInput)) inputStr := state.codeInput + strings.Repeat("_", 4-len(state.codeInput))
roomList += fmt.Sprintf("\n Enter room code: [%s] (Esc to cancel)\n", inputStr) roomList += fmt.Sprintf("\n 방 코드 입력: [%s] (Esc로 취소)\n", inputStr)
} }
return lipgloss.JoinVertical(lipgloss.Left, return lipgloss.JoinVertical(lipgloss.Left,

View File

@@ -94,6 +94,10 @@ func isQuit(key tea.KeyMsg) bool {
return isKey(key, "q", "ctrl+c") || key.Type == tea.KeyCtrlC return isKey(key, "q", "ctrl+c") || key.Type == tea.KeyCtrlC
} }
func isForceQuit(key tea.KeyMsg) bool {
return isKey(key, "ctrl+c") || key.Type == tea.KeyCtrlC
}
func isUp(key tea.KeyMsg) bool { func isUp(key tea.KeyMsg) bool {
return isKey(key, "up") || key.Type == tea.KeyUp return isKey(key, "up") || key.Type == tea.KeyUp
} }
@@ -110,6 +114,7 @@ const (
screenTitle screen = iota screenTitle screen = iota
screenLobby screenLobby
screenClassSelect screenClassSelect
screenWaiting
screenGame screenGame
screenShop screenShop
screenResult screenResult
@@ -129,6 +134,8 @@ func (m Model) screenType() screen {
return screenLobby return screenLobby
case *ClassSelectScreen: case *ClassSelectScreen:
return screenClassSelect return screenClassSelect
case *WaitingScreen:
return screenWaiting
case *GameScreen: case *GameScreen:
return screenGame return screenGame
case *ShopScreen: case *ShopScreen:

View File

@@ -111,14 +111,22 @@ func TestClassSelectToGame(t *testing.T) {
t.Fatalf("should be at class select, got %d", m3.screenType()) t.Fatalf("should be at class select, got %d", m3.screenType())
} }
// Press Enter to select Warrior (default cursor=0) // Press Enter to select Warrior (default cursor=0) → WaitingScreen
result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyEnter}) result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyEnter})
m4 := result.(Model) m4 := result.(Model)
if m4.screenType() != screenGame { if m4.screenType() != screenWaiting {
t.Errorf("after class select Enter: screen=%d, want screenGame(3)", m4.screenType()) t.Fatalf("after class select Enter: screen=%d, want screenWaiting(%d)", m4.screenType(), screenWaiting)
} }
if m4.session() == nil {
// Press Enter to ready up (solo room → immediately starts game)
result, _ = m4.Update(tea.KeyMsg{Type: tea.KeyEnter})
m5 := result.(Model)
if m5.screenType() != screenGame {
t.Errorf("after ready Enter: screen=%d, want screenGame(%d)", m5.screenType(), screenGame)
}
if m5.session() == nil {
t.Error("session should be set") t.Error("session should be set")
} }
} }

View File

@@ -1,6 +1,7 @@
package ui package ui
import ( import (
"crypto/sha256"
"fmt" "fmt"
"log/slog" "log/slog"
"strings" "strings"
@@ -9,64 +10,232 @@ import (
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
) )
// NicknameScreen handles first-time player name input. // nicknamePhase tracks the current step of the nickname/login screen.
type nicknamePhase int
const (
phaseNickname nicknamePhase = iota // entering nickname
phasePasswordLogin // existing account — enter password
phasePasswordCreate // new account — enter password
phasePasswordConfirm // new account — confirm password
)
// NicknameScreen handles player name input and optional web login.
type NicknameScreen struct { type NicknameScreen struct {
input string input string
password string
confirm string
phase nicknamePhase
error string
} }
func NewNicknameScreen() *NicknameScreen { func NewNicknameScreen() *NicknameScreen {
return &NicknameScreen{} return &NicknameScreen{}
} }
// isWebUser returns true when the player connected via the web bridge
// (no real SSH fingerprint).
func isWebUser(ctx *Context) bool {
return ctx.Fingerprint == "" || strings.HasPrefix(ctx.Fingerprint, "anon-")
}
func (s *NicknameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { func (s *NicknameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
if key, ok := msg.(tea.KeyMsg); ok { key, ok := msg.(tea.KeyMsg)
if isEnter(key) && len(s.input) > 0 { if !ok {
ctx.PlayerName = s.input return s, nil
if ctx.Store != nil && ctx.Fingerprint != "" { }
if err := ctx.Store.SaveProfile(ctx.Fingerprint, ctx.PlayerName); err != nil {
slog.Error("failed to save profile", "error", err) // Esc always goes back one step or cancels.
} if isKey(key, "esc") || key.Type == tea.KeyEsc {
} switch s.phase {
if ctx.Lobby != nil { case phaseNickname:
ctx.Lobby.PlayerOnline(ctx.Fingerprint, ctx.PlayerName)
}
// Check for active session to reconnect
if ctx.Lobby != nil {
code, session := ctx.Lobby.GetActiveSession(ctx.Fingerprint)
if session != nil {
ctx.RoomCode = code
ctx.Session = session
gs := NewGameScreen()
gs.gameState = ctx.Session.GetState()
ctx.Session.TouchActivity(ctx.Fingerprint)
ctx.Session.SendChat("System", ctx.PlayerName+" reconnected!")
return gs, gs.pollState()
}
}
ls := NewLobbyScreen()
ls.refreshLobby(ctx)
return ls, nil
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
s.input = "" s.input = ""
return NewTitleScreen(), nil return NewTitleScreen(), nil
} else if key.Type == tea.KeyBackspace && len(s.input) > 0 { case phasePasswordLogin, phasePasswordCreate:
s.input = s.input[:len(s.input)-1] s.phase = phaseNickname
} else if len(key.Runes) == 1 && len(s.input) < 12 { s.password = ""
ch := string(key.Runes) s.error = ""
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 { return s, nil
s.input += ch case phasePasswordConfirm:
} s.phase = phasePasswordCreate
s.confirm = ""
s.error = ""
return s, nil
}
return s, nil
}
switch s.phase {
case phaseNickname:
return s.updateNickname(key, ctx)
case phasePasswordLogin:
return s.updatePasswordLogin(key, ctx)
case phasePasswordCreate:
return s.updatePasswordCreate(key, ctx)
case phasePasswordConfirm:
return s.updatePasswordConfirm(key, ctx)
}
return s, nil
}
func (s *NicknameScreen) updateNickname(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) {
if isEnter(key) && len(s.input) > 0 {
// SSH users with a real fingerprint skip password entirely.
if !isWebUser(ctx) {
return s.finishLogin(ctx)
}
// Web user — need password flow.
if ctx.Store != nil && ctx.Store.HasPassword(s.input) {
s.phase = phasePasswordLogin
s.error = ""
} else {
s.phase = phasePasswordCreate
s.error = ""
}
return s, nil
}
if key.Type == tea.KeyBackspace && len(s.input) > 0 {
s.input = s.input[:len(s.input)-1]
} else if len(key.Runes) == 1 && len(s.input) < 12 {
ch := string(key.Runes)
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 {
s.input += ch
} }
} }
return s, nil return s, nil
} }
func (s *NicknameScreen) View(ctx *Context) string { func (s *NicknameScreen) updatePasswordLogin(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) {
return renderNickname(s.input, ctx.Width, ctx.Height) if isEnter(key) {
if ctx.Store == nil {
return s.finishLogin(ctx)
}
ok, err := ctx.Store.CheckPassword(s.input, s.password)
if err != nil {
s.error = "오류가 발생했습니다"
slog.Error("password check failed", "error", err)
return s, nil
}
if !ok {
s.error = "비밀번호가 틀렸습니다"
s.password = ""
return s, nil
}
// Set deterministic fingerprint for web user.
ctx.Fingerprint = webFingerprint(s.input)
return s.finishLogin(ctx)
}
s.password = handlePasswordInput(key, s.password)
return s, nil
} }
func renderNickname(input string, width, height int) string { func (s *NicknameScreen) updatePasswordCreate(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) {
title := styleHeader.Render("── Enter Your Name ──") if isEnter(key) {
if len(s.password) < 4 {
s.error = "비밀번호는 4자 이상이어야 합니다"
return s, nil
}
s.phase = phasePasswordConfirm
s.error = ""
return s, nil
}
s.password = handlePasswordInput(key, s.password)
return s, nil
}
func (s *NicknameScreen) updatePasswordConfirm(key tea.KeyMsg, ctx *Context) (Screen, tea.Cmd) {
if isEnter(key) {
if s.confirm != s.password {
s.error = "비밀번호가 일치하지 않습니다"
s.confirm = ""
return s, nil
}
if ctx.Store != nil {
if err := ctx.Store.SavePassword(s.input, s.password); err != nil {
s.error = "저장 오류가 발생했습니다"
slog.Error("failed to save password", "error", err)
return s, nil
}
}
ctx.Fingerprint = webFingerprint(s.input)
return s.finishLogin(ctx)
}
s.confirm = handlePasswordInput(key, s.confirm)
return s, nil
}
// finishLogin sets the player name, saves the profile, and transitions to lobby.
func (s *NicknameScreen) finishLogin(ctx *Context) (Screen, tea.Cmd) {
ctx.PlayerName = s.input
if ctx.Store != nil && ctx.Fingerprint != "" {
if err := ctx.Store.SaveProfile(ctx.Fingerprint, ctx.PlayerName); err != nil {
slog.Error("failed to save profile", "error", err)
}
}
if ctx.Lobby != nil {
ctx.Lobby.PlayerOnline(ctx.Fingerprint, ctx.PlayerName)
}
// Check for active session to reconnect.
if ctx.Lobby != nil {
code, session := ctx.Lobby.GetActiveSession(ctx.Fingerprint)
if session != nil {
ctx.RoomCode = code
ctx.Session = session
gs := NewGameScreen()
gs.gameState = ctx.Session.GetState()
ctx.Session.TouchActivity(ctx.Fingerprint)
ctx.Session.SendChat("System", ctx.PlayerName+" 재접속!")
return gs, gs.pollState()
}
}
ls := NewLobbyScreen()
ls.refreshLobby(ctx)
return ls, ls.pollLobby()
}
// webFingerprint produces a deterministic fingerprint for a web user.
func webFingerprint(nickname string) string {
h := sha256.Sum256([]byte("web:" + nickname))
return fmt.Sprintf("SHA256:%x", h)
}
func handlePasswordInput(key tea.KeyMsg, current string) string {
if key.Type == tea.KeyBackspace && len(current) > 0 {
return current[:len(current)-1]
}
if len(key.Runes) == 1 && len(current) < 32 {
ch := string(key.Runes)
if len(ch) == 1 && ch[0] >= 32 && ch[0] < 127 {
return current + ch
}
}
return current
}
func (s *NicknameScreen) View(ctx *Context) string {
return renderNicknameLogin(s, ctx.Width, ctx.Height)
}
func renderNicknameLogin(s *NicknameScreen, width, height int) string {
var sections []string
switch s.phase {
case phaseNickname:
sections = renderNicknamePhase(s.input)
case phasePasswordLogin:
sections = renderPasswordPhase(s.input, s.password, "비밀번호를 입력하세요", s.error)
case phasePasswordCreate:
sections = renderPasswordPhase(s.input, s.password, "비밀번호를 설정하세요 (4자 이상)", s.error)
case phasePasswordConfirm:
sections = renderPasswordPhase(s.input, s.confirm, "비밀번호를 다시 입력하세요", s.error)
}
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, sections...))
}
func renderNicknamePhase(input string) []string {
title := styleHeader.Render("── 이름을 입력하세요 ──")
display := input display := input
if display == "" { if display == "" {
@@ -81,9 +250,39 @@ func renderNickname(input string, width, height int) string {
Padding(0, 2). Padding(0, 2).
Render(stylePlayer.Render(display)) Render(stylePlayer.Render(display))
hint := styleSystem.Render(fmt.Sprintf("(%d/12 characters)", len(input))) hint := styleSystem.Render(fmt.Sprintf("(%d/12 글자)", len(input)))
footer := styleAction.Render("[Enter] Confirm [Esc] Cancel") footer := styleAction.Render("[Enter] 확인 [Esc] 취소")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, return []string{title, "", inputBox, hint, "", footer}
lipgloss.JoinVertical(lipgloss.Center, title, "", inputBox, hint, "", footer)) }
func renderPasswordPhase(nickname, password, prompt, errMsg string) []string {
title := styleHeader.Render("── " + prompt + " ──")
nameDisplay := stylePlayer.Render("이름: " + nickname)
masked := strings.Repeat("*", len(password))
if masked == "" {
masked = strings.Repeat("_", 8)
} else {
masked += "_"
}
inputBox := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(colorCyan).
Padding(0, 2).
Render(stylePlayer.Render(masked))
sections := []string{title, "", nameDisplay, "", inputBox}
if errMsg != "" {
sections = append(sections, "",
lipgloss.NewStyle().Foreground(colorRed).Bold(true).Render(errMsg))
}
footer := styleAction.Render("[Enter] 확인 [Esc] 뒤로")
sections = append(sections, "", footer)
return sections
} }

View File

@@ -35,8 +35,8 @@ func (s *ResultScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
ctx.RoomCode = "" ctx.RoomCode = ""
ls := NewLobbyScreen() ls := NewLobbyScreen()
ls.refreshLobby(ctx) ls.refreshLobby(ctx)
return ls, nil return ls, ls.pollLobby()
} else if isQuit(key) { } else if isForceQuit(key) {
return s, tea.Quit return s, tea.Quit
} }
} }
@@ -56,30 +56,30 @@ func renderResult(state game.GameState, rankings []store.RunRecord) string {
// Title // Title
if state.Victory { if state.Victory {
sb.WriteString(styleHeal.Render(" ✦ VICTORY ✦ ") + "\n\n") sb.WriteString(styleHeal.Render(" ✦ 승리 ✦ ") + "\n\n")
sb.WriteString(styleSystem.Render(" You conquered the Catacombs!") + "\n\n") sb.WriteString(styleSystem.Render(" 카타콤을 정복했습니다!") + "\n\n")
} else { } else {
sb.WriteString(styleDamage.Render(" ✦ DEFEAT ✦ ") + "\n\n") sb.WriteString(styleDamage.Render(" ✦ 패배 ✦ ") + "\n\n")
sb.WriteString(styleSystem.Render(fmt.Sprintf(" Fallen on floor B%d", state.FloorNum)) + "\n\n") sb.WriteString(styleSystem.Render(fmt.Sprintf(" B%d층에서 쓰러졌습니다", state.FloorNum)) + "\n\n")
} }
// Player summary // Player summary
sb.WriteString(styleHeader.Render("── Party Summary ──") + "\n\n") sb.WriteString(styleHeader.Render("── 파티 요약 ──") + "\n\n")
totalGold := 0 totalGold := 0
for _, p := range state.Players { for _, p := range state.Players {
status := styleHeal.Render("Alive") status := styleHeal.Render("생존")
if p.IsDead() { if p.IsDead() {
status = styleDamage.Render("Dead") status = styleDamage.Render("사망")
} }
sb.WriteString(fmt.Sprintf(" %s (%s) %s Gold: %d Items: %d Relics: %d\n", sb.WriteString(fmt.Sprintf(" %s (%s) %s 골드: %d 아이템: %d 유물: %d\n",
stylePlayer.Render(p.Name), p.Class, status, p.Gold, len(p.Inventory), len(p.Relics))) stylePlayer.Render(p.Name), p.Class, status, p.Gold, len(p.Inventory), len(p.Relics)))
totalGold += p.Gold totalGold += p.Gold
} }
sb.WriteString(fmt.Sprintf("\n Total Gold: %s\n", styleGold.Render(fmt.Sprintf("%d", totalGold)))) sb.WriteString(fmt.Sprintf("\n 총 골드: %s\n", styleGold.Render(fmt.Sprintf("%d", totalGold))))
// Rankings // Rankings
if len(rankings) > 0 { if len(rankings) > 0 {
sb.WriteString("\n" + styleHeader.Render("── Top Runs ──") + "\n\n") sb.WriteString("\n" + styleHeader.Render("── 최고 기록 ──") + "\n\n")
for i, r := range rankings { for i, r := range rankings {
medal := " " medal := " "
switch i { switch i {
@@ -90,11 +90,15 @@ func renderResult(state game.GameState, rankings []store.RunRecord) string {
case 2: case 2:
medal = styleGold.Render("🥉") medal = styleGold.Render("🥉")
} }
sb.WriteString(fmt.Sprintf(" %s %s Floor B%d Score: %d\n", medal, r.Player, r.Floor, r.Score)) party := ""
if len(r.Members) > 1 {
party = fmt.Sprintf(" (%s)", strings.Join(r.Members, ", "))
}
sb.WriteString(fmt.Sprintf(" %s %s B%d층 점수: %d%s\n", medal, r.Player, r.Floor, r.Score, party))
} }
} }
sb.WriteString("\n" + styleAction.Render(" [Enter] Return to Lobby") + "\n") sb.WriteString("\n" + styleAction.Render(" [Enter] 로비로 돌아가기") + "\n")
return sb.String() return sb.String()
} }

View File

@@ -25,10 +25,15 @@ func (s *ShopScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
case "1", "2", "3": case "1", "2", "3":
if ctx.Session != nil { if ctx.Session != nil {
idx := int(key.String()[0] - '1') idx := int(key.String()[0] - '1')
if ctx.Session.BuyItem(ctx.Fingerprint, idx) { switch ctx.Session.BuyItem(ctx.Fingerprint, idx) {
s.shopMsg = "Purchased!" case game.BuyOK:
} else { s.shopMsg = "구매 완료!"
s.shopMsg = "Not enough gold!" case game.BuyNoGold:
s.shopMsg = "골드가 부족합니다!"
case game.BuyInventoryFull:
s.shopMsg = "인벤토리가 가득 찼습니다!"
default:
s.shopMsg = "구매할 수 없습니다!"
} }
s.gameState = ctx.Session.GetState() s.gameState = ctx.Session.GetState()
} }
@@ -71,23 +76,23 @@ func renderShop(state game.GameState, width, height int, shopMsg string) string
Foreground(lipgloss.Color("196")). Foreground(lipgloss.Color("196")).
Bold(true) Bold(true)
header := headerStyle.Render("── Shop ──") header := headerStyle.Render("── 상점 ──")
// Show current player's gold // Show current player's gold
goldLine := "" goldLine := ""
for _, p := range state.Players { for _, p := range state.Players {
inventoryCount := len(p.Inventory) inventoryCount := len(p.Inventory)
goldLine += goldStyle.Render(fmt.Sprintf(" %s — Gold: %d Items: %d/10", p.Name, p.Gold, inventoryCount)) goldLine += goldStyle.Render(fmt.Sprintf(" %s — 골드: %d 아이템: %d/10", p.Name, p.Gold, inventoryCount))
goldLine += "\n" goldLine += "\n"
} }
items := "" items := ""
for i, item := range state.ShopItems { for i, item := range state.ShopItems {
label := itemTypeLabel(item) label := itemTypeLabel(item)
items += fmt.Sprintf(" [%d] %s %s — %d gold\n", i+1, item.Name, label, item.Price) items += fmt.Sprintf(" [%d] %s %s — %d 골드\n", i+1, item.Name, label, item.Price)
} }
menu := "[1-3] Buy [Q] Leave Shop" menu := "[1-3] 구매 [Q] 상점 나가기"
parts := []string{header, "", goldLine, items, "", menu} parts := []string{header, "", goldLine, items, "", menu}
if shopMsg != "" { if shopMsg != "" {

View File

@@ -33,22 +33,22 @@ func (s *StatsScreen) View(ctx *Context) string {
} }
func renderStats(playerName string, stats store.PlayerStats, width, height int) string { func renderStats(playerName string, stats store.PlayerStats, width, height int) string {
title := styleHeader.Render("── Player Statistics ──") title := styleHeader.Render("── 플레이어 통계 ──")
var content string var content string
content += stylePlayer.Render(fmt.Sprintf(" %s", playerName)) + "\n\n" 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(" 총 플레이: %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(" 최고 층: %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(" 총 골드: %s\n", styleGold.Render(fmt.Sprintf("%d", stats.TotalGold)))
content += fmt.Sprintf(" Victories: %s\n", styleHeal.Render(fmt.Sprintf("%d", stats.Victories))) content += fmt.Sprintf(" 승리 횟수: %s\n", styleHeal.Render(fmt.Sprintf("%d", stats.Victories)))
winRate := 0.0 winRate := 0.0
if stats.TotalRuns > 0 { if stats.TotalRuns > 0 {
winRate = float64(stats.Victories) / float64(stats.TotalRuns) * 100 winRate = float64(stats.Victories) / float64(stats.TotalRuns) * 100
} }
content += fmt.Sprintf(" Win Rate: %s\n", styleSystem.Render(fmt.Sprintf("%.1f%%", winRate))) content += fmt.Sprintf(" 승률: %s\n", styleSystem.Render(fmt.Sprintf("%.1f%%", winRate)))
footer := styleSystem.Render("[S] Back") footer := styleSystem.Render("[S] 뒤로")
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center,
lipgloss.JoinVertical(lipgloss.Center, title, "", content, "", footer)) lipgloss.JoinVertical(lipgloss.Center, title, "", content, "", footer))

View File

@@ -44,13 +44,13 @@ func (s *TitleScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
gs := NewGameScreen() gs := NewGameScreen()
gs.gameState = ctx.Session.GetState() gs.gameState = ctx.Session.GetState()
ctx.Session.TouchActivity(ctx.Fingerprint) ctx.Session.TouchActivity(ctx.Fingerprint)
ctx.Session.SendChat("System", ctx.PlayerName+" reconnected!") ctx.Session.SendChat("System", ctx.PlayerName+" 재접속!")
return gs, gs.pollState() return gs, gs.pollState()
} }
} }
ls := NewLobbyScreen() ls := NewLobbyScreen()
ls.refreshLobby(ctx) ls.refreshLobby(ctx)
return ls, nil return ls, ls.pollLobby()
} else if isKey(key, "h") { } else if isKey(key, "h") {
return NewHelpScreen(), nil return NewHelpScreen(), nil
} else if isKey(key, "s") { } else if isKey(key, "s") {
@@ -101,7 +101,7 @@ func renderTitle(width, height int) string {
subtitle := lipgloss.NewStyle(). subtitle := lipgloss.NewStyle().
Foreground(colorGray). Foreground(colorGray).
Render("⚔ A Cooperative Dungeon Crawler ⚔") Render("⚔ 협동 던전 크롤러 ⚔")
server := lipgloss.NewStyle(). server := lipgloss.NewStyle().
Foreground(colorCyan). Foreground(colorCyan).
@@ -110,7 +110,7 @@ func renderTitle(width, height int) string {
menu := lipgloss.NewStyle(). menu := lipgloss.NewStyle().
Foreground(colorWhite). Foreground(colorWhite).
Bold(true). Bold(true).
Render("[Enter] Start [H] Help [S] Stats [A] Achievements [L] Leaderboard [C] Codex [Q] Quit") Render("[Enter] 시작 [H] 도움말 [S] 통계 [A] 업적 [L] 리더보드 [C] 도감 [Q] 종료")
content := lipgloss.JoinVertical(lipgloss.Center, content := lipgloss.JoinVertical(lipgloss.Center,
logo, logo,

119
ui/waiting_view.go Normal file
View File

@@ -0,0 +1,119 @@
package ui
import (
"fmt"
"time"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
// WaitingScreen shows room members and lets players ready up before starting.
type WaitingScreen struct {
ready bool
}
func NewWaitingScreen() *WaitingScreen {
return &WaitingScreen{}
}
func (s *WaitingScreen) pollWaiting() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg{}
})
}
func (s *WaitingScreen) startGame(ctx *Context) (Screen, tea.Cmd) {
room := ctx.Lobby.GetRoom(ctx.RoomCode)
if room != nil && room.Session != nil {
ctx.Session = room.Session
ctx.Session.StartGame()
ctx.Lobby.StartRoom(ctx.RoomCode)
gs := NewGameScreen()
gs.gameState = ctx.Session.GetState()
return gs, gs.pollState()
}
return s, s.pollWaiting()
}
func (s *WaitingScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
switch msg.(type) {
case tickMsg:
// Check if all players are ready → start game
if ctx.Lobby != nil && ctx.Lobby.AllReady(ctx.RoomCode) {
return s.startGame(ctx)
}
return s, s.pollWaiting()
}
if key, ok := msg.(tea.KeyMsg); ok {
if isEnter(key) && !s.ready {
s.ready = true
if ctx.Lobby != nil {
ctx.Lobby.SetPlayerReady(ctx.RoomCode, ctx.Fingerprint, true)
// Solo: if only 1 player in room, start immediately
room := ctx.Lobby.GetRoom(ctx.RoomCode)
if room != nil && len(room.Players) == 1 {
return s.startGame(ctx)
}
}
} else if isKey(key, "esc") || key.Type == tea.KeyEsc {
// Leave room — unready and go back to lobby
if ctx.Lobby != nil {
ctx.Lobby.SetPlayerReady(ctx.RoomCode, ctx.Fingerprint, false)
ctx.Lobby.LeaveRoom(ctx.RoomCode, ctx.Fingerprint)
}
ctx.RoomCode = ""
ls := NewLobbyScreen()
ls.refreshLobby(ctx)
return ls, nil
}
}
return s, nil
}
func (s *WaitingScreen) View(ctx *Context) string {
headerStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("205")).
Bold(true)
readyStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("46"))
notReadyStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("240"))
header := headerStyle.Render(fmt.Sprintf("── 대기실 [%s] ──", ctx.RoomCode))
playerList := ""
if ctx.Lobby != nil {
room := ctx.Lobby.GetRoom(ctx.RoomCode)
if room != nil {
for _, p := range room.Players {
status := notReadyStyle.Render("...")
if p.Ready {
status = readyStyle.Render("준비 완료")
}
cls := p.Class
if cls == "" {
cls = "?"
}
playerList += fmt.Sprintf(" %s (%s) %s\n", p.Name, cls, status)
}
}
}
menu := "[Enter] 준비"
if s.ready {
menu = "다른 플레이어를 기다리는 중..."
}
menu += " [Esc] 나가기"
return lipgloss.JoinVertical(lipgloss.Left,
header,
"",
playerList,
"",
menu,
)
}

View File

@@ -131,7 +131,7 @@ func handleWS(w http.ResponseWriter, r *http.Request, sshPort int) {
for { for {
n, err := stdout.Read(buf) n, err := stdout.Read(buf)
if n > 0 { if n > 0 {
if writeErr := ws.WriteMessage(websocket.TextMessage, buf[:n]); writeErr != nil { if writeErr := ws.WriteMessage(websocket.BinaryMessage, buf[:n]); writeErr != nil {
return return
} }
} }

View File

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