feat: game session, turn system, lobby, and room events

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-23 23:55:08 +09:00
parent 8849bf5220
commit 13d468943a
6 changed files with 705 additions and 0 deletions

138
game/event.go Normal file
View File

@@ -0,0 +1,138 @@
package game
import (
"math/rand"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
)
func (s *GameSession) EnterRoom(roomIdx int) {
s.mu.Lock()
defer s.mu.Unlock()
s.state.Floor.CurrentRoom = roomIdx
dungeon.UpdateVisibility(s.state.Floor)
room := s.state.Floor.Rooms[roomIdx]
if room.Cleared {
return
}
switch room.Type {
case dungeon.RoomCombat:
s.spawnMonsters()
s.state.Phase = PhaseCombat
s.state.CombatTurn = 0
case dungeon.RoomBoss:
s.spawnBoss()
s.state.Phase = PhaseCombat
s.state.CombatTurn = 0
case dungeon.RoomShop:
s.generateShopItems()
s.state.Phase = PhaseShop
case dungeon.RoomTreasure:
s.grantTreasure()
room.Cleared = true
case dungeon.RoomEvent:
s.triggerEvent()
room.Cleared = true
case dungeon.RoomEmpty:
room.Cleared = true
}
}
func (s *GameSession) spawnMonsters() {
count := 1 + rand.Intn(5) // 1~5 monsters
floor := s.state.FloorNum
s.state.Monsters = make([]*entity.Monster, count)
// Pick appropriate monster type for floor
var mt entity.MonsterType
switch {
case floor <= 5:
mt = entity.MonsterSlime
case floor <= 10:
mt = entity.MonsterSkeleton
case floor <= 14:
mt = entity.MonsterOrc
default:
mt = entity.MonsterDarkKnight
}
// Solo mode: 50% HP
for i := 0; i < count; i++ {
m := entity.NewMonster(mt, floor)
if s.state.SoloMode {
m.HP = m.HP / 2
if m.HP < 1 {
m.HP = 1
}
m.MaxHP = m.HP
}
s.state.Monsters[i] = m
}
}
func (s *GameSession) spawnBoss() {
var mt entity.MonsterType
switch s.state.FloorNum {
case 5:
mt = entity.MonsterBoss5
case 10:
mt = entity.MonsterBoss10
case 15:
mt = entity.MonsterBoss15
case 20:
mt = entity.MonsterBoss20
default:
mt = entity.MonsterBoss5
}
boss := entity.NewMonster(mt, s.state.FloorNum)
if s.state.SoloMode {
boss.HP = boss.HP / 2
boss.MaxHP = boss.HP
}
s.state.Monsters = []*entity.Monster{boss}
}
func (s *GameSession) grantTreasure() {
// Random item for each player
for _, p := range s.state.Players {
if rand.Float64() < 0.5 {
p.Inventory = append(p.Inventory, entity.Item{
Name: "Iron Sword", Type: entity.ItemWeapon, Bonus: 3 + rand.Intn(6),
})
} else {
p.Inventory = append(p.Inventory, entity.Item{
Name: "Iron Shield", Type: entity.ItemArmor, Bonus: 2 + rand.Intn(4),
})
}
}
}
func (s *GameSession) generateShopItems() {
s.state.ShopItems = []entity.Item{
{Name: "HP Potion", Type: entity.ItemConsumable, Bonus: 30, Price: 20},
{Name: "Iron Sword", Type: entity.ItemWeapon, Bonus: 3 + rand.Intn(6), Price: 40 + rand.Intn(41)},
{Name: "Iron Shield", Type: entity.ItemArmor, Bonus: 2 + rand.Intn(4), Price: 30 + rand.Intn(31)},
}
}
func (s *GameSession) triggerEvent() {
// Random event: 50% trap, 50% blessing
for _, p := range s.state.Players {
if p.IsDead() {
continue
}
if rand.Float64() < 0.5 {
// Trap: 10~20 damage
dmg := 10 + rand.Intn(11)
p.TakeDamage(dmg)
} else {
// Blessing: heal 15~25
heal := 15 + rand.Intn(11)
p.Heal(heal)
}
}
}

94
game/lobby.go Normal file
View File

