diff --git a/combat/combat.go b/combat/combat.go index 33169be..cd9a4ea 100644 --- a/combat/combat.go +++ b/combat/combat.go @@ -57,6 +57,7 @@ func ResolveAttacks(intents []AttackIntent, monsters []*entity.Monster, coopBonu results[i] = AttackResult{TargetIdx: -1, Damage: totalDmg, IsAoE: true} } else { if intent.TargetIdx < 0 || intent.TargetIdx >= len(monsters) { + results[i] = AttackResult{TargetIdx: -1} // mark as invalid continue } m := monsters[intent.TargetIdx] diff --git a/combat/combo.go b/combat/combo.go index 67c252c..c4954d5 100644 --- a/combat/combo.go +++ b/combat/combo.go @@ -75,10 +75,12 @@ func DetectCombos(actions map[string]ComboAction) []ComboDef { } func matchesCombo(required []ComboAction, actions map[string]ComboAction) bool { + used := make(map[string]bool) for _, req := range required { found := false - for _, act := range actions { - if act.Class == req.Class && act.ActionType == req.ActionType { + for id, act := range actions { + if !used[id] && act.Class == req.Class && act.ActionType == req.ActionType { + used[id] = true found = true break } diff --git a/dungeon/generator.go b/dungeon/generator.go index 189644e..0e8bcdf 100644 --- a/dungeon/generator.go +++ b/dungeon/generator.go @@ -108,6 +108,9 @@ func GenerateFloor(floorNum int, rng *rand.Rand) *Floor { leaf.roomIdx = i } + // First room is always empty (safe starting area) + rooms[0].Type = RoomEmpty + // Last room is boss rooms[len(rooms)-1].Type = RoomBoss diff --git a/game/event.go b/game/event.go index 8fb08f7..604024d 100644 --- a/game/event.go +++ b/game/event.go @@ -40,6 +40,11 @@ func (s *GameSession) EnterRoom(roomIdx int) { s.state.CombatTurn = 0 s.signalCombat() case dungeon.RoomShop: + if s.hasMutation("no_shop") { + s.addLog("The shop is closed! (Weekly mutation)") + room.Cleared = true + return + } s.generateShopItems() s.state.Phase = PhaseShop case dungeon.RoomTreasure: @@ -98,9 +103,15 @@ func (s *GameSession) spawnMonsters() { m.MaxHP = m.HP 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()) } + 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 } @@ -140,6 +151,12 @@ func (s *GameSession) spawnBoss() { boss.MaxHP = boss.HP boss.DEF = int(float64(boss.DEF) * s.cfg.Combat.SoloHPReduction) } + if s.HardMode { + mult := s.cfg.Difficulty.HardModeMonsterMult + boss.HP = int(float64(boss.HP) * mult) + boss.MaxHP = boss.HP + boss.ATK = int(float64(boss.ATK) * mult) + } s.state.Monsters = []*entity.Monster{boss} // Reset skill uses for all players at combat start @@ -186,6 +203,12 @@ func (s *GameSession) generateShopItems() { potionHeal := 30 + floor potionPrice := 20 + floor/2 + if s.HardMode { + mult := s.cfg.Difficulty.HardModeShopMult + potionPrice = int(float64(potionPrice) * mult) + weaponPrice = int(float64(weaponPrice) * mult) + armorPrice = int(float64(armorPrice) * mult) + } s.state.ShopItems = []entity.Item{ {Name: "HP Potion", Type: entity.ItemConsumable, Bonus: potionHeal, Price: potionPrice}, {Name: weaponName(floor), Type: entity.ItemWeapon, Bonus: weaponBonus, Price: weaponPrice}, @@ -221,6 +244,7 @@ func armorName(floor int) string { func (s *GameSession) triggerEvent() { event := PickRandomEvent() + s.state.LastEventName = event.Name s.addLog(fmt.Sprintf("Event: %s — %s", event.Name, event.Description)) // Auto-resolve with a random choice @@ -345,6 +369,12 @@ func (s *GameSession) spawnMiniBoss() { miniBoss.MaxHP = miniBoss.HP miniBoss.DEF = int(float64(miniBoss.DEF) * s.cfg.Combat.SoloHPReduction) } + if s.HardMode { + mult := s.cfg.Difficulty.HardModeMonsterMult + miniBoss.HP = int(float64(miniBoss.HP) * mult) + miniBoss.MaxHP = miniBoss.HP + miniBoss.ATK = int(float64(miniBoss.ATK) * mult) + } s.state.Monsters = []*entity.Monster{miniBoss} s.addLog(fmt.Sprintf("A mini-boss appears: %s!", miniBoss.Name)) diff --git a/game/lobby.go b/game/lobby.go index 0c19d93..f5ab6e3 100644 --- a/game/lobby.go +++ b/game/lobby.go @@ -161,6 +161,25 @@ func (l *Lobby) JoinRoom(code, playerName, fingerprint string) error { return nil } +func (l *Lobby) LeaveRoom(code, fingerprint string) { + l.mu.Lock() + defer l.mu.Unlock() + room, ok := l.rooms[code] + if !ok { + return + } + for i, p := range room.Players { + if p.Fingerprint == fingerprint { + room.Players = append(room.Players[:i], room.Players[i+1:]...) + break + } + } + // Remove empty waiting rooms + if len(room.Players) == 0 && room.Status == RoomWaiting { + delete(l.rooms, code) + } +} + func (l *Lobby) SetPlayerClass(code, fingerprint, class string) { l.mu.Lock() defer l.mu.Unlock() diff --git a/game/mutation.go b/game/mutation.go index 9356e35..94098cf 100644 --- a/game/mutation.go +++ b/game/mutation.go @@ -24,11 +24,11 @@ var Mutations = []Mutation{ {ID: "speed_run", Name: "Speed Run", Description: "Turn timeout halved", Apply: func(cfg *config.GameConfig) { cfg.TurnTimeoutSec = max(cfg.TurnTimeoutSec/2, 2) }}, {ID: "no_shop", Name: "Shop Closed", Description: "Shops are unavailable", - 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", - 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", - Apply: func(cfg *config.GameConfig) {}}, + Apply: func(cfg *config.GameConfig) {}}, // handled at runtime in spawnMonsters } // GetWeeklyMutation returns the mutation for the current week, diff --git a/game/session.go b/game/session.go index 43165a7..e891129 100644 --- a/game/session.go +++ b/game/session.go @@ -55,6 +55,7 @@ type GameState struct { TurnResolving bool // true while logs are being replayed BossKilled bool FleeSucceeded bool + LastEventName string // name of the most recent random event (for codex) } func (s *GameSession) addLog(msg string) { @@ -93,6 +94,11 @@ type playerActionMsg struct { 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 { return &GameSession{ cfg: cfg, @@ -107,6 +113,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() { select { case <-s.done: @@ -206,6 +219,14 @@ func (s *GameSession) AddPlayer(p *entity.Player) { if p.Skills == nil { p.Skills = &entity.PlayerSkills{BranchIndex: -1} } + if s.hasMutation("glass_cannon") { + p.ATK *= 2 + p.MaxHP /= 2 + if p.MaxHP < 1 { + p.MaxHP = 1 + } + p.HP = p.MaxHP + } s.state.Players = append(s.state.Players, p) } @@ -304,6 +325,7 @@ func (s *GameSession) GetState() GameState { TurnResolving: s.state.TurnResolving, BossKilled: s.state.BossKilled, FleeSucceeded: s.state.FleeSucceeded, + LastEventName: s.state.LastEventName, } } @@ -368,25 +390,38 @@ func (s *GameSession) AllocateSkillPoint(fingerprint string, branchIdx int) erro return fmt.Errorf("player not found") } +// BuyResult describes the outcome of a shop purchase attempt. +type BuyResult int + +const ( + BuyOK BuyResult = iota + BuyNoGold + BuyInventoryFull + BuyFailed +) + // BuyItem handles shop purchases -func (s *GameSession) BuyItem(playerID string, itemIdx int) bool { +func (s *GameSession) BuyItem(playerID string, itemIdx int) BuyResult { s.mu.Lock() defer s.mu.Unlock() if s.state.Phase != PhaseShop || itemIdx < 0 || itemIdx >= len(s.state.ShopItems) { - return false + return BuyFailed } item := s.state.ShopItems[itemIdx] for _, p := range s.state.Players { - if p.Fingerprint == playerID && p.Gold >= item.Price { + if p.Fingerprint == playerID { + if p.Gold < item.Price { + return BuyNoGold + } if len(p.Inventory) >= s.cfg.Game.InventoryLimit { - return false + return BuyInventoryFull } p.Gold -= item.Price p.Inventory = append(p.Inventory, item) - return true + return BuyOK } } - return false + return BuyFailed } // SendChat appends a chat message to the combat log diff --git a/game/session_test.go b/game/session_test.go index 7687531..6449525 100644 --- a/game/session_test.go +++ b/game/session_test.go @@ -142,8 +142,8 @@ func TestBuyItemInventoryFull(t *testing.T) { } s.mu.Unlock() - if s.BuyItem("fp-buyer", 0) { - t.Error("should not buy when inventory is full") + if result := s.BuyItem("fp-buyer", 0); result != BuyInventoryFull { + t.Errorf("expected BuyInventoryFull, got %d", result) } } diff --git a/game/turn.go b/game/turn.go index 2834b2a..21747f8 100644 --- a/game/turn.go +++ b/game/turn.go @@ -72,6 +72,14 @@ collecting: } func (s *GameSession) resolvePlayerActions() { + // Record frozen players BEFORE ticking effects (freeze expires on tick) + frozenPlayers := make(map[string]bool) + for _, p := range s.state.Players { + if !p.IsOut() && p.HasEffect(entity.StatusFreeze) { + frozenPlayers[p.Fingerprint] = true + } + } + // Tick status effects with floor theme damage bonus theme := dungeon.GetTheme(s.state.FloorNum) for _, p := range s.state.Players { @@ -117,6 +125,11 @@ func (s *GameSession) resolvePlayerActions() { if p.IsOut() { continue } + // Frozen players skip their action + if frozenPlayers[p.Fingerprint] { + s.addLog(fmt.Sprintf("%s is frozen and cannot act!", p.Name)) + continue + } action, ok := s.actions[p.Fingerprint] if !ok { continue @@ -179,6 +192,9 @@ func (s *GameSession) resolvePlayerActions() { if p.Skills != nil { healAmount += p.Skills.GetSkillPower(p.Class) / 2 } + if s.HardMode { + healAmount = int(float64(healAmount) * s.cfg.Difficulty.HardModeHealMult) + } before := target.HP target.Heal(healAmount) s.addLog(fmt.Sprintf("%s healed %s for %d HP", p.Name, target.Name, target.HP-before)) @@ -194,7 +210,11 @@ func (s *GameSession) resolvePlayerActions() { for i, item := range p.Inventory { if item.Type == entity.ItemConsumable { before := p.HP - p.Heal(item.Bonus) + healAmt := item.Bonus + if s.HardMode { + healAmt = int(float64(healAmt) * s.cfg.Difficulty.HardModeHealMult) + } + p.Heal(healAmt) p.Inventory = append(p.Inventory[:i], p.Inventory[i+1:]...) s.addLog(fmt.Sprintf("%s used %s, restored %d HP", p.Name, item.Name, p.HP-before)) found = true @@ -274,6 +294,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 { results := combat.ResolveAttacks(intents, s.state.Monsters, s.cfg.Game.CoopBonus) for i, r := range results { @@ -292,6 +318,20 @@ func (s *GameSession) resolvePlayerActions() { } s.addLog(fmt.Sprintf("%s hit %s for %d dmg%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's Life Siphon heals %d HP", p.Name, heal)) + } + } + } + } + } } } diff --git a/ui/class_view.go b/ui/class_view.go index 8b5c801..24ea412 100644 --- a/ui/class_view.go +++ b/ui/class_view.go @@ -36,6 +36,8 @@ func (s *ClassSelectScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) if room != nil { if room.Session == nil { room.Session = game.NewGameSession(ctx.Lobby.Cfg()) + room.Session.HardMode = ctx.HardMode + room.Session.ApplyWeeklyMutation() } ctx.Session = room.Session player := entity.NewPlayer(ctx.PlayerName, selectedClass) @@ -44,11 +46,8 @@ func (s *ClassSelectScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) if ctx.Lobby != nil { ctx.Lobby.RegisterSession(ctx.Fingerprint, ctx.RoomCode) } - ctx.Session.StartGame() - ctx.Lobby.StartRoom(ctx.RoomCode) - gs := NewGameScreen() - gs.gameState = ctx.Session.GetState() - return gs, gs.pollState() + ws := NewWaitingScreen() + return ws, ws.pollWaiting() } } } diff --git a/ui/context.go b/ui/context.go index b6d3089..41497c3 100644 --- a/ui/context.go +++ b/ui/context.go @@ -16,4 +16,5 @@ type Context struct { Store *store.DB Session *game.GameSession RoomCode string + HardMode bool } diff --git a/ui/game_view.go b/ui/game_view.go index 1f2d04f..71b002d 100644 --- a/ui/game_view.go +++ b/ui/game_view.go @@ -86,6 +86,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 } diff --git a/ui/lobby_view.go b/ui/lobby_view.go index 0543990..3a572e8 100644 --- a/ui/lobby_view.go +++ b/ui/lobby_view.go @@ -41,6 +41,12 @@ func NewLobbyScreen() *LobbyScreen { return &LobbyScreen{} } +func (s *LobbyScreen) pollLobby() tea.Cmd { + return tea.Tick(time.Second*2, func(t time.Time) tea.Msg { + return tickMsg{} + }) +} + func (s *LobbyScreen) refreshLobby(ctx *Context) { if ctx.Lobby == nil { return @@ -71,6 +77,11 @@ func (s *LobbyScreen) refreshLobby(ctx *Context) { } func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { + switch msg.(type) { + case tickMsg: + s.refreshLobby(ctx) + return s, s.pollLobby() + } if key, ok := msg.(tea.KeyMsg); ok { // Join-by-code input mode if s.joining { @@ -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.DailyMode = true room.Session.DailyDate = time.Now().Format("2006-01-02") + room.Session.ApplyWeeklyMutation() ctx.Session = room.Session } 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 { s.hardMode = !s.hardMode + ctx.HardMode = s.hardMode } else if isKey(key, "q") { if ctx.Lobby != nil { ctx.Lobby.PlayerOffline(ctx.Fingerprint) diff --git a/ui/model.go b/ui/model.go index 479e554..62b45e8 100644 --- a/ui/model.go +++ b/ui/model.go @@ -110,6 +110,7 @@ const ( screenTitle screen = iota screenLobby screenClassSelect + screenWaiting screenGame screenShop screenResult @@ -129,6 +130,8 @@ func (m Model) screenType() screen { return screenLobby case *ClassSelectScreen: return screenClassSelect + case *WaitingScreen: + return screenWaiting case *GameScreen: return screenGame case *ShopScreen: diff --git a/ui/model_test.go b/ui/model_test.go index 9f4bd3a..22abe89 100644 --- a/ui/model_test.go +++ b/ui/model_test.go @@ -111,14 +111,22 @@ func TestClassSelectToGame(t *testing.T) { t.Fatalf("should be at class select, got %d", m3.screenType()) } - // Press Enter to select Warrior (default cursor=0) + // Press Enter to select Warrior (default cursor=0) → WaitingScreen result, _ = m3.Update(tea.KeyMsg{Type: tea.KeyEnter}) m4 := result.(Model) - if m4.screenType() != screenGame { - t.Errorf("after class select Enter: screen=%d, want screenGame(3)", m4.screenType()) + if m4.screenType() != screenWaiting { + t.Fatalf("after class select Enter: screen=%d, want screenWaiting(%d)", m4.screenType(), screenWaiting) } - if m4.session() == nil { + + // Press Enter to ready up (solo room → immediately starts game) + result, _ = m4.Update(tea.KeyMsg{Type: tea.KeyEnter}) + m5 := result.(Model) + + if m5.screenType() != screenGame { + t.Errorf("after ready Enter: screen=%d, want screenGame(%d)", m5.screenType(), screenGame) + } + if m5.session() == nil { t.Error("session should be set") } } diff --git a/ui/nickname_view.go b/ui/nickname_view.go index 0a61552..01e051c 100644 --- a/ui/nickname_view.go +++ b/ui/nickname_view.go @@ -45,7 +45,7 @@ func (s *NicknameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { } ls := NewLobbyScreen() ls.refreshLobby(ctx) - return ls, nil + return ls, ls.pollLobby() } else if isKey(key, "esc") || key.Type == tea.KeyEsc { s.input = "" return NewTitleScreen(), nil diff --git a/ui/result_view.go b/ui/result_view.go index dec0e28..cb5d2dd 100644 --- a/ui/result_view.go +++ b/ui/result_view.go @@ -35,7 +35,7 @@ func (s *ResultScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { ctx.RoomCode = "" ls := NewLobbyScreen() ls.refreshLobby(ctx) - return ls, nil + return ls, ls.pollLobby() } else if isQuit(key) { return s, tea.Quit } diff --git a/ui/shop_view.go b/ui/shop_view.go index 24681f5..727ae76 100644 --- a/ui/shop_view.go +++ b/ui/shop_view.go @@ -25,10 +25,15 @@ func (s *ShopScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { case "1", "2", "3": if ctx.Session != nil { idx := int(key.String()[0] - '1') - if ctx.Session.BuyItem(ctx.Fingerprint, idx) { + switch ctx.Session.BuyItem(ctx.Fingerprint, idx) { + case game.BuyOK: s.shopMsg = "Purchased!" - } else { + case game.BuyNoGold: s.shopMsg = "Not enough gold!" + case game.BuyInventoryFull: + s.shopMsg = "Inventory full!" + default: + s.shopMsg = "Cannot buy that!" } s.gameState = ctx.Session.GetState() } diff --git a/ui/title.go b/ui/title.go index d2fd40c..bea4b58 100644 --- a/ui/title.go +++ b/ui/title.go @@ -50,7 +50,7 @@ func (s *TitleScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { } ls := NewLobbyScreen() ls.refreshLobby(ctx) - return ls, nil + return ls, ls.pollLobby() } else if isKey(key, "h") { return NewHelpScreen(), nil } else if isKey(key, "s") { diff --git a/ui/waiting_view.go b/ui/waiting_view.go new file mode 100644 index 0000000..29f76a1 --- /dev/null +++ b/ui/waiting_view.go @@ -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("── Waiting Room [%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("READY") + } + cls := p.Class + if cls == "" { + cls = "?" + } + playerList += fmt.Sprintf(" %s (%s) %s\n", p.Name, cls, status) + } + } + } + + menu := "[Enter] Ready" + if s.ready { + menu = "Waiting for other players..." + } + menu += " [Esc] Leave" + + return lipgloss.JoinVertical(lipgloss.Left, + header, + "", + playerList, + "", + menu, + ) +}