From b0766c488c309d502f335bef28b8332f6bca3e33 Mon Sep 17 00:00:00 2001 From: tolelom <98kimsungmin@naver.com> Date: Tue, 24 Mar 2026 10:23:21 +0900 Subject: [PATCH] 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 --- game/session.go | 53 +++++++++++++++++++++++++++++++++++++++++++- game/session_test.go | 31 ++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 1 deletion(-) diff --git a/game/session.go b/game/session.go index c9a07da..a3514bf 100644 --- a/game/session.go +++ b/game/session.go @@ -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) { diff --git a/game/session_test.go b/game/session_test.go index 0a4630b..fe49a6a 100644 --- a/game/session_test.go +++ b/game/session_test.go @@ -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)