package channelserver import ( "erupe-ce/common/byteframe" ps "erupe-ce/common/pascalstring" cfg "erupe-ce/config" "erupe-ce/network/mhfpacket" "go.uber.org/zap" ) // ShopItem represents a shop item listing. type ShopItem struct { ID uint32 `db:"id"` ItemID uint32 `db:"item_id"` Cost uint32 `db:"cost"` Quantity uint16 `db:"quantity"` MinHR uint16 `db:"min_hr"` MinSR uint16 `db:"min_sr"` MinGR uint16 `db:"min_gr"` StoreLevel uint8 `db:"store_level"` MaxQuantity uint16 `db:"max_quantity"` UsedQuantity uint16 `db:"used_quantity"` RoadFloors uint16 `db:"road_floors"` RoadFatalis uint16 `db:"road_fatalis"` } func writeShopItems(bf *byteframe.ByteFrame, items []ShopItem, mode cfg.Mode) { bf.WriteUint16(uint16(len(items))) bf.WriteUint16(uint16(len(items))) for _, item := range items { if mode >= cfg.Z2 { bf.WriteUint32(item.ID) } bf.WriteUint32(item.ItemID) bf.WriteUint32(item.Cost) bf.WriteUint16(item.Quantity) bf.WriteUint16(item.MinHR) bf.WriteUint16(item.MinSR) if mode >= cfg.Z2 { bf.WriteUint16(item.MinGR) } bf.WriteUint8(0) // Unk bf.WriteUint8(item.StoreLevel) if mode >= cfg.Z2 { bf.WriteUint16(item.MaxQuantity) bf.WriteUint16(item.UsedQuantity) } if mode == cfg.Z1 { bf.WriteUint8(uint8(item.RoadFloors)) bf.WriteUint8(uint8(item.RoadFatalis)) } else if mode >= cfg.Z2 { bf.WriteUint16(item.RoadFloors) bf.WriteUint16(item.RoadFatalis) } } } func getShopItems(s *Session, shopType uint8, shopID uint32) []ShopItem { items, err := s.server.shopRepo.GetShopItems(shopType, shopID, s.charID) if err != nil { return nil } return items } func handleMsgMhfEnumerateShop(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfEnumerateShop) // Generic Shop IDs // 0: basic item // 1: gatherables // 2: hr1-4 materials // 3: hr5-7 materials // 4: decos // 5: other item // 6: g mats // 7: limited item // 8: special item switch pkt.ShopType { case 1: // Running gachas // Fundamentally, gacha works completely differently, just hide it for now. if s.server.erupeConfig.RealClientMode < cfg.G1 { doAckBufSucceed(s, pkt.AckHandle, make([]byte, 4)) return } gachas, err := s.server.gachaRepo.ListShop() if err != nil { doAckBufSucceed(s, pkt.AckHandle, make([]byte, 4)) return } bf := byteframe.NewByteFrame() bf.WriteUint16(uint16(len(gachas))) bf.WriteUint16(uint16(len(gachas))) for _, g := range gachas { if s.server.erupeConfig.RealClientMode >= cfg.G1 { bf.WriteUint32(g.ID) bf.WriteUint32(0) // Unknown rank restrictions bf.WriteUint32(0) bf.WriteUint32(0) bf.WriteUint32(0) bf.WriteUint32(g.MinGR) bf.WriteUint32(g.MinHR) bf.WriteUint32(0) // only 0 in known packet } ps.Uint8(bf, g.Name, true) if s.server.erupeConfig.RealClientMode <= cfg.GG { //For versions less than or equal to GG, each message sent to the name ends continue } ps.Uint8(bf, g.URLBanner, false) ps.Uint8(bf, g.URLFeature, false) if s.server.erupeConfig.RealClientMode >= cfg.G10 { bf.WriteBool(g.Wide) ps.Uint8(bf, g.URLThumbnail, false) } if g.Recommended { bf.WriteUint16(2) } else { bf.WriteUint16(0) } bf.WriteUint8(g.GachaType) if s.server.erupeConfig.RealClientMode >= cfg.G10 { bf.WriteBool(g.Hidden) } } doAckBufSucceed(s, pkt.AckHandle, bf.Data()) case 2: // Actual gacha bf := byteframe.NewByteFrame() bf.WriteUint32(pkt.ShopID) gachaType, err := s.server.gachaRepo.GetShopType(pkt.ShopID) if err != nil { s.logger.Error("Failed to get gacha shop type", zap.Error(err)) } entries, err := s.server.gachaRepo.GetAllEntries(pkt.ShopID) if err != nil { doAckBufSucceed(s, pkt.AckHandle, make([]byte, 4)) return } divisor, err := s.server.gachaRepo.GetWeightDivisor(pkt.ShopID) if err != nil { s.logger.Error("Failed to get gacha weight divisor", zap.Error(err)) } bf.WriteUint16(uint16(len(entries))) for _, ge := range entries { var items []GachaItem if s.server.erupeConfig.RealClientMode <= cfg.GG { // G1–GG gacha format: rewards are defined directly in gacha_entries // (item_type/item_number/item_quantity), NOT in gacha_items. // This is a completely different format from G10+/ZZ. // // WARNING: Do NOT use these example values for ZZ servers. // For ZZ, entry_type=100 rows must have item_type=0, item_number=0, // item_quantity=0; actual rewards go in the gacha_items table only. // // G1–GG example gacha_entries: // |id|gacha_id|entry_type|item_type|item_number|item_qty|weight| // |1 |1 |0 |7 |7 |1 |0 | // |4 |1 |0 |7 |8 |2 |0 | // |5 |1 |1 |7 |9 |3 |0 | // |8 |1 |100 |7 |1 |4 |1000 | // |9 |1 |100 |7 |2 |5 |9000 | bf.WriteUint8(ge.EntryType) bf.WriteUint32(ge.ID) bf.WriteUint8(ge.ItemType) bf.WriteUint32(ge.ItemNumber) bf.WriteUint16(ge.ItemQuantity) var weightPr uint16 if gachaType >= 4 { // If box weightPr = 1 } else { weightPr = uint16(ge.Weight / divisor) } bf.WriteUint16(weightPr) bf.WriteUint8(0) continue } bf.WriteUint8(ge.EntryType) bf.WriteUint32(ge.ID) bf.WriteUint8(ge.ItemType) bf.WriteUint32(ge.ItemNumber) bf.WriteUint16(ge.ItemQuantity) if gachaType >= 4 { // If box bf.WriteUint16(1) } else { bf.WriteUint16(uint16(ge.Weight / divisor)) } bf.WriteUint8(ge.Rarity) bf.WriteUint8(ge.Rolls) items, err := s.server.gachaRepo.GetItemsForEntry(ge.ID) if err != nil { bf.WriteUint8(0) } else { bf.WriteUint8(uint8(len(items))) } bf.WriteUint16(ge.FrontierPoints) bf.WriteUint8(ge.DailyLimit) if ge.EntryType < 10 { ps.Uint8(bf, ge.Name, true) } else { bf.WriteUint8(0) } for _, gi := range items { bf.WriteUint16(uint16(gi.ItemType)) bf.WriteUint16(gi.ItemID) bf.WriteUint16(gi.Quantity) } } doAckBufSucceed(s, pkt.AckHandle, bf.Data()) case 3: // Hunting Festival Exchange fallthrough case 4: // N Points, 0-6 fallthrough case 5: // GCP->Item, 0-6 fallthrough case 6: // Gacha coin->Item fallthrough case 7: // Item->GCP fallthrough case 8: // Diva fallthrough case 9: // Diva song shop fallthrough case 10: // Item shop, 0-8 bf := byteframe.NewByteFrame() items := getShopItems(s, pkt.ShopType, pkt.ShopID) if len(items) > int(pkt.Limit) { items = items[:pkt.Limit] } writeShopItems(bf, items, s.server.erupeConfig.RealClientMode) doAckBufSucceed(s, pkt.AckHandle, bf.Data()) } } func handleMsgMhfAcquireExchangeShop(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfAcquireExchangeShop) bf := byteframe.NewByteFrameFromBytes(pkt.RawDataPayload) exchanges := int(bf.ReadUint16()) for i := 0; i < exchanges; i++ { itemHash := bf.ReadUint32() if itemHash == 0 { continue } buyCount := bf.ReadUint32() if err := s.server.shopRepo.RecordPurchase(s.charID, itemHash, buyCount); err != nil { s.logger.Error("Failed to update shop item purchase count", zap.Error(err)) } } doAckSimpleSucceed(s, pkt.AckHandle, []byte{0x00, 0x00, 0x00, 0x00}) } // FPointExchange represents a frontier point exchange entry. type FPointExchange struct { ID uint32 `db:"id"` ItemType uint8 `db:"item_type"` ItemID uint16 `db:"item_id"` Quantity uint16 `db:"quantity"` FPoints uint16 `db:"fpoints"` Buyable bool `db:"buyable"` } func handleMsgMhfExchangeFpoint2Item(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfExchangeFpoint2Item) quantity, itemValue, err := s.server.shopRepo.GetFpointItem(pkt.TradeID) if err != nil { s.logger.Error("Failed to read fpoint item cost", zap.Error(err)) doAckSimpleFail(s, pkt.AckHandle, nil) return } cost := (int(pkt.Quantity) * quantity) * itemValue balance, err := s.server.userRepo.AdjustFrontierPointsDeduct(s.userID, cost) if err != nil { s.logger.Error("Failed to deduct frontier points", zap.Error(err)) doAckSimpleFail(s, pkt.AckHandle, nil) return } bf := byteframe.NewByteFrame() bf.WriteUint32(balance) doAckSimpleSucceed(s, pkt.AckHandle, bf.Data()) } func handleMsgMhfExchangeItem2Fpoint(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfExchangeItem2Fpoint) quantity, itemValue, err := s.server.shopRepo.GetFpointItem(pkt.TradeID) if err != nil { s.logger.Error("Failed to read fpoint item value", zap.Error(err)) doAckSimpleFail(s, pkt.AckHandle, nil) return } cost := (int(pkt.Quantity) / quantity) * itemValue balance, err := s.server.userRepo.AdjustFrontierPointsCredit(s.userID, cost) if err != nil { s.logger.Error("Failed to credit frontier points", zap.Error(err)) doAckSimpleFail(s, pkt.AckHandle, nil) return } bf := byteframe.NewByteFrame() bf.WriteUint32(balance) doAckSimpleSucceed(s, pkt.AckHandle, bf.Data()) } func handleMsgMhfGetFpointExchangeList(s *Session, p mhfpacket.MHFPacket) { pkt := p.(*mhfpacket.MsgMhfGetFpointExchangeList) bf := byteframe.NewByteFrame() exchanges, err := s.server.shopRepo.GetFpointExchangeList() if err != nil { s.logger.Error("Failed to get fpoint exchange list", zap.Error(err)) } var buyables uint16 for _, e := range exchanges { if e.Buyable { buyables++ } } if s.server.erupeConfig.RealClientMode <= cfg.Z2 { bf.WriteUint8(uint8(len(exchanges))) bf.WriteUint8(uint8(buyables)) } else { bf.WriteUint16(uint16(len(exchanges))) bf.WriteUint16(buyables) } for _, e := range exchanges { bf.WriteUint32(e.ID) bf.WriteUint16(0) bf.WriteUint16(0) bf.WriteUint16(0) bf.WriteUint8(e.ItemType) bf.WriteUint16(e.ItemID) bf.WriteUint16(e.Quantity) bf.WriteUint16(e.FPoints) } doAckBufSucceed(s, pkt.AckHandle, bf.Data()) }