@@ -0,0 +1,94 @@
package game
import (
"fmt"
"math/rand"
"sync"
)
type RoomStatus int
const (
RoomWaiting RoomStatus = iota
RoomPlaying
)
type LobbyRoom struct {
Code string
Name string
Players []string
Status RoomStatus
Session *GameSession
}
type Lobby struct {
mu sync.RWMutex
rooms map[string]*LobbyRoom
}
func NewLobby() *Lobby {
return &Lobby{rooms: make(map[string]*LobbyRoom)}
}
func (l *Lobby) CreateRoom(name string) string {
l.mu.Lock()
defer l.mu.Unlock()
code := generateCode()
for l.rooms[code] != nil {
code = generateCode()
}
l.rooms[code] = &LobbyRoom{
Code: code,
Name: name,
Status: RoomWaiting,
}
return code
}
func (l *Lobby) JoinRoom(code, playerName string) error {
l.mu.Lock()
defer l.mu.Unlock()
room, ok := l.rooms[code]
if !ok {
return fmt.Errorf("room %s not found", code)
}
if len(room.Players) >= 4 {
return fmt.Errorf("room %s is full", code)
}
if room.Status != RoomWaiting {
return fmt.Errorf("room %s already in progress", code)
}
room.Players = append(room.Players, playerName)
return nil
}
func (l *Lobby) GetRoom(code string) *LobbyRoom {
l.mu.RLock()
defer l.mu.RUnlock()
return l.rooms[code]
}
func (l *Lobby) ListRooms() []*LobbyRoom {
l.mu.RLock()
defer l.mu.RUnlock()
result := make([]*LobbyRoom, 0, len(l.rooms))
for _, r := range l.rooms {
result = append(result, r)
}
return result
}
func (l *Lobby) RemoveRoom(code string) {
l.mu.Lock()
defer l.mu.Unlock()
delete(l.rooms, code)
}
func generateCode() string {
const letters = "ABCDEFGHJKLMNPQRSTUVWXYZ"
b := make([]byte, 4)
for i := range b {
b[i] = letters[rand.Intn(len(letters))]
}
return string(b)
}

40
game/lobby_test.go Normal file
View File

@@ -0,0 +1,40 @@
package game
import "testing"
func TestCreateRoom(t *testing.T) {
lobby := NewLobby()
code := lobby.CreateRoom("Test Room")
if len(code) != 4 {
t.Errorf("Room code length: got %d, want 4", len(code))
}
rooms := lobby.ListRooms()
if len(rooms) != 1 {
t.Errorf("Room count: got %d, want 1", len(rooms))
}
}
func TestJoinRoom(t *testing.T) {
lobby := NewLobby()
code := lobby.CreateRoom("Test Room")
err := lobby.JoinRoom(code, "player1")
if err != nil {
t.Errorf("Join failed: %v", err)
}
room := lobby.GetRoom(code)
if len(room.Players) != 1 {
t.Errorf("Player count: got %d, want 1", len(room.Players))
}
}
func TestJoinRoomFull(t *testing.T) {
lobby := NewLobby()
code := lobby.CreateRoom("Test Room")
for i := 0; i < 4; i++ {
lobby.JoinRoom(code, "player")
}
err := lobby.JoinRoom(code, "player5")
if err == nil {
t.Error("Should reject 5th player")
}
}

133
game/session.go Normal file
View File

@@ -0,0 +1,133 @@
package game
import (
"sync"
"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
}
type GameSession struct {
mu sync.Mutex
state GameState
actions map[string]PlayerAction // playerName -> action
actionCh chan playerActionMsg
}
type playerActionMsg struct {
PlayerName string
Action PlayerAction
}
func NewGameSession() *GameSession {
return &GameSession{
state: GameState{
FloorNum: 1,
},
actions: make(map[string]PlayerAction),
actionCh: make(chan playerActionMsg, 4),
}
}
// StartGame determines solo mode from actual player count at game start
func (s *GameSession) StartGame() {
s.mu.Lock()
s.state.SoloMode = len(s.state.Players) == 1
s.mu.Unlock()
s.StartFloor()
}
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()
return s.state
}
func (s *GameSession) SubmitAction(playerName string, action PlayerAction) {
s.actionCh <- playerActionMsg{PlayerName: playerName, Action: action}
}
// BuyItem handles shop purchases
func (s *GameSession) BuyItem(playerName 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.Name == playerName && p.Gold >= item.Price {
p.Gold -= item.Price
p.Inventory = append(p.Inventory, item)
return true
}
}
return false
}
// 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
}

