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
|
lastActivity map[string]time.Time // fingerprint -> last activity time
|
||||||
HardMode bool
|
HardMode bool
|
||||||
ActiveMutation *Mutation
|
ActiveMutation *Mutation
|
||||||
|
DailyMode bool
|
||||||
|
DailyDate string
|
||||||
}
|
}
|
||||||
|
|
||||||
type playerActionMsg struct {
|
type playerActionMsg struct {
|
||||||
@@ -210,7 +212,12 @@ func (s *GameSession) AddPlayer(p *entity.Player) {
|
|||||||
func (s *GameSession) StartFloor() {
|
func (s *GameSession) StartFloor() {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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.Phase = PhaseExploring
|
||||||
s.state.TurnNum = 0
|
s.state.TurnNum = 0
|
||||||
|
|
||||||
|
|||||||
@@ -373,7 +373,12 @@ func (s *GameSession) advanceFloor() {
|
|||||||
p.Skills.Points++
|
p.Skills.Points++
|
||||||
}
|
}
|
||||||
s.state.FloorNum++
|
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.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("Descending to B%d...", s.state.FloorNum))
|
||||||
|
|||||||
@@ -10,20 +10,25 @@ import (
|
|||||||
"github.com/tolelom/catacombs/dungeon"
|
"github.com/tolelom/catacombs/dungeon"
|
||||||
"github.com/tolelom/catacombs/entity"
|
"github.com/tolelom/catacombs/entity"
|
||||||
"github.com/tolelom/catacombs/game"
|
"github.com/tolelom/catacombs/game"
|
||||||
|
"github.com/tolelom/catacombs/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// GameScreen handles the main gameplay: exploration, combat, and chat.
|
// GameScreen handles the main gameplay: exploration, combat, and chat.
|
||||||
type GameScreen struct {
|
type GameScreen struct {
|
||||||
gameState game.GameState
|
gameState game.GameState
|
||||||
targetCursor int
|
targetCursor int
|
||||||
moveCursor int
|
moveCursor int
|
||||||
chatting bool
|
chatting bool
|
||||||
chatInput string
|
chatInput string
|
||||||
rankingSaved bool
|
rankingSaved bool
|
||||||
|
codexRecorded map[string]bool
|
||||||
|
prevPhase game.GamePhase
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewGameScreen() *GameScreen {
|
func NewGameScreen() *GameScreen {
|
||||||
return &GameScreen{}
|
return &GameScreen{
|
||||||
|
codexRecorded: make(map[string]bool),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *GameScreen) pollState() tea.Cmd {
|
func (s *GameScreen) pollState() tea.Cmd {
|
||||||
@@ -58,6 +63,30 @@ func (s *GameScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
|||||||
} else {
|
} else {
|
||||||
s.targetCursor = 0
|
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 {
|
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 {
|
if len(s.gameState.Players) >= 4 {
|
||||||
ctx.Store.UnlockAchievement(ctx.PlayerName, "full_party")
|
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
|
s.rankingSaved = true
|
||||||
}
|
}
|
||||||
return NewResultScreen(s.gameState, s.rankingSaved), nil
|
return NewResultScreen(s.gameState, s.rankingSaved), nil
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -9,7 +10,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// LeaderboardScreen shows the top runs.
|
// LeaderboardScreen shows the top runs.
|
||||||
type LeaderboardScreen struct{}
|
type LeaderboardScreen struct {
|
||||||
|
tab int // 0=all-time, 1=gold, 2=daily
|
||||||
|
}
|
||||||
|
|
||||||
func NewLeaderboardScreen() *LeaderboardScreen {
|
func NewLeaderboardScreen() *LeaderboardScreen {
|
||||||
return &LeaderboardScreen{}
|
return &LeaderboardScreen{}
|
||||||
@@ -17,6 +20,10 @@ func NewLeaderboardScreen() *LeaderboardScreen {
|
|||||||
|
|
||||||
func (s *LeaderboardScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
func (s *LeaderboardScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
||||||
if key, ok := msg.(tea.KeyMsg); ok {
|
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) {
|
if isKey(key, "l") || isEnter(key) || isQuit(key) {
|
||||||
return NewTitleScreen(), nil
|
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 {
|
func (s *LeaderboardScreen) View(ctx *Context) string {
|
||||||
var byFloor, byGold []store.RunRecord
|
var byFloor, byGold []store.RunRecord
|
||||||
|
var daily []store.DailyRecord
|
||||||
if ctx.Store != nil {
|
if ctx.Store != nil {
|
||||||
byFloor, _ = ctx.Store.TopRuns(10)
|
byFloor, _ = ctx.Store.TopRuns(10)
|
||||||
byGold, _ = ctx.Store.TopRunsByGold(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 ──")
|
title := styleHeader.Render("── Leaderboard ──")
|
||||||
|
|
||||||
// By Floor
|
// Tab header
|
||||||
var floorSection string
|
tabs := []string{"Floor", "Gold", "Daily"}
|
||||||
floorSection += styleCoop.Render(" Top by Floor") + "\n"
|
var tabLine string
|
||||||
for i, r := range byFloor {
|
for i, t := range tabs {
|
||||||
if i >= 5 {
|
if i == tab {
|
||||||
break
|
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 content string
|
||||||
var goldSection string
|
|
||||||
goldSection += styleCoop.Render("\n Top by Gold") + "\n"
|
switch tab {
|
||||||
for i, r := range byGold {
|
case 0: // By Floor
|
||||||
if i >= 5 {
|
content += styleCoop.Render(" Top by Floor") + "\n"
|
||||||
break
|
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)
|
case 1: // By Gold
|
||||||
cls := ""
|
content += styleCoop.Render(" Top by Gold") + "\n"
|
||||||
if r.Class != "" {
|
for i, r := range byGold {
|
||||||
cls = fmt.Sprintf(" [%s]", r.Class)
|
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,
|
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 (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
@@ -120,6 +121,22 @@ func (s *LobbyScreen) Update(msg tea.Msg, ctx *Context) (Screen, tea.Cmd) {
|
|||||||
return NewClassSelectScreen(), nil
|
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 {
|
} else if isKey(key, "h") && s.hardUnlocked {
|
||||||
s.hardMode = !s.hardMode
|
s.hardMode = !s.hardMode
|
||||||
} else if isKey(key, "q") {
|
} else if isKey(key, "q") {
|
||||||
@@ -171,7 +188,7 @@ func renderLobby(state lobbyState, width, height int) string {
|
|||||||
Padding(0, 1)
|
Padding(0, 1)
|
||||||
|
|
||||||
header := headerStyle.Render(fmt.Sprintf("── Lobby ── %d online ──", state.online))
|
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 {
|
if state.hardUnlocked {
|
||||||
hardStatus := "OFF"
|
hardStatus := "OFF"
|
||||||
if state.hardMode {
|
if state.hardMode {
|
||||||
|
|||||||
Reference in New Issue
Block a user