DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

@@ -0,0 +1,333 @@
package dnfrobot
import (
"context"
"fmt"
"hash/fnv"
"strings"
"time"
dnfrobotdomain "longheng.io/server/internal/modules/dnf/robot"
)
const (
// AutomaticFleetTarget is deliberately below robot.MaxProfiles so operators
// can still keep manual profiles beside the zero-touch fleet.
AutomaticFleetTarget = 500
// AutomaticFleetOnlineTarget keeps all 500 persistent identities available
// while bounding native game sessions. The recovered robot supervisor used
// a finite actor/pending pool; treating every profile as an independently
// reconnecting actor can starve real character-selection transactions.
AutomaticFleetOnlineTarget = 64
// automaticFleetPendingLimit mirrors the recovered supervisor's bounded
// actor backlog: at most eight batches may be waiting to log in or
// reconnect. With the current two-session native handshake limit this keeps
// repository pressure bounded at sixteen pending automatic runtimes.
automaticFleetPendingLimit = 16
automaticFleetRetry = 30 * time.Second
automaticFleetGatewayRetry = 2 * time.Second
automaticFleetGatewaySamples = 3
automaticFleetGatewayGap = 250 * time.Millisecond
automaticFleetPreferredChannelID = 19
automaticFleetConfigVersion = 7
automaticFleetProfilePrefix = "auto-generated-"
)
const (
automationActionMove = "move"
automationActionChat = "chat"
automationActionStore = "store"
automationActionMegaphone = "megaphone"
)
// AutomaticFleetProvider creates or reuses one native, repository-backed
// character. Implementations must be idempotent for the same index.
type AutomaticFleetProvider interface {
EnsureAutomaticRobotProfile(ctx context.Context, index int) (dnfrobotdomain.Profile, error)
}
// DefaultAutomaticFleetPolicy is the zero-operation policy installed only
// when no prior automation policy exists. The intervals are intentionally
// broad; per-profile personalities further stagger every action.
func DefaultAutomaticFleetPolicy() dnfrobotdomain.AutomationPolicy {
return dnfrobotdomain.AutomationPolicy{
Enabled: true, TargetOnline: AutomaticFleetOnlineTarget,
ScaleIntervalSeconds: 5, ScaleUpBatch: 2, ScaleDownBatch: 2,
MoveIntervalMinSeconds: 12, MoveIntervalMaxSeconds: 45, MoveRadius: 120,
ChatIntervalMinSeconds: 180, ChatIntervalMaxSeconds: 600, ChatMode: 3,
ChatMessages: []string{
"今天也要努力冒险", "有人一起刷图吗", "整理一下背包", "城镇里真热闹",
"看看我的小店吧", "休息一会再出发", "祝大家都出好装备", "准备下一次冒险",
},
MegaphoneIntervalMinSeconds: 900, MegaphoneIntervalMaxSeconds: 1800,
MegaphoneProfileModulo: 50,
StoreIntervalMinSeconds: 90, StoreIntervalMaxSeconds: 300,
StoreDurationSeconds: 120, StoreConcurrent: 2, StoreMaximumRows: 8,
StoreUnitPrice: 10000, StoreTitle: "冒险家商店",
}
}
func (m *Manager) runAutomaticFleet(ctx context.Context) {
defer m.wg.Done()
provider, ok := m.endpoint.(AutomaticFleetProvider)
if !ok {
return
}
if stability, available := m.endpoint.(AutomaticFleetGatewayStability); available {
for {
if err := stability.WaitRobotGameEndpointStable(ctx, automaticFleetPreferredChannelID, automaticFleetGatewaySamples, automaticFleetGatewayGap); err == nil {
break
} else if ctx.Err() != nil {
return
} else {
m.logger.Warn("dnf automatic robot gateway is not stable", "channel", automaticFleetPreferredChannelID, "error", err)
}
timer := time.NewTimer(automaticFleetGatewayRetry)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}
ensured := make(map[int]struct{}, AutomaticFleetTarget)
timer := time.NewTimer(0)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
}
operation := m.BeginOperation("automatic_fleet", fmt.Sprintf("target=%d", AutomaticFleetTarget))
succeeded, failed := 0, 0
for index := 1; index <= AutomaticFleetTarget; index++ {
if _, ok := ensured[index]; ok {
succeeded++
continue
}
if ctx.Err() != nil {
m.CompleteOperation(operation.ID, fmt.Sprintf("ensured=%d pending=%d", succeeded, AutomaticFleetTarget-succeeded), ctx.Err())
return
}
profile, err := provider.EnsureAutomaticRobotProfile(ctx, index)
if err == nil {
// Fleet profiles are scheduler-owned. Direct autostart would fan out
// hundreds of character-selection transactions at process startup.
profile.AutoStart = false
err = m.installAutomaticProfile(ctx, profile)
}
if err != nil {
failed++
m.logger.Warn("dnf automatic robot ensure deferred", "index", index, "error", err)
// Repository pressure is shared by every following profile. Stop at
// the first failure instead of immediately starting hundreds of
// transactions that are expected to fail for the same reason.
break
}
ensured[index] = struct{}{}
succeeded++
provisionDelay := m.markAutomaticFleetProfileEnsured()
if provisionDelay > 0 && index < AutomaticFleetTarget {
pace := time.NewTimer(provisionDelay)
select {
case <-ctx.Done():
pace.Stop()
m.CompleteOperation(operation.ID, fmt.Sprintf("ensured=%d pending=%d", succeeded, AutomaticFleetTarget-succeeded), ctx.Err())
return
case <-pace.C:
}
}
}
summary := fmt.Sprintf("ensured=%d pending=%d", succeeded, AutomaticFleetTarget-succeeded)
if failed == 0 {
m.CompleteOperation(operation.ID, summary, nil)
return
}
m.CompleteOperation(operation.ID, summary, fmt.Errorf("%d automatic robot profiles are pending", failed))
timer.Reset(automaticFleetRetry)
}
}
func (m *Manager) markAutomaticFleetProfileEnsured() time.Duration {
if m == nil {
return 0
}
m.mu.Lock()
defer m.mu.Unlock()
if !m.automaticFleetReady {
m.automaticFleetReady = true
m.nextScale = time.Time{}
}
return m.fleetProvisionDelay
}
func (m *Manager) installAutomaticProfile(ctx context.Context, profile dnfrobotdomain.Profile) error {
m.mu.RLock()
current, found := findProfile(m.config, profile.ID)
if found && current == profile {
m.mu.RUnlock()
return nil
}
for _, candidate := range m.config.Profiles {
if candidate.CharacterID == profile.CharacterID && profileKey(candidate.ID) != profileKey(profile.ID) {
m.mu.RUnlock()
return fmt.Errorf("automatic character %d is already assigned to profile %q", profile.CharacterID, candidate.ID)
}
}
m.mu.RUnlock()
_, err := m.Upsert(ctx, profile)
return err
}
func automationBehaviorSeed(profile dnfrobotdomain.Profile) uint64 {
hash := fnv.New64a()
_, _ = hash.Write([]byte(strings.ToLower(strings.TrimSpace(profile.ID))))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write([]byte(profile.AccountID))
_, _ = hash.Write([]byte{byte(profile.CharacterID), byte(profile.CharacterID >> 8)})
return hash.Sum64()
}
func automationPersonality(seed uint64) string {
switch seed % 4 {
case 0:
return "merchant"
case 1:
return "wanderer"
case 2:
return "social"
default:
return "balanced"
}
}
func automationBehaviorDelay(minSeconds, maxSeconds, cursor int, seed uint64, personality, action string) time.Duration {
if minSeconds <= 0 {
return 0
}
numerator := 100
switch personality {
case "merchant":
switch action {
case automationActionStore:
numerator = 70
case automationActionMove:
numerator = 140
case automationActionChat:
numerator = 120
}
case "wanderer":
if action == automationActionMove {
numerator = 65
} else if action == automationActionStore {
numerator = 150
}
case "social":
if action == automationActionChat {
numerator = 60
} else if action == automationActionStore {
numerator = 130
}
}
minSeconds = maxInt(1, minSeconds*numerator/100)
maxSeconds = maxInt(minSeconds, maxSeconds*numerator/100)
if seed == 0 {
return automationDelay(minSeconds, maxSeconds, cursor)
}
width := maxSeconds - minSeconds + 1
offset := int(automationMix(seed, uint64(cursor), stringSeed(action)) % uint64(width))
return time.Duration(minSeconds+offset) * time.Second
}
func automationBehaviorPosition(originX, originY, radius uint16, cursor int, seed uint64) (uint16, uint16) {
if seed == 0 {
return automationPosition(originX, originY, radius, cursor)
}
if radius == 0 {
return originX, originY
}
mixed := automationMix(seed, uint64(cursor), 0x6d6f7665)
distance := int(radius)/2 + int(mixed%uint64(maxInt(1, int(radius)/2+1)))
directions := [8][2]int{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}}
direction := directions[(mixed>>8)&7]
x := clampAutomationCoordinate(int(originX) + direction[0]*distance)
y := clampAutomationCoordinate(int(originY) + direction[1]*distance)
return uint16(x), uint16(y)
}
func automationBehaviorPositionForProfile(profile dnfrobotdomain.Profile, radius uint16, cursor int, seed uint64) (uint16, uint16) {
x, y := automationBehaviorPosition(profile.X, profile.Y, radius, cursor, seed)
if !profile.MoveBoundsSet {
return x, y
}
return clampAutomationRange(x, profile.MoveMinX, profile.MoveMaxX), clampAutomationRange(y, profile.MoveMinY, profile.MoveMaxY)
}
func clampAutomationRange(value, minimum, maximum uint16) uint16 {
if value < minimum {
return minimum
}
if value > maximum {
return maximum
}
return value
}
func automationMessageIndex(cursor int, seed uint64, count int) int {
if count <= 0 {
return 0
}
if seed == 0 {
return cursor % count
}
return int(automationMix(seed, uint64(cursor), 0x63686174) % uint64(count))
}
func automationStorePrice(base uint32, seed uint64, cursor int) uint32 {
if base == 0 || seed == 0 {
return base
}
percent := uint64(85) + automationMix(seed, uint64(cursor), 0x7072696365)%31
price := uint64(base) * percent / 100
if price == 0 {
return 1
}
if price > uint64(^uint32(0)) {
return ^uint32(0)
}
return uint32(price)
}
func automationMix(seed, cursor, salt uint64) uint64 {
value := seed ^ salt ^ (cursor+1)*0x9e3779b97f4a7c15
value ^= value >> 30
value *= 0xbf58476d1ce4e5b9
value ^= value >> 27
value *= 0x94d049bb133111eb
return value ^ (value >> 31)
}
func stringSeed(value string) uint64 {
hash := fnv.New64a()
_, _ = hash.Write([]byte(value))
return hash.Sum64()
}
func clampAutomationCoordinate(value int) int {
if value < 0 {
return 0
}
if value > 65535 {
return 65535
}
return value
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}
@@ -0,0 +1,280 @@
package dnfrobot
import (
"context"
"errors"
"fmt"
"io"
"log/slog"
"strconv"
"sync"
"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 automaticFleetTestEndpoint struct {
group dnfrepo.Group
mu sync.Mutex
created int
gatewayChecks int
}
func (e *automaticFleetTestEndpoint) RobotGameEndpoint(int) (string, error) {
return "", errors.New("fixture listener is intentionally offline")
}
func (e *automaticFleetTestEndpoint) WaitRobotGameEndpointStable(_ context.Context, channelID int, samples int, interval time.Duration) error {
if channelID != automaticFleetPreferredChannelID || samples != automaticFleetGatewaySamples || interval != automaticFleetGatewayGap {
return fmt.Errorf("unexpected gateway probe channel=%d samples=%d interval=%s", channelID, samples, interval)
}
e.mu.Lock()
e.gatewayChecks++
e.mu.Unlock()
return nil
}
func (e *automaticFleetTestEndpoint) EnsureAutomaticRobotProfile(ctx context.Context, index int) (dnfrobotdomain.Profile, error) {
accountID := fmt.Sprintf("auto-account-%03d", index)
characterID := strconv.Itoa(index)
if _, found, err := e.group.Character.Load(ctx, characterID); err != nil {
return dnfrobotdomain.Profile{}, err
} else if !found {
if err := e.group.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: characterID, AccountID: accountID, Name: fmt.Sprintf("Auto%03d", index), Level: 90,
}); err != nil {
return dnfrobotdomain.Profile{}, err
}
e.mu.Lock()
e.created++
e.mu.Unlock()
}
return dnfrobotdomain.Profile{
ID: fmt.Sprintf("auto-generated-%03d", index), AccountID: accountID,
CharacterID: uint16(index), ChannelID: 19, AutoStart: false,
Village: 38, Area: 1, X: uint16(400 + index), Y: 234,
}, nil
}
func TestManagerAutomaticallyEnsuresFiveHundredPersistentProfilesWithoutDuplicates(t *testing.T) {
runtime := dnfmemory.NewRuntime()
group := runtime.Group()
endpoint := &automaticFleetTestEndpoint{group: group}
provider := func() (dnfrepo.Group, bool) { return group, true }
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
startAndWait := func() *Manager {
manager := NewManager(logger, provider, endpoint, "fixture-secret")
manager.fleetProvisionDelay = 0
if err := manager.Start(context.Background()); err != nil {
t.Fatalf("Start: %v", err)
}
deadline := time.Now().Add(5 * time.Second)
for len(manager.List()) != AutomaticFleetTarget {
if time.Now().After(deadline) {
t.Fatalf("automatic fleet size = %d, want %d", len(manager.List()), AutomaticFleetTarget)
}
time.Sleep(time.Millisecond)
}
return manager
}
first := startAndWait()
policy := first.Automation()
if !policy.Enabled || policy.TargetOnline != AutomaticFleetOnlineTarget || policy.ScaleUpBatch != 2 || policy.StoreConcurrent != 2 ||
policy.StoreMaximumRows != 8 || policy.MegaphoneProfileModulo != 50 || len(policy.ChatMessages) == 0 {
t.Fatalf("default automatic policy = %+v", policy)
}
for _, snapshot := range first.List() {
if snapshot.Profile.AutoStart {
t.Fatalf("automatic snapshot = %+v", snapshot)
}
}
stopCtx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
if err := first.Stop(stopCtx); err != nil {
cancel()
t.Fatalf("first Stop: %v", err)
}
cancel()
second := startAndWait()
stopCtx, cancel = context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()
if err := second.Stop(stopCtx); err != nil {
t.Fatalf("second Stop: %v", err)
}
endpoint.mu.Lock()
created := endpoint.created
gatewayChecks := endpoint.gatewayChecks
endpoint.mu.Unlock()
if created != AutomaticFleetTarget {
t.Fatalf("native characters created = %d, want exactly %d across two starts", created, AutomaticFleetTarget)
}
if gatewayChecks != 2 {
t.Fatalf("gateway stability checks = %d, want one before each manager start", gatewayChecks)
}
config, err := dnfrobotdomain.NewOwner(provider).Load(context.Background())
if err != nil || len(config.Profiles) != AutomaticFleetTarget || config.AutomaticFleetVersion != automaticFleetConfigVersion {
t.Fatalf("persisted automatic config = %d profiles, err=%v", len(config.Profiles), err)
}
}
func TestAutomationPersonalitiesStaggerActionsDeterministically(t *testing.T) {
profileA := dnfrobotdomain.Profile{ID: "auto-a", AccountID: "a", CharacterID: 1}
profileB := dnfrobotdomain.Profile{ID: "auto-b", AccountID: "b", CharacterID: 2}
seedA, seedB := automationBehaviorSeed(profileA), automationBehaviorSeed(profileB)
if seedA == seedB || automationPersonality(seedA) == "" || automationPersonality(seedB) == "" {
t.Fatalf("behavior seeds/personalities are not stable: %d/%s %d/%s", seedA, automationPersonality(seedA), seedB, automationPersonality(seedB))
}
delayA := automationBehaviorDelay(30, 120, 0, seedA, automationPersonality(seedA), automationActionMove)
delayB := automationBehaviorDelay(30, 120, 0, seedB, automationPersonality(seedB), automationActionMove)
if delayA <= 0 || delayB <= 0 || delayA == delayB {
t.Fatalf("staggered delays = %v / %v", delayA, delayB)
}
if repeat := automationBehaviorDelay(30, 120, 0, seedA, automationPersonality(seedA), automationActionMove); repeat != delayA {
t.Fatalf("behavior delay changed across identical decisions: %v / %v", delayA, repeat)
}
}
func TestAutomaticFleetProvisioningOnlyArmsTheScaleLoopOnce(t *testing.T) {
wantNext := time.Unix(1_800_000_000, 0).UTC()
manager := &Manager{automaticFleetReady: true, nextScale: wantNext, fleetProvisionDelay: 250 * time.Millisecond}
if delay := manager.markAutomaticFleetProfileEnsured(); delay != 250*time.Millisecond || !manager.nextScale.Equal(wantNext) {
t.Fatalf("already-ready fleet delay=%s next=%s", delay, manager.nextScale)
}
manager.automaticFleetReady = false
if delay := manager.markAutomaticFleetProfileEnsured(); delay != 250*time.Millisecond || !manager.automaticFleetReady || !manager.nextScale.IsZero() {
t.Fatalf("first-ready fleet delay=%s ready=%t next=%s", delay, manager.automaticFleetReady, manager.nextScale)
}
}
func TestRobotNativeLoginSlotsBoundConcurrentHandshakes(t *testing.T) {
manager := &Manager{connectSlots: make(chan struct{}, 2)}
releaseFirst, err := manager.acquireConnectSlot(context.Background())
if err != nil {
t.Fatalf("first slot: %v", err)
}
releaseSecond, err := manager.acquireConnectSlot(context.Background())
if err != nil {
releaseFirst()
t.Fatalf("second slot: %v", err)
}
blockedCtx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
defer cancel()
if _, err := manager.acquireConnectSlot(blockedCtx); !errors.Is(err, context.DeadlineExceeded) {
releaseSecond()
releaseFirst()
t.Fatalf("third slot err=%v, want deadline", err)
}
releaseFirst()
releaseThird, err := manager.acquireConnectSlot(context.Background())
if err != nil {
releaseSecond()
t.Fatalf("released slot was not reusable: %v", err)
}
releaseThird()
releaseSecond()
if got := len(manager.connectSlots); got != 0 {
t.Fatalf("connect slots leaked: %d", got)
}
}
func TestAutomaticFleetClampsCurrentVersionPersistedTarget(t *testing.T) {
runtime := dnfmemory.NewRuntime()
group := runtime.Group()
provider := func() (dnfrepo.Group, bool) { return group, true }
owner := dnfrobotdomain.NewOwner(provider)
if _, err := owner.Save(context.Background(), dnfrobotdomain.Config{
AutomaticFleetVersion: automaticFleetConfigVersion,
Automation: dnfrobotdomain.AutomationPolicy{
Enabled: true, TargetOnline: AutomaticFleetTarget,
},
}); err != nil {
t.Fatalf("save oversized current-version policy: %v", err)
}
endpoint := &automaticFleetTestEndpoint{group: group}
manager := NewManager(slog.New(slog.NewTextHandler(io.Discard, nil)), provider, endpoint, "fixture-secret")
canceled, cancel := context.WithCancel(context.Background())
cancel()
if err := manager.Start(canceled); err != nil {
t.Fatalf("Start: %v", err)
}
if got := manager.Automation().TargetOnline; got != AutomaticFleetOnlineTarget {
t.Fatalf("live automatic target = %d, want %d", got, AutomaticFleetOnlineTarget)
}
persisted, err := owner.Load(context.Background())
if err != nil || persisted.Automation.TargetOnline != AutomaticFleetOnlineTarget {
t.Fatalf("persisted automatic target = %d, err=%v", persisted.Automation.TargetOnline, err)
}
if err := manager.Stop(context.Background()); err != nil {
t.Fatalf("Stop: %v", err)
}
}
func TestAutomaticFleetSchedulerHardCapsLegacyInMemoryTarget(t *testing.T) {
manager := NewManager(
slog.New(slog.NewTextHandler(io.Discard, nil)), nil,
&automaticFleetTestEndpoint{}, "fixture-secret",
)
manager.config.Automation = dnfrobotdomain.AutomationPolicy{
Enabled: true, TargetOnline: AutomaticFleetTarget,
ScaleIntervalSeconds: 1, ScaleUpBatch: 2, ScaleDownBatch: 2,
}
for index := 1; index <= AutomaticFleetOnlineTarget+1; index++ {
profile := dnfrobotdomain.Profile{
ID: fmt.Sprintf("auto-generated-%03d", index),
AccountID: fmt.Sprintf("account-%03d", index),
CharacterID: uint16(index), ChannelID: 19,
}
manager.config.Profiles = append(manager.config.Profiles, profile)
done := make(chan struct{})
close(done)
manager.running[profileKey(profile.ID)] = &profileRuntime{
profile: profile, done: done, schedulerManaged: true,
}
}
manager.reconcileTargetOnline(time.Unix(1_000, 0).UTC())
status := manager.Status()
if status.Running != AutomaticFleetOnlineTarget || status.SchedulerManaged != AutomaticFleetOnlineTarget {
t.Fatalf("hard-capped automatic status = %+v", status)
}
}
func TestAutomaticFleetSchedulerStopsScalingAtPendingActorLimit(t *testing.T) {
manager := NewManager(
slog.New(slog.NewTextHandler(io.Discard, nil)), nil,
&automaticFleetTestEndpoint{}, "fixture-secret",
)
manager.config.Automation = dnfrobotdomain.AutomationPolicy{
Enabled: true, TargetOnline: AutomaticFleetOnlineTarget,
ScaleIntervalSeconds: 1, ScaleUpBatch: 2, ScaleDownBatch: 2,
}
for index := 1; index <= automaticFleetPendingLimit+1; index++ {
profile := dnfrobotdomain.Profile{
ID: fmt.Sprintf("auto-generated-%03d", index),
AccountID: fmt.Sprintf("account-%03d", index),
CharacterID: uint16(index), ChannelID: 19,
}
manager.config.Profiles = append(manager.config.Profiles, profile)
if index <= automaticFleetPendingLimit {
manager.running[profileKey(profile.ID)] = &profileRuntime{
profile: profile, done: make(chan struct{}), schedulerManaged: true,
}
}
}
manager.reconcileTargetOnline(time.Unix(2_000, 0).UTC())
status := manager.Status()
if status.Running != automaticFleetPendingLimit || status.SchedulerManaged != automaticFleetPendingLimit {
t.Fatalf("pending-capped automatic status = %+v", status)
}
if got := automaticFleetPendingRuntimes(manager.running); got != automaticFleetPendingLimit {
t.Fatalf("pending automatic runtimes = %d, want %d", got, automaticFleetPendingLimit)
}
}
+178
View File
@@ -0,0 +1,178 @@
package dnfrobot
import (
"context"
"errors"
"fmt"
"strings"
)
const (
BatchActionStart = "start"
BatchActionStop = "stop"
BatchActionMove = "move"
BatchActionChat = "chat"
BatchActionStoreOpen = "store_open"
BatchActionStoreClose = "store_close"
)
type BatchRequest struct {
Action string `json:"action"`
IDs []string `json:"ids,omitempty"`
Village uint8 `json:"village,omitempty"`
Area uint8 `json:"area,omitempty"`
X uint16 `json:"x,omitempty"`
Y uint16 `json:"y,omitempty"`
MoveType uint8 `json:"move_type,omitempty"`
ChatMode uint8 `json:"chat_mode,omitempty"`
Message string `json:"message,omitempty"`
StoreTitle string `json:"store_title,omitempty"`
StoreRows int `json:"store_rows,omitempty"`
StorePrice uint32 `json:"store_price,omitempty"`
}
type BatchActionResult struct {
ID string `json:"id"`
OK bool `json:"ok"`
Error string `json:"error,omitempty"`
Snapshot RuntimeSnapshot `json:"snapshot"`
}
type BatchResult struct {
Action string `json:"action"`
Requested int `json:"requested"`
Succeeded int `json:"succeeded"`
Failed int `json:"failed"`
Results []BatchActionResult `json:"results"`
}
type ManagerStatus struct {
Configured int `json:"configured"`
Running int `json:"running"`
Connecting int `json:"connecting"`
TownReady int `json:"town_ready"`
StoreReady int `json:"store_ready"`
Pending int `json:"pending"`
Errors int `json:"errors"`
Reconnects int `json:"reconnects"`
TargetOnline int `json:"target_online"`
SchedulerManaged int `json:"scheduler_managed"`
}
func (m *Manager) Status() ManagerStatus {
status := ManagerStatus{TargetOnline: m.Automation().TargetOnline}
for _, snapshot := range m.List() {
status.Configured++
status.Reconnects += snapshot.Reconnects
if snapshot.Running {
status.Running++
}
if snapshot.SchedulerManaged {
status.SchedulerManaged++
}
switch snapshot.State {
case StateConnecting, StateBinding, StateSelecting:
status.Connecting++
case StateTownReady:
status.TownReady++
case StateStoreReady:
status.StoreReady++
case StateStoreOpening, StateStoreClosing:
status.Pending++
case StateError:
status.Errors++
}
if snapshot.LastError != "" && snapshot.State != StateError {
status.Errors++
}
}
return status
}
func (m *Manager) Batch(ctx context.Context, request BatchRequest) (BatchResult, error) {
if m == nil {
return BatchResult{}, ErrManagerStopped
}
action := strings.ToLower(strings.TrimSpace(request.Action))
switch action {
case BatchActionStart, BatchActionStop, BatchActionMove, BatchActionChat, BatchActionStoreOpen, BatchActionStoreClose:
default:
return BatchResult{}, errors.New("invalid dnf robot batch action")
}
ids := m.batchProfileIDs(request.IDs)
if len(ids) == 0 {
return BatchResult{}, ErrProfileNotFound
}
operation := m.BeginOperation("batch", action)
result := BatchResult{Action: action, Requested: len(ids), Results: make([]BatchActionResult, 0, len(ids))}
for _, id := range ids {
var (
snapshot RuntimeSnapshot
err error
)
switch action {
case BatchActionStart:
snapshot, err = m.StartProfile(id)
case BatchActionStop:
snapshot, err = m.StopProfile(id)
case BatchActionMove:
err = m.Move(id, request.Village, request.Area, request.X, request.Y, request.MoveType)
snapshot = m.snapshotByID(id)
case BatchActionChat:
err = m.Chat(id, request.ChatMode, request.Message)
snapshot = m.snapshotByID(id)
case BatchActionStoreOpen:
err = m.OpenPrivateStore(ctx, id, request.StoreTitle, request.StoreRows, request.StorePrice)
snapshot = m.snapshotByID(id)
case BatchActionStoreClose:
err = m.ClosePrivateStore(id)
snapshot = m.snapshotByID(id)
}
row := BatchActionResult{ID: id, OK: err == nil, Snapshot: snapshot}
if err != nil {
row.Error = err.Error()
result.Failed++
} else {
result.Succeeded++
}
result.Results = append(result.Results, row)
}
m.CompleteOperation(operation.ID, fmt.Sprintf("requested=%d succeeded=%d failed=%d", result.Requested, result.Succeeded, result.Failed), nil)
return result, nil
}
func (m *Manager) batchProfileIDs(requested []string) []string {
if len(requested) == 0 {
m.mu.RLock()
ids := make([]string, 0, len(m.config.Profiles))
for _, profile := range m.config.Profiles {
ids = append(ids, profile.ID)
}
m.mu.RUnlock()
return ids
}
seen := make(map[string]struct{}, len(requested))
ids := make([]string, 0, len(requested))
for _, id := range requested {
id = strings.TrimSpace(id)
key := profileKey(id)
if key == "" {
continue
}
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
ids = append(ids, id)
}
return ids
}
func (m *Manager) snapshotByID(id string) RuntimeSnapshot {
for _, snapshot := range m.List() {
if profileKey(snapshot.Profile.ID) == profileKey(id) {
return snapshot
}
}
return RuntimeSnapshot{}
}
@@ -0,0 +1,49 @@
package dnfrobot
import (
"context"
"testing"
dnfrobotdomain "longheng.io/server/internal/modules/dnf/robot"
)
func TestManagerBatchProfileIDsAreStableAndDeduplicated(t *testing.T) {
manager := &Manager{config: dnfrobotdomain.Config{Profiles: []dnfrobotdomain.Profile{{ID: "Alpha"}, {ID: "beta"}}}}
all := manager.batchProfileIDs(nil)
if len(all) != 2 || all[0] != "Alpha" || all[1] != "beta" {
t.Fatalf("all ids = %v", all)
}
selected := manager.batchProfileIDs([]string{" alpha ", "ALPHA", "beta", ""})
if len(selected) != 2 || selected[0] != "alpha" || selected[1] != "beta" {
t.Fatalf("selected ids = %v", selected)
}
}
func TestManagerBatchRejectsUnknownActionBeforeMutation(t *testing.T) {
manager := &Manager{}
if _, err := manager.Batch(context.Background(), BatchRequest{Action: "invented"}); err == nil {
t.Fatal("Batch accepted an unknown action")
}
}
func TestManagerStatusCountsCurrentProtocolStates(t *testing.T) {
manager := &Manager{
config: dnfrobotdomain.Config{Profiles: []dnfrobotdomain.Profile{{ID: "ready"}, {ID: "store"}, {ID: "offline"}}},
running: map[string]*profileRuntime{
"ready": {profile: dnfrobotdomain.Profile{ID: "ready"}, client: clientWithState(StateTownReady), reconnects: 2},
"store": {profile: dnfrobotdomain.Profile{ID: "store"}, client: clientWithState(StateStoreReady)},
},
}
status := manager.Status()
if status.Configured != 3 || status.Running != 2 || status.TownReady != 1 || status.StoreReady != 1 || status.Reconnects != 2 {
t.Fatalf("status = %+v", status)
}
}
func clientWithState(state State) *Client {
client := NewClient(dnfrobotdomain.Profile{}, "test", []byte("secret"))
client.mu.Lock()
client.state = state
client.mu.Unlock()
return client
}
+664
View File
@@ -0,0 +1,664 @@
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
}
}
@@ -0,0 +1,377 @@
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}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,418 @@
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)
}
}
@@ -0,0 +1,74 @@
package dnfrobot
import (
"strings"
"time"
)
const maximumOperationHistory = 20
type OperationStatus struct {
ID int64 `json:"id"`
Type string `json:"type"`
Scope string `json:"scope,omitempty"`
State string `json:"state"`
Summary string `json:"summary,omitempty"`
Error string `json:"error,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
func (m *Manager) BeginOperation(operationType, scope string) OperationStatus {
if m == nil {
return OperationStatus{}
}
now := time.Now().UTC()
m.historyMu.Lock()
m.nextOperationID++
operation := OperationStatus{
ID: m.nextOperationID, Type: strings.TrimSpace(operationType), Scope: strings.TrimSpace(scope),
State: "running", StartedAt: now, UpdatedAt: now,
}
m.operations = append([]OperationStatus{operation}, m.operations...)
if len(m.operations) > maximumOperationHistory {
m.operations = m.operations[:maximumOperationHistory]
}
m.historyMu.Unlock()
return operation
}
func (m *Manager) CompleteOperation(id int64, summary string, operationErr error) OperationStatus {
if m == nil {
return OperationStatus{}
}
now := time.Now().UTC()
m.historyMu.Lock()
defer m.historyMu.Unlock()
for index := range m.operations {
if m.operations[index].ID != id {
continue
}
m.operations[index].Summary = strings.TrimSpace(summary)
m.operations[index].UpdatedAt = now
m.operations[index].FinishedAt = now
if operationErr != nil {
m.operations[index].State = "failed"
m.operations[index].Error = operationErr.Error()
} else {
m.operations[index].State = "done"
}
return m.operations[index]
}
return OperationStatus{ID: id, State: "unknown", Summary: strings.TrimSpace(summary), UpdatedAt: now}
}
func (m *Manager) Operations() []OperationStatus {
if m == nil {
return nil
}
m.historyMu.Lock()
result := append([]OperationStatus(nil), m.operations...)
m.historyMu.Unlock()
return result
}
@@ -0,0 +1,27 @@
package dnfrobot
import (
"errors"
"fmt"
"testing"
)
func TestManagerOperationHistoryIsBoundedAndFrozen(t *testing.T) {
manager := &Manager{}
for index := 0; index < maximumOperationHistory+3; index++ {
operation := manager.BeginOperation("batch", fmt.Sprintf("scope-%d", index))
if index == 2 {
manager.CompleteOperation(operation.ID, "failed row", errors.New("boom"))
} else {
manager.CompleteOperation(operation.ID, "done", nil)
}
}
history := manager.Operations()
if len(history) != maximumOperationHistory || history[0].Scope != "scope-22" || history[len(history)-1].Scope != "scope-3" {
t.Fatalf("operation history=%+v", history)
}
history[0].Scope = "mutated"
if manager.Operations()[0].Scope == "mutated" {
t.Fatal("operation history leaked mutable storage")
}
}
@@ -0,0 +1,134 @@
package dnfrobot
import (
"crypto/hmac"
"crypto/rand"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"io"
"strings"
"time"
)
const (
robotBindOpcode uint16 = 0xfff0
robotBindVersion = byte(1)
maximumFrameSize = 1024 * 1024
outerHeaderSize = 12
innerHeaderSize = 9
businessHeaderSize = 7
)
type packet struct {
Classification byte
Opcode uint16
Body []byte
}
func buildPacket(opcode, sequence uint16, body []byte) ([]byte, error) {
total := 13 + len(body)
if total > maximumFrameSize {
return nil, fmt.Errorf("dnf robot packet too large: %d", total)
}
result := make([]byte, total)
result[0] = 1
binary.LittleEndian.PutUint16(result[1:3], opcode)
binary.LittleEndian.PutUint32(result[3:7], uint32(total))
binary.LittleEndian.PutUint16(result[11:13], sequence)
copy(result[13:], body)
return result, nil
}
func buildBindBody(secret []byte, now time.Time, characterID uint16, accountID string) ([]byte, error) {
accountID = strings.TrimSpace(accountID)
if len(secret) == 0 || accountID == "" || len(accountID) > 96 || strings.IndexByte(accountID, 0) >= 0 {
return nil, errors.New("invalid dnf robot bind identity")
}
var nonce [8]byte
if _, err := io.ReadFull(rand.Reader, nonce[:]); err != nil {
return nil, err
}
body := make([]byte, 1+8+8+2+1+len(accountID)+sha256.Size)
body[0] = robotBindVersion
binary.LittleEndian.PutUint64(body[1:9], uint64(now.Unix()))
copy(body[9:17], nonce[:])
binary.LittleEndian.PutUint16(body[17:19], characterID)
body[19] = byte(len(accountID))
copy(body[20:], accountID)
mac := hmac.New(sha256.New, secret)
_, _ = mac.Write(body[:20+len(accountID)])
copy(body[20+len(accountID):], mac.Sum(nil))
return body, nil
}
func nextFrame(buffer []byte) (size int, outer bool, ready bool, valid bool) {
if len(buffer) < 4 {
return 0, false, false, true
}
outerSize := int(binary.LittleEndian.Uint16(buffer[2:4]))
if binary.LittleEndian.Uint16(buffer[:2]) == 0 && outerSize >= outerHeaderSize+innerHeaderSize+businessHeaderSize {
if outerSize > maximumFrameSize {
return 0, false, false, false
}
if len(buffer) < outerHeaderSize+innerHeaderSize {
return 0, true, false, true
}
businessSize := int(binary.LittleEndian.Uint16(buffer[17:19]))
if buffer[12] == 2 && businessSize >= businessHeaderSize && outerHeaderSize+innerHeaderSize+businessSize <= outerSize {
return outerSize, true, len(buffer) >= outerSize, true
}
}
if len(buffer) < 7 {
return 0, false, false, true
}
rawSize := int(binary.LittleEndian.Uint32(buffer[3:7]))
if rawSize < 13 || rawSize > maximumFrameSize {
return 0, false, false, false
}
return rawSize, false, len(buffer) >= rawSize, true
}
func decodeFrame(frame []byte, outer bool) ([]packet, error) {
if !outer {
if len(frame) < 13 {
return nil, errors.New("short dnf robot raw frame")
}
headerSize := 13
opcode := binary.LittleEndian.Uint16(frame[1:3])
if opcode == 8 && frame[0] == 0 && len(frame) >= 15 {
headerSize = 15
} else if len(frame) >= 16 && frame[13] == 0 && frame[14] == 0 && frame[15] == 0 {
headerSize = 16
}
return []packet{{Classification: frame[0], Opcode: opcode, Body: append([]byte(nil), frame[headerSize:]...)}}, nil
}
if len(frame) < outerHeaderSize {
return nil, errors.New("short dnf robot outer frame")
}
limit := int(binary.LittleEndian.Uint16(frame[2:4]))
if limit > len(frame) {
return nil, errors.New("invalid dnf robot outer length")
}
result := make([]packet, 0, 2)
for offset := outerHeaderSize; offset+innerHeaderSize <= limit; {
if frame[offset] != 2 {
return nil, errors.New("invalid dnf robot inner frame")
}
businessSize := int(binary.LittleEndian.Uint16(frame[offset+5 : offset+7]))
start := offset + innerHeaderSize
end := start + businessSize
if businessSize < businessHeaderSize || end > limit {
return nil, errors.New("invalid dnf robot business frame")
}
business := frame[start:end]
result = append(result, packet{
Classification: business[0],
Opcode: binary.LittleEndian.Uint16(business[1:3]),
Body: append([]byte(nil), business[7:]...),
})
offset = end
}
return result, nil
}