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:
@@ -155,7 +155,58 @@ func (s *GameSession) StartFloor() {
|
|||||||
func (s *GameSession) GetState() GameState {
|
func (s *GameSession) GetState() GameState {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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) {
|
func (s *GameSession) SubmitAction(playerName string, action PlayerAction) {
|
||||||
|
|||||||
@@ -7,6 +7,37 @@ import (
|
|||||||
"github.com/tolelom/catacombs/entity"
|
"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) {
|
func TestSessionTurnTimeout(t *testing.T) {
|
||||||
s := NewGameSession()
|
s := NewGameSession()
|
||||||
p := entity.NewPlayer("test", entity.ClassWarrior)
|
p := entity.NewPlayer("test", entity.ClassWarrior)
|
||||||
|
|||||||
Reference in New Issue
Block a user