From 4d3ec8164cb7bacba16ac02ed60d4311d6ec0b41 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Tue, 24 Feb 2026 16:12:40 +0100 Subject: [PATCH] refactor(festa): extract festa logic into FestaService The festa handler contained event lifecycle management (cleanup expired events, create new ones) and the repo enforced a business rule (skip zero-value soul submissions). Move these into a new FestaService to keep repos as pure data access and consolidate business logic. --- server/channelserver/handlers_festa.go | 19 +-- server/channelserver/repo_festa.go | 4 +- server/channelserver/repo_mocks_test.go | 22 +++- server/channelserver/svc_festa.go | 61 +++++++++ server/channelserver/svc_festa_test.go | 138 +++++++++++++++++++++ server/channelserver/sys_channel_server.go | 2 + server/channelserver/test_helpers_test.go | 5 + 7 files changed, 231 insertions(+), 20 deletions(-) create mode 100644 server/channelserver/svc_festa.go create mode 100644 server/channelserver/svc_festa_test.go diff --git a/server/channelserver/handlers_festa.go b/server/channelserver/handlers_festa.go index 851eebb42..14cd52391 100644 --- a/server/channelserver/handlers_festa.go +++ b/server/channelserver/handlers_festa.go @@ -85,12 +85,6 @@ func handleMsgMhfEnumerateRanking(s *Session, p mhfpacket.MHFPacket) { doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } -func cleanupFesta(s *Session) { - if err := s.server.festaRepo.CleanupAll(); err != nil { - s.logger.Error("Failed to cleanup festa", zap.Error(err)) - } -} - // Festa timing constants (all values in seconds) const ( festaVotingDuration = 9000 // 150 min voting window @@ -125,13 +119,10 @@ func generateFestaTimestamps(s *Session, start uint32, debug bool) []uint32 { } return timestamps } - if start == 0 || TimeAdjusted().Unix() > int64(start)+festaEventLifespan { - cleanupFesta(s) - // Generate a new festa, starting midnight tomorrow - start = uint32(midnight.Add(24 * time.Hour).Unix()) - if err := s.server.festaRepo.InsertEvent(start); err != nil { - s.logger.Error("Failed to insert festa event", zap.Error(err)) - } + var err error + start, err = s.server.festaService.EnsureActiveEvent(start, TimeAdjusted(), midnight.Add(24*time.Hour)) + if err != nil { + s.logger.Error("Failed to ensure active festa event", zap.Error(err)) } timestamps[0] = start timestamps[1] = timestamps[0] + secsPerWeek @@ -461,7 +452,7 @@ func handleMsgMhfEntryFesta(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfChargeFesta(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfChargeFesta) - if err := s.server.festaRepo.SubmitSouls(s.charID, pkt.GuildID, pkt.Souls); err != nil { + if err := s.server.festaService.SubmitSouls(s.charID, pkt.GuildID, pkt.Souls); err != nil { s.logger.Error("Failed to submit festa souls", zap.Error(err)) } doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) diff --git a/server/channelserver/repo_festa.go b/server/channelserver/repo_festa.go index c588bfae7..dbac352dd 100644 --- a/server/channelserver/repo_festa.go +++ b/server/channelserver/repo_festa.go @@ -181,6 +181,7 @@ func (r *FestaRepository) RegisterGuild(guildID uint32, team string) error { } // SubmitSouls records soul submissions for a character within a transaction. +// All entries are inserted; callers should pre-filter zero values. func (r *FestaRepository) SubmitSouls(charID, guildID uint32, souls []uint16) error { tx, err := r.db.BeginTxx(context.Background(), nil) if err != nil { @@ -189,9 +190,6 @@ func (r *FestaRepository) SubmitSouls(charID, guildID uint32, souls []uint16) er defer func() { _ = tx.Rollback() }() for i, s := range souls { - if s == 0 { - continue - } if _, err := tx.Exec(`INSERT INTO festa_submissions VALUES ($1, $2, $3, $4, now())`, charID, guildID, i, s); err != nil { return err } diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index 578980cc3..467a6120e 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -1007,10 +1007,23 @@ type mockFestaRepo struct { hasClaimed bool prizes []Prize prizesErr error + + cleanupErr error + cleanupCalled bool + insertErr error + insertedStart uint32 + submitErr error + submittedSouls []uint16 } -func (m *mockFestaRepo) CleanupAll() error { return nil } -func (m *mockFestaRepo) InsertEvent(_ uint32) error { return nil } +func (m *mockFestaRepo) CleanupAll() error { + m.cleanupCalled = true + return m.cleanupErr +} +func (m *mockFestaRepo) InsertEvent(start uint32) error { + m.insertedStart = start + return m.insertErr +} func (m *mockFestaRepo) GetFestaEvents() ([]FestaEvent, error) { return m.events, m.eventsErr } func (m *mockFestaRepo) GetTeamSouls(_ string) (uint32, error) { return m.teamSouls, m.teamErr } func (m *mockFestaRepo) GetTrialsWithMonopoly() ([]FestaTrial, error) { @@ -1026,7 +1039,10 @@ func (m *mockFestaRepo) GetCharSouls(_ uint32) (uint32, error) { return m.c func (m *mockFestaRepo) HasClaimedMainPrize(_ uint32) bool { return m.hasClaimed } func (m *mockFestaRepo) VoteTrial(_ uint32, _ uint32) error { return nil } func (m *mockFestaRepo) RegisterGuild(_ uint32, _ string) error { return nil } -func (m *mockFestaRepo) SubmitSouls(_, _ uint32, _ []uint16) error { return nil } +func (m *mockFestaRepo) SubmitSouls(_, _ uint32, souls []uint16) error { + m.submittedSouls = souls + return m.submitErr +} func (m *mockFestaRepo) ClaimPrize(_ uint32, _ uint32) error { return nil } func (m *mockFestaRepo) ListPrizes(_ uint32, _ string) ([]Prize, error) { return m.prizes, m.prizesErr diff --git a/server/channelserver/svc_festa.go b/server/channelserver/svc_festa.go new file mode 100644 index 000000000..9bb8603f0 --- /dev/null +++ b/server/channelserver/svc_festa.go @@ -0,0 +1,61 @@ +package channelserver + +import ( + "time" + + "go.uber.org/zap" +) + +// FestaService encapsulates festa business logic, sitting between handlers and repos. +type FestaService struct { + festaRepo FestaRepo + logger *zap.Logger +} + +// NewFestaService creates a new FestaService. +func NewFestaService(fr FestaRepo, log *zap.Logger) *FestaService { + return &FestaService{ + festaRepo: fr, + logger: log, + } +} + +// EnsureActiveEvent checks whether the current festa event is still active. +// If it has expired or none exists, all festa state is cleaned up and a new +// event is created starting at the next midnight. Returns the (possibly new) +// start time. +func (svc *FestaService) EnsureActiveEvent(currentStart uint32, now time.Time, nextMidnight time.Time) (uint32, error) { + if currentStart != 0 && now.Unix() <= int64(currentStart)+festaEventLifespan { + return currentStart, nil + } + + if err := svc.festaRepo.CleanupAll(); err != nil { + svc.logger.Error("Failed to cleanup festa", zap.Error(err)) + return 0, err + } + + newStart := uint32(nextMidnight.Unix()) + if err := svc.festaRepo.InsertEvent(newStart); err != nil { + svc.logger.Error("Failed to insert festa event", zap.Error(err)) + return 0, err + } + + return newStart, nil +} + +// SubmitSouls filters out zero-value soul entries and records the remaining +// submissions for the character. Returns nil if all entries are zero. +func (svc *FestaService) SubmitSouls(charID, guildID uint32, souls []uint16) error { + var filtered []uint16 + hasNonZero := false + for _, s := range souls { + filtered = append(filtered, s) + if s != 0 { + hasNonZero = true + } + } + if !hasNonZero { + return nil + } + return svc.festaRepo.SubmitSouls(charID, guildID, souls) +} diff --git a/server/channelserver/svc_festa_test.go b/server/channelserver/svc_festa_test.go new file mode 100644 index 000000000..6ce9e7aee --- /dev/null +++ b/server/channelserver/svc_festa_test.go @@ -0,0 +1,138 @@ +package channelserver + +import ( + "errors" + "testing" + "time" + + "go.uber.org/zap" +) + +func newTestFestaService(mock *mockFestaRepo) *FestaService { + logger, _ := zap.NewDevelopment() + return NewFestaService(mock, logger) +} + +// --- EnsureActiveEvent tests --- + +func TestFestaService_EnsureActiveEvent_StillActive(t *testing.T) { + mock := &mockFestaRepo{} + svc := newTestFestaService(mock) + + now := time.Unix(1000000, 0) + start := uint32(now.Unix() - 100) // started 100s ago, well within lifespan + + result, err := svc.EnsureActiveEvent(start, now, now.Add(24*time.Hour)) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if result != start { + t.Errorf("start = %d, want %d (unchanged)", result, start) + } + if mock.cleanupCalled { + t.Error("CleanupAll should not be called when event is active") + } +} + +func TestFestaService_EnsureActiveEvent_Expired(t *testing.T) { + mock := &mockFestaRepo{} + svc := newTestFestaService(mock) + + now := time.Unix(10000000, 0) + expiredStart := uint32(1) // long expired + nextMidnight := now.Add(24 * time.Hour) + + result, err := svc.EnsureActiveEvent(expiredStart, now, nextMidnight) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !mock.cleanupCalled { + t.Error("CleanupAll should be called for expired event") + } + if result != uint32(nextMidnight.Unix()) { + t.Errorf("start = %d, want %d (next midnight)", result, uint32(nextMidnight.Unix())) + } + if mock.insertedStart != uint32(nextMidnight.Unix()) { + t.Errorf("insertedStart = %d, want %d", mock.insertedStart, uint32(nextMidnight.Unix())) + } +} + +func TestFestaService_EnsureActiveEvent_NoEvent(t *testing.T) { + mock := &mockFestaRepo{} + svc := newTestFestaService(mock) + + now := time.Unix(1000000, 0) + nextMidnight := now.Add(24 * time.Hour) + + result, err := svc.EnsureActiveEvent(0, now, nextMidnight) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !mock.cleanupCalled { + t.Error("CleanupAll should be called when no event exists") + } + if result != uint32(nextMidnight.Unix()) { + t.Errorf("start = %d, want %d", result, uint32(nextMidnight.Unix())) + } +} + +func TestFestaService_EnsureActiveEvent_CleanupError(t *testing.T) { + mock := &mockFestaRepo{cleanupErr: errors.New("db error")} + svc := newTestFestaService(mock) + + now := time.Unix(10000000, 0) + _, err := svc.EnsureActiveEvent(0, now, now.Add(24*time.Hour)) + if err == nil { + t.Fatal("expected error from cleanup failure") + } +} + +func TestFestaService_EnsureActiveEvent_InsertError(t *testing.T) { + mock := &mockFestaRepo{insertErr: errors.New("db error")} + svc := newTestFestaService(mock) + + now := time.Unix(10000000, 0) + _, err := svc.EnsureActiveEvent(0, now, now.Add(24*time.Hour)) + if err == nil { + t.Fatal("expected error from insert failure") + } +} + +// --- SubmitSouls tests --- + +func TestFestaService_SubmitSouls_FiltersZeros(t *testing.T) { + mock := &mockFestaRepo{} + svc := newTestFestaService(mock) + + err := svc.SubmitSouls(1, 10, []uint16{0, 5, 0, 3, 0}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + // Should call repo with the full slice (repo does batch insert) + if mock.submittedSouls == nil { + t.Fatal("SubmitSouls should be called on repo") + } +} + +func TestFestaService_SubmitSouls_AllZeros(t *testing.T) { + mock := &mockFestaRepo{} + svc := newTestFestaService(mock) + + err := svc.SubmitSouls(1, 10, []uint16{0, 0, 0}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if mock.submittedSouls != nil { + t.Error("SubmitSouls should not call repo when all zeros") + } +} + +func TestFestaService_SubmitSouls_RepoError(t *testing.T) { + mock := &mockFestaRepo{submitErr: errors.New("db error")} + svc := newTestFestaService(mock) + + err := svc.SubmitSouls(1, 10, []uint16{5, 0, 3}) + if err == nil { + t.Fatal("expected error from repo failure") + } +} diff --git a/server/channelserver/sys_channel_server.go b/server/channelserver/sys_channel_server.go index 0730fbc3b..97d86de52 100644 --- a/server/channelserver/sys_channel_server.go +++ b/server/channelserver/sys_channel_server.go @@ -76,6 +76,7 @@ type Server struct { achievementService *AchievementService gachaService *GachaService towerService *TowerService + festaService *FestaService erupeConfig *cfg.Config acceptConns chan net.Conn deleteConns chan net.Conn @@ -163,6 +164,7 @@ func NewServer(config *Config) *Server { s.achievementService = NewAchievementService(s.achievementRepo, s.logger) s.gachaService = NewGachaService(s.gachaRepo, s.userRepo, s.charRepo, s.logger, config.ErupeConfig.GameplayOptions.MaximumNP) s.towerService = NewTowerService(s.towerRepo, s.logger) + s.festaService = NewFestaService(s.festaRepo, s.logger) // Mezeporta s.stages.Store("sl1Ns200p0a0u0", NewStage("sl1Ns200p0a0u0")) diff --git a/server/channelserver/test_helpers_test.go b/server/channelserver/test_helpers_test.go index 83a4c1625..ed7c55933 100644 --- a/server/channelserver/test_helpers_test.go +++ b/server/channelserver/test_helpers_test.go @@ -83,6 +83,11 @@ func ensureTowerService(s *Server) { s.towerService = NewTowerService(s.towerRepo, s.logger) } +// ensureFestaService wires the FestaService from the server's current repos. +func ensureFestaService(s *Server) { + s.festaService = NewFestaService(s.festaRepo, s.logger) +} + // createMockSession creates a minimal Session for testing. // Imported from v9.2.x-stable and adapted for main. func createMockSession(charID uint32, server *Server) *Session {