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.
This commit is contained in:
Houmgaor
2026-02-24 16:12:40 +01:00
parent 7a56810e78
commit 4d3ec8164c
7 changed files with 231 additions and 20 deletions

View File

@@ -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))

View File

@@ -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
}

View File

@@ -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

View File

@@ -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)
}

View File

@@ -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")
}
}

View File

@@ -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"))

View File

@@ -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 {