fix: 13 bugs found via systematic code review and testing

Multiplayer:
- Add WaitingScreen between class select and game start; previously
  selecting a class immediately started the game and locked the room,
  preventing other players from joining
- Add periodic lobby room list refresh (2s interval)
- Add LeaveRoom method for backing out of waiting room

Combat & mechanics:
- Mark invalid attack targets with TargetIdx=-1 to suppress misleading
  "0 dmg" combat log entries
- Make Freeze effect actually skip frozen player's action (was purely
  cosmetic before - expired during tick before action processing)
- Implement Life Siphon relic heal-on-damage effect (was defined but
  never applied in combat)
- Fix combo matching to track used actions and prevent reuse

Game modes:
- Wire up weekly mutations to GameSession via ApplyWeeklyMutation()
- Implement 3 mutation runtime effects: no_shop, glass_cannon, elite_flood
- Pass HardMode toggle from lobby UI through Context to GameSession
- Apply HardMode difficulty multipliers (1.5x monsters, 2x shop, 0.5x heal)

Polish:
- Set starting room (index 0) to always be Empty (safe start)
- Distinguish shop purchase errors: "Not enough gold" vs "Inventory full"
- Record random events in codex for discovery tracking

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-25 20:45:56 +09:00
parent 97aa4667a1
commit 1563091de1
20 changed files with 316 additions and 29 deletions

View File

@@ -55,6 +55,7 @@ type GameState struct {
TurnResolving bool // true while logs are being replayed
BossKilled bool
FleeSucceeded bool
LastEventName string // name of the most recent random event (for codex)
}
func (s *GameSession) addLog(msg string) {
@@ -93,6 +94,11 @@ type playerActionMsg struct {
Action PlayerAction
}
// hasMutation returns true if the session has the given mutation active.
func (s *GameSession) hasMutation(id string) bool {
return s.ActiveMutation != nil && s.ActiveMutation.ID == id
}
func NewGameSession(cfg *config.Config) *GameSession {
return &GameSession{
cfg: cfg,
@@ -107,6 +113,13 @@ func NewGameSession(cfg *config.Config) *GameSession {
}
}
// ApplyWeeklyMutation sets the current week's mutation on this session.
func (s *GameSession) ApplyWeeklyMutation() {
mut := GetWeeklyMutation()
s.ActiveMutation = &mut
mut.Apply(&s.cfg.Game)
}
func (s *GameSession) Stop() {
select {
case <-s.done:
@@ -206,6 +219,14 @@ func (s *GameSession) AddPlayer(p *entity.Player) {
if p.Skills == nil {
p.Skills = &entity.PlayerSkills{BranchIndex: -1}
}
if s.hasMutation("glass_cannon") {
p.ATK *= 2
p.MaxHP /= 2
if p.MaxHP < 1 {
p.MaxHP = 1
}
p.HP = p.MaxHP
}
s.state.Players = append(s.state.Players, p)
}
@@ -304,6 +325,7 @@ func (s *GameSession) GetState() GameState {
TurnResolving: s.state.TurnResolving,
BossKilled: s.state.BossKilled,
FleeSucceeded: s.state.FleeSucceeded,
LastEventName: s.state.LastEventName,
}
}
@@ -368,25 +390,38 @@ func (s *GameSession) AllocateSkillPoint(fingerprint string, branchIdx int) erro
return fmt.Errorf("player not found")
}
// BuyResult describes the outcome of a shop purchase attempt.
type BuyResult int
const (
BuyOK BuyResult = iota
BuyNoGold
BuyInventoryFull
BuyFailed
)
// BuyItem handles shop purchases
func (s *GameSession) BuyItem(playerID string, itemIdx int) bool {
func (s *GameSession) BuyItem(playerID string, itemIdx int) BuyResult {
s.mu.Lock()
defer s.mu.Unlock()
if s.state.Phase != PhaseShop || itemIdx < 0 || itemIdx >= len(s.state.ShopItems) {
return false
return BuyFailed
}
item := s.state.ShopItems[itemIdx]
for _, p := range s.state.Players {
if p.Fingerprint == playerID && p.Gold >= item.Price {
if p.Fingerprint == playerID {
if p.Gold < item.Price {
return BuyNoGold
}
if len(p.Inventory) >= s.cfg.Game.InventoryLimit {
return false
return BuyInventoryFull
}
p.Gold -= item.Price
p.Inventory = append(p.Inventory, item)
return true
return BuyOK
}
}
return false
return BuyFailed
}
// SendChat appends a chat message to the combat log