服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
378 lines
12 KiB
Go
378 lines
12 KiB
Go
package dnfrobot
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"io"
|
|
"net"
|
|
"testing"
|
|
"time"
|
|
|
|
dnfrobotdomain "longheng.io/server/internal/modules/dnf/robot"
|
|
)
|
|
|
|
func TestClientRunsCurrentTownHandshakeAndActions(t *testing.T) {
|
|
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("Listen: %v", err)
|
|
}
|
|
defer listener.Close()
|
|
serverErr := make(chan error, 1)
|
|
ready := make(chan struct{})
|
|
go func() {
|
|
conn, acceptErr := listener.Accept()
|
|
if acceptErr != nil {
|
|
serverErr <- acceptErr
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
steps := []struct {
|
|
want uint16
|
|
send uint16
|
|
body []byte
|
|
}{
|
|
{robotBindOpcode, robotBindOpcode, []byte{1, 3, 7, 0}},
|
|
// The production server answers GET_USERINFO with a class-0/op2
|
|
// fixed-16 upper roster, not the older op8 test response.
|
|
{8, 2, make([]byte, 153)},
|
|
{4, 4, nil},
|
|
{143, 24, nil},
|
|
{1345, 0, nil},
|
|
{36, 0, nil},
|
|
{35, 0, nil},
|
|
{17, 0, nil},
|
|
{17, 0, nil},
|
|
{36, 0, nil},
|
|
{35, 0, nil},
|
|
}
|
|
for index, step := range steps {
|
|
opcode, body, readErr := readClientPacket(conn)
|
|
if readErr != nil {
|
|
serverErr <- readErr
|
|
return
|
|
}
|
|
if opcode != step.want {
|
|
serverErr <- &unexpectedOpcodeError{got: opcode, want: step.want}
|
|
return
|
|
}
|
|
if index == 7 {
|
|
if len(body) < 12 || body[0] != 3 || binary.LittleEndian.Uint32(body[7:11]) == 0 {
|
|
serverErr <- io.ErrUnexpectedEOF
|
|
return
|
|
}
|
|
}
|
|
if index == 8 {
|
|
if len(body) < 12 || body[0] != 11 || binary.LittleEndian.Uint32(body[3:7]) != 23 || binary.LittleEndian.Uint32(body[7:11]) == 0 {
|
|
serverErr <- io.ErrUnexpectedEOF
|
|
return
|
|
}
|
|
}
|
|
if step.send != 0 {
|
|
if _, writeErr := conn.Write(buildServerPacket(step.send, step.body)); writeErr != nil {
|
|
serverErr <- writeErr
|
|
return
|
|
}
|
|
}
|
|
if index == 6 {
|
|
close(ready)
|
|
}
|
|
}
|
|
serverErr <- nil
|
|
_, _ = io.Copy(io.Discard, conn)
|
|
}()
|
|
|
|
profile := dnfrobotdomain.Profile{
|
|
ID: "fixture", AccountID: "account", CharacterID: 7, ChannelID: 19,
|
|
Village: 1, Area: 2, X: 100, Y: 200,
|
|
}
|
|
client := NewClient(profile, listener.Addr().String(), []byte("secret"))
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
runErr := make(chan error, 1)
|
|
go func() { runErr <- client.Run(ctx) }()
|
|
select {
|
|
case <-ready:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("client did not complete current town handshake")
|
|
}
|
|
deadline := time.Now().Add(time.Second)
|
|
for client.Snapshot().State != StateTownReady && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if snapshot := client.Snapshot(); snapshot.State != StateTownReady || snapshot.CharacterID != 7 || snapshot.Slot != 3 {
|
|
t.Fatalf("unexpected ready snapshot: %+v", snapshot)
|
|
}
|
|
if err := client.Chat(3, "你好"); err != nil {
|
|
t.Fatalf("Chat: %v", err)
|
|
}
|
|
if err := client.Megaphone(23, "你好"); err != nil {
|
|
t.Fatalf("Megaphone: %v", err)
|
|
}
|
|
if err := client.Move(2, 4, 300, 400, 1); err != nil {
|
|
t.Fatalf("Move: %v", err)
|
|
}
|
|
if err := <-serverErr; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-runErr:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("client did not stop after context cancellation")
|
|
}
|
|
}
|
|
|
|
type unexpectedOpcodeError struct{ got, want uint16 }
|
|
|
|
func (e *unexpectedOpcodeError) Error() string { return "unexpected opcode" }
|
|
|
|
func readClientPacket(conn net.Conn) (uint16, []byte, error) {
|
|
header := make([]byte, 13)
|
|
if _, err := io.ReadFull(conn, header); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
size := int(binary.LittleEndian.Uint32(header[3:7]))
|
|
if size < 13 || size > maximumFrameSize {
|
|
return 0, nil, io.ErrUnexpectedEOF
|
|
}
|
|
body := make([]byte, size-13)
|
|
if _, err := io.ReadFull(conn, body); err != nil {
|
|
return 0, nil, err
|
|
}
|
|
return binary.LittleEndian.Uint16(header[1:3]), body, nil
|
|
}
|
|
|
|
func buildServerPacket(opcode uint16, body []byte) []byte {
|
|
headerSize := 13
|
|
if opcode == 2 {
|
|
headerSize = 16
|
|
}
|
|
frame := make([]byte, headerSize+len(body))
|
|
frame[0] = 0
|
|
binary.LittleEndian.PutUint16(frame[1:3], opcode)
|
|
binary.LittleEndian.PutUint32(frame[3:7], uint32(len(frame)))
|
|
copy(frame[headerSize:], body)
|
|
return frame
|
|
}
|
|
|
|
func TestDecodeFrameAcceptsCurrentOuterBusinessPacket(t *testing.T) {
|
|
business := make([]byte, 7+3)
|
|
business[0] = 1
|
|
binary.LittleEndian.PutUint16(business[1:3], 17)
|
|
copy(business[7:], []byte{1, 2, 3})
|
|
frame := make([]byte, outerHeaderSize+innerHeaderSize+len(business))
|
|
binary.LittleEndian.PutUint16(frame[2:4], uint16(len(frame)))
|
|
frame[12] = 2
|
|
binary.LittleEndian.PutUint16(frame[17:19], uint16(len(business)))
|
|
copy(frame[outerHeaderSize+innerHeaderSize:], business)
|
|
size, outer, ready, valid := nextFrame(frame)
|
|
if !valid || !ready || !outer || size != len(frame) {
|
|
t.Fatalf("nextFrame = size %d outer %v ready %v valid %v", size, outer, ready, valid)
|
|
}
|
|
packets, err := decodeFrame(frame, true)
|
|
if err != nil || len(packets) != 1 || packets[0].Opcode != 17 || string(packets[0].Body) != "\x01\x02\x03" {
|
|
t.Fatalf("decodeFrame = %+v, %v", packets, err)
|
|
}
|
|
}
|
|
|
|
func TestClientOpensAndClosesCurrentPrivateStore(t *testing.T) {
|
|
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
|
if err != nil {
|
|
t.Fatalf("Listen: %v", err)
|
|
}
|
|
defer listener.Close()
|
|
serverErr := make(chan error, 1)
|
|
ready := make(chan struct{})
|
|
storeReady := make(chan struct{})
|
|
go func() {
|
|
conn, acceptErr := listener.Accept()
|
|
if acceptErr != nil {
|
|
serverErr <- acceptErr
|
|
return
|
|
}
|
|
defer conn.Close()
|
|
steps := []struct {
|
|
want uint16
|
|
send uint16
|
|
body []byte
|
|
}{
|
|
{robotBindOpcode, robotBindOpcode, []byte{1, 3, 7, 0}},
|
|
{8, 8, nil}, {4, 4, nil}, {143, 24, nil},
|
|
{1345, 0, nil}, {36, 0, nil}, {35, 0, nil},
|
|
{privateStoreCreateOpcode, privateStoreCreateOpcode, []byte{1}},
|
|
{privateStoreCompleteOpcode, privateStoreCompleteOpcode, []byte{1, 0}},
|
|
{privateStoreCloseOpcode, privateStoreCloseOpcode, []byte{1}},
|
|
}
|
|
for index, step := range steps {
|
|
opcode, body, readErr := readClientPacket(conn)
|
|
if readErr != nil {
|
|
serverErr <- readErr
|
|
return
|
|
}
|
|
if opcode != step.want {
|
|
serverErr <- &unexpectedOpcodeError{got: opcode, want: step.want}
|
|
return
|
|
}
|
|
if index == 7 {
|
|
if len(body) != 8 || body[0] != 1 || body[1] != 2 || binary.LittleEndian.Uint16(body[6:8]) != 0xffff {
|
|
serverErr <- io.ErrUnexpectedEOF
|
|
return
|
|
}
|
|
}
|
|
if index == 8 {
|
|
titleLength := int(binary.LittleEndian.Uint32(body[:4]))
|
|
if titleLength != 4 || string(body[4:8]) != "shop" || body[8] != 2 ||
|
|
binary.LittleEndian.Uint32(body[11:15]) != 1000 || body[15] != 0 ||
|
|
binary.LittleEndian.Uint16(body[16:18]) != 10 || binary.LittleEndian.Uint32(body[18:22]) != 5 {
|
|
serverErr <- io.ErrUnexpectedEOF
|
|
return
|
|
}
|
|
}
|
|
if step.send != 0 {
|
|
if _, writeErr := conn.Write(buildServerPacket(step.send, step.body)); writeErr != nil {
|
|
serverErr <- writeErr
|
|
return
|
|
}
|
|
}
|
|
if index == 6 {
|
|
close(ready)
|
|
}
|
|
if index == 8 {
|
|
close(storeReady)
|
|
}
|
|
}
|
|
serverErr <- nil
|
|
_, _ = io.Copy(io.Discard, conn)
|
|
}()
|
|
profile := dnfrobotdomain.Profile{ID: "store", AccountID: "account", CharacterID: 7, ChannelID: 19, Village: 1, Area: 2, X: 100, Y: 200}
|
|
client := NewClient(profile, listener.Addr().String(), []byte("secret"))
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
defer cancel()
|
|
runErr := make(chan error, 1)
|
|
go func() { runErr <- client.Run(ctx) }()
|
|
select {
|
|
case <-ready:
|
|
case <-time.After(3 * time.Second):
|
|
t.Fatal("client did not become ready")
|
|
}
|
|
deadline := time.Now().Add(time.Second)
|
|
for client.Snapshot().State != StateTownReady && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if err := client.OpenPrivateStore("shop", []PrivateStoreRow{
|
|
{DisplayIndex: 0, Price: 1000, ListType: 0, Slot: 10, WireValue: 5},
|
|
{DisplayIndex: 1, Price: 2000, ListType: 1, Slot: 11, WireValue: 1},
|
|
}); err != nil {
|
|
t.Fatalf("OpenPrivateStore: %v", err)
|
|
}
|
|
select {
|
|
case <-storeReady:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("client did not send private store contents")
|
|
}
|
|
deadline = time.Now().Add(time.Second)
|
|
for client.Snapshot().State != StateStoreReady && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if state := client.Snapshot().State; state != StateStoreReady {
|
|
t.Fatalf("state after open = %s", state)
|
|
}
|
|
if err := client.ClosePrivateStore(); err != nil {
|
|
t.Fatalf("ClosePrivateStore: %v", err)
|
|
}
|
|
if err := <-serverErr; err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
deadline = time.Now().Add(time.Second)
|
|
for client.Snapshot().State != StateTownReady && time.Now().Before(deadline) {
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
if state := client.Snapshot().State; state != StateTownReady {
|
|
t.Fatalf("state after close = %s", state)
|
|
}
|
|
cancel()
|
|
select {
|
|
case <-runErr:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("client did not stop")
|
|
}
|
|
}
|
|
|
|
func TestClientRetriesPrivateStoreDisplayAfterLostCompletionResponse(t *testing.T) {
|
|
serverConn, clientConn := net.Pipe()
|
|
defer serverConn.Close()
|
|
defer clientConn.Close()
|
|
|
|
client := NewClient(dnfrobotdomain.Profile{ID: "store-retry", CharacterID: 7}, "pipe", []byte("secret"))
|
|
now := time.Now().UTC()
|
|
client.mu.Lock()
|
|
client.conn = clientConn
|
|
client.state = StateStoreOpening
|
|
client.pendingStore = &pendingPrivateStore{
|
|
title: []byte("shop"),
|
|
rows: []PrivateStoreRow{{DisplayIndex: 0, Price: 1000, ListType: 0, Slot: 10, WireValue: 5}},
|
|
}
|
|
client.storeCreated = true
|
|
client.storeAttempts = 1
|
|
client.storeRetryAt = now.Add(-time.Second)
|
|
client.mu.Unlock()
|
|
|
|
type receivedPacket struct {
|
|
opcode uint16
|
|
body []byte
|
|
err error
|
|
}
|
|
received := make(chan receivedPacket, 1)
|
|
go func() {
|
|
opcode, body, err := readClientPacket(serverConn)
|
|
received <- receivedPacket{opcode: opcode, body: body, err: err}
|
|
}()
|
|
attempted, err := client.retryPrivateStoreDisplay(now, false)
|
|
if err != nil || !attempted {
|
|
t.Fatalf("retry attempted=%t err=%v", attempted, err)
|
|
}
|
|
packet := <-received
|
|
if packet.err != nil || packet.opcode != privateStoreCompleteOpcode ||
|
|
!bytes.Equal(packet.body, buildPrivateStoreCompleteBody(client.pendingStore)) {
|
|
t.Fatalf("retry packet opcode=%d body=%x err=%v", packet.opcode, packet.body, packet.err)
|
|
}
|
|
client.mu.RLock()
|
|
attempts, retryAt := client.storeAttempts, client.storeRetryAt
|
|
client.mu.RUnlock()
|
|
if attempts != 2 || !retryAt.Equal(now.Add(privateStoreDisplayRetryGap)) {
|
|
t.Fatalf("retry state attempts=%d retry_at=%v", attempts, retryAt)
|
|
}
|
|
if attempted, err := client.retryPrivateStoreDisplay(now.Add(time.Second), false); err != nil || attempted {
|
|
t.Fatalf("early retry attempted=%t err=%v", attempted, err)
|
|
}
|
|
if err := client.handle(packetForTest(privateStoreCompleteOpcode, []byte{1, 0})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
client.mu.RLock()
|
|
state, pending, created, attempts := client.state, client.pendingStore, client.storeCreated, client.storeAttempts
|
|
client.mu.RUnlock()
|
|
if state != StateStoreReady || pending != nil || created || attempts != 0 {
|
|
t.Fatalf("completion state=%s pending=%+v created=%t attempts=%d", state, pending, created, attempts)
|
|
}
|
|
}
|
|
|
|
func TestClientStopsPrivateStoreDisplayAfterFourAttempts(t *testing.T) {
|
|
client := NewClient(dnfrobotdomain.Profile{ID: "store-retry-limit", CharacterID: 7}, "unused", []byte("secret"))
|
|
now := time.Now().UTC()
|
|
client.mu.Lock()
|
|
client.state = StateStoreOpening
|
|
client.pendingStore = &pendingPrivateStore{title: []byte("shop"), rows: []PrivateStoreRow{{DisplayIndex: 0, Price: 1, Slot: 1, WireValue: 1}}}
|
|
client.storeCreated = true
|
|
client.storeAttempts = privateStoreDisplayMaxAttempts
|
|
client.storeRetryAt = now.Add(-time.Second)
|
|
client.mu.Unlock()
|
|
if attempted, err := client.retryPrivateStoreDisplay(now, false); err == nil || attempted {
|
|
t.Fatalf("retry limit attempted=%t err=%v", attempted, err)
|
|
}
|
|
}
|
|
|
|
func packetForTest(opcode uint16, body []byte) packet {
|
|
return packet{Opcode: opcode, Body: body}
|
|
}
|