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