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

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

323 lines
13 KiB
Go

// 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
}