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

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

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

No files matched your search

@@ -0,0 +1,333 @@
package dnfrobot
import (
"context"
"fmt"
"hash/fnv"
"strings"
"time"
dnfrobotdomain "longheng.io/server/internal/modules/dnf/robot"
)
const (
// AutomaticFleetTarget is deliberately below robot.MaxProfiles so operators
// can still keep manual profiles beside the zero-touch fleet.
AutomaticFleetTarget = 500
// AutomaticFleetOnlineTarget keeps all 500 persistent identities available
// while bounding native game sessions. The recovered robot supervisor used
// a finite actor/pending pool; treating every profile as an independently
// reconnecting actor can starve real character-selection transactions.
AutomaticFleetOnlineTarget = 64
// automaticFleetPendingLimit mirrors the recovered supervisor's bounded
// actor backlog: at most eight batches may be waiting to log in or
// reconnect. With the current two-session native handshake limit this keeps
// repository pressure bounded at sixteen pending automatic runtimes.
automaticFleetPendingLimit = 16
automaticFleetRetry = 30 * time.Second
automaticFleetGatewayRetry = 2 * time.Second
automaticFleetGatewaySamples = 3
automaticFleetGatewayGap = 250 * time.Millisecond
automaticFleetPreferredChannelID = 19
automaticFleetConfigVersion = 7
automaticFleetProfilePrefix = "auto-generated-"
)
const (
automationActionMove = "move"
automationActionChat = "chat"
automationActionStore = "store"
automationActionMegaphone = "megaphone"
)
// AutomaticFleetProvider creates or reuses one native, repository-backed
// character. Implementations must be idempotent for the same index.
type AutomaticFleetProvider interface {
EnsureAutomaticRobotProfile(ctx context.Context, index int) (dnfrobotdomain.Profile, error)
}
// DefaultAutomaticFleetPolicy is the zero-operation policy installed only
// when no prior automation policy exists. The intervals are intentionally
// broad; per-profile personalities further stagger every action.
func DefaultAutomaticFleetPolicy() dnfrobotdomain.AutomationPolicy {
return dnfrobotdomain.AutomationPolicy{
Enabled: true, TargetOnline: AutomaticFleetOnlineTarget,
ScaleIntervalSeconds: 5, ScaleUpBatch: 2, ScaleDownBatch: 2,
MoveIntervalMinSeconds: 12, MoveIntervalMaxSeconds: 45, MoveRadius: 120,
ChatIntervalMinSeconds: 180, ChatIntervalMaxSeconds: 600, ChatMode: 3,
ChatMessages: []string{
"今天也要努力冒险", "有人一起刷图吗", "整理一下背包", "城镇里真热闹",
"看看我的小店吧", "休息一会再出发", "祝大家都出好装备", "准备下一次冒险",
},
MegaphoneIntervalMinSeconds: 900, MegaphoneIntervalMaxSeconds: 1800,
MegaphoneProfileModulo: 50,
StoreIntervalMinSeconds: 90, StoreIntervalMaxSeconds: 300,
StoreDurationSeconds: 120, StoreConcurrent: 2, StoreMaximumRows: 8,
StoreUnitPrice: 10000, StoreTitle: "冒险家商店",
}
}
func (m *Manager) runAutomaticFleet(ctx context.Context) {
defer m.wg.Done()
provider, ok := m.endpoint.(AutomaticFleetProvider)
if !ok {
return
}
if stability, available := m.endpoint.(AutomaticFleetGatewayStability); available {
for {
if err := stability.WaitRobotGameEndpointStable(ctx, automaticFleetPreferredChannelID, automaticFleetGatewaySamples, automaticFleetGatewayGap); err == nil {
break
} else if ctx.Err() != nil {
return
} else {
m.logger.Warn("dnf automatic robot gateway is not stable", "channel", automaticFleetPreferredChannelID, "error", err)
}
timer := time.NewTimer(automaticFleetGatewayRetry)
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
}
}
}
ensured := make(map[int]struct{}, AutomaticFleetTarget)
timer := time.NewTimer(0)
defer timer.Stop()
for {
select {
case <-ctx.Done():
return
case <-timer.C:
}
operation := m.BeginOperation("automatic_fleet", fmt.Sprintf("target=%d", AutomaticFleetTarget))
succeeded, failed := 0, 0
for index := 1; index <= AutomaticFleetTarget; index++ {
if _, ok := ensured[index]; ok {
succeeded++
continue
}
if ctx.Err() != nil {
m.CompleteOperation(operation.ID, fmt.Sprintf("ensured=%d pending=%d", succeeded, AutomaticFleetTarget-succeeded), ctx.Err())
return
}
profile, err := provider.EnsureAutomaticRobotProfile(ctx, index)
if err == nil {
// Fleet profiles are scheduler-owned. Direct autostart would fan out
// hundreds of character-selection transactions at process startup.
profile.AutoStart = false
err = m.installAutomaticProfile(ctx, profile)
}
if err != nil {
failed++
m.logger.Warn("dnf automatic robot ensure deferred", "index", index, "error", err)
// Repository pressure is shared by every following profile. Stop at
// the first failure instead of immediately starting hundreds of
// transactions that are expected to fail for the same reason.
break
}
ensured[index] = struct{}{}
succeeded++
provisionDelay := m.markAutomaticFleetProfileEnsured()
if provisionDelay > 0 && index < AutomaticFleetTarget {
pace := time.NewTimer(provisionDelay)
select {
case <-ctx.Done():
pace.Stop()
m.CompleteOperation(operation.ID, fmt.Sprintf("ensured=%d pending=%d", succeeded, AutomaticFleetTarget-succeeded), ctx.Err())
return
case <-pace.C:
}
}
}
summary := fmt.Sprintf("ensured=%d pending=%d", succeeded, AutomaticFleetTarget-succeeded)
if failed == 0 {
m.CompleteOperation(operation.ID, summary, nil)
return
}
m.CompleteOperation(operation.ID, summary, fmt.Errorf("%d automatic robot profiles are pending", failed))
timer.Reset(automaticFleetRetry)
}
}
func (m *Manager) markAutomaticFleetProfileEnsured() time.Duration {
if m == nil {
return 0
}
m.mu.Lock()
defer m.mu.Unlock()
if !m.automaticFleetReady {
m.automaticFleetReady = true
m.nextScale = time.Time{}
}
return m.fleetProvisionDelay
}
func (m *Manager) installAutomaticProfile(ctx context.Context, profile dnfrobotdomain.Profile) error {
m.mu.RLock()
current, found := findProfile(m.config, profile.ID)
if found && current == profile {
m.mu.RUnlock()
return nil
}
for _, candidate := range m.config.Profiles {
if candidate.CharacterID == profile.CharacterID && profileKey(candidate.ID) != profileKey(profile.ID) {
m.mu.RUnlock()
return fmt.Errorf("automatic character %d is already assigned to profile %q", profile.CharacterID, candidate.ID)
}
}
m.mu.RUnlock()
_, err := m.Upsert(ctx, profile)
return err
}
func automationBehaviorSeed(profile dnfrobotdomain.Profile) uint64 {
hash := fnv.New64a()
_, _ = hash.Write([]byte(strings.ToLower(strings.TrimSpace(profile.ID))))
_, _ = hash.Write([]byte{0})
_, _ = hash.Write([]byte(profile.AccountID))
_, _ = hash.Write([]byte{byte(profile.CharacterID), byte(profile.CharacterID >> 8)})
return hash.Sum64()
}
func automationPersonality(seed uint64) string {
switch seed % 4 {
case 0:
return "merchant"
case 1:
return "wanderer"
case 2:
return "social"
default:
return "balanced"
}
}
func automationBehaviorDelay(minSeconds, maxSeconds, cursor int, seed uint64, personality, action string) time.Duration {
if minSeconds <= 0 {
return 0
}
numerator := 100
switch personality {
case "merchant":
switch action {
case automationActionStore:
numerator = 70
case automationActionMove:
numerator = 140
case automationActionChat:
numerator = 120
}
case "wanderer":
if action == automationActionMove {
numerator = 65
} else if action == automationActionStore {
numerator = 150
}
case "social":
if action == automationActionChat {
numerator = 60
} else if action == automationActionStore {
numerator = 130
}
}
minSeconds = maxInt(1, minSeconds*numerator/100)
maxSeconds = maxInt(minSeconds, maxSeconds*numerator/100)
if seed == 0 {
return automationDelay(minSeconds, maxSeconds, cursor)
}
width := maxSeconds - minSeconds + 1
offset := int(automationMix(seed, uint64(cursor), stringSeed(action)) % uint64(width))
return time.Duration(minSeconds+offset) * time.Second
}
func automationBehaviorPosition(originX, originY, radius uint16, cursor int, seed uint64) (uint16, uint16) {
if seed == 0 {
return automationPosition(originX, originY, radius, cursor)
}
if radius == 0 {
return originX, originY
}
mixed := automationMix(seed, uint64(cursor), 0x6d6f7665)
distance := int(radius)/2 + int(mixed%uint64(maxInt(1, int(radius)/2+1)))
directions := [8][2]int{{1, 0}, {1, 1}, {0, 1}, {-1, 1}, {-1, 0}, {-1, -1}, {0, -1}, {1, -1}}
direction := directions[(mixed>>8)&7]
x := clampAutomationCoordinate(int(originX) + direction[0]*distance)
y := clampAutomationCoordinate(int(originY) + direction[1]*distance)
return uint16(x), uint16(y)
}
func automationBehaviorPositionForProfile(profile dnfrobotdomain.Profile, radius uint16, cursor int, seed uint64) (uint16, uint16) {
x, y := automationBehaviorPosition(profile.X, profile.Y, radius, cursor, seed)
if !profile.MoveBoundsSet {
return x, y
}
return clampAutomationRange(x, profile.MoveMinX, profile.MoveMaxX), clampAutomationRange(y, profile.MoveMinY, profile.MoveMaxY)
}
func clampAutomationRange(value, minimum, maximum uint16) uint16 {
if value < minimum {
return minimum
}
if value > maximum {
return maximum
}
return value
}
func automationMessageIndex(cursor int, seed uint64, count int) int {
if count <= 0 {
return 0
}
if seed == 0 {
return cursor % count
}
return int(automationMix(seed, uint64(cursor), 0x63686174) % uint64(count))
}
func automationStorePrice(base uint32, seed uint64, cursor int) uint32 {
if base == 0 || seed == 0 {
return base
}
percent := uint64(85) + automationMix(seed, uint64(cursor), 0x7072696365)%31
price := uint64(base) * percent / 100
if price == 0 {
return 1
}
if price > uint64(^uint32(0)) {
return ^uint32(0)
}
return uint32(price)
}
func automationMix(seed, cursor, salt uint64) uint64 {
value := seed ^ salt ^ (cursor+1)*0x9e3779b97f4a7c15
value ^= value >> 30
value *= 0xbf58476d1ce4e5b9
value ^= value >> 27
value *= 0x94d049bb133111eb
return value ^ (value >> 31)
}
func stringSeed(value string) uint64 {
hash := fnv.New64a()
_, _ = hash.Write([]byte(value))
return hash.Sum64()
}
func clampAutomationCoordinate(value int) int {
if value < 0 {
return 0
}
if value > 65535 {
return 65535
}
return value
}
func maxInt(a, b int) int {
if a > b {
return a
}
return b
}