diff --git a/game/session.go b/game/session.go index 94d6810..43165a7 100644 --- a/game/session.go +++ b/game/session.go @@ -84,6 +84,8 @@ type GameSession struct { lastActivity map[string]time.Time // fingerprint -> last activity time HardMode bool ActiveMutation *Mutation + DailyMode bool + DailyDate string } type playerActionMsg struct { @@ -210,7 +212,12 @@ func (s *GameSession) AddPlayer(p *entity.Player) { func (s *GameSession) StartFloor() { s.mu.Lock() defer s.mu.Unlock() - s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(time.Now().UnixNano()))) + if s.DailyMode { + seed := DailySeed(s.DailyDate) + int64(s.state.FloorNum) + s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(seed))) + } else { + s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(time.Now().UnixNano()))) + } s.state.Phase = PhaseExploring s.state.TurnNum = 0 diff --git a/game/turn.go b/game/turn.go index b1abb09..2834b2a 100644 --- a/game/turn.go +++ b/game/turn.go @@ -373,7 +373,12 @@ func (s *GameSession) advanceFloor() { p.Skills.Points++ } s.state.FloorNum++ - s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(time.Now().UnixNano()))) + if s.DailyMode { + seed := DailySeed(s.DailyDate) + int64(s.state.FloorNum) + s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(seed))) + } else { + s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum, rand.New(rand.NewSource(time.Now().UnixNano()))) + } s.state.Phase = PhaseExploring s.state.CombatTurn = 0 s.addLog(fmt.Sprintf("Descending to B%d...", s.state.FloorNum)) diff --git a/ui/game_view.go b/ui/game_view.go index 1124006..1f2d04f 100644 --- a/ui/game_view.go +++ b/ui/game_view.go @@ -10,20 +10,25 @@ import ( "github.com/tolelom/catacombs/dungeon" "github.com/tolelom/catacombs/entity" "github.com/tolelom/catacombs/game" + "github.com/tolelom/catacombs/store" ) // GameScreen handles the main gameplay: exploration, combat, and chat. type GameScreen struct { - gameState game.GameState - targetCursor int - moveCursor int - chatting bool - chatInput string - rankingSaved bool + gameState game.GameState + targetCursor int + moveCursor int + chatting bool + chatInput string + rankingSaved bool + codexRecorded map[string]bool + prevPhase game.GamePhase } func NewGameScreen() *GameScreen { - return &GameScreen{} + return &GameScreen{ + codexRecorded: make(map[string]bool), + } } func (s *GameScreen) pollState() tea.Cmd { @@ -58,6 +63,30 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { } else { s.targetCursor = 0 } + + // Record codex entries for monsters when entering combat + if ctx.Store != nil && s.gameState.Phase == game.PhaseCombat { + for _, m := range s.gameState.Monsters { + key := "monster:" + m.Name + if !s.codexRecorded[key] { + ctx.Store.RecordCodexEntry(ctx.Fingerprint, "monster", m.Name) + s.codexRecorded[key] = true + } + } + } + + // Record codex entries for shop items when entering shop + if ctx.Store != nil && s.gameState.Phase == game.PhaseShop && s.prevPhase != game.PhaseShop { + for _, item := range s.gameState.ShopItems { + key := "item:" + item.Name + if !s.codexRecorded[key] { + ctx.Store.RecordCodexEntry(ctx.Fingerprint, "item", item.Name) + s.codexRecorded[key] = true + } + } + } + + s.prevPhase = s.gameState.Phase } if s.gameState.GameOver { @@ -104,6 +133,54 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { if len(s.gameState.Players) >= 4 { ctx.Store.UnlockAchievement(ctx.PlayerName, "full_party") } + + // Unlock triggers + if s.gameState.FloorNum >= 10 { + ctx.Store.UnlockContent(ctx.Fingerprint, "fifth_class") + } + if len(s.gameState.Players) >= 3 && s.gameState.FloorNum >= 5 { + ctx.Store.UnlockContent(ctx.Fingerprint, "hard_mode") + } + if s.gameState.Victory { + ctx.Store.UnlockContent(ctx.Fingerprint, "mutations") + } + + // Title triggers + ctx.Store.EarnTitle(ctx.Fingerprint, "novice") + if s.gameState.FloorNum >= 5 { + ctx.Store.EarnTitle(ctx.Fingerprint, "explorer") + } + if s.gameState.FloorNum >= 10 { + ctx.Store.EarnTitle(ctx.Fingerprint, "veteran") + } + if s.gameState.Victory { + ctx.Store.EarnTitle(ctx.Fingerprint, "champion") + } + // Check player gold for gold_king title + for _, p := range s.gameState.Players { + if p.Fingerprint == ctx.Fingerprint && p.Gold >= 500 { + ctx.Store.EarnTitle(ctx.Fingerprint, "gold_king") + } + } + + // Save daily record if in daily mode + if ctx.Session != nil && ctx.Session.DailyMode { + playerGold := 0 + for _, p := range s.gameState.Players { + if p.Fingerprint == ctx.Fingerprint { + playerGold = p.Gold + break + } + } + ctx.Store.SaveDaily(store.DailyRecord{ + Date: ctx.Session.DailyDate, + Player: ctx.Fingerprint, + PlayerName: ctx.PlayerName, + FloorReached: s.gameState.FloorNum, + GoldEarned: playerGold, + }) + } + s.rankingSaved = true } return NewResultScreen(s.gameState, s.rankingSaved), nil diff --git a/ui/leaderboard_view.go b/ui/leaderboard_view.go index 82c813c..0f2bb36 100644 --- a/ui/leaderboard_view.go +++ b/ui/leaderboard_view.go @@ -2,6 +2,7 @@ package ui import ( "fmt" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -9,7 +10,9 @@ import ( ) // LeaderboardScreen shows the top runs. -type LeaderboardScreen struct{} +type LeaderboardScreen struct { + tab int // 0=all-time, 1=gold, 2=daily +} func NewLeaderboardScreen() *LeaderboardScreen { return &LeaderboardScreen{} @@ -17,6 +20,10 @@ func NewLeaderboardScreen() *LeaderboardScreen { func (s *LeaderboardScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { if key, ok := msg.(tea.KeyMsg); ok { + if isKey(key, "tab") || key.Type == tea.KeyTab { + s.tab = (s.tab + 1) % 3 + return s, nil + } if isKey(key, "l") || isEnter(key) || isQuit(key) { return NewTitleScreen(), nil } @@ -26,52 +33,80 @@ func (s *LeaderboardScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) func (s *LeaderboardScreen) View(ctx *Context) string { var byFloor, byGold []store.RunRecord + var daily []store.DailyRecord if ctx.Store != nil { byFloor, _ = ctx.Store.TopRuns(10) byGold, _ = ctx.Store.TopRunsByGold(10) + daily, _ = ctx.Store.GetDailyLeaderboard(time.Now().Format("2006-01-02"), 20) } - return renderLeaderboard(byFloor, byGold, ctx.Width, ctx.Height) + return renderLeaderboard(byFloor, byGold, daily, s.tab, ctx.Width, ctx.Height) } -func renderLeaderboard(byFloor, byGold []store.RunRecord, width, height int) string { +func renderLeaderboard(byFloor, byGold []store.RunRecord, daily []store.DailyRecord, tab, width, height int) string { title := styleHeader.Render("── Leaderboard ──") - // By Floor - var floorSection string - floorSection += styleCoop.Render(" Top by Floor") + "\n" - for i, r := range byFloor { - if i >= 5 { - break + // Tab header + tabs := []string{"Floor", "Gold", "Daily"} + var tabLine string + for i, t := range tabs { + if i == tab { + tabLine += styleHeader.Render(fmt.Sprintf(" [%s] ", t)) + } else { + tabLine += styleSystem.Render(fmt.Sprintf(" %s ", t)) } - medal := fmt.Sprintf(" %d.", i+1) - cls := "" - if r.Class != "" { - cls = fmt.Sprintf(" [%s]", r.Class) - } - floorSection += fmt.Sprintf(" %s %s%s B%d %s\n", - medal, stylePlayer.Render(r.Player), styleSystem.Render(cls), - r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score))) } - // By Gold - var goldSection string - goldSection += styleCoop.Render("\n Top by Gold") + "\n" - for i, r := range byGold { - if i >= 5 { - break + var content string + + switch tab { + case 0: // By Floor + content += styleCoop.Render(" Top by Floor") + "\n" + for i, r := range byFloor { + if i >= 10 { + break + } + medal := fmt.Sprintf(" %d.", i+1) + cls := "" + if r.Class != "" { + cls = fmt.Sprintf(" [%s]", r.Class) + } + content += fmt.Sprintf(" %s %s%s B%d %s\n", + medal, stylePlayer.Render(r.Player), styleSystem.Render(cls), + r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score))) } - medal := fmt.Sprintf(" %d.", i+1) - cls := "" - if r.Class != "" { - cls = fmt.Sprintf(" [%s]", r.Class) + case 1: // By Gold + content += styleCoop.Render(" Top by Gold") + "\n" + for i, r := range byGold { + if i >= 10 { + break + } + medal := fmt.Sprintf(" %d.", i+1) + cls := "" + if r.Class != "" { + cls = fmt.Sprintf(" [%s]", r.Class) + } + content += fmt.Sprintf(" %s %s%s B%d %s\n", + medal, stylePlayer.Render(r.Player), styleSystem.Render(cls), + r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score))) + } + case 2: // Daily + content += styleCoop.Render(fmt.Sprintf(" Daily Challenge — %s", time.Now().Format("2006-01-02"))) + "\n" + if len(daily) == 0 { + content += " No daily runs yet today.\n" + } + for i, r := range daily { + if i >= 20 { + break + } + medal := fmt.Sprintf(" %d.", i+1) + content += fmt.Sprintf(" %s %s B%d %s\n", + medal, stylePlayer.Render(r.PlayerName), + r.FloorReached, styleGold.Render(fmt.Sprintf("%dg", r.GoldEarned))) } - goldSection += fmt.Sprintf(" %s %s%s B%d %s\n", - medal, stylePlayer.Render(r.Player), styleSystem.Render(cls), - r.Floor, styleGold.Render(fmt.Sprintf("%dg", r.Score))) } - footer := styleSystem.Render("\n[L] Back") + footer := styleSystem.Render("\n[Tab] Switch Tab [L] Back") return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, - lipgloss.JoinVertical(lipgloss.Center, title, "", floorSection, goldSection, footer)) + lipgloss.JoinVertical(lipgloss.Center, title, tabLine, "", content, footer)) } diff --git a/ui/lobby_view.go b/ui/lobby_view.go index 0447164..0543990 100644 --- a/ui/lobby_view.go +++ b/ui/lobby_view.go @@ -3,6 +3,7 @@ package ui import ( "fmt" "strings" + "time" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" @@ -120,6 +121,22 @@ func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) { return NewClassSelectScreen(), nil } } + } else if isKey(key, "d") { + // Daily Challenge: create a private solo daily session + if ctx.Lobby != nil { + code := ctx.Lobby.CreateRoom(ctx.PlayerName + "'s Daily") + if err := ctx.Lobby.JoinRoom(code, ctx.PlayerName, ctx.Fingerprint); err == nil { + ctx.RoomCode = code + room := ctx.Lobby.GetRoom(code) + if room != nil { + room.Session = game.NewGameSession(ctx.Lobby.Cfg()) + room.Session.DailyMode = true + room.Session.DailyDate = time.Now().Format("2006-01-02") + ctx.Session = room.Session + } + return NewClassSelectScreen(), nil + } + } } else if isKey(key, "h") && s.hardUnlocked { s.hardMode = !s.hardMode } else if isKey(key, "q") { @@ -171,7 +188,7 @@ func renderLobby(state lobbyState, width, height int) string { Padding(0, 1) header := headerStyle.Render(fmt.Sprintf("── Lobby ── %d online ──", state.online)) - menu := "[C] Create Room [J] Join by Code [Up/Down] Select [Enter] Join [Q] Back" + menu := "[C] Create Room [J] Join by Code [D] Daily Challenge [Up/Down] Select [Enter] Join [Q] Back" if state.hardUnlocked { hardStatus := "OFF" if state.hardMode {