mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-03-21 23:22:34 +01:00
The G1-GG gacha code path (PR #150) included example data in comments that used non-zero item_type/item_number/item_quantity on entry_type=100 rows. Users copying these values for ZZ servers caused client crashes because the ZZ client interprets those fields as material requirements. - Replace unclear PR #150 comment with explicit WARNING against using G1-GG example values for ZZ servers - Add gacha configuration guide header to GachaDemo.sql explaining the 3-table system, entry types, and item type codes - Add Mega Potion example showing correct way to add custom items - Reference Enumerations.md for the full item type list
337 lines
9.8 KiB
Go
337 lines
9.8 KiB
Go
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())
|
||
}
|