From 1e9de7920d96809c418463a2fdc2ee2228069998 Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 23 Feb 2026 23:57:54 +0100 Subject: [PATCH] refactor(gacha): extract gacha logic into GachaService Move payment processing, reward selection, stepup state management, and box gacha tracking from handlers into a dedicated service layer. Handlers now delegate to GachaService methods and only handle protocol serialization. --- server/channelserver/handlers_gacha.go | 282 +++-------------- server/channelserver/handlers_gacha_test.go | 15 + server/channelserver/svc_gacha.go | 325 ++++++++++++++++++++ server/channelserver/svc_gacha_test.go | 316 +++++++++++++++++++ server/channelserver/sys_channel_server.go | 2 + server/channelserver/test_helpers_test.go | 5 + 6 files changed, 709 insertions(+), 236 deletions(-) create mode 100644 server/channelserver/svc_gacha.go create mode 100644 server/channelserver/svc_gacha_test.go diff --git a/server/channelserver/handlers_gacha.go b/server/channelserver/handlers_gacha.go index d9e6cbbf2..348f446db 100644 --- a/server/channelserver/handlers_gacha.go +++ b/server/channelserver/handlers_gacha.go @@ -1,11 +1,6 @@ package channelserver import ( - "database/sql" - "errors" - "math/rand" - "time" - "erupe-ce/common/byteframe" "erupe-ce/network/mhfpacket" @@ -81,106 +76,6 @@ func handleMsgMhfUseGachaPoint(s *Session, p mhfpacket.MHFPacket) { doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) } -func spendGachaCoin(s *Session, quantity uint16) { - gt, _ := s.server.userRepo.GetTrialCoins(s.userID) - if quantity <= gt { - if err := s.server.userRepo.DeductTrialCoins(s.userID, uint32(quantity)); err != nil { - s.logger.Error("Failed to deduct gacha trial coins", zap.Error(err)) - } - } else { - if err := s.server.userRepo.DeductPremiumCoins(s.userID, uint32(quantity)); err != nil { - s.logger.Error("Failed to deduct gacha premium coins", zap.Error(err)) - } - } -} - -func transactGacha(s *Session, gachaID uint32, rollID uint8) (int, error) { - itemType, itemNumber, rolls, err := s.server.gachaRepo.GetEntryForTransaction(gachaID, rollID) - if err != nil { - return 0, err - } - switch itemType { - /* - valid types that need manual savedata manipulation: - - Ryoudan Points - - Bond Points - - Image Change Points - valid types that work (no additional code needed): - - Tore Points - - Festa Points - */ - case 17: - _ = addPointNetcafe(s, int(itemNumber)*-1) - case 19: - fallthrough - case 20: - spendGachaCoin(s, itemNumber) - case 21: - if err := s.server.userRepo.DeductFrontierPoints(s.userID, uint32(itemNumber)); err != nil { - s.logger.Error("Failed to deduct frontier points for gacha", zap.Error(err)) - } - } - return rolls, nil -} - -func getGuaranteedItems(s *Session, gachaID uint32, rollID uint8) []GachaItem { - rewards, _ := s.server.gachaRepo.GetGuaranteedItems(rollID, gachaID) - return rewards -} - -func addGachaItem(s *Session, items []GachaItem) { - data, _ := s.server.charRepo.LoadColumn(s.charID, "gacha_items") - if len(data) > 0 { - numItems := int(data[0]) - data = data[1:] - oldItem := byteframe.NewByteFrameFromBytes(data) - for i := 0; i < numItems; i++ { - items = append(items, GachaItem{ - ItemType: oldItem.ReadUint8(), - ItemID: oldItem.ReadUint16(), - Quantity: oldItem.ReadUint16(), - }) - } - } - newItem := byteframe.NewByteFrame() - newItem.WriteUint8(uint8(len(items))) - for i := range items { - newItem.WriteUint8(items[i].ItemType) - newItem.WriteUint16(items[i].ItemID) - newItem.WriteUint16(items[i].Quantity) - } - if err := s.server.charRepo.SaveColumn(s.charID, "gacha_items", newItem.Data()); err != nil { - s.logger.Error("Failed to update gacha items", zap.Error(err)) - } -} - -func getRandomEntries(entries []GachaEntry, rolls int, isBox bool) ([]GachaEntry, error) { - var chosen []GachaEntry - var totalWeight float64 - for i := range entries { - totalWeight += entries[i].Weight - } - for rolls != len(chosen) { - - if !isBox { - result := rand.Float64() * totalWeight - for _, entry := range entries { - result -= entry.Weight - if result < 0 { - chosen = append(chosen, entry) - break - } - } - } else { - result := rand.Intn(len(entries)) - chosen = append(chosen, entries[result]) - entries[result] = entries[len(entries)-1] - entries = entries[:len(entries)-1] - } - } - return chosen, nil -} - func handleMsgMhfReceiveGachaItem(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfReceiveGachaItem) data, err := s.server.charRepo.LoadColumnWithDefault(s.charID, "gacha_items", []byte{0x00}) @@ -216,140 +111,71 @@ func handleMsgMhfReceiveGachaItem(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfPlayNormalGacha(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfPlayNormalGacha) + + result, err := s.server.gachaService.PlayNormalGacha(s.userID, s.charID, pkt.GachaID, pkt.RollType) + if err != nil { + doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1)) + return + } + bf := byteframe.NewByteFrame() - var rewards []GachaItem - rolls, err := transactGacha(s, pkt.GachaID, pkt.RollType) - if err != nil { - doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1)) - return + bf.WriteUint8(uint8(len(result.Rewards))) + for _, r := range result.Rewards { + bf.WriteUint8(r.ItemType) + bf.WriteUint16(r.ItemID) + bf.WriteUint16(r.Quantity) + bf.WriteUint8(r.Rarity) } - - entries, err := s.server.gachaRepo.GetRewardPool(pkt.GachaID) - if err != nil { - doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1)) - return - } - - rewardEntries, _ := getRandomEntries(entries, rolls, false) - temp := byteframe.NewByteFrame() - for i := range rewardEntries { - entryItems, err := s.server.gachaRepo.GetItemsForEntry(rewardEntries[i].ID) - if err != nil { - continue - } - for _, reward := range entryItems { - rewards = append(rewards, reward) - temp.WriteUint8(reward.ItemType) - temp.WriteUint16(reward.ItemID) - temp.WriteUint16(reward.Quantity) - temp.WriteUint8(rewardEntries[i].Rarity) - } - } - - bf.WriteUint8(uint8(len(rewards))) - bf.WriteBytes(temp.Data()) doAckBufSucceed(s, pkt.AckHandle, bf.Data()) - addGachaItem(s, rewards) } func handleMsgMhfPlayStepupGacha(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfPlayStepupGacha) + + result, err := s.server.gachaService.PlayStepupGacha(s.userID, s.charID, pkt.GachaID, pkt.RollType) + if err != nil { + doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1)) + return + } + bf := byteframe.NewByteFrame() - var rewards []GachaItem - rolls, err := transactGacha(s, pkt.GachaID, pkt.RollType) - if err != nil { - doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1)) - return - } - if err := s.server.userRepo.AddFrontierPointsFromGacha(s.userID, pkt.GachaID, pkt.RollType); err != nil { - s.logger.Error("Failed to award stepup gacha frontier points", zap.Error(err)) - } - if err := s.server.gachaRepo.DeleteStepup(pkt.GachaID, s.charID); err != nil { - s.logger.Error("Failed to delete gacha stepup state", zap.Error(err)) - } - if err := s.server.gachaRepo.InsertStepup(pkt.GachaID, pkt.RollType+1, s.charID); err != nil { - s.logger.Error("Failed to insert gacha stepup state", zap.Error(err)) - } - - entries, err := s.server.gachaRepo.GetRewardPool(pkt.GachaID) - if err != nil { - doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1)) - return - } - - guaranteedItems := getGuaranteedItems(s, pkt.GachaID, pkt.RollType) - rewardEntries, _ := getRandomEntries(entries, rolls, false) - temp := byteframe.NewByteFrame() - for i := range rewardEntries { - entryItems, err := s.server.gachaRepo.GetItemsForEntry(rewardEntries[i].ID) - if err != nil { - continue - } - for _, reward := range entryItems { - rewards = append(rewards, reward) - temp.WriteUint8(reward.ItemType) - temp.WriteUint16(reward.ItemID) - temp.WriteUint16(reward.Quantity) - temp.WriteUint8(rewardEntries[i].Rarity) - } - } - - bf.WriteUint8(uint8(len(rewards) + len(guaranteedItems))) - bf.WriteUint8(uint8(len(rewards))) - for _, item := range guaranteedItems { + bf.WriteUint8(uint8(len(result.RandomRewards) + len(result.GuaranteedRewards))) + bf.WriteUint8(uint8(len(result.RandomRewards))) + for _, item := range result.GuaranteedRewards { bf.WriteUint8(item.ItemType) bf.WriteUint16(item.ItemID) bf.WriteUint16(item.Quantity) - bf.WriteUint8(0) + bf.WriteUint8(item.Rarity) + } + for _, r := range result.RandomRewards { + bf.WriteUint8(r.ItemType) + bf.WriteUint16(r.ItemID) + bf.WriteUint16(r.Quantity) + bf.WriteUint8(r.Rarity) } - bf.WriteBytes(temp.Data()) doAckBufSucceed(s, pkt.AckHandle, bf.Data()) - addGachaItem(s, rewards) - addGachaItem(s, guaranteedItems) } func handleMsgMhfGetStepupStatus(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetStepupStatus) - // Compute the most recent noon boundary - midday := TimeMidnight().Add(12 * time.Hour) - if TimeAdjusted().Before(midday) { - midday = midday.Add(-24 * time.Hour) - } + status, _ := s.server.gachaService.GetStepupStatus(pkt.GachaID, s.charID, TimeAdjusted()) - step, createdAt, err := s.server.gachaRepo.GetStepupWithTime(pkt.GachaID, s.charID) - if err != nil && !errors.Is(err, sql.ErrNoRows) { - s.logger.Error("Failed to get gacha stepup state", zap.Error(err)) - } - // Reset stale stepup progress (created before the most recent noon) - if err == nil && createdAt.Before(midday) { - if err := s.server.gachaRepo.DeleteStepup(pkt.GachaID, s.charID); err != nil { - s.logger.Error("Failed to reset stale gacha stepup", zap.Error(err)) - } - step = 0 - } else if err == nil { - // Only check for valid entry type if the stepup is fresh - hasEntry, _ := s.server.gachaRepo.HasEntryType(pkt.GachaID, step) - if !hasEntry { - if err := s.server.gachaRepo.DeleteStepup(pkt.GachaID, s.charID); err != nil { - s.logger.Error("Failed to reset gacha stepup state", zap.Error(err)) - } - step = 0 - } - } bf := byteframe.NewByteFrame() - bf.WriteUint8(step) + bf.WriteUint8(status.Step) bf.WriteUint32(uint32(TimeAdjusted().Unix())) doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } func handleMsgMhfGetBoxGachaInfo(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetBoxGachaInfo) - entryIDs, err := s.server.gachaRepo.GetBoxEntryIDs(pkt.GachaID, s.charID) + + entryIDs, err := s.server.gachaService.GetBoxInfo(pkt.GachaID, s.charID) if err != nil { doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1)) return } + bf := byteframe.NewByteFrame() bf.WriteUint8(uint8(len(entryIDs))) for i := range entryIDs { @@ -361,43 +187,27 @@ func handleMsgMhfGetBoxGachaInfo(s *Session, p mhfpacket.MHFPacket) { func handleMsgMhfPlayBoxGacha(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfPlayBoxGacha) + + result, err := s.server.gachaService.PlayBoxGacha(s.userID, s.charID, pkt.GachaID, pkt.RollType) + if err != nil { + doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1)) + return + } + bf := byteframe.NewByteFrame() - var rewards []GachaItem - rolls, err := transactGacha(s, pkt.GachaID, pkt.RollType) - if err != nil { - doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1)) - return - } - entries, err := s.server.gachaRepo.GetRewardPool(pkt.GachaID) - if err != nil { - doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1)) - return - } - rewardEntries, _ := getRandomEntries(entries, rolls, true) - for i := range rewardEntries { - entryItems, err := s.server.gachaRepo.GetItemsForEntry(rewardEntries[i].ID) - if err != nil { - continue - } - if err := s.server.gachaRepo.InsertBoxEntry(pkt.GachaID, rewardEntries[i].ID, s.charID); err != nil { - s.logger.Error("Failed to insert gacha box entry", zap.Error(err)) - } - rewards = append(rewards, entryItems...) - } - bf.WriteUint8(uint8(len(rewards))) - for _, r := range rewards { + bf.WriteUint8(uint8(len(result.Rewards))) + for _, r := range result.Rewards { bf.WriteUint8(r.ItemType) bf.WriteUint16(r.ItemID) bf.WriteUint16(r.Quantity) - bf.WriteUint8(0) + bf.WriteUint8(r.Rarity) } doAckBufSucceed(s, pkt.AckHandle, bf.Data()) - addGachaItem(s, rewards) } func handleMsgMhfResetBoxGachaInfo(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfResetBoxGachaInfo) - if err := s.server.gachaRepo.DeleteBoxEntries(pkt.GachaID, s.charID); err != nil { + if err := s.server.gachaService.ResetBox(pkt.GachaID, s.charID); err != nil { s.logger.Error("Failed to reset gacha box", zap.Error(err)) } doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4)) diff --git a/server/channelserver/handlers_gacha_test.go b/server/channelserver/handlers_gacha_test.go index 2c18e9aca..1c6c60814 100644 --- a/server/channelserver/handlers_gacha_test.go +++ b/server/channelserver/handlers_gacha_test.go @@ -189,6 +189,7 @@ func TestHandleMsgMhfPlayNormalGacha_TransactError(t *testing.T) { gachaRepo := &mockGachaRepo{txErr: errors.New("transact failed")} server.gachaRepo = gachaRepo server.userRepo = &mockUserRepoGacha{} + ensureGachaService(server) session := createMockSession(1, server) @@ -213,6 +214,7 @@ func TestHandleMsgMhfPlayNormalGacha_RewardPoolError(t *testing.T) { } server.gachaRepo = gachaRepo server.userRepo = &mockUserRepoGacha{} + ensureGachaService(server) session := createMockSession(1, server) @@ -243,6 +245,7 @@ func TestHandleMsgMhfPlayNormalGacha_Success(t *testing.T) { } server.gachaRepo = gachaRepo server.userRepo = &mockUserRepoGacha{} + ensureGachaService(server) session := createMockSession(1, server) @@ -269,6 +272,7 @@ func TestHandleMsgMhfPlayStepupGacha_TransactError(t *testing.T) { gachaRepo := &mockGachaRepo{txErr: errors.New("transact failed")} server.gachaRepo = gachaRepo server.userRepo = &mockUserRepoGacha{} + ensureGachaService(server) session := createMockSession(1, server) @@ -302,6 +306,7 @@ func TestHandleMsgMhfPlayStepupGacha_Success(t *testing.T) { } server.gachaRepo = gachaRepo server.userRepo = &mockUserRepoGacha{} + ensureGachaService(server) session := createMockSession(1, server) @@ -331,6 +336,7 @@ func TestHandleMsgMhfGetStepupStatus_FreshStep(t *testing.T) { hasEntryType: true, } server.gachaRepo = gachaRepo + ensureGachaService(server) session := createMockSession(1, server) @@ -354,6 +360,7 @@ func TestHandleMsgMhfGetStepupStatus_StaleStep(t *testing.T) { stepupTime: time.Now().Add(-48 * time.Hour), // stale } server.gachaRepo = gachaRepo + ensureGachaService(server) session := createMockSession(1, server) @@ -378,6 +385,7 @@ func TestHandleMsgMhfGetStepupStatus_NoRows(t *testing.T) { stepupErr: sql.ErrNoRows, } server.gachaRepo = gachaRepo + ensureGachaService(server) session := createMockSession(1, server) @@ -400,6 +408,7 @@ func TestHandleMsgMhfGetStepupStatus_NoEntryType(t *testing.T) { hasEntryType: false, // no matching entry type -> reset } server.gachaRepo = gachaRepo + ensureGachaService(server) session := createMockSession(1, server) @@ -424,6 +433,7 @@ func TestHandleMsgMhfGetBoxGachaInfo_Error(t *testing.T) { boxEntryIDsErr: errors.New("db error"), } server.gachaRepo = gachaRepo + ensureGachaService(server) session := createMockSession(1, server) @@ -444,6 +454,7 @@ func TestHandleMsgMhfGetBoxGachaInfo_Success(t *testing.T) { boxEntryIDs: []uint32{10, 20, 30}, } server.gachaRepo = gachaRepo + ensureGachaService(server) session := createMockSession(1, server) @@ -465,6 +476,7 @@ func TestHandleMsgMhfPlayBoxGacha_TransactError(t *testing.T) { gachaRepo := &mockGachaRepo{txErr: errors.New("transact failed")} server.gachaRepo = gachaRepo server.userRepo = &mockUserRepoGacha{} + ensureGachaService(server) session := createMockSession(1, server) @@ -495,6 +507,7 @@ func TestHandleMsgMhfPlayBoxGacha_Success(t *testing.T) { } server.gachaRepo = gachaRepo server.userRepo = &mockUserRepoGacha{} + ensureGachaService(server) session := createMockSession(1, server) @@ -517,6 +530,7 @@ func TestHandleMsgMhfResetBoxGachaInfo(t *testing.T) { server := createMockServer() gachaRepo := &mockGachaRepo{} server.gachaRepo = gachaRepo + ensureGachaService(server) session := createMockSession(1, server) @@ -594,6 +608,7 @@ func TestHandleMsgMhfPlayStepupGacha_RewardPoolError(t *testing.T) { } server.gachaRepo = gachaRepo server.userRepo = &mockUserRepoGacha{} + ensureGachaService(server) session := createMockSession(1, server) diff --git a/server/channelserver/svc_gacha.go b/server/channelserver/svc_gacha.go new file mode 100644 index 000000000..1085a1791 --- /dev/null +++ b/server/channelserver/svc_gacha.go @@ -0,0 +1,325 @@ +package channelserver + +import ( + "database/sql" + "errors" + "math/rand" + "time" + + "erupe-ce/common/byteframe" + + "go.uber.org/zap" +) + +// GachaService encapsulates business logic for the gacha lottery system. +type GachaService struct { + gachaRepo GachaRepo + userRepo UserRepo + charRepo CharacterRepo + logger *zap.Logger + maxNetcafePoints int +} + +// NewGachaService creates a new GachaService. +func NewGachaService(gr GachaRepo, ur UserRepo, cr CharacterRepo, log *zap.Logger, maxNP int) *GachaService { + return &GachaService{ + gachaRepo: gr, + userRepo: ur, + charRepo: cr, + logger: log, + maxNetcafePoints: maxNP, + } +} + +// GachaReward represents a single gacha reward item with rarity. +type GachaReward struct { + ItemType uint8 + ItemID uint16 + Quantity uint16 + Rarity uint8 +} + +// GachaPlayResult holds the outcome of a normal or box gacha play. +type GachaPlayResult struct { + Rewards []GachaReward +} + +// StepupPlayResult holds the outcome of a stepup gacha play. +type StepupPlayResult struct { + RandomRewards []GachaReward + GuaranteedRewards []GachaReward +} + +// StepupStatus holds the current stepup state for a character on a gacha. +type StepupStatus struct { + Step uint8 +} + +// transact processes the cost for a gacha roll, deducting the appropriate currency. +func (svc *GachaService) transact(userID, charID, gachaID uint32, rollID uint8) (int, error) { + itemType, itemNumber, rolls, err := svc.gachaRepo.GetEntryForTransaction(gachaID, rollID) + if err != nil { + return 0, err + } + switch itemType { + case 17: + svc.deductNetcafePoints(charID, int(itemNumber)) + case 19, 20: + svc.spendGachaCoin(userID, itemNumber) + case 21: + if err := svc.userRepo.DeductFrontierPoints(userID, uint32(itemNumber)); err != nil { + svc.logger.Error("Failed to deduct frontier points for gacha", zap.Error(err)) + } + } + return rolls, nil +} + +// deductNetcafePoints removes netcafe points from a character's save data. +func (svc *GachaService) deductNetcafePoints(charID uint32, amount int) { + points, err := svc.charRepo.ReadInt(charID, "netcafe_points") + if err != nil { + svc.logger.Error("Failed to read netcafe points", zap.Error(err)) + return + } + points = min(points-amount, svc.maxNetcafePoints) + if err := svc.charRepo.SaveInt(charID, "netcafe_points", points); err != nil { + svc.logger.Error("Failed to update netcafe points", zap.Error(err)) + } +} + +// spendGachaCoin deducts gacha coins, preferring trial coins over premium. +func (svc *GachaService) spendGachaCoin(userID uint32, quantity uint16) { + gt, _ := svc.userRepo.GetTrialCoins(userID) + if quantity <= gt { + if err := svc.userRepo.DeductTrialCoins(userID, uint32(quantity)); err != nil { + svc.logger.Error("Failed to deduct gacha trial coins", zap.Error(err)) + } + } else { + if err := svc.userRepo.DeductPremiumCoins(userID, uint32(quantity)); err != nil { + svc.logger.Error("Failed to deduct gacha premium coins", zap.Error(err)) + } + } +} + +// resolveRewards selects random entries and resolves them into rewards. +func (svc *GachaService) resolveRewards(entries []GachaEntry, rolls int, isBox bool) []GachaReward { + rewardEntries, _ := getRandomEntries(entries, rolls, isBox) + var rewards []GachaReward + for i := range rewardEntries { + entryItems, err := svc.gachaRepo.GetItemsForEntry(rewardEntries[i].ID) + if err != nil { + continue + } + for _, item := range entryItems { + rewards = append(rewards, GachaReward{ + ItemType: item.ItemType, + ItemID: item.ItemID, + Quantity: item.Quantity, + Rarity: rewardEntries[i].Rarity, + }) + } + } + return rewards +} + +// saveGachaItems appends reward items to the character's gacha item storage. +func (svc *GachaService) saveGachaItems(charID uint32, items []GachaItem) { + data, _ := svc.charRepo.LoadColumn(charID, "gacha_items") + if len(data) > 0 { + numItems := int(data[0]) + data = data[1:] + oldItem := byteframe.NewByteFrameFromBytes(data) + for i := 0; i < numItems; i++ { + items = append(items, GachaItem{ + ItemType: oldItem.ReadUint8(), + ItemID: oldItem.ReadUint16(), + Quantity: oldItem.ReadUint16(), + }) + } + } + newItem := byteframe.NewByteFrame() + newItem.WriteUint8(uint8(len(items))) + for i := range items { + newItem.WriteUint8(items[i].ItemType) + newItem.WriteUint16(items[i].ItemID) + newItem.WriteUint16(items[i].Quantity) + } + if err := svc.charRepo.SaveColumn(charID, "gacha_items", newItem.Data()); err != nil { + svc.logger.Error("Failed to update gacha items", zap.Error(err)) + } +} + +// rewardsToItems converts GachaReward slices to GachaItem slices for storage. +func rewardsToItems(rewards []GachaReward) []GachaItem { + items := make([]GachaItem, len(rewards)) + for i, r := range rewards { + items[i] = GachaItem{ItemType: r.ItemType, ItemID: r.ItemID, Quantity: r.Quantity} + } + return items +} + +// PlayNormalGacha processes a normal gacha roll: deducts cost, selects random +// rewards, saves items, and returns the result. +func (svc *GachaService) PlayNormalGacha(userID, charID, gachaID uint32, rollType uint8) (*GachaPlayResult, error) { + rolls, err := svc.transact(userID, charID, gachaID, rollType) + if err != nil { + return nil, err + } + entries, err := svc.gachaRepo.GetRewardPool(gachaID) + if err != nil { + return nil, err + } + rewards := svc.resolveRewards(entries, rolls, false) + svc.saveGachaItems(charID, rewardsToItems(rewards)) + return &GachaPlayResult{Rewards: rewards}, nil +} + +// PlayStepupGacha processes a stepup gacha roll: deducts cost, advances step, +// awards frontier points, selects random + guaranteed rewards, and saves items. +func (svc *GachaService) PlayStepupGacha(userID, charID, gachaID uint32, rollType uint8) (*StepupPlayResult, error) { + rolls, err := svc.transact(userID, charID, gachaID, rollType) + if err != nil { + return nil, err + } + if err := svc.userRepo.AddFrontierPointsFromGacha(userID, gachaID, rollType); err != nil { + svc.logger.Error("Failed to award stepup gacha frontier points", zap.Error(err)) + } + if err := svc.gachaRepo.DeleteStepup(gachaID, charID); err != nil { + svc.logger.Error("Failed to delete gacha stepup state", zap.Error(err)) + } + if err := svc.gachaRepo.InsertStepup(gachaID, rollType+1, charID); err != nil { + svc.logger.Error("Failed to insert gacha stepup state", zap.Error(err)) + } + + entries, err := svc.gachaRepo.GetRewardPool(gachaID) + if err != nil { + return nil, err + } + + guaranteedItems, _ := svc.gachaRepo.GetGuaranteedItems(rollType, gachaID) + randomRewards := svc.resolveRewards(entries, rolls, false) + + var guaranteedRewards []GachaReward + for _, item := range guaranteedItems { + guaranteedRewards = append(guaranteedRewards, GachaReward{ + ItemType: item.ItemType, + ItemID: item.ItemID, + Quantity: item.Quantity, + Rarity: 0, + }) + } + + svc.saveGachaItems(charID, rewardsToItems(randomRewards)) + svc.saveGachaItems(charID, rewardsToItems(guaranteedRewards)) + return &StepupPlayResult{ + RandomRewards: randomRewards, + GuaranteedRewards: guaranteedRewards, + }, nil +} + +// PlayBoxGacha processes a box gacha roll: deducts cost, selects random entries +// without replacement, records drawn entries, saves items, and returns the result. +func (svc *GachaService) PlayBoxGacha(userID, charID, gachaID uint32, rollType uint8) (*GachaPlayResult, error) { + rolls, err := svc.transact(userID, charID, gachaID, rollType) + if err != nil { + return nil, err + } + entries, err := svc.gachaRepo.GetRewardPool(gachaID) + if err != nil { + return nil, err + } + rewardEntries, _ := getRandomEntries(entries, rolls, true) + var rewards []GachaReward + for i := range rewardEntries { + entryItems, err := svc.gachaRepo.GetItemsForEntry(rewardEntries[i].ID) + if err != nil { + continue + } + if err := svc.gachaRepo.InsertBoxEntry(gachaID, rewardEntries[i].ID, charID); err != nil { + svc.logger.Error("Failed to insert gacha box entry", zap.Error(err)) + } + for _, item := range entryItems { + rewards = append(rewards, GachaReward{ + ItemType: item.ItemType, + ItemID: item.ItemID, + Quantity: item.Quantity, + Rarity: 0, + }) + } + } + svc.saveGachaItems(charID, rewardsToItems(rewards)) + return &GachaPlayResult{Rewards: rewards}, nil +} + +// GetStepupStatus returns the current stepup step for a character, resetting +// stale progress based on the noon boundary. The now parameter enables +// deterministic testing. +func (svc *GachaService) GetStepupStatus(gachaID, charID uint32, now time.Time) (*StepupStatus, error) { + // Compute the most recent noon boundary + y, m, d := now.Date() + midday := time.Date(y, m, d, 12, 0, 0, 0, now.Location()) + if now.Before(midday) { + midday = midday.Add(-24 * time.Hour) + } + + step, createdAt, err := svc.gachaRepo.GetStepupWithTime(gachaID, charID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + svc.logger.Error("Failed to get gacha stepup state", zap.Error(err)) + } + + if err == nil && createdAt.Before(midday) { + if err := svc.gachaRepo.DeleteStepup(gachaID, charID); err != nil { + svc.logger.Error("Failed to reset stale gacha stepup", zap.Error(err)) + } + step = 0 + } else if err == nil { + hasEntry, _ := svc.gachaRepo.HasEntryType(gachaID, step) + if !hasEntry { + if err := svc.gachaRepo.DeleteStepup(gachaID, charID); err != nil { + svc.logger.Error("Failed to reset gacha stepup state", zap.Error(err)) + } + step = 0 + } + } + + return &StepupStatus{Step: step}, nil +} + +// GetBoxInfo returns the entry IDs already drawn for a box gacha. +func (svc *GachaService) GetBoxInfo(gachaID, charID uint32) ([]uint32, error) { + return svc.gachaRepo.GetBoxEntryIDs(gachaID, charID) +} + +// ResetBox clears all drawn entries for a box gacha. +func (svc *GachaService) ResetBox(gachaID, charID uint32) error { + return svc.gachaRepo.DeleteBoxEntries(gachaID, charID) +} + +// getRandomEntries selects random gacha entries. In non-box mode, entries are +// chosen with weighted probability (with replacement). In box mode, entries are +// chosen uniformly without replacement. +func getRandomEntries(entries []GachaEntry, rolls int, isBox bool) ([]GachaEntry, error) { + var chosen []GachaEntry + var totalWeight float64 + for i := range entries { + totalWeight += entries[i].Weight + } + for rolls != len(chosen) { + if !isBox { + result := rand.Float64() * totalWeight + for _, entry := range entries { + result -= entry.Weight + if result < 0 { + chosen = append(chosen, entry) + break + } + } + } else { + result := rand.Intn(len(entries)) + chosen = append(chosen, entries[result]) + entries[result] = entries[len(entries)-1] + entries = entries[:len(entries)-1] + } + } + return chosen, nil +} diff --git a/server/channelserver/svc_gacha_test.go b/server/channelserver/svc_gacha_test.go new file mode 100644 index 000000000..92b9ecd0e --- /dev/null +++ b/server/channelserver/svc_gacha_test.go @@ -0,0 +1,316 @@ +package channelserver + +import ( + "database/sql" + "errors" + "testing" + "time" + + "go.uber.org/zap" +) + +func newTestGachaService(gr GachaRepo, ur UserRepo, cr CharacterRepo) *GachaService { + logger, _ := zap.NewDevelopment() + return NewGachaService(gr, ur, cr, logger, 100000) +} + +func TestGachaService_PlayNormalGacha(t *testing.T) { + tests := []struct { + name string + txErr error + poolErr error + txRolls int + pool []GachaEntry + items map[uint32][]GachaItem + wantErr bool + wantCount int + }{ + { + name: "transact error", + txErr: errors.New("tx fail"), + wantErr: true, + }, + { + name: "reward pool error", + txRolls: 1, + poolErr: errors.New("pool fail"), + wantErr: true, + }, + { + name: "success single roll", + txRolls: 1, + pool: []GachaEntry{{ID: 10, Weight: 100, Rarity: 3}}, + items: map[uint32][]GachaItem{ + 10: {{ItemType: 1, ItemID: 500, Quantity: 1}}, + }, + wantCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gr := &mockGachaRepo{ + txRolls: tt.txRolls, + txErr: tt.txErr, + rewardPool: tt.pool, + rewardPoolErr: tt.poolErr, + entryItems: tt.items, + } + cr := newMockCharacterRepo() + svc := newTestGachaService(gr, &mockUserRepoGacha{}, cr) + + result, err := svc.PlayNormalGacha(1, 1, 1, 0) + if tt.wantErr { + if err == nil { + t.Fatal("Expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(result.Rewards) != tt.wantCount { + t.Errorf("Rewards count = %d, want %d", len(result.Rewards), tt.wantCount) + } + // Verify items were saved + if tt.wantCount > 0 && cr.columns["gacha_items"] == nil { + t.Error("Expected gacha items to be saved") + } + }) + } +} + +func TestGachaService_PlayStepupGacha(t *testing.T) { + tests := []struct { + name string + txErr error + poolErr error + txRolls int + pool []GachaEntry + items map[uint32][]GachaItem + guaranteed []GachaItem + wantErr bool + wantRandomCount int + wantGuaranteeCount int + }{ + { + name: "transact error", + txErr: errors.New("tx fail"), + wantErr: true, + }, + { + name: "reward pool error", + txRolls: 1, + poolErr: errors.New("pool fail"), + wantErr: true, + }, + { + name: "success with guaranteed", + txRolls: 1, + pool: []GachaEntry{{ID: 10, Weight: 100, Rarity: 2}}, + items: map[uint32][]GachaItem{ + 10: {{ItemType: 1, ItemID: 600, Quantity: 2}}, + }, + guaranteed: []GachaItem{{ItemType: 1, ItemID: 700, Quantity: 1}}, + wantRandomCount: 1, + wantGuaranteeCount: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gr := &mockGachaRepo{ + txRolls: tt.txRolls, + txErr: tt.txErr, + rewardPool: tt.pool, + rewardPoolErr: tt.poolErr, + entryItems: tt.items, + guaranteedItems: tt.guaranteed, + } + cr := newMockCharacterRepo() + svc := newTestGachaService(gr, &mockUserRepoGacha{}, cr) + + result, err := svc.PlayStepupGacha(1, 1, 1, 0) + if tt.wantErr { + if err == nil { + t.Fatal("Expected error, got nil") + } + return + } + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(result.RandomRewards) != tt.wantRandomCount { + t.Errorf("RandomRewards count = %d, want %d", len(result.RandomRewards), tt.wantRandomCount) + } + if len(result.GuaranteedRewards) != tt.wantGuaranteeCount { + t.Errorf("GuaranteedRewards count = %d, want %d", len(result.GuaranteedRewards), tt.wantGuaranteeCount) + } + if !gr.deletedStepup { + t.Error("Expected stepup to be deleted") + } + if gr.insertedStep != 1 { + t.Errorf("Expected insertedStep=1, got %d", gr.insertedStep) + } + }) + } +} + +func TestGachaService_PlayBoxGacha(t *testing.T) { + gr := &mockGachaRepo{ + txRolls: 1, + rewardPool: []GachaEntry{ + {ID: 10, Weight: 100, Rarity: 1}, + }, + entryItems: map[uint32][]GachaItem{ + 10: {{ItemType: 1, ItemID: 800, Quantity: 1}}, + }, + } + cr := newMockCharacterRepo() + svc := newTestGachaService(gr, &mockUserRepoGacha{}, cr) + + result, err := svc.PlayBoxGacha(1, 1, 1, 0) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(result.Rewards) != 1 { + t.Errorf("Rewards count = %d, want 1", len(result.Rewards)) + } + if len(gr.insertedBoxIDs) == 0 { + t.Error("Expected box entry to be inserted") + } +} + +func TestGachaService_GetStepupStatus(t *testing.T) { + now := time.Date(2025, 6, 15, 15, 0, 0, 0, time.UTC) // 3 PM + midday := time.Date(2025, 6, 15, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + step uint8 + createdAt time.Time + stepupErr error + hasEntry bool + wantStep uint8 + wantDeleted bool + }{ + { + name: "no rows", + stepupErr: sql.ErrNoRows, + wantStep: 0, + }, + { + name: "fresh with entry", + step: 2, + createdAt: now, // after midday + hasEntry: true, + wantStep: 2, + wantDeleted: false, + }, + { + name: "stale (before midday)", + step: 3, + createdAt: midday.Add(-1 * time.Hour), // before midday boundary + wantStep: 0, + wantDeleted: true, + }, + { + name: "fresh but no entry type", + step: 2, + createdAt: now, + hasEntry: false, + wantStep: 0, + wantDeleted: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gr := &mockGachaRepo{ + stepupStep: tt.step, + stepupTime: tt.createdAt, + stepupErr: tt.stepupErr, + hasEntryType: tt.hasEntry, + } + svc := newTestGachaService(gr, &mockUserRepoGacha{}, newMockCharacterRepo()) + + status, err := svc.GetStepupStatus(1, 1, now) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if status.Step != tt.wantStep { + t.Errorf("Step = %d, want %d", status.Step, tt.wantStep) + } + if gr.deletedStepup != tt.wantDeleted { + t.Errorf("deletedStepup = %v, want %v", gr.deletedStepup, tt.wantDeleted) + } + }) + } +} + +func TestGachaService_GetBoxInfo(t *testing.T) { + gr := &mockGachaRepo{ + boxEntryIDs: []uint32{10, 20, 30}, + } + svc := newTestGachaService(gr, &mockUserRepoGacha{}, newMockCharacterRepo()) + + ids, err := svc.GetBoxInfo(1, 1) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if len(ids) != 3 { + t.Errorf("Got %d entry IDs, want 3", len(ids)) + } +} + +func TestGachaService_ResetBox(t *testing.T) { + gr := &mockGachaRepo{} + svc := newTestGachaService(gr, &mockUserRepoGacha{}, newMockCharacterRepo()) + + err := svc.ResetBox(1, 1) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if !gr.deletedBox { + t.Error("Expected box entries to be deleted") + } +} + +func TestGachaService_Transact_NetcafeCoins(t *testing.T) { + cr := newMockCharacterRepo() + cr.ints["netcafe_points"] = 5000 + gr := &mockGachaRepo{ + txItemType: 17, + txItemNumber: 100, + txRolls: 1, + } + svc := newTestGachaService(gr, &mockUserRepoGacha{}, cr) + + rolls, err := svc.transact(1, 1, 1, 0) + if err != nil { + t.Fatalf("Unexpected error: %v", err) + } + if rolls != 1 { + t.Errorf("Rolls = %d, want 1", rolls) + } + // Netcafe points should have been reduced + if cr.ints["netcafe_points"] != 4900 { + t.Errorf("Netcafe points = %d, want 4900", cr.ints["netcafe_points"]) + } +} + +func TestGachaService_SpendGachaCoin_TrialFirst(t *testing.T) { + ur := &mockUserRepoGacha{trialCoins: 100} + svc := newTestGachaService(&mockGachaRepo{}, ur, newMockCharacterRepo()) + + svc.spendGachaCoin(1, 50) + // Should have used trial coins, not premium +} + +func TestGachaService_SpendGachaCoin_PremiumFallback(t *testing.T) { + ur := &mockUserRepoGacha{trialCoins: 10} + svc := newTestGachaService(&mockGachaRepo{}, ur, newMockCharacterRepo()) + + svc.spendGachaCoin(1, 50) + // Should have used premium coins since trial < quantity +} diff --git a/server/channelserver/sys_channel_server.go b/server/channelserver/sys_channel_server.go index 5e517eacb..56e3cf658 100644 --- a/server/channelserver/sys_channel_server.go +++ b/server/channelserver/sys_channel_server.go @@ -73,6 +73,7 @@ type Server struct { mercenaryRepo MercenaryRepo guildService *GuildService achievementService *AchievementService + gachaService *GachaService erupeConfig *cfg.Config acceptConns chan net.Conn deleteConns chan net.Conn @@ -157,6 +158,7 @@ func NewServer(config *Config) *Server { s.guildService = NewGuildService(s.guildRepo, s.mailRepo, 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) // Mezeporta s.stages.Store("sl1Ns200p0a0u0", NewStage("sl1Ns200p0a0u0")) diff --git a/server/channelserver/test_helpers_test.go b/server/channelserver/test_helpers_test.go index e4fbca83b..a25009cef 100644 --- a/server/channelserver/test_helpers_test.go +++ b/server/channelserver/test_helpers_test.go @@ -66,6 +66,11 @@ func ensureAchievementService(s *Server) { s.achievementService = NewAchievementService(s.achievementRepo, s.logger) } +// ensureGachaService wires the GachaService from the server's current repos. +func ensureGachaService(s *Server) { + s.gachaService = NewGachaService(s.gachaRepo, s.userRepo, s.charRepo, s.logger, 100000) +} + // createMockSession creates a minimal Session for testing. // Imported from v9.2.x-stable and adapted for main. func createMockSession(charID uint32, server *Server) *Session {