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,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)
}
}