服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
419 lines
15 KiB
Go
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)
|
|
}
|
|
}
|