fix: deep-copy GameState in GetState to prevent data race

Replace shallow struct copy with full deep copy of Players, Monsters,
Floor/Rooms, Inventory, Relics, ShopItems, and CombatLog slices so
concurrent readers via GetState never alias the combatLoop's live data.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-24 10:23:21 +09:00
parent ae3375a023
commit b0766c488c
2 changed files with 83 additions and 1 deletions

View File

@@ -155,7 +155,58 @@ func (s *GameSession) StartFloor() {
func (s *GameSession) GetState() GameState {
s.mu.Lock()
defer s.mu.Unlock()
return s.state
// 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)
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)
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,
}
}
func (s *GameSession) SubmitAction(playerName string, action PlayerAction) {

View File

@@ -7,6 +7,37 @@ import (
"github.com/tolelom/catacombs/entity"
)
func TestGetStateNoRace(t *testing.T) {
s := NewGameSession()
p := entity.NewPlayer("Racer", entity.ClassWarrior)
s.AddPlayer(p)
s.StartGame()
done := make(chan struct{})
go func() {
defer close(done)
for i := 0; i < 100; i++ {
st := s.GetState()
for _, p := range st.Players {
_ = p.HP
_ = p.Gold
}
for _, m := range st.Monsters {
_ = m.HP
}
}
}()
for i := 0; i < 10; i++ {
select {
case s.actionCh <- playerActionMsg{PlayerName: "Racer", Action: PlayerAction{Type: ActionWait}}:
default:
}
time.Sleep(10 * time.Millisecond)
}
<-done
}
func TestSessionTurnTimeout(t *testing.T) {
s := NewGameSession()
p := entity.NewPlayer("test", entity.ClassWarrior)