From 3e9f3d1b62f154974961b752ecf0d484023f3c1f Mon Sep 17 00:00:00 2001 From: Houmgaor Date: Mon, 6 Apr 2026 17:58:04 +0200 Subject: [PATCH] feat(protbot): add boost and gacha inspection scenarios Adds non-destructive test scenarios for the #187 boost-time fix and the #175 / gacha-logging changes so regressions in those paths can be caught without a full game client. - boost: queries GET_BOOST_TIME_LIMIT, GET_BOOST_RIGHT, and GET_KEEP_LOGIN_BOOST_STATUS, flagging a zero boost_limit and all-zero login boost entries as the expected DisableBoostTime/DisableLoginBoost state. - gacha: snapshots GET_GACHA_POINT and RECEIVE_GACHA_ITEM (freeze=true, so temp storage is not cleared), with an opt-in --roll flag that exercises PLAY_NORMAL_GACHA end-to-end. Detects the post-#175 single-byte validation-failure ACK. --- cmd/protbot/main.go | 123 ++++++++++++++++++++++++++++- cmd/protbot/protocol/opcodes.go | 45 +++++++---- cmd/protbot/protocol/packets.go | 81 +++++++++++++++++++ cmd/protbot/scenario/boost.go | 113 +++++++++++++++++++++++++++ cmd/protbot/scenario/gacha.go | 133 ++++++++++++++++++++++++++++++++ 5 files changed, 477 insertions(+), 18 deletions(-) create mode 100644 cmd/protbot/scenario/boost.go create mode 100644 cmd/protbot/scenario/gacha.go diff --git a/cmd/protbot/main.go b/cmd/protbot/main.go index 87771fe39..354ed9bf6 100644 --- a/cmd/protbot/main.go +++ b/cmd/protbot/main.go @@ -7,6 +7,8 @@ // protbot --sign-addr 127.0.0.1:53312 --user test --pass test --action session // protbot --sign-addr 127.0.0.1:53312 --user test --pass test --action chat --message "Hello" // protbot --sign-addr 127.0.0.1:53312 --user test --pass test --action quests +// protbot --sign-addr 127.0.0.1:53312 --user test --pass test --action boost +// protbot --sign-addr 127.0.0.1:53312 --user test --pass test --action gacha --gacha-id 1 --roll-type 0 package main import ( @@ -24,8 +26,12 @@ func main() { signAddr := flag.String("sign-addr", "127.0.0.1:53312", "Sign server address (host:port)") user := flag.String("user", "", "Username") pass := flag.String("pass", "", "Password") - action := flag.String("action", "login", "Action to perform: login, lobby, session, chat, quests, achievement") + action := flag.String("action", "login", "Action to perform: login, lobby, session, chat, quests, achievement, boost, gacha") message := flag.String("message", "", "Chat message to send (used with --action chat)") + gachaID := flag.Uint("gacha-id", 1, "Gacha ID to roll (used with --action gacha)") + rollType := flag.Uint("roll-type", 0, "Gacha roll type: 0=single, 1=ten-pull (used with --action gacha)") + gachaType := flag.Uint("gacha-type", 0, "Gacha type code (used with --action gacha)") + doRoll := flag.Bool("roll", false, "Actually perform a paid roll (default: only inspect gacha state)") flag.Parse() if *user == "" || *pass == "" { @@ -210,8 +216,121 @@ func main() { _ = scenario.Logout(result.Channel) + case "boost": + result, err := scenario.Login(*signAddr, *user, *pass) + if err != nil { + fmt.Fprintf(os.Stderr, "login failed: %v\n", err) + os.Exit(1) + } + charID := result.Sign.CharIDs[0] + if _, err := scenario.SetupSession(result.Channel, charID); err != nil { + fmt.Fprintf(os.Stderr, "session setup failed: %v\n", err) + _ = result.Channel.Close() + os.Exit(1) + } + if err := scenario.EnterLobby(result.Channel); err != nil { + fmt.Fprintf(os.Stderr, "enter lobby failed: %v\n", err) + _ = result.Channel.Close() + os.Exit(1) + } + + // Boost time inspection (#187): confirm GetBoostTimeLimit returns 0 + // for a fresh character when DisableBoostTime is either set or when + // the character has never started a boost. + limit, err := scenario.GetBoostTimeLimit(result.Channel) + if err != nil { + fmt.Fprintf(os.Stderr, "get boost time limit failed: %v\n", err) + } else { + fmt.Printf("[boost] BoostLimitUnix = %d", limit.BoostLimitUnix) + if limit.BoostLimitUnix == 0 { + fmt.Println(" (inactive/disabled — #187 fix OK)") + } else { + fmt.Printf(" (active until %s)\n", time.Unix(int64(limit.BoostLimitUnix), 0)) + } + } + + right, err := scenario.GetBoostRight(result.Channel) + if err != nil { + fmt.Fprintf(os.Stderr, "get boost right failed: %v\n", err) + } else { + fmt.Printf("[boost] BoostRight = %d (0=disabled, 1=active, 2=available)\n", right.State) + } + + login, err := scenario.GetKeepLoginBoostStatus(result.Channel) + if err != nil { + fmt.Fprintf(os.Stderr, "get login boost status failed: %v\n", err) + } else { + allZero := true + for _, e := range login.Entries { + if e.WeekReq != 0 || e.Active || e.WeekCount != 0 || e.Expiration != 0 { + allZero = false + } + fmt.Printf("[boost] login-boost week=%d active=%v count=%d exp=%d\n", + e.WeekReq, e.Active, e.WeekCount, e.Expiration) + } + if allZero { + fmt.Println("[boost] all entries zeroed — DisableLoginBoost honored (#187 fix OK)") + } + } + _ = scenario.Logout(result.Channel) + + case "gacha": + result, err := scenario.Login(*signAddr, *user, *pass) + if err != nil { + fmt.Fprintf(os.Stderr, "login failed: %v\n", err) + os.Exit(1) + } + charID := result.Sign.CharIDs[0] + if _, err := scenario.SetupSession(result.Channel, charID); err != nil { + fmt.Fprintf(os.Stderr, "session setup failed: %v\n", err) + _ = result.Channel.Close() + os.Exit(1) + } + if err := scenario.EnterLobby(result.Channel); err != nil { + fmt.Fprintf(os.Stderr, "enter lobby failed: %v\n", err) + _ = result.Channel.Close() + os.Exit(1) + } + + // Gacha state inspection (#175, 6fa07ae). By default do NOT roll — + // we just snapshot existing gacha points and pending items. Pass + // --roll to exercise the full PLAY_NORMAL_GACHA path against a + // configured gacha_id. + pts, err := scenario.GetGachaPoint(result.Channel) + if err != nil { + fmt.Fprintf(os.Stderr, "get gacha point failed: %v\n", err) + } else { + fmt.Printf("[gacha] Points: premium=%d trial=%d frontier=%d\n", + pts.Premium, pts.Trial, pts.Frontier) + } + + stored, err := scenario.ReceiveGachaItem(result.Channel, 36, true) + if err != nil { + fmt.Fprintf(os.Stderr, "receive gacha item failed: %v\n", err) + } else { + fmt.Printf("[gacha] %d item(s) pending in temp storage (freeze=true, not cleared)\n", len(stored)) + for _, it := range stored { + fmt.Printf(" type=%d id=%d qty=%d\n", it.ItemType, it.ItemID, it.Quantity) + } + } + + if *doRoll { + rewards, err := scenario.PlayNormalGacha(result.Channel, + uint32(*gachaID), uint8(*rollType), uint8(*gachaType)) + if err != nil { + fmt.Fprintf(os.Stderr, "play normal gacha failed: %v\n", err) + } else { + fmt.Printf("[gacha] Rolled %d reward(s):\n", len(rewards)) + for _, r := range rewards { + fmt.Printf(" type=%d id=%d qty=%d rarity=%d\n", + r.ItemType, r.ItemID, r.Quantity, r.Rarity) + } + } + } + _ = scenario.Logout(result.Channel) + default: - fmt.Fprintf(os.Stderr, "unknown action: %s (supported: login, lobby, session, chat, quests, achievement)\n", *action) + fmt.Fprintf(os.Stderr, "unknown action: %s (supported: login, lobby, session, chat, quests, achievement, boost, gacha)\n", *action) os.Exit(1) } } diff --git a/cmd/protbot/protocol/opcodes.go b/cmd/protbot/protocol/opcodes.go index 2342859d4..ec5a0b7dc 100644 --- a/cmd/protbot/protocol/opcodes.go +++ b/cmd/protbot/protocol/opcodes.go @@ -3,24 +3,37 @@ package protocol // Packet opcodes (subset from Erupe's network/packetid.go iota). const ( - MSG_SYS_ACK uint16 = 0x0012 - MSG_SYS_LOGIN uint16 = 0x0014 - MSG_SYS_LOGOUT uint16 = 0x0015 - MSG_SYS_PING uint16 = 0x0017 - MSG_SYS_CAST_BINARY uint16 = 0x0018 - MSG_SYS_TIME uint16 = 0x001A - MSG_SYS_CASTED_BINARY uint16 = 0x001B - MSG_SYS_ISSUE_LOGKEY uint16 = 0x001D - MSG_SYS_ENTER_STAGE uint16 = 0x0022 - MSG_SYS_ENUMERATE_STAGE uint16 = 0x002F - MSG_SYS_INSERT_USER uint16 = 0x0050 - MSG_SYS_DELETE_USER uint16 = 0x0051 - MSG_SYS_UPDATE_RIGHT uint16 = 0x0058 - MSG_SYS_RIGHTS_RELOAD uint16 = 0x005D - MSG_MHF_LOADDATA uint16 = 0x0061 - MSG_MHF_ENUMERATE_QUEST uint16 = 0x009F + MSG_SYS_ACK uint16 = 0x0012 + MSG_SYS_LOGIN uint16 = 0x0014 + MSG_SYS_LOGOUT uint16 = 0x0015 + MSG_SYS_PING uint16 = 0x0017 + MSG_SYS_CAST_BINARY uint16 = 0x0018 + MSG_SYS_TIME uint16 = 0x001A + MSG_SYS_CASTED_BINARY uint16 = 0x001B + MSG_SYS_ISSUE_LOGKEY uint16 = 0x001D + MSG_SYS_ENTER_STAGE uint16 = 0x0022 + MSG_SYS_ENUMERATE_STAGE uint16 = 0x002F + MSG_SYS_INSERT_USER uint16 = 0x0050 + MSG_SYS_DELETE_USER uint16 = 0x0051 + MSG_SYS_UPDATE_RIGHT uint16 = 0x0058 + MSG_SYS_RIGHTS_RELOAD uint16 = 0x005D + MSG_MHF_LOADDATA uint16 = 0x0061 + MSG_MHF_ENUMERATE_QUEST uint16 = 0x009F MSG_MHF_GET_ACHIEVEMENT uint16 = 0x00D4 MSG_MHF_ADD_ACHIEVEMENT uint16 = 0x00D6 MSG_MHF_DISPLAYED_ACHIEVEMENT uint16 = 0x00D8 MSG_MHF_GET_WEEKLY_SCHED uint16 = 0x00E1 + + // Boost time / login boost (issue #187) + MSG_MHF_GET_BOOST_TIME uint16 = 0x0126 + MSG_MHF_GET_BOOST_TIME_LIMIT uint16 = 0x0128 + MSG_MHF_GET_BOOST_RIGHT uint16 = 0x013E + MSG_MHF_START_BOOST_TIME uint16 = 0x013F + MSG_MHF_GET_KEEP_LOGIN_BOOST_STATUS uint16 = 0x0159 + MSG_MHF_USE_KEEP_LOGIN_BOOST uint16 = 0x015A + + // Gacha (issues #175, gacha logging) + MSG_MHF_GET_GACHA_POINT uint16 = 0x0131 + MSG_MHF_RECEIVE_GACHA_ITEM uint16 = 0x0137 + MSG_MHF_PLAY_NORMAL_GACHA uint16 = 0x0150 ) diff --git a/cmd/protbot/protocol/packets.go b/cmd/protbot/protocol/packets.go index 3e673048d..8c22faf37 100644 --- a/cmd/protbot/protocol/packets.go +++ b/cmd/protbot/protocol/packets.go @@ -265,6 +265,87 @@ func BuildDisplayedAchievementPacket() []byte { return bf.Data() } +// BuildSimpleAckPacket builds a packet whose body is just an ack handle. +// Used by several trivial query packets (boost time, gacha point, ...). +// +// uint16 opcode +// uint32 ackHandle +// 0x00 0x10 terminator +func BuildSimpleAckPacket(opcode uint16, ackHandle uint32) []byte { + bf := byteframe.NewByteFrame() + bf.WriteUint16(opcode) + bf.WriteUint32(ackHandle) + bf.WriteBytes([]byte{0x00, 0x10}) + return bf.Data() +} + +// BuildStartBoostTimePacket builds a MSG_MHF_START_BOOST_TIME packet. +func BuildStartBoostTimePacket(ackHandle uint32) []byte { + return BuildSimpleAckPacket(MSG_MHF_START_BOOST_TIME, ackHandle) +} + +// BuildGetBoostTimeLimitPacket builds a MSG_MHF_GET_BOOST_TIME_LIMIT packet. +func BuildGetBoostTimeLimitPacket(ackHandle uint32) []byte { + return BuildSimpleAckPacket(MSG_MHF_GET_BOOST_TIME_LIMIT, ackHandle) +} + +// BuildGetBoostRightPacket builds a MSG_MHF_GET_BOOST_RIGHT packet. +func BuildGetBoostRightPacket(ackHandle uint32) []byte { + return BuildSimpleAckPacket(MSG_MHF_GET_BOOST_RIGHT, ackHandle) +} + +// BuildGetKeepLoginBoostStatusPacket builds a MSG_MHF_GET_KEEP_LOGIN_BOOST_STATUS packet. +func BuildGetKeepLoginBoostStatusPacket(ackHandle uint32) []byte { + return BuildSimpleAckPacket(MSG_MHF_GET_KEEP_LOGIN_BOOST_STATUS, ackHandle) +} + +// BuildGetGachaPointPacket builds a MSG_MHF_GET_GACHA_POINT packet. +func BuildGetGachaPointPacket(ackHandle uint32) []byte { + return BuildSimpleAckPacket(MSG_MHF_GET_GACHA_POINT, ackHandle) +} + +// BuildPlayNormalGachaPacket builds a MSG_MHF_PLAY_NORMAL_GACHA packet. +// Layout mirrors Erupe's MsgMhfPlayNormalGacha.Parse: +// +// uint16 opcode +// uint32 ackHandle +// uint32 gachaID +// uint8 rollType (0=single, 1=ten-pull) +// uint8 gachaType +// 0x00 0x10 terminator +func BuildPlayNormalGachaPacket(ackHandle, gachaID uint32, rollType, gachaType uint8) []byte { + bf := byteframe.NewByteFrame() + bf.WriteUint16(MSG_MHF_PLAY_NORMAL_GACHA) + bf.WriteUint32(ackHandle) + bf.WriteUint32(gachaID) + bf.WriteUint8(rollType) + bf.WriteUint8(gachaType) + bf.WriteBytes([]byte{0x00, 0x10}) + return bf.Data() +} + +// BuildReceiveGachaItemPacket builds a MSG_MHF_RECEIVE_GACHA_ITEM packet. +// Layout mirrors Erupe's MsgMhfReceiveGachaItem.Parse: +// +// uint16 opcode +// uint32 ackHandle +// uint8 max +// uint8 freeze (bool; if non-zero, server does not clear the stored items) +// 0x00 0x10 terminator +func BuildReceiveGachaItemPacket(ackHandle uint32, max uint8, freeze bool) []byte { + bf := byteframe.NewByteFrame() + bf.WriteUint16(MSG_MHF_RECEIVE_GACHA_ITEM) + bf.WriteUint32(ackHandle) + bf.WriteUint8(max) + if freeze { + bf.WriteUint8(1) + } else { + bf.WriteUint8(0) + } + bf.WriteBytes([]byte{0x00, 0x10}) + return bf.Data() +} + // BuildGetWeeklySchedulePacket builds a MSG_MHF_GET_WEEKLY_SCHEDULE packet. // // uint16 opcode diff --git a/cmd/protbot/scenario/boost.go b/cmd/protbot/scenario/boost.go new file mode 100644 index 000000000..9e3f55ffd --- /dev/null +++ b/cmd/protbot/scenario/boost.go @@ -0,0 +1,113 @@ +package scenario + +import ( + "fmt" + "time" + + "erupe-ce/cmd/protbot/protocol" + "erupe-ce/common/byteframe" +) + +// BoostTimeStatus holds the parsed response of MSG_MHF_GET_BOOST_TIME_LIMIT. +// When boost time is disabled server-side, or has not been started yet, +// BoostLimitUnix is 0. Prior to the #187 fix, unset boost_time columns +// wrapped to a large uint32 the client interpreted as permanently active. +type BoostTimeStatus struct { + BoostLimitUnix uint32 +} + +// BoostRight holds the parsed response of MSG_MHF_GET_BOOST_RIGHT. +// 0 = disabled, 1 = active, 2 = available. +type BoostRight struct { + State uint32 +} + +// LoginBoostEntry holds a single entry of the 5-entry MSG_MHF_GET_KEEP_LOGIN_BOOST_STATUS response. +type LoginBoostEntry struct { + WeekReq uint8 + Active bool + WeekCount uint8 + Expiration uint32 +} + +// LoginBoostStatus holds the full parsed keep login boost status response. +type LoginBoostStatus struct { + Entries []LoginBoostEntry +} + +// GetBoostTimeLimit sends MSG_MHF_GET_BOOST_TIME_LIMIT and parses the response. +func GetBoostTimeLimit(ch *protocol.ChannelConn) (*BoostTimeStatus, error) { + ack := ch.NextAckHandle() + pkt := protocol.BuildGetBoostTimeLimitPacket(ack) + fmt.Printf("[boost] Sending GET_BOOST_TIME_LIMIT (ackHandle=%d)...\n", ack) + if err := ch.SendPacket(pkt); err != nil { + return nil, fmt.Errorf("get boost time limit send: %w", err) + } + resp, err := ch.WaitForAck(ack, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("get boost time limit ack: %w", err) + } + if resp.ErrorCode != 0 { + return nil, fmt.Errorf("get boost time limit failed: error code %d", resp.ErrorCode) + } + if len(resp.Data) < 4 { + return nil, fmt.Errorf("get boost time limit response too short: %d bytes", len(resp.Data)) + } + bf := byteframe.NewByteFrameFromBytes(resp.Data) + return &BoostTimeStatus{BoostLimitUnix: bf.ReadUint32()}, nil +} + +// GetBoostRight sends MSG_MHF_GET_BOOST_RIGHT and parses the response. +func GetBoostRight(ch *protocol.ChannelConn) (*BoostRight, error) { + ack := ch.NextAckHandle() + pkt := protocol.BuildGetBoostRightPacket(ack) + fmt.Printf("[boost] Sending GET_BOOST_RIGHT (ackHandle=%d)...\n", ack) + if err := ch.SendPacket(pkt); err != nil { + return nil, fmt.Errorf("get boost right send: %w", err) + } + resp, err := ch.WaitForAck(ack, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("get boost right ack: %w", err) + } + if resp.ErrorCode != 0 { + return nil, fmt.Errorf("get boost right failed: error code %d", resp.ErrorCode) + } + if len(resp.Data) < 4 { + return nil, fmt.Errorf("get boost right response too short: %d bytes", len(resp.Data)) + } + bf := byteframe.NewByteFrameFromBytes(resp.Data) + return &BoostRight{State: bf.ReadUint32()}, nil +} + +// GetKeepLoginBoostStatus sends MSG_MHF_GET_KEEP_LOGIN_BOOST_STATUS and parses the response. +// The server returns either 35 bytes (5 entries × 7 bytes) or 35 zero bytes +// when DisableLoginBoost is set. +func GetKeepLoginBoostStatus(ch *protocol.ChannelConn) (*LoginBoostStatus, error) { + ack := ch.NextAckHandle() + pkt := protocol.BuildGetKeepLoginBoostStatusPacket(ack) + fmt.Printf("[boost] Sending GET_KEEP_LOGIN_BOOST_STATUS (ackHandle=%d)...\n", ack) + if err := ch.SendPacket(pkt); err != nil { + return nil, fmt.Errorf("get login boost status send: %w", err) + } + resp, err := ch.WaitForAck(ack, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("get login boost status ack: %w", err) + } + if resp.ErrorCode != 0 { + return nil, fmt.Errorf("get login boost status failed: error code %d", resp.ErrorCode) + } + if len(resp.Data) < 35 { + return nil, fmt.Errorf("login boost status response too short: %d bytes", len(resp.Data)) + } + bf := byteframe.NewByteFrameFromBytes(resp.Data) + status := &LoginBoostStatus{} + for i := 0; i < 5; i++ { + status.Entries = append(status.Entries, LoginBoostEntry{ + WeekReq: bf.ReadUint8(), + Active: bf.ReadBool(), + WeekCount: bf.ReadUint8(), + Expiration: bf.ReadUint32(), + }) + } + return status, nil +} diff --git a/cmd/protbot/scenario/gacha.go b/cmd/protbot/scenario/gacha.go new file mode 100644 index 000000000..be98cb2cf --- /dev/null +++ b/cmd/protbot/scenario/gacha.go @@ -0,0 +1,133 @@ +package scenario + +import ( + "fmt" + "time" + + "erupe-ce/cmd/protbot/protocol" + "erupe-ce/common/byteframe" +) + +// GachaPoints holds the parsed MSG_MHF_GET_GACHA_POINT response. +type GachaPoints struct { + Premium uint32 + Trial uint32 + Frontier uint32 +} + +// GachaReward is one item returned by a normal gacha roll. +type GachaReward struct { + ItemType uint8 + ItemID uint16 + Quantity uint16 + Rarity uint8 +} + +// GachaStoredItem is one item currently sitting in the character's +// gacha_items column (the client's "temp storage" display). +type GachaStoredItem struct { + ItemType uint8 + ItemID uint16 + Quantity uint16 +} + +// GetGachaPoint sends MSG_MHF_GET_GACHA_POINT and parses the response. +func GetGachaPoint(ch *protocol.ChannelConn) (*GachaPoints, error) { + ack := ch.NextAckHandle() + pkt := protocol.BuildGetGachaPointPacket(ack) + fmt.Printf("[gacha] Sending GET_GACHA_POINT (ackHandle=%d)...\n", ack) + if err := ch.SendPacket(pkt); err != nil { + return nil, fmt.Errorf("get gacha point send: %w", err) + } + resp, err := ch.WaitForAck(ack, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("get gacha point ack: %w", err) + } + if resp.ErrorCode != 0 { + return nil, fmt.Errorf("get gacha point failed: error code %d", resp.ErrorCode) + } + if len(resp.Data) < 12 { + return nil, fmt.Errorf("gacha point response too short: %d bytes", len(resp.Data)) + } + bf := byteframe.NewByteFrameFromBytes(resp.Data) + return &GachaPoints{ + Premium: bf.ReadUint32(), + Trial: bf.ReadUint32(), + Frontier: bf.ReadUint32(), + }, nil +} + +// PlayNormalGacha sends MSG_MHF_PLAY_NORMAL_GACHA and parses the reward list. +// Response layout: uint8 count, then count * (uint8 type, uint16 id, uint16 qty, uint8 rarity). +// The server returns a single 0x00 byte ACK payload on validation failure +// (e.g. empty reward pool), which this function reports as an error. +func PlayNormalGacha(ch *protocol.ChannelConn, gachaID uint32, rollType, gachaType uint8) ([]GachaReward, error) { + ack := ch.NextAckHandle() + pkt := protocol.BuildPlayNormalGachaPacket(ack, gachaID, rollType, gachaType) + fmt.Printf("[gacha] Sending PLAY_NORMAL_GACHA (gachaID=%d, rollType=%d)...\n", gachaID, rollType) + if err := ch.SendPacket(pkt); err != nil { + return nil, fmt.Errorf("play normal gacha send: %w", err) + } + resp, err := ch.WaitForAck(ack, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("play normal gacha ack: %w", err) + } + if resp.ErrorCode != 0 { + return nil, fmt.Errorf("play normal gacha failed: error code %d", resp.ErrorCode) + } + if len(resp.Data) < 1 { + return nil, fmt.Errorf("play normal gacha response too short: %d bytes", len(resp.Data)) + } + bf := byteframe.NewByteFrameFromBytes(resp.Data) + count := bf.ReadUint8() + // A single-byte (count=0) reply from an otherwise-successful ACK means + // the server's validation failed. Surface it so callers can tell the + // difference from a genuinely empty roll. + if count == 0 && len(resp.Data) == 1 { + return nil, fmt.Errorf("play normal gacha: server returned empty reward pool (check gacha config)") + } + rewards := make([]GachaReward, 0, count) + for i := uint8(0); i < count; i++ { + rewards = append(rewards, GachaReward{ + ItemType: bf.ReadUint8(), + ItemID: bf.ReadUint16(), + Quantity: bf.ReadUint16(), + Rarity: bf.ReadUint8(), + }) + } + return rewards, nil +} + +// ReceiveGachaItem sends MSG_MHF_RECEIVE_GACHA_ITEM and parses the pending +// items in the character's gacha_items column. When freeze=true the server +// does not clear the column, allowing non-destructive inspection. +// Response layout: uint8 count + count * (uint8 type, uint16 id, uint16 qty). +func ReceiveGachaItem(ch *protocol.ChannelConn, max uint8, freeze bool) ([]GachaStoredItem, error) { + ack := ch.NextAckHandle() + pkt := protocol.BuildReceiveGachaItemPacket(ack, max, freeze) + fmt.Printf("[gacha] Sending RECEIVE_GACHA_ITEM (max=%d, freeze=%v)...\n", max, freeze) + if err := ch.SendPacket(pkt); err != nil { + return nil, fmt.Errorf("receive gacha item send: %w", err) + } + resp, err := ch.WaitForAck(ack, 10*time.Second) + if err != nil { + return nil, fmt.Errorf("receive gacha item ack: %w", err) + } + if resp.ErrorCode != 0 { + return nil, fmt.Errorf("receive gacha item failed: error code %d", resp.ErrorCode) + } + if len(resp.Data) < 1 { + return nil, fmt.Errorf("receive gacha item response too short: %d bytes", len(resp.Data)) + } + bf := byteframe.NewByteFrameFromBytes(resp.Data) + count := bf.ReadUint8() + items := make([]GachaStoredItem, 0, count) + for i := uint8(0); i < count; i++ { + items = append(items, GachaStoredItem{ + ItemType: bf.ReadUint8(), + ItemID: bf.ReadUint16(), + Quantity: bf.ReadUint16(), + }) + } + return items, nil +}