Files
Catacombs/game/session.go
tolelom a7bca9d2f2 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>
2026-03-25 17:13:10 +09:00

410 lines
9.7 KiB
Go

package game
import (
"fmt"
"log/slog"
"math/rand"
"sync"
"time"
"github.com/tolelom/catacombs/config"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
)
type GamePhase int
const (
PhaseExploring GamePhase = iota
PhaseCombat
PhaseShop
PhaseResult
)
type PlayerAction struct {
Type ActionType
TargetIdx int
}
type ActionType int
const (
ActionAttack ActionType = iota
ActionSkill
ActionItem
ActionFlee
ActionWait
)
type GameState struct {
Floor *dungeon.Floor
Players []*entity.Player
Monsters []*entity.Monster
Phase GamePhase
FloorNum int
TurnNum int
CombatTurn int // reset per combat encounter
SoloMode bool
GameOver bool
Victory bool
ShopItems []entity.Item
CombatLog []string // recent combat messages
TurnDeadline time.Time
SubmittedActions map[string]string // fingerprint -> action description
PendingLogs []string // logs waiting to be revealed one by one
TurnResolving bool // true while logs are being replayed
BossKilled bool
FleeSucceeded bool
}
func (s *GameSession) addLog(msg string) {
if s.state.TurnResolving {
s.state.PendingLogs = append(s.state.PendingLogs, msg)
} else {
s.state.CombatLog = append(s.state.CombatLog, msg)
if len(s.state.CombatLog) > 8 {
s.state.CombatLog = s.state.CombatLog[len(s.state.CombatLog)-8:]
}
}
}
func (s *GameSession) clearLog() {
s.state.CombatLog = nil
}
type GameSession struct {
mu sync.Mutex
cfg *config.Config
state GameState
started bool
actions map[string]PlayerAction // playerName -> action
actionCh chan playerActionMsg
combatSignal chan struct{}
done chan struct{}
lastActivity map[string]time.Time // fingerprint -> last activity time
HardMode bool
ActiveMutation *Mutation
DailyMode bool
DailyDate string
}
type playerActionMsg struct {
PlayerID string
Action PlayerAction
}
func NewGameSession(cfg *config.Config) *GameSession {
return &GameSession{
cfg: cfg,
state: GameState{
FloorNum: 1,
},
actions: make(map[string]PlayerAction),
actionCh: make(chan playerActionMsg, 4),
combatSignal: make(chan struct{}, 1),
done: make(chan struct{}),
lastActivity: make(map[string]time.Time),
}
}
func (s *GameSession) Stop() {
select {
case <-s.done:
// already stopped
default:
close(s.done)
}
}
// StartGame determines solo mode from actual player count at game start
func (s *GameSession) StartGame() {
s.mu.Lock()
if s.started {
s.mu.Unlock()
return
}
s.started = true
s.state.SoloMode = len(s.state.Players) == 1
now := time.Now()
for _, p := range s.state.Players {
s.lastActivity[p.Fingerprint] = now
}
s.mu.Unlock()
s.StartFloor()
go s.combatLoop()
}
// combatLoop continuously runs turns while in combat phase
func (s *GameSession) combatLoop() {
for {
select {
case <-s.done:
return
default:
}
s.mu.Lock()
phase := s.state.Phase
gameOver := s.state.GameOver
s.mu.Unlock()
if gameOver {
slog.Info("game over", "floor", s.state.FloorNum, "victory", s.state.Victory)
return
}
// Remove players inactive for >60 seconds
s.mu.Lock()
now := time.Now()
changed := false
remaining := make([]*entity.Player, 0, len(s.state.Players))
for _, p := range s.state.Players {
if p.Fingerprint != "" && !p.IsOut() {
if last, ok := s.lastActivity[p.Fingerprint]; ok {
if now.Sub(last) > 60*time.Second {
slog.Warn("player inactive removed", "fingerprint", p.Fingerprint, "name", p.Name)
s.addLog(fmt.Sprintf("%s removed (disconnected)", p.Name))
changed = true
continue
}
}
}
remaining = append(remaining, p)
}
if changed {
s.state.Players = remaining
if len(s.state.Players) == 0 {
s.state.GameOver = true
s.mu.Unlock()
return
}
}
s.mu.Unlock()
if phase == PhaseCombat {
s.RunTurn()
} else {
select {
case <-s.combatSignal:
case <-s.done:
return
}
}
}
}
func (s *GameSession) signalCombat() {
select {
case s.combatSignal <- struct{}{}:
default:
}
}
func (s *GameSession) AddPlayer(p *entity.Player) {
s.mu.Lock()
defer s.mu.Unlock()
if p.Skills == nil {
p.Skills = &entity.PlayerSkills{BranchIndex: -1}
}
s.state.Players = append(s.state.Players, p)
}
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
// Revive dead players at 30% HP
for _, p := range s.state.Players {
if p.IsDead() {
p.Revive(0.30)
}
}
}
func (s *GameSession) GetState() GameState {
s.mu.Lock()
defer s.mu.Unlock()
// Deep copy players
players := make([]*entity.Player, len(s.state.Players))
for i, p := range s.state.Players {
cp := *p
cp.Inventory = make([]entity.Item, len(p.Inventory))
copy(cp.Inventory, p.Inventory)
cp.Relics = make([]entity.Relic, len(p.Relics))
copy(cp.Relics, p.Relics)
cp.Effects = make([]entity.ActiveEffect, len(p.Effects))
copy(cp.Effects, p.Effects)
if p.Skills != nil {
skillsCopy := *p.Skills
cp.Skills = &skillsCopy
}
players[i] = &cp
}
// Deep copy monsters
monsters := make([]*entity.Monster, len(s.state.Monsters))
for i, m := range s.state.Monsters {
cm := *m
monsters[i] = &cm
}
// Deep copy floor
var floorCopy *dungeon.Floor
if s.state.Floor != nil {
fc := *s.state.Floor
fc.Rooms = make([]*dungeon.Room, len(s.state.Floor.Rooms))
for i, r := range s.state.Floor.Rooms {
rc := *r
rc.Neighbors = make([]int, len(r.Neighbors))
copy(rc.Neighbors, r.Neighbors)
fc.Rooms[i] = &rc
}
floorCopy = &fc
}
// Copy combat log
logCopy := make([]string, len(s.state.CombatLog))
copy(logCopy, s.state.CombatLog)
// Copy submitted actions
submittedCopy := make(map[string]string, len(s.state.SubmittedActions))
for k, v := range s.state.SubmittedActions {
submittedCopy[k] = v
}
// Copy pending logs
pendingCopy := make([]string, len(s.state.PendingLogs))
copy(pendingCopy, s.state.PendingLogs)
return GameState{
Floor: floorCopy,
Players: players,
Monsters: monsters,
Phase: s.state.Phase,
FloorNum: s.state.FloorNum,
TurnNum: s.state.TurnNum,
CombatTurn: s.state.CombatTurn,
SoloMode: s.state.SoloMode,
GameOver: s.state.GameOver,
Victory: s.state.Victory,
ShopItems: append([]entity.Item{}, s.state.ShopItems...),
CombatLog: logCopy,
TurnDeadline: s.state.TurnDeadline,
SubmittedActions: submittedCopy,
PendingLogs: pendingCopy,
TurnResolving: s.state.TurnResolving,
BossKilled: s.state.BossKilled,
FleeSucceeded: s.state.FleeSucceeded,
}
}
func (s *GameSession) SubmitAction(playerID string, action PlayerAction) {
s.mu.Lock()
s.lastActivity[playerID] = time.Now()
desc := ""
switch action.Type {
case ActionAttack:
desc = "Attacking"
case ActionSkill:
desc = "Using Skill"
case ActionItem:
desc = "Using Item"
case ActionFlee:
desc = "Fleeing"
case ActionWait:
desc = "Defending"
}
if s.state.SubmittedActions == nil {
s.state.SubmittedActions = make(map[string]string)
}
s.state.SubmittedActions[playerID] = desc
s.mu.Unlock()
s.actionCh <- playerActionMsg{PlayerID: playerID, Action: action}
}
// RevealNextLog moves one log from PendingLogs to CombatLog. Returns true if there was one to reveal.
func (s *GameSession) RevealNextLog() bool {
s.mu.Lock()
defer s.mu.Unlock()
if len(s.state.PendingLogs) == 0 {
return false
}
msg := s.state.PendingLogs[0]
s.state.PendingLogs = s.state.PendingLogs[1:]
s.state.CombatLog = append(s.state.CombatLog, msg)
if len(s.state.CombatLog) > 8 {
s.state.CombatLog = s.state.CombatLog[len(s.state.CombatLog)-8:]
}
return true
}
func (s *GameSession) TouchActivity(fingerprint string) {
s.mu.Lock()
defer s.mu.Unlock()
s.lastActivity[fingerprint] = time.Now()
}
// AllocateSkillPoint spends one skill point into the given branch for the player.
func (s *GameSession) AllocateSkillPoint(fingerprint string, branchIdx int) error {
s.mu.Lock()
defer s.mu.Unlock()
for _, p := range s.state.Players {
if p.Fingerprint == fingerprint {
if p.Skills == nil || p.Skills.Points <= p.Skills.Allocated {
return fmt.Errorf("no skill points available")
}
return p.Skills.Allocate(branchIdx, p.Class)
}
}
return fmt.Errorf("player not found")
}
// BuyItem handles shop purchases
func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.state.Phase != PhaseShop || itemIdx < 0 || itemIdx >= len(s.state.ShopItems) {
return false
}
item := s.state.ShopItems[itemIdx]
for _, p := range s.state.Players {
if p.Fingerprint == playerID && p.Gold >= item.Price {
if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
return false
}
p.Gold -= item.Price
p.Inventory = append(p.Inventory, item)
return true
}
}
return false
}
// SendChat appends a chat message to the combat log
func (s *GameSession) SendChat(playerName, message string) {
s.mu.Lock()
defer s.mu.Unlock()
if emoteText, ok := ParseEmote(message); ok {
s.addLog(fmt.Sprintf("✨ %s %s", playerName, emoteText))
} else {
s.addLog(fmt.Sprintf("[%s] %s", playerName, message))
}
}
// LeaveShop exits the shop phase
func (s *GameSession) LeaveShop() {
s.mu.Lock()
defer s.mu.Unlock()
s.state.Phase = PhaseExploring
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
}