Files
90CNServer/server/internal/services/dnfrobot/client.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

665 lines
18 KiB
Go

package dnfrobot
import (
"context"
"encoding/binary"
"errors"
"fmt"
"io"
"net"
"sync"
"time"
"golang.org/x/text/encoding/simplifiedchinese"
dnfrobotdomain "longheng.io/server/internal/modules/dnf/robot"
)
var (
ErrNotReady = errors.New("dnf robot is not ready")
ErrBindRejected = errors.New("dnf robot session bind was rejected")
)
type State string
const (
StateStopped State = "stopped"
StateConnecting State = "connecting"
StateBinding State = "binding"
StateSelecting State = "selecting"
StateTownReady State = "town_ready"
StateStoreOpening State = "store_opening"
StateStoreReady State = "store_ready"
StateStoreClosing State = "store_closing"
StateError State = "error"
)
const (
privateStoreCreateOpcode uint16 = 85
privateStoreCloseOpcode uint16 = 86
privateStoreCompleteOpcode uint16 = 87
privateStoreMaximumRows = 14
privateStoreDisplayRetryGap = 2 * time.Second
privateStoreDisplayMaxAttempts = 4
)
type PrivateStoreRow struct {
DisplayIndex uint16 `json:"display_index"`
Price uint32 `json:"price"`
ListType uint8 `json:"list_type"`
Slot int16 `json:"slot"`
WireValue uint32 `json:"wire_value"`
}
type pendingPrivateStore struct {
title []byte
rows []PrivateStoreRow
}
type ClientSnapshot struct {
State State `json:"state"`
Endpoint string `json:"endpoint,omitempty"`
CharacterID uint16 `json:"character_id"`
Slot uint8 `json:"slot,omitempty"`
Village uint8 `json:"village"`
Area uint8 `json:"area"`
X uint16 `json:"x"`
Y uint16 `json:"y"`
ReadyAt time.Time `json:"ready_at,omitempty"`
StateSince time.Time `json:"state_since,omitempty"`
LastReceive time.Time `json:"last_receive_at,omitempty"`
LastSend time.Time `json:"last_send_at,omitempty"`
LastError string `json:"last_error,omitempty"`
}
type Client struct {
profile dnfrobotdomain.Profile
endpoint string
secret []byte
mu sync.RWMutex
state State
conn net.Conn
selectedID uint16
selectedSlot uint8
village uint8
area uint8
x uint16
y uint16
readyAt time.Time
stateSince time.Time
lastReceive time.Time
lastSend time.Time
lastError string
progressSent bool
selectionSent bool
townReadySent bool
pendingStore *pendingPrivateStore
storeActive bool
storeCreated bool
storeAttempts int
storeRetryAt time.Time
sendMu sync.Mutex
sequence uint16
ready chan struct{}
readyOnce sync.Once
readErr chan error
closeOnce sync.Once
}
func NewClient(profile dnfrobotdomain.Profile, endpoint string, secret []byte) *Client {
return &Client{
profile: profile,
endpoint: endpoint,
secret: append([]byte(nil), secret...),
state: StateStopped,
stateSince: time.Now().UTC(),
village: profile.Village,
area: profile.Area,
x: profile.X,
y: profile.Y,
ready: make(chan struct{}),
readErr: make(chan error, 1),
}
}
func (c *Client) Run(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
c.setState(StateConnecting, nil)
conn, err := (&net.Dialer{Timeout: 8 * time.Second, KeepAlive: 30 * time.Second}).DialContext(ctx, "tcp", c.endpoint)
if err != nil {
c.setState(StateError, err)
return err
}
c.mu.Lock()
c.conn = conn
c.mu.Unlock()
defer c.Close()
go c.readLoop(conn)
c.setState(StateBinding, nil)
bindBody, err := buildBindBody(c.secret, time.Now(), c.profile.CharacterID, c.profile.AccountID)
if err != nil {
c.setState(StateError, err)
return err
}
if err := c.send(robotBindOpcode, bindBody); err != nil {
c.setState(StateError, err)
return err
}
select {
case <-ctx.Done():
return ctx.Err()
case err := <-c.readErr:
c.setState(StateError, err)
return err
case <-c.ready:
}
select {
case <-ctx.Done():
return ctx.Err()
case err := <-c.readErr:
c.setState(StateError, err)
return err
}
}
func (c *Client) Close() error {
var err error
c.closeOnce.Do(func() {
c.mu.Lock()
conn := c.conn
c.conn = nil
c.setStateLocked(StateStopped, nil)
c.mu.Unlock()
if conn != nil {
err = conn.Close()
}
})
return err
}
func (c *Client) Snapshot() ClientSnapshot {
c.mu.RLock()
defer c.mu.RUnlock()
return ClientSnapshot{
State: c.state, Endpoint: c.endpoint, CharacterID: c.selectedID,
Slot: c.selectedSlot, Village: c.village, Area: c.area, X: c.x, Y: c.y,
ReadyAt: c.readyAt, StateSince: c.stateSince, LastReceive: c.lastReceive, LastSend: c.lastSend, LastError: c.lastError,
}
}
func (c *Client) Move(village, area uint8, x, y uint16, moveType uint8) error {
c.mu.RLock()
ready := c.state == StateTownReady
oldVillage, oldArea := c.village, c.area
c.mu.RUnlock()
if !ready {
return ErrNotReady
}
if village != oldVillage || area != oldArea {
body := make([]byte, 16)
body[0], body[1] = village, area
binary.LittleEndian.PutUint16(body[2:4], x)
binary.LittleEndian.PutUint16(body[4:6], y)
body[6] = moveType
binary.LittleEndian.PutUint16(body[7:9], uint16(oldVillage))
binary.LittleEndian.PutUint16(body[9:11], uint16(oldArea))
if err := c.send(36, body); err != nil {
return err
}
}
body := make([]byte, 7)
binary.LittleEndian.PutUint16(body[0:2], x)
binary.LittleEndian.PutUint16(body[2:4], y)
body[4] = moveType
binary.LittleEndian.PutUint16(body[5:7], 100)
if err := c.send(35, body); err != nil {
return err
}
c.mu.Lock()
c.village, c.area, c.x, c.y = village, area, x, y
c.mu.Unlock()
return nil
}
func (c *Client) Chat(mode uint8, message string) error {
return c.chat(mode, 0, message)
}
// Megaphone sends the current client's global mode-11 chat request. The
// argument is the exact main-inventory slot of item 36 and is consumed by the
// server's normal megaphone owner before broadcasting.
func (c *Client) Megaphone(slot int16, message string) error {
if slot < 0 {
return fmt.Errorf("invalid dnf robot megaphone slot")
}
return c.chat(11, uint32(uint16(slot)), message)
}
func (c *Client) chat(mode uint8, argument uint32, message string) error {
c.mu.RLock()
ready := c.state == StateTownReady
c.mu.RUnlock()
if !ready {
return ErrNotReady
}
encoded, err := simplifiedchinese.GB18030.NewEncoder().Bytes([]byte(message))
if err != nil || len(encoded) == 0 || len(encoded) > 512 {
return fmt.Errorf("invalid dnf robot chat message")
}
if mode == 0 {
mode = 3
}
body := make([]byte, 11+len(encoded))
body[0] = mode
binary.LittleEndian.PutUint32(body[3:7], argument)
binary.LittleEndian.PutUint32(body[7:11], uint32(len(encoded)))
copy(body[11:], encoded)
return c.send(17, body)
}
func (c *Client) OpenPrivateStore(title string, rows []PrivateStoreRow) error {
c.mu.Lock()
if c.state != StateTownReady || c.pendingStore != nil || c.storeActive {
c.mu.Unlock()
return ErrNotReady
}
encodedTitle, err := simplifiedchinese.GB18030.NewEncoder().Bytes([]byte(title))
if err != nil || len(encodedTitle) == 0 || len(encodedTitle) > 80 || len(rows) == 0 || len(rows) > privateStoreMaximumRows {
c.mu.Unlock()
return errors.New("invalid dnf robot private store")
}
seenDisplay := make(map[uint16]struct{}, len(rows))
seenSlot := make(map[string]struct{}, len(rows))
for _, row := range rows {
key := fmt.Sprintf("%d:%d", row.ListType, row.Slot)
if row.DisplayIndex >= privateStoreMaximumRows || row.Price == 0 || row.WireValue == 0 || row.Slot < 0 || (row.ListType != 0 && row.ListType != 1) {
c.mu.Unlock()
return errors.New("invalid dnf robot private store row")
}
if _, exists := seenDisplay[row.DisplayIndex]; exists {
c.mu.Unlock()
return errors.New("duplicate dnf robot private store display row")
}
if _, exists := seenSlot[key]; exists {
c.mu.Unlock()
return errors.New("duplicate dnf robot private store inventory row")
}
seenDisplay[row.DisplayIndex] = struct{}{}
seenSlot[key] = struct{}{}
}
c.pendingStore = &pendingPrivateStore{title: append([]byte(nil), encodedTitle...), rows: append([]PrivateStoreRow(nil), rows...)}
c.storeCreated = false
c.storeAttempts = 0
c.storeRetryAt = time.Time{}
c.setStateLocked(StateStoreOpening, nil)
village, area, x, y := c.village, c.area, c.x, c.y
c.mu.Unlock()
body := make([]byte, 8)
body[0], body[1] = village, area
binary.LittleEndian.PutUint16(body[2:4], x)
binary.LittleEndian.PutUint16(body[4:6], y)
binary.LittleEndian.PutUint16(body[6:8], 0xffff)
if err := c.send(privateStoreCreateOpcode, body); err != nil {
c.resetPrivateStore(err)
return err
}
return nil
}
func (c *Client) ClosePrivateStore() error {
c.mu.Lock()
if !c.storeActive || c.state != StateStoreReady {
c.mu.Unlock()
return ErrNotReady
}
c.setStateLocked(StateStoreClosing, nil)
c.mu.Unlock()
if err := c.send(privateStoreCloseOpcode, nil); err != nil {
c.mu.Lock()
if c.state == StateStoreClosing {
c.setStateLocked(StateStoreReady, err)
}
c.mu.Unlock()
return err
}
return nil
}
func (c *Client) readLoop(conn net.Conn) {
buffer := make([]byte, 0, 8192)
chunk := make([]byte, 8192)
for {
n, err := conn.Read(chunk)
if n > 0 {
buffer = append(buffer, chunk[:n]...)
for len(buffer) >= 4 {
size, outer, ready, valid := nextFrame(buffer)
if !valid {
c.signalReadError(errors.New("invalid dnf robot server frame"))
return
}
if !ready {
break
}
packets, decodeErr := decodeFrame(buffer[:size], outer)
buffer = append(buffer[:0], buffer[size:]...)
if decodeErr != nil {
c.signalReadError(decodeErr)
return
}
for _, message := range packets {
if handleErr := c.handle(message); handleErr != nil {
c.signalReadError(handleErr)
return
}
}
}
if len(buffer) > maximumFrameSize {
c.signalReadError(errors.New("dnf robot receive buffer exceeded limit"))
return
}
}
if err != nil {
if !errors.Is(err, io.EOF) && !errors.Is(err, net.ErrClosed) {
c.signalReadError(err)
} else {
c.signalReadError(io.EOF)
}
return
}
}
}
func (c *Client) handle(message packet) error {
c.mu.Lock()
c.lastReceive = time.Now().UTC()
c.mu.Unlock()
switch message.Opcode {
case robotBindOpcode:
if len(message.Body) != 4 || message.Body[0] != 1 {
code := byte(0)
if len(message.Body) > 1 {
code = message.Body[1]
}
return fmt.Errorf("%w: code %d", ErrBindRejected, code)
}
c.mu.Lock()
c.selectedSlot = message.Body[1]
c.selectedID = binary.LittleEndian.Uint16(message.Body[2:4])
c.setStateLocked(StateSelecting, nil)
c.mu.Unlock()
return c.send(8, []byte{0xff, 0xff, 0x02})
case 2:
// The current server answers GET_USERINFO with the class-0/op2
// character roster. The authenticated bind already froze the exact
// character and slot, so the robot only needs this roster boundary
// before sending SELECT_CHARACTER. Older test/reference servers used
// op8 here; keep that path below for compatibility.
if message.Classification != 0 {
return nil
}
return c.sendSelectedCharacterOnce()
case 8:
return c.sendSelectedCharacterOnce()
case 4:
c.mu.Lock()
if c.progressSent {
c.mu.Unlock()
return nil
}
c.progressSent = true
c.mu.Unlock()
if err := c.send(143, []byte{0, 36, 0, 0, 0, 1}); err != nil {
c.mu.Lock()
c.progressSent = false
c.mu.Unlock()
return err
}
case 24:
c.mu.RLock()
alreadyReady := c.townReadySent
c.mu.RUnlock()
if alreadyReady {
return nil
}
if err := c.send(1345, []byte{2, 0, 0, 0}); err != nil {
return err
}
area := make([]byte, 16)
area[0], area[1] = c.profile.Village, c.profile.Area
binary.LittleEndian.PutUint16(area[2:4], c.profile.X)
binary.LittleEndian.PutUint16(area[4:6], c.profile.Y)
if err := c.send(36, area); err != nil {
return err
}
position := make([]byte, 7)
binary.LittleEndian.PutUint16(position[0:2], c.profile.X)
binary.LittleEndian.PutUint16(position[2:4], c.profile.Y)
position[4] = c.profile.MoveType
binary.LittleEndian.PutUint16(position[5:7], 100)
if err := c.send(35, position); err != nil {
return err
}
c.mu.Lock()
c.townReadySent = true
c.setStateLocked(StateTownReady, nil)
c.readyAt = time.Now().UTC()
c.lastError = ""
c.mu.Unlock()
c.readyOnce.Do(func() { close(c.ready) })
case privateStoreCreateOpcode:
if len(message.Body) == 0 || message.Body[0] != 1 {
c.resetPrivateStore(errors.New("dnf robot private store creation was rejected"))
return nil
}
c.mu.Lock()
c.storeCreated = true
c.mu.Unlock()
_, err := c.retryPrivateStoreDisplay(time.Now().UTC(), true)
return err
case privateStoreCompleteOpcode:
if len(message.Body) == 0 || message.Body[0] != 1 {
c.resetPrivateStore(errors.New("dnf robot private store contents were rejected"))
return nil
}
c.mu.Lock()
c.pendingStore = nil
c.storeActive = true
c.storeCreated = false
c.storeAttempts = 0
c.storeRetryAt = time.Time{}
c.setStateLocked(StateStoreReady, nil)
c.lastError = ""
c.mu.Unlock()
case privateStoreCloseOpcode:
if len(message.Body) != 0 && message.Body[0] == 0 {
c.mu.Lock()
c.setStateLocked(StateStoreReady, errors.New("dnf robot private store close was rejected"))
c.mu.Unlock()
return nil
}
c.mu.Lock()
c.pendingStore = nil
c.storeActive = false
c.storeCreated = false
c.storeAttempts = 0
c.storeRetryAt = time.Time{}
c.setStateLocked(StateTownReady, nil)
c.mu.Unlock()
}
return nil
}
func (c *Client) sendSelectedCharacterOnce() error {
c.mu.Lock()
if c.selectionSent {
c.mu.Unlock()
return nil
}
selectedID, slot := c.selectedID, c.selectedSlot
c.selectionSent = true
c.mu.Unlock()
body := make([]byte, 11)
binary.LittleEndian.PutUint32(body[0:4], uint32(selectedID))
body[4] = slot
if err := c.send(4, body); err != nil {
c.mu.Lock()
c.selectionSent = false
c.mu.Unlock()
return err
}
return nil
}
func clonePendingPrivateStore(pending *pendingPrivateStore) *pendingPrivateStore {
if pending == nil {
return nil
}
return &pendingPrivateStore{title: append([]byte(nil), pending.title...), rows: append([]PrivateStoreRow(nil), pending.rows...)}
}
func buildPrivateStoreCompleteBody(pending *pendingPrivateStore) []byte {
if pending == nil {
return nil
}
body := make([]byte, 4+len(pending.title)+1+len(pending.rows)*13+2)
binary.LittleEndian.PutUint32(body[0:4], uint32(len(pending.title)))
copy(body[4:], pending.title)
offset := 4 + len(pending.title)
body[offset] = byte(len(pending.rows))
offset++
for _, row := range pending.rows {
binary.LittleEndian.PutUint16(body[offset:offset+2], row.DisplayIndex)
binary.LittleEndian.PutUint32(body[offset+2:offset+6], row.Price)
body[offset+6] = row.ListType
binary.LittleEndian.PutUint16(body[offset+7:offset+9], uint16(row.Slot))
binary.LittleEndian.PutUint32(body[offset+9:offset+13], row.WireValue)
offset += 13
}
binary.LittleEndian.PutUint16(body[offset:offset+2], 0xffff)
return body
}
// retryPrivateStoreDisplay mirrors the recovered 20260825 runtime's bounded
// CompletePrivateStoreDisplay retry. The create acknowledgement is the
// irreversible boundary: only after it arrives may op87 be retried, at most
// four total attempts and no faster than once every two seconds.
func (c *Client) retryPrivateStoreDisplay(now time.Time, immediate bool) (bool, error) {
if c == nil {
return false, ErrNotReady
}
if now.IsZero() {
now = time.Now().UTC()
}
c.mu.Lock()
if c.state != StateStoreOpening || !c.storeCreated || c.pendingStore == nil {
c.mu.Unlock()
return false, nil
}
if !immediate && !c.storeRetryAt.IsZero() && now.Before(c.storeRetryAt) {
c.mu.Unlock()
return false, nil
}
if c.storeAttempts >= privateStoreDisplayMaxAttempts {
c.mu.Unlock()
return false, errors.New("dnf robot private store display acknowledgement timed out")
}
pending := clonePendingPrivateStore(c.pendingStore)
c.storeAttempts++
c.storeRetryAt = now.Add(privateStoreDisplayRetryGap)
c.mu.Unlock()
if err := c.send(privateStoreCompleteOpcode, buildPrivateStoreCompleteBody(pending)); err != nil {
c.resetPrivateStore(err)
return false, err
}
return true, nil
}
func (c *Client) resetPrivateStore(err error) {
c.mu.Lock()
c.pendingStore = nil
c.storeActive = false
c.storeCreated = false
c.storeAttempts = 0
c.storeRetryAt = time.Time{}
if c.state != StateStopped {
c.setStateLocked(StateTownReady, err)
}
if err != nil && c.state == StateStopped {
c.lastError = err.Error()
}
c.mu.Unlock()
}
func (c *Client) send(opcode uint16, body []byte) error {
c.sendMu.Lock()
defer c.sendMu.Unlock()
c.mu.RLock()
conn := c.conn
c.mu.RUnlock()
if conn == nil {
return net.ErrClosed
}
frame, err := buildPacket(opcode, c.sequence, body)
if err != nil {
return err
}
if _, err := conn.Write(frame); err != nil {
return err
}
c.mu.Lock()
c.lastSend = time.Now().UTC()
c.mu.Unlock()
c.sequence++
return nil
}
func (c *Client) signalReadError(err error) {
select {
case c.readErr <- err:
default:
}
}
func (c *Client) setState(state State, err error) {
c.mu.Lock()
c.setStateLocked(state, err)
c.mu.Unlock()
}
func (c *Client) setStateLocked(state State, err error) {
if c.state != state {
c.state = state
c.stateSince = time.Now().UTC()
}
if err != nil {
c.lastError = err.Error()
} else if state == StateTownReady || state == StateStoreReady {
c.lastError = ""
}
}
func (c *Client) pendingOperationTimedOut(now time.Time, timeout time.Duration) (State, bool) {
if c == nil || timeout <= 0 {
return StateStopped, false
}
c.mu.RLock()
state, since := c.state, c.stateSince
c.mu.RUnlock()
switch state {
case StateConnecting, StateBinding, StateSelecting, StateStoreOpening, StateStoreClosing:
return state, !since.IsZero() && now.Sub(since) >= timeout
default:
return state, false
}
}