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