feat: integrate daily challenges, codex recording, and unlock triggers
- Add DailyMode/DailyDate fields to GameSession; use daily seed for floor generation - Add [D] Daily Challenge button in lobby for solo daily sessions - Record codex entries for monsters and shop items during gameplay - Trigger unlock checks (fifth_class, hard_mode, mutations) on game over - Trigger title checks (novice, explorer, veteran, champion, gold_king) on game over - Save daily records on game over for daily mode sessions - Add daily leaderboard tab with Tab key cycling in leaderboard view Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user