mirror of
https://github.com/Mezeporta/Erupe.git
synced 2026-05-06 14:24:15 +02:00
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.
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
113
cmd/protbot/scenario/boost.go
Normal file
113
cmd/protbot/scenario/boost.go
Normal file
@@ -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
|
||||
}
|
||||
133
cmd/protbot/scenario/gacha.go
Normal file
133
cmd/protbot/scenario/gacha.go
Normal file
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user