mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-25 17:12:52 +01:00
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.
This commit is contained in:
@@ -1,11 +1,6 @@
|
|||||||
package channelserver
|
package channelserver
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"database/sql"
|
|
||||||
"errors"
|
|
||||||
"math/rand"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"erupe-ce/common/byteframe"
|
"erupe-ce/common/byteframe"
|
||||||
"erupe-ce/network/mhfpacket"
|
"erupe-ce/network/mhfpacket"
|
||||||
|
|
||||||
@@ -81,106 +76,6 @@ func handleMsgMhfUseGachaPoint(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
|
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) {
|
func handleMsgMhfReceiveGachaItem(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfReceiveGachaItem)
|
pkt := p.(*mhfpacket.MsgMhfReceiveGachaItem)
|
||||||
data, err := s.server.charRepo.LoadColumnWithDefault(s.charID, "gacha_items", []byte{0x00})
|
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) {
|
func handleMsgMhfPlayNormalGacha(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfPlayNormalGacha)
|
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()
|
bf := byteframe.NewByteFrame()
|
||||||
var rewards []GachaItem
|
bf.WriteUint8(uint8(len(result.Rewards)))
|
||||||
rolls, err := transactGacha(s, pkt.GachaID, pkt.RollType)
|
for _, r := range result.Rewards {
|
||||||
if err != nil {
|
bf.WriteUint8(r.ItemType)
|
||||||
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1))
|
bf.WriteUint16(r.ItemID)
|
||||||
return
|
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())
|
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
|
||||||
addGachaItem(s, rewards)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleMsgMhfPlayStepupGacha(s *Session, p mhfpacket.MHFPacket) {
|
func handleMsgMhfPlayStepupGacha(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfPlayStepupGacha)
|
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()
|
bf := byteframe.NewByteFrame()
|
||||||
var rewards []GachaItem
|
bf.WriteUint8(uint8(len(result.RandomRewards) + len(result.GuaranteedRewards)))
|
||||||
rolls, err := transactGacha(s, pkt.GachaID, pkt.RollType)
|
bf.WriteUint8(uint8(len(result.RandomRewards)))
|
||||||
if err != nil {
|
for _, item := range result.GuaranteedRewards {
|
||||||
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(item.ItemType)
|
bf.WriteUint8(item.ItemType)
|
||||||
bf.WriteUint16(item.ItemID)
|
bf.WriteUint16(item.ItemID)
|
||||||
bf.WriteUint16(item.Quantity)
|
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())
|
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
|
||||||
addGachaItem(s, rewards)
|
|
||||||
addGachaItem(s, guaranteedItems)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleMsgMhfGetStepupStatus(s *Session, p mhfpacket.MHFPacket) {
|
func handleMsgMhfGetStepupStatus(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetStepupStatus)
|
pkt := p.(*mhfpacket.MsgMhfGetStepupStatus)
|
||||||
|
|
||||||
// Compute the most recent noon boundary
|
status, _ := s.server.gachaService.GetStepupStatus(pkt.GachaID, s.charID, TimeAdjusted())
|
||||||
midday := TimeMidnight().Add(12 * time.Hour)
|
|
||||||
if TimeAdjusted().Before(midday) {
|
|
||||||
midday = midday.Add(-24 * time.Hour)
|
|
||||||
}
|
|
||||||
|
|
||||||
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 := byteframe.NewByteFrame()
|
||||||
bf.WriteUint8(step)
|
bf.WriteUint8(status.Step)
|
||||||
bf.WriteUint32(uint32(TimeAdjusted().Unix()))
|
bf.WriteUint32(uint32(TimeAdjusted().Unix()))
|
||||||
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
|
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleMsgMhfGetBoxGachaInfo(s *Session, p mhfpacket.MHFPacket) {
|
func handleMsgMhfGetBoxGachaInfo(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfGetBoxGachaInfo)
|
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 {
|
if err != nil {
|
||||||
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1))
|
doAckBufSucceed(s, pkt.AckHandle, make([]byte, 1))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
bf := byteframe.NewByteFrame()
|
bf := byteframe.NewByteFrame()
|
||||||
bf.WriteUint8(uint8(len(entryIDs)))
|
bf.WriteUint8(uint8(len(entryIDs)))
|
||||||
for i := range entryIDs {
|
for i := range entryIDs {
|
||||||
@@ -361,43 +187,27 @@ func handleMsgMhfGetBoxGachaInfo(s *Session, p mhfpacket.MHFPacket) {
|
|||||||
|
|
||||||
func handleMsgMhfPlayBoxGacha(s *Session, p mhfpacket.MHFPacket) {
|
func handleMsgMhfPlayBoxGacha(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfPlayBoxGacha)
|
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()
|
bf := byteframe.NewByteFrame()
|
||||||
var rewards []GachaItem
|
bf.WriteUint8(uint8(len(result.Rewards)))
|
||||||
rolls, err := transactGacha(s, pkt.GachaID, pkt.RollType)
|
for _, r := range result.Rewards {
|
||||||
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(r.ItemType)
|
bf.WriteUint8(r.ItemType)
|
||||||
bf.WriteUint16(r.ItemID)
|
bf.WriteUint16(r.ItemID)
|
||||||
bf.WriteUint16(r.Quantity)
|
bf.WriteUint16(r.Quantity)
|
||||||
bf.WriteUint8(0)
|
bf.WriteUint8(r.Rarity)
|
||||||
}
|
}
|
||||||
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
|
doAckBufSucceed(s, pkt.AckHandle, bf.Data())
|
||||||
addGachaItem(s, rewards)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleMsgMhfResetBoxGachaInfo(s *Session, p mhfpacket.MHFPacket) {
|
func handleMsgMhfResetBoxGachaInfo(s *Session, p mhfpacket.MHFPacket) {
|
||||||
pkt := p.(*mhfpacket.MsgMhfResetBoxGachaInfo)
|
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))
|
s.logger.Error("Failed to reset gacha box", zap.Error(err))
|
||||||
}
|
}
|
||||||
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
|
doAckSimpleSucceed(s, pkt.AckHandle, make([]byte, 4))
|
||||||
|
|||||||
@@ -189,6 +189,7 @@ func TestHandleMsgMhfPlayNormalGacha_TransactError(t *testing.T) {
|
|||||||
gachaRepo := &mockGachaRepo{txErr: errors.New("transact failed")}
|
gachaRepo := &mockGachaRepo{txErr: errors.New("transact failed")}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
server.userRepo = &mockUserRepoGacha{}
|
server.userRepo = &mockUserRepoGacha{}
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -213,6 +214,7 @@ func TestHandleMsgMhfPlayNormalGacha_RewardPoolError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
server.userRepo = &mockUserRepoGacha{}
|
server.userRepo = &mockUserRepoGacha{}
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -243,6 +245,7 @@ func TestHandleMsgMhfPlayNormalGacha_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
server.userRepo = &mockUserRepoGacha{}
|
server.userRepo = &mockUserRepoGacha{}
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -269,6 +272,7 @@ func TestHandleMsgMhfPlayStepupGacha_TransactError(t *testing.T) {
|
|||||||
gachaRepo := &mockGachaRepo{txErr: errors.New("transact failed")}
|
gachaRepo := &mockGachaRepo{txErr: errors.New("transact failed")}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
server.userRepo = &mockUserRepoGacha{}
|
server.userRepo = &mockUserRepoGacha{}
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -302,6 +306,7 @@ func TestHandleMsgMhfPlayStepupGacha_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
server.userRepo = &mockUserRepoGacha{}
|
server.userRepo = &mockUserRepoGacha{}
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -331,6 +336,7 @@ func TestHandleMsgMhfGetStepupStatus_FreshStep(t *testing.T) {
|
|||||||
hasEntryType: true,
|
hasEntryType: true,
|
||||||
}
|
}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -354,6 +360,7 @@ func TestHandleMsgMhfGetStepupStatus_StaleStep(t *testing.T) {
|
|||||||
stepupTime: time.Now().Add(-48 * time.Hour), // stale
|
stepupTime: time.Now().Add(-48 * time.Hour), // stale
|
||||||
}
|
}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -378,6 +385,7 @@ func TestHandleMsgMhfGetStepupStatus_NoRows(t *testing.T) {
|
|||||||
stepupErr: sql.ErrNoRows,
|
stepupErr: sql.ErrNoRows,
|
||||||
}
|
}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -400,6 +408,7 @@ func TestHandleMsgMhfGetStepupStatus_NoEntryType(t *testing.T) {
|
|||||||
hasEntryType: false, // no matching entry type -> reset
|
hasEntryType: false, // no matching entry type -> reset
|
||||||
}
|
}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -424,6 +433,7 @@ func TestHandleMsgMhfGetBoxGachaInfo_Error(t *testing.T) {
|
|||||||
boxEntryIDsErr: errors.New("db error"),
|
boxEntryIDsErr: errors.New("db error"),
|
||||||
}
|
}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -444,6 +454,7 @@ func TestHandleMsgMhfGetBoxGachaInfo_Success(t *testing.T) {
|
|||||||
boxEntryIDs: []uint32{10, 20, 30},
|
boxEntryIDs: []uint32{10, 20, 30},
|
||||||
}
|
}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -465,6 +476,7 @@ func TestHandleMsgMhfPlayBoxGacha_TransactError(t *testing.T) {
|
|||||||
gachaRepo := &mockGachaRepo{txErr: errors.New("transact failed")}
|
gachaRepo := &mockGachaRepo{txErr: errors.New("transact failed")}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
server.userRepo = &mockUserRepoGacha{}
|
server.userRepo = &mockUserRepoGacha{}
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -495,6 +507,7 @@ func TestHandleMsgMhfPlayBoxGacha_Success(t *testing.T) {
|
|||||||
}
|
}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
server.userRepo = &mockUserRepoGacha{}
|
server.userRepo = &mockUserRepoGacha{}
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -517,6 +530,7 @@ func TestHandleMsgMhfResetBoxGachaInfo(t *testing.T) {
|
|||||||
server := createMockServer()
|
server := createMockServer()
|
||||||
gachaRepo := &mockGachaRepo{}
|
gachaRepo := &mockGachaRepo{}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
@@ -594,6 +608,7 @@ func TestHandleMsgMhfPlayStepupGacha_RewardPoolError(t *testing.T) {
|
|||||||
}
|
}
|
||||||
server.gachaRepo = gachaRepo
|
server.gachaRepo = gachaRepo
|
||||||
server.userRepo = &mockUserRepoGacha{}
|
server.userRepo = &mockUserRepoGacha{}
|
||||||
|
ensureGachaService(server)
|
||||||
|
|
||||||
session := createMockSession(1, server)
|
session := createMockSession(1, server)
|
||||||
|
|
||||||
|
|||||||
325
server/channelserver/svc_gacha.go
Normal file
325
server/channelserver/svc_gacha.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
316
server/channelserver/svc_gacha_test.go
Normal file
316
server/channelserver/svc_gacha_test.go
Normal file
@@ -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
|
||||||
|
}
|
||||||
@@ -73,6 +73,7 @@ type Server struct {
|
|||||||
mercenaryRepo MercenaryRepo
|
mercenaryRepo MercenaryRepo
|
||||||
guildService *GuildService
|
guildService *GuildService
|
||||||
achievementService *AchievementService
|
achievementService *AchievementService
|
||||||
|
gachaService *GachaService
|
||||||
erupeConfig *cfg.Config
|
erupeConfig *cfg.Config
|
||||||
acceptConns chan net.Conn
|
acceptConns chan net.Conn
|
||||||
deleteConns 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.guildService = NewGuildService(s.guildRepo, s.mailRepo, 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)
|
||||||
|
|
||||||
// Mezeporta
|
// Mezeporta
|
||||||
s.stages.Store("sl1Ns200p0a0u0", NewStage("sl1Ns200p0a0u0"))
|
s.stages.Store("sl1Ns200p0a0u0", NewStage("sl1Ns200p0a0u0"))
|
||||||
|
|||||||
@@ -66,6 +66,11 @@ func ensureAchievementService(s *Server) {
|
|||||||
s.achievementService = NewAchievementService(s.achievementRepo, s.logger)
|
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.
|
// 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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user