refactor(tower): extract tower logic into TowerService

The tower repo had business logic beyond simple CRUD: AddGem used a
fetch-transform-save pattern, progress capping was inline in the
handler, and RP donation orchestrated multiple repo calls with
conditional page advancement. Move these into a new TowerService
following the established service layer pattern.
This commit is contained in:
Houmgaor
2026-02-24 16:07:37 +01:00
parent 76d139538b
commit 7a56810e78
8 changed files with 322 additions and 56 deletions

View File

@@ -307,7 +307,7 @@ func handleMsgMhfGetTenrouirai(s *Session, p mhfpacket.MHFPacket) {
data = append(data, bf) data = append(data, bf)
} }
case 4: case 4:
progress, err := s.server.towerRepo.GetTenrouiraiProgress(pkt.GuildID) progress, err := s.server.towerService.GetTenrouiraiProgressCapped(pkt.GuildID)
if err != nil { if err != nil {
s.logger.Error("Failed to read tower mission page", zap.Error(err)) s.logger.Error("Failed to read tower mission page", zap.Error(err))
} else { } else {
@@ -317,19 +317,6 @@ func handleMsgMhfGetTenrouirai(s *Session, p mhfpacket.MHFPacket) {
tenrouirai.Progress[0].Mission3 = progress.Mission3 tenrouirai.Progress[0].Mission3 = progress.Mission3
} }
if tenrouirai.Progress[0].Page < 1 {
tenrouirai.Progress[0].Page = 1
}
if tenrouirai.Progress[0].Mission1 > tenrouiraiData[(tenrouirai.Progress[0].Page*3)-3].Goal {
tenrouirai.Progress[0].Mission1 = tenrouiraiData[(tenrouirai.Progress[0].Page*3)-3].Goal
}
if tenrouirai.Progress[0].Mission2 > tenrouiraiData[(tenrouirai.Progress[0].Page*3)-2].Goal {
tenrouirai.Progress[0].Mission2 = tenrouiraiData[(tenrouirai.Progress[0].Page*3)-2].Goal
}
if tenrouirai.Progress[0].Mission3 > tenrouiraiData[(tenrouirai.Progress[0].Page*3)-1].Goal {
tenrouirai.Progress[0].Mission3 = tenrouiraiData[(tenrouirai.Progress[0].Page*3)-1].Goal
}
for _, progress := range tenrouirai.Progress { for _, progress := range tenrouirai.Progress {
bf := byteframe.NewByteFrame() bf := byteframe.NewByteFrame()
bf.WriteUint8(progress.Page) bf.WriteUint8(progress.Page)
@@ -384,33 +371,18 @@ func handleMsgMhfPostTenrouirai(s *Session, p mhfpacket.MHFPacket) {
} }
if pkt.Op == 2 { if pkt.Op == 2 {
page, donated, err := s.server.towerRepo.GetGuildTowerPageAndRP(pkt.GuildID)
if err != nil {
s.logger.Error("Failed to read guild tower state for donation", zap.Error(err))
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
return
}
var requirement int
for i := 0; i < (page*3)+1; i++ {
requirement += int(tenrouiraiData[i].Cost)
}
bf := byteframe.NewByteFrame() bf := byteframe.NewByteFrame()
sd, err := GetCharacterSaveData(s, s.charID) sd, err := GetCharacterSaveData(s, s.charID)
if err == nil && sd != nil { if err == nil && sd != nil {
sd.RP -= pkt.DonatedRP sd.RP -= pkt.DonatedRP
sd.Save(s) sd.Save(s)
if donated+int(pkt.DonatedRP) >= requirement { result, err := s.server.towerService.DonateGuildTowerRP(pkt.GuildID, pkt.DonatedRP)
if err := s.server.towerRepo.AdvanceTenrouiraiPage(pkt.GuildID); err != nil { if err != nil {
s.logger.Error("Failed to advance tower mission page", zap.Error(err)) s.logger.Error("Failed to process tower RP donation", zap.Error(err))
} bf.WriteUint32(0)
pkt.DonatedRP = uint16(requirement - donated) } else {
} bf.WriteUint32(uint32(result.ActualDonated))
bf.WriteUint32(uint32(pkt.DonatedRP))
if err := s.server.towerRepo.DonateGuildTowerRP(pkt.GuildID, pkt.DonatedRP); err != nil {
s.logger.Error("Failed to update guild tower RP", zap.Error(err))
} }
} else { } else {
bf.WriteUint32(0) bf.WriteUint32(0)
@@ -509,7 +481,7 @@ func handleMsgMhfPostGemInfo(s *Session, p mhfpacket.MHFPacket) {
switch pkt.Op { switch pkt.Op {
case 1: // Add gem case 1: // Add gem
i := int((pkt.Gem >> 8 * 5) + (pkt.Gem - pkt.Gem&0xFF00 - 1%5)) i := int((pkt.Gem >> 8 * 5) + (pkt.Gem - pkt.Gem&0xFF00 - 1%5))
if err := s.server.towerRepo.AddGem(s.charID, i, int(pkt.Quantity)); err != nil { if err := s.server.towerService.AddGem(s.charID, i, int(pkt.Quantity)); err != nil {
s.logger.Error("Failed to update tower gems", zap.Error(err)) s.logger.Error("Failed to update tower gems", zap.Error(err))
} }
case 2: // Transfer gem case 2: // Transfer gem

View File

@@ -210,7 +210,6 @@ type TowerRepo interface {
UpdateProgress(charID uint32, tr, trp, cost, block1 int32) error UpdateProgress(charID uint32, tr, trp, cost, block1 int32) error
GetGems(charID uint32) (string, error) GetGems(charID uint32) (string, error)
UpdateGems(charID uint32, gems string) error UpdateGems(charID uint32, gems string) error
AddGem(charID uint32, gemIndex int, quantity int) error
GetTenrouiraiProgress(guildID uint32) (TenrouiraiProgressData, error) GetTenrouiraiProgress(guildID uint32) (TenrouiraiProgressData, error)
GetTenrouiraiMissionScores(guildID uint32, missionIndex uint8) ([]TenrouiraiCharScore, error) GetTenrouiraiMissionScores(guildID uint32, missionIndex uint8) ([]TenrouiraiCharScore, error)
GetGuildTowerRP(guildID uint32) (uint32, error) GetGuildTowerRP(guildID uint32) (uint32, error)

View File

@@ -944,6 +944,7 @@ type mockTowerRepo struct {
skillsErr error skillsErr error
gems string gems string
gemsErr error gemsErr error
updatedGems string
progress TenrouiraiProgressData progress TenrouiraiProgressData
progressErr error progressErr error
@@ -954,6 +955,10 @@ type mockTowerRepo struct {
page int page int
donated int donated int
pageRPErr error pageRPErr error
advanceErr error
advanceCalled bool
donateErr error
donatedRP uint16
} }
func (m *mockTowerRepo) GetTowerData(_ uint32) (TowerData, error) { return m.towerData, m.towerDataErr } func (m *mockTowerRepo) GetTowerData(_ uint32) (TowerData, error) { return m.towerData, m.towerDataErr }
@@ -961,8 +966,10 @@ func (m *mockTowerRepo) GetSkills(_ uint32) (string, error) { return m.s
func (m *mockTowerRepo) UpdateSkills(_ uint32, _ string, _ int32) error { return nil } func (m *mockTowerRepo) UpdateSkills(_ uint32, _ string, _ int32) error { return nil }
func (m *mockTowerRepo) UpdateProgress(_ uint32, _, _, _, _ int32) error { return nil } func (m *mockTowerRepo) UpdateProgress(_ uint32, _, _, _, _ int32) error { return nil }
func (m *mockTowerRepo) GetGems(_ uint32) (string, error) { return m.gems, m.gemsErr } func (m *mockTowerRepo) GetGems(_ uint32) (string, error) { return m.gems, m.gemsErr }
func (m *mockTowerRepo) UpdateGems(_ uint32, _ string) error { return nil } func (m *mockTowerRepo) UpdateGems(_ uint32, gems string) error {
func (m *mockTowerRepo) AddGem(_ uint32, _ int, _ int) error { return nil } m.updatedGems = gems
return nil
}
func (m *mockTowerRepo) GetTenrouiraiProgress(_ uint32) (TenrouiraiProgressData, error) { func (m *mockTowerRepo) GetTenrouiraiProgress(_ uint32) (TenrouiraiProgressData, error) {
return m.progress, m.progressErr return m.progress, m.progressErr
} }
@@ -973,8 +980,14 @@ func (m *mockTowerRepo) GetGuildTowerRP(_ uint32) (uint32, error) { return m.gui
func (m *mockTowerRepo) GetGuildTowerPageAndRP(_ uint32) (int, int, error) { func (m *mockTowerRepo) GetGuildTowerPageAndRP(_ uint32) (int, int, error) {
return m.page, m.donated, m.pageRPErr return m.page, m.donated, m.pageRPErr
} }
func (m *mockTowerRepo) AdvanceTenrouiraiPage(_ uint32) error { return nil } func (m *mockTowerRepo) AdvanceTenrouiraiPage(_ uint32) error {
func (m *mockTowerRepo) DonateGuildTowerRP(_ uint32, _ uint16) error { return nil } m.advanceCalled = true
return m.advanceErr
}
func (m *mockTowerRepo) DonateGuildTowerRP(_ uint32, rp uint16) error {
m.donatedRP = rp
return m.donateErr
}
// --- mockFestaRepo --- // --- mockFestaRepo ---

View File

@@ -3,8 +3,6 @@ package channelserver
import ( import (
"fmt" "fmt"
"erupe-ce/common/stringsupport"
"github.com/jmoiron/sqlx" "github.com/jmoiron/sqlx"
) )
@@ -78,16 +76,6 @@ func (r *TowerRepository) UpdateGems(charID uint32, gems string) error {
return err return err
} }
// AddGem adds quantity to a specific gem index.
func (r *TowerRepository) AddGem(charID uint32, gemIndex int, quantity int) error {
gems, err := r.GetGems(charID)
if err != nil {
return err
}
newGems := stringsupport.CSVSetIndex(gems, gemIndex, stringsupport.CSVGetIndex(gems, gemIndex)+quantity)
return r.UpdateGems(charID, newGems)
}
// TenrouiraiProgressData holds the guild's tenrouirai (sky corridor) progress. // TenrouiraiProgressData holds the guild's tenrouirai (sky corridor) progress.
type TenrouiraiProgressData struct { type TenrouiraiProgressData struct {
Page uint8 Page uint8

View File

@@ -0,0 +1,102 @@
package channelserver
import (
"erupe-ce/common/stringsupport"
"go.uber.org/zap"
)
// DonateRPResult holds the outcome of a guild tower RP donation.
type DonateRPResult struct {
ActualDonated uint16
Advanced bool
}
// TowerService encapsulates tower business logic, sitting between handlers and repos.
type TowerService struct {
towerRepo TowerRepo
logger *zap.Logger
}
// NewTowerService creates a new TowerService.
func NewTowerService(tr TowerRepo, log *zap.Logger) *TowerService {
return &TowerService{
towerRepo: tr,
logger: log,
}
}
// AddGem adds quantity to a specific gem index for a character.
// This is a fetch-transform-save operation that reads the current gems CSV,
// updates the value at the given index, and writes back.
func (svc *TowerService) AddGem(charID uint32, gemIndex int, quantity int) error {
gems, err := svc.towerRepo.GetGems(charID)
if err != nil {
return err
}
newGems := stringsupport.CSVSetIndex(gems, gemIndex, stringsupport.CSVGetIndex(gems, gemIndex)+quantity)
return svc.towerRepo.UpdateGems(charID, newGems)
}
// GetTenrouiraiProgressCapped returns the guild's tenrouirai progress with
// mission scores capped to their respective goals.
func (svc *TowerService) GetTenrouiraiProgressCapped(guildID uint32) (TenrouiraiProgressData, error) {
progress, err := svc.towerRepo.GetTenrouiraiProgress(guildID)
if err != nil {
return progress, err
}
if progress.Page < 1 {
progress.Page = 1
}
idx := int(progress.Page*3) - 3
if idx >= 0 && idx+2 < len(tenrouiraiData) {
if progress.Mission1 > tenrouiraiData[idx].Goal {
progress.Mission1 = tenrouiraiData[idx].Goal
}
if progress.Mission2 > tenrouiraiData[idx+1].Goal {
progress.Mission2 = tenrouiraiData[idx+1].Goal
}
if progress.Mission3 > tenrouiraiData[idx+2].Goal {
progress.Mission3 = tenrouiraiData[idx+2].Goal
}
}
return progress, nil
}
// DonateGuildTowerRP processes a tower RP donation, advancing the mission page
// if the cumulative donation meets the requirement. Returns the actual RP consumed
// and whether the page was advanced.
func (svc *TowerService) DonateGuildTowerRP(guildID uint32, donatedRP uint16) (*DonateRPResult, error) {
page, donated, err := svc.towerRepo.GetGuildTowerPageAndRP(guildID)
if err != nil {
return nil, err
}
var requirement int
for i := 0; i < (page*3)+1 && i < len(tenrouiraiData); i++ {
requirement += int(tenrouiraiData[i].Cost)
}
result := &DonateRPResult{
ActualDonated: donatedRP,
}
if donated+int(donatedRP) >= requirement {
if err := svc.towerRepo.AdvanceTenrouiraiPage(guildID); err != nil {
svc.logger.Error("Failed to advance tower mission page", zap.Error(err))
return nil, err
}
result.ActualDonated = uint16(requirement - donated)
result.Advanced = true
}
if err := svc.towerRepo.DonateGuildTowerRP(guildID, result.ActualDonated); err != nil {
svc.logger.Error("Failed to update guild tower RP", zap.Error(err))
return nil, err
}
return result, nil
}

View File

@@ -0,0 +1,185 @@
package channelserver
import (
"errors"
"testing"
"go.uber.org/zap"
)
func newTestTowerService(mock *mockTowerRepo) *TowerService {
logger, _ := zap.NewDevelopment()
return NewTowerService(mock, logger)
}
// --- AddGem tests ---
func TestTowerService_AddGem_Success(t *testing.T) {
mock := &mockTowerRepo{gems: "0,0,5,0,0"}
svc := newTestTowerService(mock)
err := svc.AddGem(1, 2, 3)
if err != nil {
t.Fatalf("AddGem returned error: %v", err)
}
// Gem at index 2 was 5, added 3, so should be 8
if mock.updatedGems != "0,0,8,0,0" {
t.Errorf("updatedGems = %q, want %q", mock.updatedGems, "0,0,8,0,0")
}
}
func TestTowerService_AddGem_GetGemsError(t *testing.T) {
mock := &mockTowerRepo{gemsErr: errors.New("db error")}
svc := newTestTowerService(mock)
err := svc.AddGem(1, 0, 1)
if err == nil {
t.Fatal("AddGem should return error when GetGems fails")
}
}
// --- GetTenrouiraiProgressCapped tests ---
func TestTowerService_GetTenrouiraiProgressCapped_CapsToGoals(t *testing.T) {
// Page 1 missions have goals: 80, 16, 50 (from tenrouiraiData indices 0,1,2)
mock := &mockTowerRepo{
progress: TenrouiraiProgressData{
Page: 1,
Mission1: 9999,
Mission2: 9999,
Mission3: 9999,
},
}
svc := newTestTowerService(mock)
result, err := svc.GetTenrouiraiProgressCapped(10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Mission1 != tenrouiraiData[0].Goal {
t.Errorf("Mission1 = %d, want %d", result.Mission1, tenrouiraiData[0].Goal)
}
if result.Mission2 != tenrouiraiData[1].Goal {
t.Errorf("Mission2 = %d, want %d", result.Mission2, tenrouiraiData[1].Goal)
}
if result.Mission3 != tenrouiraiData[2].Goal {
t.Errorf("Mission3 = %d, want %d", result.Mission3, tenrouiraiData[2].Goal)
}
}
func TestTowerService_GetTenrouiraiProgressCapped_BelowGoals(t *testing.T) {
mock := &mockTowerRepo{
progress: TenrouiraiProgressData{
Page: 1,
Mission1: 10,
Mission2: 5,
Mission3: 20,
},
}
svc := newTestTowerService(mock)
result, err := svc.GetTenrouiraiProgressCapped(10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Mission1 != 10 {
t.Errorf("Mission1 = %d, want 10", result.Mission1)
}
if result.Mission2 != 5 {
t.Errorf("Mission2 = %d, want 5", result.Mission2)
}
if result.Mission3 != 20 {
t.Errorf("Mission3 = %d, want 20", result.Mission3)
}
}
func TestTowerService_GetTenrouiraiProgressCapped_MinPage1(t *testing.T) {
mock := &mockTowerRepo{
progress: TenrouiraiProgressData{Page: 0},
}
svc := newTestTowerService(mock)
result, err := svc.GetTenrouiraiProgressCapped(10)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Page != 1 {
t.Errorf("Page = %d, want 1", result.Page)
}
}
func TestTowerService_GetTenrouiraiProgressCapped_DBError(t *testing.T) {
mock := &mockTowerRepo{progressErr: errors.New("db error")}
svc := newTestTowerService(mock)
_, err := svc.GetTenrouiraiProgressCapped(10)
if err == nil {
t.Fatal("expected error from DB failure")
}
}
// --- DonateGuildTowerRP tests ---
func TestTowerService_DonateGuildTowerRP_NoAdvance(t *testing.T) {
mock := &mockTowerRepo{
page: 1,
donated: 0,
}
svc := newTestTowerService(mock)
result, err := svc.DonateGuildTowerRP(10, 1)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if result.Advanced {
t.Error("should not advance when donation < requirement")
}
if result.ActualDonated != 1 {
t.Errorf("ActualDonated = %d, want 1", result.ActualDonated)
}
if mock.advanceCalled {
t.Error("AdvanceTenrouiraiPage should not be called")
}
if mock.donatedRP != 1 {
t.Errorf("donatedRP = %d, want 1", mock.donatedRP)
}
}
func TestTowerService_DonateGuildTowerRP_AdvancesPage(t *testing.T) {
// Compute the requirement for page 1: sum of Cost for indices 0..3
var requirement int
for i := 0; i < 4; i++ {
requirement += int(tenrouiraiData[i].Cost)
}
mock := &mockTowerRepo{
page: 1,
donated: requirement - 10, // 10 short of requirement
}
svc := newTestTowerService(mock)
result, err := svc.DonateGuildTowerRP(10, 100) // donating 100, but only 10 needed
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if !result.Advanced {
t.Error("should advance when donation meets requirement")
}
if result.ActualDonated != 10 {
t.Errorf("ActualDonated = %d, want 10 (capped to remaining)", result.ActualDonated)
}
if !mock.advanceCalled {
t.Error("AdvanceTenrouiraiPage should be called")
}
}
func TestTowerService_DonateGuildTowerRP_DBError(t *testing.T) {
mock := &mockTowerRepo{pageRPErr: errors.New("db error")}
svc := newTestTowerService(mock)
_, err := svc.DonateGuildTowerRP(10, 100)
if err == nil {
t.Fatal("expected error from DB failure")
}
}

View File

@@ -75,6 +75,7 @@ type Server struct {
guildService *GuildService guildService *GuildService
achievementService *AchievementService achievementService *AchievementService
gachaService *GachaService gachaService *GachaService
towerService *TowerService
erupeConfig *cfg.Config erupeConfig *cfg.Config
acceptConns chan net.Conn acceptConns chan net.Conn
deleteConns chan net.Conn deleteConns chan net.Conn
@@ -161,6 +162,7 @@ func NewServer(config *Config) *Server {
s.guildService = NewGuildService(s.guildRepo, s.mailService, s.charRepo, s.logger) s.guildService = NewGuildService(s.guildRepo, s.mailService, s.charRepo, s.logger)
s.achievementService = NewAchievementService(s.achievementRepo, s.logger) s.achievementService = NewAchievementService(s.achievementRepo, s.logger)
s.gachaService = NewGachaService(s.gachaRepo, s.userRepo, s.charRepo, s.logger, config.ErupeConfig.GameplayOptions.MaximumNP) s.gachaService = NewGachaService(s.gachaRepo, s.userRepo, s.charRepo, s.logger, config.ErupeConfig.GameplayOptions.MaximumNP)
s.towerService = NewTowerService(s.towerRepo, s.logger)
// Mezeporta // Mezeporta
s.stages.Store("sl1Ns200p0a0u0", NewStage("sl1Ns200p0a0u0")) s.stages.Store("sl1Ns200p0a0u0", NewStage("sl1Ns200p0a0u0"))

View File

@@ -78,6 +78,11 @@ func ensureGachaService(s *Server) {
s.gachaService = NewGachaService(s.gachaRepo, s.userRepo, s.charRepo, s.logger, 100000) s.gachaService = NewGachaService(s.gachaRepo, s.userRepo, s.charRepo, s.logger, 100000)
} }
// ensureTowerService wires the TowerService from the server's current repos.
func ensureTowerService(s *Server) {
s.towerService = NewTowerService(s.towerRepo, s.logger)
}
// createMockSession creates a minimal Session for testing. // createMockSession creates a minimal Session for testing.
// Imported from v9.2.x-stable and adapted for main. // Imported from v9.2.x-stable and adapted for main.
func createMockSession(charID uint32, server *Server) *Session { func createMockSession(charID uint32, server *Server) *Session {