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,322 @@
|
||||
// Package robot owns the durable configuration of server-managed DNF robots.
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
SettingsScope = "dnf_robot_manager"
|
||||
settingsVersionKey = "version"
|
||||
settingsConfigKey = "config"
|
||||
settingsVersion = "1"
|
||||
MaxProfiles = 1024
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnavailable = errors.New("dnf robot settings repository is unavailable")
|
||||
ErrInvalid = errors.New("dnf robot configuration is invalid")
|
||||
)
|
||||
|
||||
// Profile identifies one real character that is driven through the current
|
||||
// game protocol. Secrets are process configuration and are never persisted.
|
||||
type Profile struct {
|
||||
ID string `json:"id"`
|
||||
AccountID string `json:"account_id"`
|
||||
CharacterID uint16 `json:"character_id"`
|
||||
ChannelID int `json:"channel_id"`
|
||||
AutoStart bool `json:"auto_start"`
|
||||
Village uint8 `json:"village"`
|
||||
Area uint8 `json:"area"`
|
||||
X uint16 `json:"x"`
|
||||
Y uint16 `json:"y"`
|
||||
MoveType uint8 `json:"move_type,omitempty"`
|
||||
MoveBoundsSet bool `json:"move_bounds_set,omitempty"`
|
||||
MoveMinX uint16 `json:"move_min_x,omitempty"`
|
||||
MoveMaxX uint16 `json:"move_max_x,omitempty"`
|
||||
MoveMinY uint16 `json:"move_min_y,omitempty"`
|
||||
MoveMaxY uint16 `json:"move_max_y,omitempty"`
|
||||
AutomaticStoreDisabled bool `json:"automatic_store_disabled,omitempty"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Profiles []Profile `json:"profiles"`
|
||||
Automation AutomationPolicy `json:"automation,omitempty"`
|
||||
AutomaticFleetVersion int `json:"automatic_fleet_version,omitempty"`
|
||||
}
|
||||
|
||||
// AutomationPolicy is the bounded town-only behavior policy recovered from
|
||||
// the 2026-08-25 robot runtime. Private stores use the current client's
|
||||
// op85/op87 acknowledgement chain; dungeon actions remain disabled until their
|
||||
// current-client request/ack contracts are complete.
|
||||
type AutomationPolicy struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
TargetOnline int `json:"target_online,omitempty"`
|
||||
ScaleIntervalSeconds int `json:"scale_interval_seconds,omitempty"`
|
||||
ScaleUpBatch int `json:"scale_up_batch,omitempty"`
|
||||
ScaleDownBatch int `json:"scale_down_batch,omitempty"`
|
||||
MoveIntervalMinSeconds int `json:"move_interval_min_seconds,omitempty"`
|
||||
MoveIntervalMaxSeconds int `json:"move_interval_max_seconds,omitempty"`
|
||||
MoveRadius uint16 `json:"move_radius,omitempty"`
|
||||
ChatIntervalMinSeconds int `json:"chat_interval_min_seconds,omitempty"`
|
||||
ChatIntervalMaxSeconds int `json:"chat_interval_max_seconds,omitempty"`
|
||||
ChatMode uint8 `json:"chat_mode,omitempty"`
|
||||
ChatMessages []string `json:"chat_messages,omitempty"`
|
||||
MegaphoneIntervalMinSeconds int `json:"megaphone_interval_min_seconds,omitempty"`
|
||||
MegaphoneIntervalMaxSeconds int `json:"megaphone_interval_max_seconds,omitempty"`
|
||||
MegaphoneProfileModulo int `json:"megaphone_profile_modulo,omitempty"`
|
||||
StoreIntervalMinSeconds int `json:"store_interval_min_seconds,omitempty"`
|
||||
StoreIntervalMaxSeconds int `json:"store_interval_max_seconds,omitempty"`
|
||||
StoreDurationSeconds int `json:"store_duration_seconds,omitempty"`
|
||||
StoreConcurrent int `json:"store_concurrent,omitempty"`
|
||||
StoreMaximumRows int `json:"store_maximum_rows,omitempty"`
|
||||
StoreUnitPrice uint32 `json:"store_unit_price,omitempty"`
|
||||
StoreTitle string `json:"store_title,omitempty"`
|
||||
}
|
||||
|
||||
type Owner struct {
|
||||
provider func() (dnfrepo.Group, bool)
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewOwner(provider func() (dnfrepo.Group, bool)) *Owner {
|
||||
return &Owner{provider: provider, now: time.Now}
|
||||
}
|
||||
|
||||
func (o *Owner) Load(ctx context.Context) (Config, error) {
|
||||
repo, err := o.settings()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
record, ok, err := repo.Load(ctx, SettingsScope)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if !ok {
|
||||
return Config{}, nil
|
||||
}
|
||||
if version := strings.TrimSpace(record.Values[settingsVersionKey]); version != "" && version != settingsVersion {
|
||||
return Config{}, fmt.Errorf("%w: unsupported version %q", ErrInvalid, version)
|
||||
}
|
||||
raw := strings.TrimSpace(record.Values[settingsConfigKey])
|
||||
if raw == "" {
|
||||
return Config{}, nil
|
||||
}
|
||||
var config Config
|
||||
if err := json.Unmarshal([]byte(raw), &config); err != nil {
|
||||
return Config{}, fmt.Errorf("%w: decode: %v", ErrInvalid, err)
|
||||
}
|
||||
return Normalize(config)
|
||||
}
|
||||
|
||||
func (o *Owner) Save(ctx context.Context, config Config) (Config, error) {
|
||||
normalized, err := Normalize(config)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
repo, err := o.settings()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
raw, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if o != nil && o.now != nil {
|
||||
now = o.now().UTC()
|
||||
}
|
||||
record := dnfrepo.SettingsRecord{
|
||||
Scope: SettingsScope,
|
||||
Values: map[string]string{
|
||||
settingsVersionKey: settingsVersion,
|
||||
settingsConfigKey: string(raw),
|
||||
},
|
||||
UpdatedAt: now,
|
||||
}
|
||||
if err := repo.Save(ctx, record); err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return CloneConfig(normalized), nil
|
||||
}
|
||||
|
||||
func Normalize(config Config) (Config, error) {
|
||||
if len(config.Profiles) > MaxProfiles {
|
||||
return Config{}, fmt.Errorf("%w: profile count %d exceeds %d", ErrInvalid, len(config.Profiles), MaxProfiles)
|
||||
}
|
||||
automation, err := normalizeAutomation(config.Automation)
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
if config.AutomaticFleetVersion < 0 || config.AutomaticFleetVersion > 7 {
|
||||
return Config{}, fmt.Errorf("%w: unsupported automatic fleet version %d", ErrInvalid, config.AutomaticFleetVersion)
|
||||
}
|
||||
result := Config{
|
||||
Profiles: make([]Profile, 0, len(config.Profiles)), Automation: automation,
|
||||
AutomaticFleetVersion: config.AutomaticFleetVersion,
|
||||
}
|
||||
seenIDs := make(map[string]struct{}, len(config.Profiles))
|
||||
seenCharacters := make(map[uint16]struct{}, len(config.Profiles))
|
||||
for _, profile := range config.Profiles {
|
||||
profile.ID = strings.TrimSpace(profile.ID)
|
||||
profile.AccountID = strings.TrimSpace(profile.AccountID)
|
||||
if profile.ID == "" || len(profile.ID) > 64 || strings.IndexByte(profile.ID, 0) >= 0 {
|
||||
return Config{}, fmt.Errorf("%w: invalid profile id", ErrInvalid)
|
||||
}
|
||||
if profile.AccountID == "" || len(profile.AccountID) > 96 || strings.IndexByte(profile.AccountID, 0) >= 0 {
|
||||
return Config{}, fmt.Errorf("%w: profile %q has invalid account", ErrInvalid, profile.ID)
|
||||
}
|
||||
if profile.CharacterID == 0 || profile.ChannelID <= 0 || profile.ChannelID > 65535 {
|
||||
return Config{}, fmt.Errorf("%w: profile %q has invalid character/channel", ErrInvalid, profile.ID)
|
||||
}
|
||||
if profile.MoveBoundsSet {
|
||||
if profile.MoveMinX > profile.MoveMaxX || profile.MoveMinY > profile.MoveMaxY ||
|
||||
profile.X < profile.MoveMinX || profile.X > profile.MoveMaxX ||
|
||||
profile.Y < profile.MoveMinY || profile.Y > profile.MoveMaxY {
|
||||
return Config{}, fmt.Errorf("%w: profile %q has invalid movement bounds", ErrInvalid, profile.ID)
|
||||
}
|
||||
} else if profile.MoveMinX != 0 || profile.MoveMaxX != 0 || profile.MoveMinY != 0 || profile.MoveMaxY != 0 {
|
||||
return Config{}, fmt.Errorf("%w: profile %q has movement bounds without ownership", ErrInvalid, profile.ID)
|
||||
}
|
||||
key := strings.ToLower(profile.ID)
|
||||
if _, ok := seenIDs[key]; ok {
|
||||
return Config{}, fmt.Errorf("%w: duplicate profile id %q", ErrInvalid, profile.ID)
|
||||
}
|
||||
if _, ok := seenCharacters[profile.CharacterID]; ok {
|
||||
return Config{}, fmt.Errorf("%w: duplicate character %d", ErrInvalid, profile.CharacterID)
|
||||
}
|
||||
seenIDs[key] = struct{}{}
|
||||
seenCharacters[profile.CharacterID] = struct{}{}
|
||||
result.Profiles = append(result.Profiles, profile)
|
||||
}
|
||||
sort.SliceStable(result.Profiles, func(i, j int) bool {
|
||||
return strings.ToLower(result.Profiles[i].ID) < strings.ToLower(result.Profiles[j].ID)
|
||||
})
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func CloneConfig(config Config) Config {
|
||||
result := Config{
|
||||
Profiles: append([]Profile(nil), config.Profiles...), Automation: config.Automation,
|
||||
AutomaticFleetVersion: config.AutomaticFleetVersion,
|
||||
}
|
||||
result.Automation.ChatMessages = append([]string(nil), config.Automation.ChatMessages...)
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizeAutomation(policy AutomationPolicy) (AutomationPolicy, error) {
|
||||
if policy.TargetOnline < 0 || policy.TargetOnline > MaxProfiles {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: automation target online exceeds %d", ErrInvalid, MaxProfiles)
|
||||
}
|
||||
if policy.TargetOnline > 0 {
|
||||
if policy.ScaleIntervalSeconds == 0 {
|
||||
policy.ScaleIntervalSeconds = 5
|
||||
}
|
||||
if policy.ScaleUpBatch == 0 {
|
||||
policy.ScaleUpBatch = 2
|
||||
}
|
||||
if policy.ScaleDownBatch == 0 {
|
||||
policy.ScaleDownBatch = 2
|
||||
}
|
||||
if policy.ScaleIntervalSeconds < 1 || policy.ScaleIntervalSeconds > 300 ||
|
||||
policy.ScaleUpBatch < 1 || policy.ScaleUpBatch > 64 || policy.ScaleDownBatch < 1 || policy.ScaleDownBatch > 64 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: invalid automation scaling policy", ErrInvalid)
|
||||
}
|
||||
}
|
||||
if policy.MoveIntervalMinSeconds < 0 || policy.MoveIntervalMaxSeconds < 0 ||
|
||||
policy.ChatIntervalMinSeconds < 0 || policy.ChatIntervalMaxSeconds < 0 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: automation intervals must not be negative", ErrInvalid)
|
||||
}
|
||||
if (policy.MoveIntervalMinSeconds == 0) != (policy.MoveIntervalMaxSeconds == 0) ||
|
||||
policy.MoveIntervalMinSeconds > policy.MoveIntervalMaxSeconds || policy.MoveIntervalMaxSeconds > 3600 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: invalid automation move interval", ErrInvalid)
|
||||
}
|
||||
if policy.MoveIntervalMinSeconds > 0 {
|
||||
if policy.MoveRadius == 0 {
|
||||
policy.MoveRadius = 80
|
||||
}
|
||||
if policy.MoveRadius > 2000 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: automation move radius exceeds 2000", ErrInvalid)
|
||||
}
|
||||
}
|
||||
if (policy.ChatIntervalMinSeconds == 0) != (policy.ChatIntervalMaxSeconds == 0) ||
|
||||
policy.ChatIntervalMinSeconds > policy.ChatIntervalMaxSeconds || policy.ChatIntervalMaxSeconds > 86400 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: invalid automation chat interval", ErrInvalid)
|
||||
}
|
||||
if policy.ChatMode == 0 {
|
||||
policy.ChatMode = 3
|
||||
}
|
||||
if len(policy.ChatMessages) > 64 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: automation chat message count exceeds 64", ErrInvalid)
|
||||
}
|
||||
messages := make([]string, 0, len(policy.ChatMessages))
|
||||
for _, message := range policy.ChatMessages {
|
||||
message = strings.TrimSpace(message)
|
||||
if message == "" || len(message) > 512 || strings.IndexByte(message, 0) >= 0 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: invalid automation chat message", ErrInvalid)
|
||||
}
|
||||
messages = append(messages, message)
|
||||
}
|
||||
policy.ChatMessages = messages
|
||||
if policy.ChatIntervalMinSeconds > 0 && len(messages) == 0 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: automation chat interval requires messages", ErrInvalid)
|
||||
}
|
||||
if policy.MegaphoneIntervalMinSeconds < 0 || policy.MegaphoneIntervalMaxSeconds < 0 || policy.MegaphoneProfileModulo < 0 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: automation megaphone values must not be negative", ErrInvalid)
|
||||
}
|
||||
if (policy.MegaphoneIntervalMinSeconds == 0) != (policy.MegaphoneIntervalMaxSeconds == 0) ||
|
||||
policy.MegaphoneIntervalMinSeconds > policy.MegaphoneIntervalMaxSeconds || policy.MegaphoneIntervalMaxSeconds > 86400 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: invalid automation megaphone interval", ErrInvalid)
|
||||
}
|
||||
if policy.MegaphoneIntervalMinSeconds > 0 {
|
||||
if len(messages) == 0 || policy.MegaphoneProfileModulo < 1 || policy.MegaphoneProfileModulo > MaxProfiles {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: invalid automation megaphone policy", ErrInvalid)
|
||||
}
|
||||
}
|
||||
if policy.StoreIntervalMinSeconds < 0 || policy.StoreIntervalMaxSeconds < 0 || policy.StoreDurationSeconds < 0 || policy.StoreConcurrent < 0 || policy.StoreMaximumRows < 0 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: automation store values must not be negative", ErrInvalid)
|
||||
}
|
||||
if (policy.StoreIntervalMinSeconds == 0) != (policy.StoreIntervalMaxSeconds == 0) ||
|
||||
policy.StoreIntervalMinSeconds > policy.StoreIntervalMaxSeconds || policy.StoreIntervalMaxSeconds > 86400 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: invalid automation store interval", ErrInvalid)
|
||||
}
|
||||
policy.StoreTitle = strings.TrimSpace(policy.StoreTitle)
|
||||
if policy.StoreIntervalMinSeconds > 0 {
|
||||
if policy.StoreDurationSeconds <= 0 || policy.StoreDurationSeconds > 86400 || policy.StoreUnitPrice == 0 ||
|
||||
policy.StoreTitle == "" || len(policy.StoreTitle) > 80 || strings.IndexByte(policy.StoreTitle, 0) >= 0 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: invalid automation private store", ErrInvalid)
|
||||
}
|
||||
if policy.StoreConcurrent == 0 {
|
||||
policy.StoreConcurrent = 1
|
||||
}
|
||||
if policy.StoreConcurrent > 64 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: automation store concurrency exceeds 64", ErrInvalid)
|
||||
}
|
||||
if policy.StoreMaximumRows == 0 {
|
||||
policy.StoreMaximumRows = 8
|
||||
}
|
||||
if policy.StoreMaximumRows > 14 {
|
||||
return AutomationPolicy{}, fmt.Errorf("%w: automation store rows exceed 14", ErrInvalid)
|
||||
}
|
||||
}
|
||||
return policy, nil
|
||||
}
|
||||
|
||||
func (o *Owner) settings() (dnfrepo.SettingsRepository, error) {
|
||||
if o == nil || o.provider == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
group, ok := o.provider()
|
||||
if !ok || group.Settings == nil {
|
||||
return nil, ErrUnavailable
|
||||
}
|
||||
return group.Settings, nil
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
package robot
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
type settingsFixture struct {
|
||||
record dnfrepo.SettingsRecord
|
||||
has bool
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *settingsFixture) Check(context.Context) error { return nil }
|
||||
func (f *settingsFixture) Load(_ context.Context, key string) (dnfrepo.SettingsRecord, bool, error) {
|
||||
if key != SettingsScope {
|
||||
return dnfrepo.SettingsRecord{}, false, errors.New("unexpected scope")
|
||||
}
|
||||
return dnfrepo.CloneSettings(f.record), f.has, f.err
|
||||
}
|
||||
func (f *settingsFixture) Save(_ context.Context, record dnfrepo.SettingsRecord) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.record, f.has = dnfrepo.CloneSettings(record), true
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestOwnerRoundTripsNormalizedProfiles(t *testing.T) {
|
||||
repo := &settingsFixture{}
|
||||
owner := NewOwner(func() (dnfrepo.Group, bool) {
|
||||
return dnfrepo.Group{Settings: repo}, true
|
||||
})
|
||||
owner.now = func() time.Time { return time.Unix(123, 0) }
|
||||
want := Config{Profiles: []Profile{
|
||||
{ID: " beta ", AccountID: " account-b ", CharacterID: 2, ChannelID: 19},
|
||||
{ID: "alpha", AccountID: "account-a", CharacterID: 1, ChannelID: 6, AutoStart: true, X: 20, Y: 30},
|
||||
}}
|
||||
saved, err := owner.Save(context.Background(), want)
|
||||
if err != nil {
|
||||
t.Fatalf("Save: %v", err)
|
||||
}
|
||||
if len(saved.Profiles) != 2 || saved.Profiles[0].ID != "alpha" || saved.Profiles[1].ID != "beta" {
|
||||
t.Fatalf("unexpected normalized profiles: %+v", saved.Profiles)
|
||||
}
|
||||
loaded, err := owner.Load(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
if len(loaded.Profiles) != 2 || loaded.Profiles[0].AccountID != "account-a" || loaded.Profiles[1].AccountID != "account-b" {
|
||||
t.Fatalf("unexpected loaded profiles: %+v", loaded.Profiles)
|
||||
}
|
||||
saved.Profiles[0].AccountID = "mutated"
|
||||
if loaded.Profiles[0].AccountID != "account-a" {
|
||||
t.Fatal("owner result leaked mutable profile storage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRejectsConflictingCharacterOwnership(t *testing.T) {
|
||||
_, err := Normalize(Config{Profiles: []Profile{
|
||||
{ID: "a", AccountID: "one", CharacterID: 7, ChannelID: 19},
|
||||
{ID: "b", AccountID: "two", CharacterID: 7, ChannelID: 19},
|
||||
}})
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("Normalize error = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAutomationPolicyAndCloneMessages(t *testing.T) {
|
||||
config, err := Normalize(Config{
|
||||
Profiles: []Profile{{ID: "robot", AccountID: "account", CharacterID: 1, ChannelID: 19}},
|
||||
Automation: AutomationPolicy{
|
||||
Enabled: true, MoveIntervalMinSeconds: 5, MoveIntervalMaxSeconds: 10,
|
||||
ChatIntervalMinSeconds: 30, ChatIntervalMaxSeconds: 60,
|
||||
ChatMessages: []string{" hello ", "world"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize: %v", err)
|
||||
}
|
||||
if config.Automation.MoveRadius != 80 || config.Automation.ChatMode != 3 || config.Automation.ChatMessages[0] != "hello" {
|
||||
t.Fatalf("unexpected normalized automation: %+v", config.Automation)
|
||||
}
|
||||
clone := CloneConfig(config)
|
||||
clone.Automation.ChatMessages[0] = "mutated"
|
||||
if config.Automation.ChatMessages[0] != "hello" {
|
||||
t.Fatal("CloneConfig leaked automation message storage")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizePreservesSupportedAutomaticFleetVersion(t *testing.T) {
|
||||
config, err := Normalize(Config{AutomaticFleetVersion: 7})
|
||||
if err != nil || config.AutomaticFleetVersion != 7 || CloneConfig(config).AutomaticFleetVersion != 7 {
|
||||
t.Fatalf("automatic fleet version config=%+v err=%v", config, err)
|
||||
}
|
||||
if _, err := Normalize(Config{AutomaticFleetVersion: 8}); err == nil {
|
||||
t.Fatal("expected unsupported automatic fleet version rejection")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeValidatesProfileMovementBounds(t *testing.T) {
|
||||
profile := Profile{
|
||||
ID: "bounded", AccountID: "account", CharacterID: 1, ChannelID: 19,
|
||||
X: 500, Y: 200, MoveBoundsSet: true, MoveMinX: 400, MoveMaxX: 600, MoveMinY: 190, MoveMaxY: 210,
|
||||
}
|
||||
if _, err := Normalize(Config{Profiles: []Profile{profile}}); err != nil {
|
||||
t.Fatalf("Normalize valid bounds: %v", err)
|
||||
}
|
||||
profile.X = 700
|
||||
if _, err := Normalize(Config{Profiles: []Profile{profile}}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("Normalize outside bounds error = %v, want ErrInvalid", err)
|
||||
}
|
||||
profile = Profile{ID: "orphan", AccountID: "account", CharacterID: 2, ChannelID: 19, MoveMaxX: 1}
|
||||
if _, err := Normalize(Config{Profiles: []Profile{profile}}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("Normalize orphan bounds error = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAutomationMegaphone(t *testing.T) {
|
||||
config, err := Normalize(Config{Automation: AutomationPolicy{
|
||||
ChatMessages: []string{"hello"},
|
||||
MegaphoneIntervalMinSeconds: 900, MegaphoneIntervalMaxSeconds: 1800,
|
||||
MegaphoneProfileModulo: 50,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize megaphone: %v", err)
|
||||
}
|
||||
if config.Automation.MegaphoneProfileModulo != 50 {
|
||||
t.Fatalf("megaphone policy = %+v", config.Automation)
|
||||
}
|
||||
if _, err := Normalize(Config{Automation: AutomationPolicy{
|
||||
MegaphoneIntervalMinSeconds: 1, MegaphoneIntervalMaxSeconds: 2, MegaphoneProfileModulo: 1,
|
||||
}}); !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("megaphone without messages error = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeRejectsPartialAutomationIntervals(t *testing.T) {
|
||||
_, err := Normalize(Config{Automation: AutomationPolicy{MoveIntervalMinSeconds: 5}})
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("Normalize error = %v, want ErrInvalid", err)
|
||||
}
|
||||
_, err = Normalize(Config{Automation: AutomationPolicy{
|
||||
ChatIntervalMinSeconds: 5, ChatIntervalMaxSeconds: 5,
|
||||
}})
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("Normalize error = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAutomationPrivateStore(t *testing.T) {
|
||||
config, err := Normalize(Config{Automation: AutomationPolicy{
|
||||
Enabled: true, StoreIntervalMinSeconds: 30, StoreIntervalMaxSeconds: 60,
|
||||
StoreDurationSeconds: 120, StoreUnitPrice: 10000, StoreTitle: " robot shop ",
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize: %v", err)
|
||||
}
|
||||
if config.Automation.StoreConcurrent != 1 || config.Automation.StoreMaximumRows != 8 || config.Automation.StoreTitle != "robot shop" {
|
||||
t.Fatalf("store automation = %+v", config.Automation)
|
||||
}
|
||||
_, err = Normalize(Config{Automation: AutomationPolicy{
|
||||
StoreIntervalMinSeconds: 30, StoreIntervalMaxSeconds: 60,
|
||||
StoreDurationSeconds: 120, StoreTitle: "missing price",
|
||||
}})
|
||||
if !errors.Is(err, ErrInvalid) {
|
||||
t.Fatalf("Normalize missing price error = %v, want ErrInvalid", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeAutomationScaling(t *testing.T) {
|
||||
config, err := Normalize(Config{Automation: AutomationPolicy{Enabled: true, TargetOnline: 12}})
|
||||
if err != nil {
|
||||
t.Fatalf("Normalize scaling: %v", err)
|
||||
}
|
||||
policy := config.Automation
|
||||
if policy.ScaleIntervalSeconds != 5 || policy.ScaleUpBatch != 2 || policy.ScaleDownBatch != 2 {
|
||||
t.Fatalf("scaling defaults = %+v", policy)
|
||||
}
|
||||
if _, err := Normalize(Config{Automation: AutomationPolicy{TargetOnline: MaxProfiles + 1}}); err == nil {
|
||||
t.Fatal("oversized scaling target unexpectedly accepted")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user