Files
90CNServer/server/internal/services/dnfrobot/manager_test.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

419 lines
15 KiB
Go

package dnfrobot
import (
"context"
"encoding/binary"
"errors"
"io"
"log/slog"
"net"
"strconv"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfmemory "longheng.io/server/internal/modules/dnf/repository/memory"
dnfrobotdomain "longheng.io/server/internal/modules/dnf/robot"
)
type staticRobotEndpoint string
func (endpoint staticRobotEndpoint) RobotGameEndpoint(channelID int) (string, error) {
if channelID != 19 {
return "", errors.New("unexpected channel")
}
return string(endpoint), nil
}
type robotStoreEndpoint struct {
staticRobotEndpoint
rows []PrivateStoreRow
}
func (endpoint robotStoreEndpoint) RobotPrivateStoreRows(_ context.Context, characterID uint16, maximumRows int, unitPrice uint32) ([]PrivateStoreRow, error) {
if characterID != 7 || maximumRows != 2 || unitPrice != 1234 {
return nil, errors.New("unexpected private store plan")
}
rows := append([]PrivateStoreRow(nil), endpoint.rows...)
for index := range rows {
rows[index].Price = unitPrice
}
return rows, nil
}
func TestManagerAutostartsPersistedProfileAndDrivesReadyClient(t *testing.T) {
runtime := dnfmemory.NewRuntime()
group := runtime.Group()
ctx := context.Background()
character := dnfrepo.CharacterRecord{
CharacterID: strconv.Itoa(7), AccountID: "robot-account", Slot: 3,
Name: "robot-seven", Level: 90,
}
if err := group.Character.Save(ctx, character); err != nil {
t.Fatalf("Save character: %v", err)
}
owner := dnfrobotdomain.NewOwner(func() (dnfrepo.Group, bool) { return group, true })
profile := dnfrobotdomain.Profile{
ID: "robot-7", AccountID: character.AccountID, CharacterID: 7, ChannelID: 19,
AutoStart: true, Village: 1, Area: 2, X: 100, Y: 200,
}
if _, err := owner.Save(ctx, dnfrobotdomain.Config{Profiles: []dnfrobotdomain.Profile{profile}}); err != nil {
t.Fatalf("Save config: %v", err)
}
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)
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},
{17, 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 && (len(body) < 12 || body[0] != 3 || binary.LittleEndian.Uint32(body[7:11]) == 0) {
serverErr <- io.ErrUnexpectedEOF
return
}
if index == 8 && (len(body) != 7 || binary.LittleEndian.Uint16(body[0:2]) != 333 || binary.LittleEndian.Uint16(body[2:4]) != 444) {
serverErr <- io.ErrUnexpectedEOF
return
}
if step.send != 0 {
if _, writeErr := conn.Write(buildServerPacket(step.send, step.body)); writeErr != nil {
serverErr <- writeErr
return
}
}
}
serverErr <- nil
_, _ = io.Copy(io.Discard, conn)
}()
manager := NewManager(
slog.New(slog.NewTextHandler(io.Discard, nil)),
func() (dnfrepo.Group, bool) { return group, true },
staticRobotEndpoint(listener.Addr().String()), "fixture-secret",
)
managerCtx, cancel := context.WithCancel(context.Background())
defer cancel()
if err := manager.Start(managerCtx); err != nil {
t.Fatalf("Start: %v", err)
}
deadline := time.Now().Add(3 * time.Second)
for {
snapshots := manager.List()
if len(snapshots) == 1 && snapshots[0].State == StateTownReady {
break
}
if time.Now().After(deadline) {
t.Fatalf("robot did not become ready: %+v", snapshots)
}
time.Sleep(time.Millisecond)
}
if err := manager.Chat(profile.ID, 3, "hello"); err != nil {
t.Fatalf("Chat: %v", err)
}
if err := manager.Move(profile.ID, 1, 2, 333, 444, 1); err != nil {
t.Fatalf("Move: %v", err)
}
if err := <-serverErr; err != nil {
t.Fatal(err)
}
stopCtx, stopCancel := context.WithTimeout(context.Background(), time.Second)
defer stopCancel()
if err := manager.Stop(stopCtx); err != nil {
t.Fatalf("Stop: %v", err)
}
loaded, err := owner.Load(ctx)
if err != nil || len(loaded.Profiles) != 1 || loaded.Profiles[0].ID != profile.ID {
t.Fatalf("persisted config = %+v, %v", loaded, err)
}
}
func TestManagerRejectsProfileWhoseCharacterBelongsToAnotherAccount(t *testing.T) {
runtime := dnfmemory.NewRuntime()
group := runtime.Group()
if err := group.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "9", AccountID: "actual-account", Slot: 1, Name: "nine",
}); err != nil {
t.Fatalf("Save character: %v", err)
}
manager := NewManager(
slog.New(slog.NewTextHandler(io.Discard, nil)),
func() (dnfrepo.Group, bool) { return group, true },
staticRobotEndpoint("127.0.0.1:1"), "fixture-secret",
)
if err := manager.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
defer manager.Stop(context.Background())
_, err := manager.Upsert(context.Background(), dnfrobotdomain.Profile{
ID: "wrong-owner", AccountID: "other-account", CharacterID: 9, ChannelID: 19,
})
if !errors.Is(err, dnfrobotdomain.ErrInvalid) {
t.Fatalf("Upsert error = %v, want ErrInvalid", err)
}
}
func TestManagerAutomationTickUsesReadyTownClientOnly(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
client := NewClient(dnfrobotdomain.Profile{}, "pipe", []byte("secret"))
client.mu.Lock()
client.conn = clientConn
client.state = StateTownReady
client.village, client.area, client.x, client.y = 1, 2, 100, 200
client.mu.Unlock()
now := time.Unix(100, 0).UTC()
runtime := &profileRuntime{
profile: dnfrobotdomain.Profile{ID: "auto", Village: 1, Area: 2, X: 100, Y: 200, MoveType: 1},
client: client, nextMove: now.Add(-time.Second), nextChat: now.Add(-time.Second),
}
manager := NewManager(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, staticRobotEndpoint("pipe"), "secret")
result := make(chan error, 1)
go func() {
manager.automateRuntime(now, dnfrobotdomain.AutomationPolicy{
Enabled: true, MoveIntervalMinSeconds: 5, MoveIntervalMaxSeconds: 5, MoveRadius: 20,
ChatIntervalMinSeconds: 10, ChatIntervalMaxSeconds: 10, ChatMode: 3,
ChatMessages: []string{"hello"},
}, runtime)
result <- nil
}()
opcode, body, err := readClientPacket(serverConn)
if err != nil || opcode != 35 || len(body) != 7 || binary.LittleEndian.Uint16(body[0:2]) != 120 || binary.LittleEndian.Uint16(body[2:4]) != 200 {
t.Fatalf("move packet = op%d %x, %v", opcode, body, err)
}
opcode, body, err = readClientPacket(serverConn)
if err != nil || opcode != 17 || len(body) < 16 || body[0] != 3 || string(body[11:]) != "hello" {
t.Fatalf("chat packet = op%d %x, %v", opcode, body, err)
}
if err := <-result; err != nil {
t.Fatal(err)
}
runtime.mu.RLock()
nextMove, nextChat := runtime.nextMove, runtime.nextChat
runtime.mu.RUnlock()
if !nextMove.Equal(now.Add(5*time.Second)) || !nextChat.Equal(now.Add(10*time.Second)) {
t.Fatalf("next actions = %v / %v", nextMove, nextChat)
}
}
func TestManagerAutomationOpensAndClosesCurrentPrivateStore(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
profile := dnfrobotdomain.Profile{ID: "store", AccountID: "account", CharacterID: 7, ChannelID: 19, Village: 1, Area: 2, X: 100, Y: 200}
client := NewClient(profile, "pipe", []byte("secret"))
client.mu.Lock()
client.conn = clientConn
client.state = StateTownReady
client.village, client.area, client.x, client.y = 1, 2, 100, 200
client.mu.Unlock()
now := time.Unix(200, 0).UTC()
runtime := &profileRuntime{profile: profile, client: client, nextStore: now.Add(-time.Second)}
manager := NewManager(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, robotStoreEndpoint{
staticRobotEndpoint: "pipe",
rows: []PrivateStoreRow{{DisplayIndex: 0, ListType: 0, Slot: 12, WireValue: 4}},
}, "secret")
policy := dnfrobotdomain.AutomationPolicy{
Enabled: true, StoreIntervalMinSeconds: 10, StoreIntervalMaxSeconds: 10,
StoreDurationSeconds: 5, StoreConcurrent: 1, StoreMaximumRows: 2,
StoreUnitPrice: 1234, StoreTitle: "robot shop",
}
done := make(chan struct{})
go func() { manager.automateRuntime(now, policy, runtime); close(done) }()
opcode, body, err := readClientPacket(serverConn)
if err != nil || opcode != privateStoreCreateOpcode || len(body) != 8 || binary.LittleEndian.Uint16(body[6:8]) != 0xffff {
t.Fatalf("create packet = op%d %x, %v", opcode, body, err)
}
<-done
handled := make(chan error, 1)
go func() { handled <- client.handle(packet{Opcode: privateStoreCreateOpcode, Body: []byte{1}}) }()
opcode, body, err = readClientPacket(serverConn)
if err != nil || opcode != privateStoreCompleteOpcode || len(body) < 20 || body[4+len("robot shop")] != 1 {
t.Fatalf("complete packet = op%d %x, %v", opcode, body, err)
}
if err := <-handled; err != nil {
t.Fatal(err)
}
if err := client.handle(packet{Opcode: privateStoreCompleteOpcode, Body: []byte{1}}); err != nil {
t.Fatal(err)
}
done = make(chan struct{})
go func() { manager.automateRuntime(now.Add(5*time.Second), policy, runtime); close(done) }()
opcode, body, err = readClientPacket(serverConn)
if err != nil || opcode != privateStoreCloseOpcode || len(body) != 0 {
t.Fatalf("close packet = op%d %x, %v", opcode, body, err)
}
<-done
}
func TestManagerAutomationRelocatesAndReconnectsAfterPrivateStoreRejection(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
profile := dnfrobotdomain.Profile{
ID: "store-retry", AccountID: "account", CharacterID: 7, ChannelID: 19,
Village: 38, Area: 1, X: 500, Y: 600,
}
client := NewClient(profile, "pipe", []byte("secret"))
client.mu.Lock()
client.conn = clientConn
client.state = StateStoreOpening
client.mu.Unlock()
client.resetPrivateStore(errors.New("fixture store rejected"))
now := time.Unix(250, 0).UTC()
runtime := &profileRuntime{
profile: profile, client: client, autoStore: true,
behaviorSeed: automationBehaviorSeed(profile), storeCursor: 3,
}
manager := NewManager(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, staticRobotEndpoint("pipe"), "secret")
manager.automateRuntime(now, dnfrobotdomain.AutomationPolicy{
Enabled: true, StoreIntervalMinSeconds: 10, StoreIntervalMaxSeconds: 10,
}, runtime)
if state := client.Snapshot().State; state != StateStopped {
t.Fatalf("client state = %s, want stopped", state)
}
runtime.mu.RLock()
gotProfile, retryAt, lastAction := runtime.profile, runtime.storeRetryAt, runtime.lastAction
runtime.mu.RUnlock()
if gotProfile.X == profile.X && gotProfile.Y == profile.Y {
t.Fatalf("store retry did not relocate profile: %+v", gotProfile)
}
if !retryAt.Equal(now.Add(30*time.Second)) || lastAction != "store_retry_relocate" {
t.Fatalf("store retry state = retry:%v action:%q", retryAt, lastAction)
}
}
func TestAutomationBehaviorPositionClampsToProfileBounds(t *testing.T) {
profile := dnfrobotdomain.Profile{
X: 500, Y: 200, MoveBoundsSet: true,
MoveMinX: 420, MoveMaxX: 580, MoveMinY: 194, MoveMaxY: 206,
}
for cursor := 0; cursor < 100; cursor++ {
x, y := automationBehaviorPositionForProfile(profile, 120, cursor, uint64(cursor+1))
if x < profile.MoveMinX || x > profile.MoveMaxX || y < profile.MoveMinY || y > profile.MoveMaxY {
t.Fatalf("cursor %d position=(%d,%d) outside profile=%+v", cursor, x, y, profile)
}
}
}
func TestManagerAutomationDisablesStoresAtDungeonEntrances(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
defer clientConn.Close()
profile := dnfrobotdomain.Profile{
ID: "gate", AccountID: "account", CharacterID: 7, ChannelID: 19,
Village: 38, Area: 3, X: 500, Y: 200, AutomaticStoreDisabled: true,
}
client := NewClient(profile, "pipe", []byte("secret"))
client.mu.Lock()
client.conn = clientConn
client.state = StateTownReady
client.mu.Unlock()
now := time.Unix(300, 0).UTC()
runtime := &profileRuntime{profile: profile, client: client, nextStore: now.Add(-time.Second)}
manager := NewManager(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, staticRobotEndpoint("pipe"), "secret")
manager.automateRuntime(now, dnfrobotdomain.AutomationPolicy{
Enabled: true, StoreIntervalMinSeconds: 10, StoreIntervalMaxSeconds: 10,
StoreDurationSeconds: 5, StoreConcurrent: 1, StoreMaximumRows: 2,
StoreUnitPrice: 1234, StoreTitle: "robot shop",
}, runtime)
runtime.mu.RLock()
nextStore, automaticStore := runtime.nextStore, runtime.autoStore
runtime.mu.RUnlock()
if !nextStore.IsZero() || automaticStore {
t.Fatalf("dungeon entrance store state next=%v active=%t", nextStore, automaticStore)
}
}
func TestManagerExpiresUnacknowledgedCurrentProtocolOperation(t *testing.T) {
serverConn, clientConn := net.Pipe()
defer serverConn.Close()
now := time.Unix(500, 0).UTC()
client := NewClient(dnfrobotdomain.Profile{}, "pipe", []byte("secret"))
client.mu.Lock()
client.conn = clientConn
client.state = StateStoreOpening
client.stateSince = now.Add(-31 * time.Second)
client.mu.Unlock()
runtime := &profileRuntime{profile: dnfrobotdomain.Profile{ID: "timeout"}, client: client}
manager := NewManager(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, staticRobotEndpoint("pipe"), "secret")
manager.running["timeout"] = runtime
manager.automationTick(now)
if state := client.Snapshot().State; state != StateStopped {
t.Fatalf("state = %s, want stopped", state)
}
runtime.mu.RLock()
lastError := runtime.lastError
runtime.mu.RUnlock()
if lastError != "dnf robot store_opening acknowledgement timed out" {
t.Fatalf("last error = %q", lastError)
}
}
func TestManagerSchedulerScalesOnlySchedulerManagedProfiles(t *testing.T) {
manager := NewManager(slog.New(slog.NewTextHandler(io.Discard, nil)), nil, staticRobotEndpoint("127.0.0.1:1"), "secret")
ctx, cancel := context.WithCancel(context.Background())
manager.ctx, manager.cancel = ctx, cancel
for index := 1; index <= 3; index++ {
manager.config.Profiles = append(manager.config.Profiles, dnfrobotdomain.Profile{
ID: "scale-" + strconv.Itoa(index), AccountID: "account-" + strconv.Itoa(index),
CharacterID: uint16(index), ChannelID: 19,
})
}
manager.config.Automation = dnfrobotdomain.AutomationPolicy{
Enabled: true, TargetOnline: 2, ScaleIntervalSeconds: 1, ScaleUpBatch: 2, ScaleDownBatch: 2,
}
now := time.Unix(900, 0).UTC()
manager.reconcileTargetOnline(now)
if status := manager.Status(); status.Running != 2 || status.SchedulerManaged != 2 {
t.Fatalf("scaled-up status = %+v", status)
}
if _, err := manager.StartProfile("scale-3"); err != nil {
t.Fatalf("manual StartProfile: %v", err)
}
manager.mu.Lock()
manager.config.Automation.TargetOnline = 1
manager.nextScale = time.Time{}
manager.mu.Unlock()
manager.reconcileTargetOnline(now.Add(time.Second))
status := manager.Status()
if status.Running != 1 || status.SchedulerManaged != 0 {
t.Fatalf("scaled-down status = %+v", status)
}
if snapshots := manager.List(); len(snapshots) != 3 || !snapshots[2].Running || snapshots[2].SchedulerManaged {
t.Fatalf("manual profile was not preserved: %+v", snapshots)
}
stopCtx, stopCancel := context.WithTimeout(context.Background(), time.Second)
defer stopCancel()
if err := manager.Stop(stopCtx); err != nil {
t.Fatalf("Stop: %v", err)
}
}