29
game/session_test.go Normal file
View File

@@ -0,0 +1,29 @@
package game
import (
"testing"
"time"
"github.com/tolelom/catacombs/entity"
)
func TestSessionTurnTimeout(t *testing.T) {
s := NewGameSession()
p := entity.NewPlayer("test", entity.ClassWarrior)
s.AddPlayer(p)
s.StartFloor()
// Don't submit any action, wait for timeout
done := make(chan struct{})
go func() {
s.RunTurn()
close(done)
}()
select {
case <-done:
// Turn completed via timeout
case <-time.After(7 * time.Second):
t.Error("Turn did not timeout within 7 seconds")
}
}

271
game/turn.go Normal file
View File

@@ -0,0 +1,271 @@
package game
import (
"math/rand"
"time"
"github.com/tolelom/catacombs/combat"
"github.com/tolelom/catacombs/dungeon"
"github.com/tolelom/catacombs/entity"
)
const TurnTimeout = 5 * time.Second
func (s *GameSession) RunTurn() {
s.mu.Lock()
s.state.TurnNum++
s.state.CombatTurn++
s.actions = make(map[string]PlayerAction)
aliveCount := 0
for _, p := range s.state.Players {
if !p.IsDead() {
aliveCount++
}
}
s.mu.Unlock()
// Collect actions with timeout
timer := time.NewTimer(TurnTimeout)
collected := 0
for collected < aliveCount {
select {
case msg := <-s.actionCh:
s.mu.Lock()
s.actions[msg.PlayerName] = msg.Action
s.mu.Unlock()
collected++
case <-timer.C:
goto resolve
}
}
timer.Stop()
resolve:
s.mu.Lock()
defer s.mu.Unlock()
// Default action for players who didn't submit: Wait
for _, p := range s.state.Players {
if !p.IsDead() {
if _, ok := s.actions[p.Name]; !ok {
s.actions[p.Name] = PlayerAction{Type: ActionWait}
}
}
}
s.resolvePlayerActions()
s.resolveMonsterActions()
}
func (s *GameSession) resolvePlayerActions() {
var intents []combat.AttackIntent
// Track which monsters were alive before this turn (for gold awards)
aliveBeforeTurn := make(map[int]bool)
for i, m := range s.state.Monsters {
if !m.IsDead() {
aliveBeforeTurn[i] = true
}
}
// Check if ALL alive players chose flee — only then the party flees
fleeCount := 0
aliveCount := 0
for _, p := range s.state.Players {
if p.IsDead() {
continue
}
aliveCount++
if action, ok := s.actions[p.Name]; ok && action.Type == ActionFlee {
fleeCount++
}
}
if fleeCount == aliveCount && aliveCount > 0 {
if combat.AttemptFlee() {
s.state.Phase = PhaseExploring
return
}
// Flee failed — all fleeing players waste their turn, continue to monster phase
return
}
for _, p := range s.state.Players {
if p.IsDead() {
continue
}
action, ok := s.actions[p.Name]
if !ok {
continue
}
switch action.Type {
case ActionAttack:
intents = append(intents, combat.AttackIntent{
PlayerATK: p.EffectiveATK(),
TargetIdx: action.TargetIdx,
Multiplier: 1.0,
IsAoE: false,
})
case ActionSkill:
switch p.Class {
case entity.ClassWarrior:
// Taunt: mark all monsters to target this warrior
for _, m := range s.state.Monsters {
if !m.IsDead() {
m.TauntTarget = true
m.TauntTurns = 2
}
}
case entity.ClassMage:
intents = append(intents, combat.AttackIntent{
PlayerATK: p.EffectiveATK(),
TargetIdx: -1,
Multiplier: 0.8,
IsAoE: true,
})
case entity.ClassHealer:
if action.TargetIdx >= 0 && action.TargetIdx < len(s.state.Players) {
s.state.Players[action.TargetIdx].Heal(30)
}
case entity.ClassRogue:
// Scout: reveal neighboring rooms
currentRoom := s.state.Floor.Rooms[s.state.Floor.CurrentRoom]
for _, neighborIdx := range currentRoom.Neighbors {
s.state.Floor.Rooms[neighborIdx].Visited = true
}
}
case ActionItem:
// Use first consumable from inventory
for i, item := range p.Inventory {
if item.Type == entity.ItemConsumable {
p.Heal(item.Bonus)
p.Inventory = append(p.Inventory[:i], p.Inventory[i+1:]...)
break
}
}
case ActionFlee:
// Individual flee does nothing if not unanimous (already handled above)
case ActionWait:
// Defensive stance — no action
}
}
if len(intents) > 0 && len(s.state.Monsters) > 0 {
combat.ResolveAttacks(intents, s.state.Monsters)
}
// Award gold only for monsters that JUST died this turn
for i, m := range s.state.Monsters {
if m.IsDead() && aliveBeforeTurn[i] {
goldReward := 5 + s.state.FloorNum
if goldReward > 15 {
goldReward = 15
}
for _, p := range s.state.Players {
if !p.IsDead() {
p.Gold += goldReward
}
}
// Boss kill: drop relic
if m.IsBoss {
s.grantBossRelic()
}
}
}
// Filter out dead monsters
alive := make([]*entity.Monster, 0)
for _, m := range s.state.Monsters {
if !m.IsDead() {
alive = append(alive, m)
}
}
s.state.Monsters = alive
// Check if combat is over
if len(s.state.Monsters) == 0 {
s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Cleared = true
// Check if this was the boss room -> advance floor
if s.state.Floor.Rooms[s.state.Floor.CurrentRoom].Type == dungeon.RoomBoss {
s.advanceFloor()
} else {
s.state.Phase = PhaseExploring
}
}
}
func (s *GameSession) advanceFloor() {
if s.state.FloorNum >= 20 {
s.state.Phase = PhaseResult
s.state.Victory = true
s.state.GameOver = true
return
}
s.state.FloorNum++
s.state.Floor = dungeon.GenerateFloor(s.state.FloorNum)
s.state.Phase = PhaseExploring
s.state.CombatTurn = 0
// Revive dead players at 30% HP
for _, p := range s.state.Players {
if p.IsDead() {
p.Revive(0.30)
}
}
}
func (s *GameSession) grantBossRelic() {
relics := []entity.Relic{
{Name: "Vampiric Ring", Effect: entity.RelicHealOnKill, Value: 5, Price: 100},
{Name: "Power Amulet", Effect: entity.RelicATKBoost, Value: 3, Price: 120},
{Name: "Iron Ward", Effect: entity.RelicDEFBoost, Value: 2, Price: 100},
{Name: "Gold Charm", Effect: entity.RelicGoldBoost, Value: 5, Price: 150},
}
for _, p := range s.state.Players {
if !p.IsDead() {
r := relics[rand.Intn(len(relics))]
p.Relics = append(p.Relics, r)
}
}
}
func (s *GameSession) resolveMonsterActions() {
if s.state.Phase != PhaseCombat {
return
}
for _, m := range s.state.Monsters {
if m.IsDead() {
continue
}
targetIdx, isAoE := combat.MonsterAI(m, s.state.Players, s.state.CombatTurn)
if isAoE {
// Boss AoE: 0.5x damage to all
for _, p := range s.state.Players {
if !p.IsDead() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 0.5)
p.TakeDamage(dmg)
}
}
} else {
if targetIdx >= 0 && targetIdx < len(s.state.Players) {
p := s.state.Players[targetIdx]
if !p.IsDead() {
dmg := combat.CalcDamage(m.ATK, p.EffectiveDEF(), 1.0)
p.TakeDamage(dmg)
}
}
}
m.TickTaunt()
}
// Check party wipe
allPlayersDead := true
for _, p := range s.state.Players {
if !p.IsDead() {
allPlayersDead = false
break
}
}
if allPlayersDead {
s.state.Phase = PhaseResult
}
}