Files
Catacombs/game/session.go
tolelom afe4ee1056 feat: add structured logging with log/slog and panic recovery
Replace log.Printf/Println with slog.Info/Error/Warn across the codebase.
Initialize slog with JSON handler in main.go. Add panic recovery defer
in SSH session handler. Add structured game event logging (room created,
player joined, game started, game over, player inactive removed).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-25 13:18:06 +09:00

374 lines
8.6 KiB
Go

package game
import (
"fmt"
"log/slog"
"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
}
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()
s.state.Players = append(s.state.Players, p)
}
func (s *GameSession) StartFloor() {
s.mu.Lock()
defer s.mu.Unlock()
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
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)
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()
}
// 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()
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
}