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