package gameserver import ( "crypto/rand" "fmt" "math/big" "gac-hkrpg-go/internal/common" pb "gac-hkrpg-go/protocol/proto" ) func init() { handle("GetGachaInfo", func(s *Session, req *pb.GetGachaInfoCsReq, rsp *pb.GetGachaInfoScRsp) error { banners, _, err := common.LoadBrowsableGachaBanners() if err != nil { return err } if len(banners) == 0 { rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT) PacketLog("GACHA entry rejected: no verified installed banner assets") return nil } config, err := common.LoadRuntimeConfig() if err != nil { return err } for _, banner := range banners { info := &pb.GachaInfo{GachaId: banner.ID, BeginTime: *config.ActivitySchedule.BeginTime, EndTime: *config.ActivitySchedule.EndTime, GachaCeiling: &pb.GachaCeiling{}} if banner.AvatarID != 0 { info.PrizeItemList = []uint32{banner.AvatarID} info.ItemDetailList = []uint32{banner.AvatarID} } rsp.GachaInfoList = append(rsp.GachaInfoList, info) } // Synthetic IDs are not native Client config rows. The presentation // adapter registers its own pools; sending these IDs in GetGachaInfo has // caused Client response handlers to throw. Keep the preview opt-in. mapped, _, err := common.LoadLimitedGachaBanners() if err != nil { return err } _, _, policy, err := common.LoadSandboxDrawPool() if err != nil { return err } count := policy.MappedBannerPreviewCount if count > uint32(len(mapped)) { count = uint32(len(mapped)) } for i := uint32(0); i < count; i++ { banner := mapped[i] info := &pb.GachaInfo{GachaId: banner.GachaID, BeginTime: *config.ActivitySchedule.BeginTime, EndTime: *config.ActivitySchedule.EndTime, GachaCeiling: &pb.GachaCeiling{}} info.PrizeItemList = []uint32{banner.AvatarID} info.ItemDetailList = append([]uint32{banner.AvatarID}, banner.FourStarIDs...) rsp.GachaInfoList = append(rsp.GachaInfoList, info) } PacketLog(fmt.Sprintf("GACHA_INFO banners=%d mapped_preview=%d", len(banners), count)) return nil }) handle("DoGacha", func(s *Session, req *pb.DoGachaCsReq, rsp *pb.DoGachaScRsp) error { rsp.GachaId = req.GachaId rsp.Retcode = uint32(pb.Retcode_RET_GACHA_ID_NOT_EXIST) banners, _, err := common.LoadFeaturedGachaBanners() if err != nil { return err } var avatarID uint32 for _, row := range banners { if row.ID == req.GachaId { avatarID = row.AvatarID break } } if avatarID == 0 { mapped, _, err := common.LoadLimitedGachaBanners() if err != nil { return err } for _, row := range mapped { if row.GachaID == req.GachaId { return drawLimitedAvatar(s, req, rsp, row) } } } if avatarID != 0 { return drawFeaturedAvatar(s, req, rsp, avatarID) } // Featured-only path found nothing. Verified browsable banners may // still draw from the policy-declared sandbox pool: every pick is a // duplicate grant on an avatar from the live imported roster, so no // invented IDs or first-acquisition synthesis are involved. sandboxDrawn, err := drawSandboxPool(s, req, rsp) if err != nil { return err } if sandboxDrawn { return nil } rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT) return nil }) } // drawFeaturedAvatar grants req.GachaNum copies of one featured avatar using // its configured first Eidolon cost as the duplicate conversion for captured // basename-derived banners. Mapped limited banners use drawLimitedAvatar. func drawFeaturedAvatar(s *Session, req *pb.DoGachaCsReq, rsp *pb.DoGachaScRsp, avatarID uint32) error { _, policy, err := common.LoadFeaturedGachaBanners() if err != nil { return err } rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NUM_INVALID) allowed := false for _, n := range policy.DrawCounts { allowed = allowed || n == req.GachaNum } if !allowed { return nil } rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT) if _, active := CURRENT_BATTLE.Load(s.Token); active { return nil } player := s.JSON() if player == nil || player.Avatars[avatarID] == nil { return nil } resources, err := loadEidolonResources() if err != nil { return err } row, exists := resources.Avatars[avatarID] if !exists || len(row.Ranks) == 0 { return nil } additions := map[uint32]uint32{} for _, cost := range row.Ranks[0].Cost { amount := uint64(cost.ItemNum)*uint64(req.GachaNum) + uint64(additions[cost.ItemID]) if amount > uint64(^uint32(0)) { return nil } additions[cost.ItemID] = uint32(amount) } bag, err := bagMaterials(player) if err != nil { return err } baseline := map[uint32]uint32{} for _, item := range bag { baseline[item.Tid] = item.Num } updated, err := common.GrantGachaDuplicate(avatarID, additions, baseline) if err != nil { PacketLog("GACHA rejected: " + err.Error()) return nil } if player.Materials == nil { player.Materials = map[uint32]uint32{} } sync := &pb.PlayerSyncScNotify{} for id, count := range updated { player.Materials[id] = count sync.MaterialList = append(sync.MaterialList, &pb.Material{Tid: id, Num: count}) } for i := uint32(0); i < req.GachaNum; i++ { transfer := &pb.ItemList{} for _, cost := range row.Ranks[0].Cost { transfer.ItemList = append(transfer.ItemList, &pb.Item{ItemId: cost.ItemID, Num: cost.ItemNum}) } rsp.GachaItemList = append(rsp.GachaItemList, &pb.GachaItem{GachaItem: &pb.Item{ItemId: avatarID, Num: 1}, TokenItem: &pb.ItemList{}, TransferItemList: transfer}) } if err = s.Send(sync); err != nil { return err } rsp.Retcode = 0 rsp.GachaNum = req.GachaNum PacketLog(fmt.Sprintf("GACHA banner=%d avatar=%d draws=%d sandbox=featured", req.GachaId, avatarID, req.GachaNum)) return nil } // drawSandboxPool serves a draw from the policy-declared sandbox pool for an // asset-verified browsable banner. Every pick is a duplicate-material grant on // an avatar from the live imported roster; nothing is synthesized. Returns // false when the request is not a verified banner or the pool is disabled, so // the caller keeps its original rejection. func drawSandboxPool(s *Session, req *pb.DoGachaCsReq, rsp *pb.DoGachaScRsp) (bool, error) { verified, _, err := common.LoadBrowsableGachaBanners() if err != nil { return false, err } isVerified := false for _, row := range verified { isVerified = isVerified || row.ID == req.GachaId } if !isVerified { return false, nil } enabled, pool, policy, err := common.LoadSandboxDrawPool() if err != nil { return false, err } if !enabled || len(pool) == 0 { return false, nil } rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NUM_INVALID) allowed := false for _, n := range policy.DrawCounts { allowed = allowed || n == req.GachaNum } if !allowed { return true, nil } rsp.Retcode = uint32(pb.Retcode_RET_GACHA_NOT_SUPPORT) if _, active := CURRENT_BATTLE.Load(s.Token); active { return true, nil } resources, err := loadEidolonResources() if err != nil { return false, err } // Uniform sandbox picks. Avatars without a configured first-rank Eidolon // cost are excluded: their duplicate grant has no declared conversion. type rosterAvatar struct { id uint32 itemID, itemNum uint32 } effective := make([]rosterAvatar, 0, len(pool)) for _, id := range pool { row, exists := resources.Avatars[id] if !exists || len(row.Ranks) == 0 || len(row.Ranks[0].Cost) == 0 { continue } effective = append(effective, rosterAvatar{id: id, itemID: row.Ranks[0].Cost[0].ItemID, itemNum: row.Ranks[0].Cost[0].ItemNum}) } if len(effective) == 0 { PacketLog("GACHA sandbox pool empty: no roster avatar with configured Eidolon cost") return true, nil } player := s.JSON() if player == nil { return true, nil } picks := make([]rosterAvatar, 0, req.GachaNum) for i := uint32(0); i < req.GachaNum; i++ { n, err := rand.Int(rand.Reader, big.NewInt(int64(len(effective)))) if err != nil { return false, err } picks = append(picks, effective[n.Int64()]) } counts := map[uint32]uint32{} costOf := map[uint32]rosterAvatar{} for _, pick := range picks { counts[pick.id]++ costOf[pick.id] = pick } bag, err := bagMaterials(player) if err != nil { return false, err } baseline := map[uint32]uint32{} for _, item := range bag { baseline[item.Tid] = item.Num } updated := map[uint32]uint32{} for id, times := range counts { cost := costOf[id] total := uint64(cost.itemNum) * uint64(times) if total > uint64(^uint32(0)) { return true, nil } additions := map[uint32]uint32{cost.itemID: uint32(total)} got, err := common.GrantGachaDuplicate(id, additions, baseline) if err != nil { PacketLog("GACHA sandbox rejected: " + err.Error()) return true, nil } for itemID, count := range got { updated[itemID] = count } } if player.Materials == nil { player.Materials = map[uint32]uint32{} } sync := &pb.PlayerSyncScNotify{} for id, count := range updated { player.Materials[id] = count sync.MaterialList = append(sync.MaterialList, &pb.Material{Tid: id, Num: count}) } for _, pick := range picks { transfer := &pb.ItemList{} transfer.ItemList = append(transfer.ItemList, &pb.Item{ItemId: pick.itemID, Num: pick.itemNum}) rsp.GachaItemList = append(rsp.GachaItemList, &pb.GachaItem{GachaItem: &pb.Item{ItemId: pick.id, Num: 1}, TokenItem: &pb.ItemList{}, TransferItemList: transfer}) } if err = s.Send(sync); err != nil { return false, err } rsp.Retcode = 0 rsp.GachaNum = req.GachaNum PacketLog(fmt.Sprintf("GACHA banner=%d draws=%d sandbox=imported_roster pool=%d distinct=%d", req.GachaId, req.GachaNum, len(effective), len(counts))) return true, nil }