diff --git a/server/channelserver/handlers_tower.go b/server/channelserver/handlers_tower.go index 2fead4cf7..346b3b18a 100644 --- a/server/channelserver/handlers_tower.go +++ b/server/channelserver/handlers_tower.go @@ -307,7 +307,7 @@ func handleMsgMhfGetTenrouirai(s *Session, p mhfpacket.MHFPacket) { data = append(data, bf) } case 4: - progress, err := s.server.towerRepo.GetTenrouiraiProgress(pkt.GuildID) + progress, err := s.server.towerService.GetTenrouiraiProgressCapped(pkt.GuildID) if err != nil { s.logger.Error("Failed to read tower mission page", zap.Error(err)) } else { @@ -317,19 +317,6 @@ func handleMsgMhfGetTenrouirai(s *Session, p mhfpacket.MHFPacket) { 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 { bf := byteframe.NewByteFrame() bf.WriteUint8(progress.Page) @@ -384,33 +371,18 @@ func handleMsgMhfPostTenrouirai(s *Session, p mhfpacket.MHFPacket) { } 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() sd, err := GetCharacterSaveData(s, s.charID) if err == nil && sd != nil { sd.RP -= pkt.DonatedRP sd.Save(s) - if donated+int(pkt.DonatedRP) >= requirement { - if err := s.server.towerRepo.AdvanceTenrouiraiPage(pkt.GuildID); err != nil { - s.logger.Error("Failed to advance tower mission page", zap.Error(err)) - } - pkt.DonatedRP = uint16(requirement - donated) - } - 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)) + result, err := s.server.towerService.DonateGuildTowerRP(pkt.GuildID, pkt.DonatedRP) + if err != nil { + s.logger.Error("Failed to process tower RP donation", zap.Error(err)) + bf.WriteUint32(0) + } else { + bf.WriteUint32(uint32(result.ActualDonated)) } } else { bf.WriteUint32(0) @@ -509,7 +481,7 @@ func handleMsgMhfPostGemInfo(s *Session, p mhfpacket.MHFPacket) { switch pkt.Op { case 1: // Add gem 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)) } case 2: // Transfer gem diff --git a/server/channelserver/repo_interfaces.go b/server/channelserver/repo_interfaces.go index ba7cf6cf5..b04cb824d 100644 --- a/server/channelserver/repo_interfaces.go +++ b/server/channelserver/repo_interfaces.go @@ -210,7 +210,6 @@ type TowerRepo interface { UpdateProgress(charID uint32, tr, trp, cost, block1 int32) error GetGems(charID uint32) (string, error) UpdateGems(charID uint32, gems string) error - AddGem(charID uint32, gemIndex int, quantity int) error GetTenrouiraiProgress(guildID uint32) (TenrouiraiProgressData, error) GetTenrouiraiMissionScores(guildID uint32, missionIndex uint8) ([]TenrouiraiCharScore, error) GetGuildTowerRP(guildID uint32) (uint32, error) diff --git a/server/channelserver/repo_mocks_test.go b/server/channelserver/repo_mocks_test.go index 9499d908f..578980cc3 100644 --- a/server/channelserver/repo_mocks_test.go +++ b/server/channelserver/repo_mocks_test.go @@ -944,6 +944,7 @@ type mockTowerRepo struct { skillsErr error gems string gemsErr error + updatedGems string progress TenrouiraiProgressData progressErr error @@ -951,9 +952,13 @@ type mockTowerRepo struct { scoresErr error guildRP uint32 guildRPErr error - page int - donated int - pageRPErr error + page int + donated int + pageRPErr error + advanceErr error + advanceCalled bool + donateErr error + donatedRP uint16 } 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) UpdateProgress(_ uint32, _, _, _, _ int32) error { return nil } func (m *mockTowerRepo) GetGems(_ uint32) (string, error) { return m.gems, m.gemsErr } -func (m *mockTowerRepo) UpdateGems(_ uint32, _ string) error { return nil } -func (m *mockTowerRepo) AddGem(_ uint32, _ int, _ int) error { return nil } +func (m *mockTowerRepo) UpdateGems(_ uint32, gems string) error { + m.updatedGems = gems + return nil +} func (m *mockTowerRepo) GetTenrouiraiProgress(_ uint32) (TenrouiraiProgressData, error) { 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) { return m.page, m.donated, m.pageRPErr } -func (m *mockTowerRepo) AdvanceTenrouiraiPage(_ uint32) error { return nil } -func (m *mockTowerRepo) DonateGuildTowerRP(_ uint32, _ uint16) error { return nil } +func (m *mockTowerRepo) AdvanceTenrouiraiPage(_ uint32) error { + m.advanceCalled = true + return m.advanceErr +} +func (m *mockTowerRepo) DonateGuildTowerRP(_ uint32, rp uint16) error { + m.donatedRP = rp + return m.donateErr +} // --- mockFestaRepo --- diff --git a/server/channelserver/repo_tower.go b/server/channelserver/repo_tower.go index f87e2cf58..1e653476e 100644 --- a/server/channelserver/repo_tower.go +++ b/server/channelserver/repo_tower.go @@ -3,8 +3,6 @@ package channelserver import ( "fmt" - "erupe-ce/common/stringsupport" - "github.com/jmoiron/sqlx" ) @@ -78,16 +76,6 @@ func (r *TowerRepository) UpdateGems(charID uint32, gems string) error { 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. type TenrouiraiProgressData struct { Page uint8 diff --git a/server/channelserver/svc_tower.go b/server/channelserver/svc_tower.go new file mode 100644 index 000000000..f66e335ba --- /dev/null +++ b/server/channelserver/svc_tower.go @@ -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 +} diff --git a/server/channelserver/svc_tower_test.go b/server/channelserver/svc_tower_test.go new file mode 100644 index 000000000..c3ee0bfd5 --- /dev/null +++ b/server/channelserver/svc_tower_test.go @@ -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") + } +} diff --git a/server/channelserver/sys_channel_server.go b/server/channelserver/sys_channel_server.go index cbc6dd2c9..0730fbc3b 100644 --- a/server/channelserver/sys_channel_server.go +++ b/server/channelserver/sys_channel_server.go @@ -75,6 +75,7 @@ type Server struct { guildService *GuildService achievementService *AchievementService gachaService *GachaService + towerService *TowerService erupeConfig *cfg.Config acceptConns 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.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) // Mezeporta s.stages.Store("sl1Ns200p0a0u0", NewStage("sl1Ns200p0a0u0")) diff --git a/server/channelserver/test_helpers_test.go b/server/channelserver/test_helpers_test.go index a403ff7cb..83a4c1625 100644 --- a/server/channelserver/test_helpers_test.go +++ b/server/channelserver/test_helpers_test.go @@ -78,6 +78,11 @@ func ensureGachaService(s *Server) { 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. // Imported from v9.2.x-stable and adapted for main. func createMockSession(charID uint32, server *Server) *Session {