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:
2026-03-25 17:13:10 +09:00
parent 00581880f2
commit a7bca9d2f2
5 changed files with 183 additions and 42 deletions

View File

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

View File

@@ -373,7 +373,12 @@ func (s *GameSession) advanceFloor() {
p.Skills.Points++
}
s.state.FloorNum++
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))

View File

@@ -10,6 +10,7 @@ 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.
@@ -20,10 +21,14 @@ type GameScreen struct {
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

View File

@@ -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,21 +33,36 @@ 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"
// 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))
}
}
var content string
switch tab {
case 0: // By Floor
content += styleCoop.Render(" Top by Floor") + "\n"
for i, r := range byFloor {
if i >= 5 {
if i >= 10 {
break
}
medal := fmt.Sprintf(" %d.", i+1)
@@ -48,16 +70,14 @@ func renderLeaderboard(byFloor, byGold []store.RunRecord, width, height int) str
if r.Class != "" {
cls = fmt.Sprintf(" [%s]", r.Class)
}
floorSection += fmt.Sprintf(" %s %s%s B%d %s\n",
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)))
}
// By Gold
var goldSection string
goldSection += styleCoop.Render("\n Top by Gold") + "\n"
case 1: // By Gold
content += styleCoop.Render(" Top by Gold") + "\n"
for i, r := range byGold {
if i >= 5 {
if i >= 10 {
break
}
medal := fmt.Sprintf(" %d.", i+1)
@@ -65,13 +85,28 @@ func renderLeaderboard(byFloor, byGold []store.RunRecord, width, height int) str
if r.Class != "" {
cls = fmt.Sprintf(" [%s]", r.Class)
}
goldSection += fmt.Sprintf(" %s %s%s B%d %s\n",
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)))
}
}
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))
}

View File

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