DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,852 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"hash/fnv"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
"longheng.io/server/internal/modules/dnf/jobmap"
|
||||
"longheng.io/server/internal/modules/dnf/premium"
|
||||
dnfquest "longheng.io/server/internal/modules/dnf/quest"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrobotdomain "longheng.io/server/internal/modules/dnf/robot"
|
||||
dnftown "longheng.io/server/internal/modules/dnf/town"
|
||||
"longheng.io/server/internal/services/dnfrobot"
|
||||
)
|
||||
|
||||
const (
|
||||
automaticRobotProfilePrefix = "auto-generated"
|
||||
automaticRobotAccountPrefix = "dnf-auto-robot"
|
||||
automaticRobotPreferredChannelID = 19
|
||||
automaticRobotTownID = 38
|
||||
automaticRobotTownAreaID = 0
|
||||
// The recovered 20260825 robot service provisions a durable type-8 premium
|
||||
// row before it ever attempts op85/op87. Current automatic accounts need the
|
||||
// same actor permission: op78 with permissionSlot=ffff means "reuse the
|
||||
// already projected companion", not "create a companion without a
|
||||
// contract". Renew only after expiry so restarts do not extend the row.
|
||||
automaticRobotStoreContractSeconds = int64(10 * 365 * 24 * 60 * 60)
|
||||
// The planner requires cutoff < eligibility level. This virtual level is
|
||||
// never persisted; it lets level-cap robots close PVF main quests whose
|
||||
// minimum level is exactly 90 while their durable character level stays 90.
|
||||
automaticRobotStoryEligibilityLevel = currentAdventureCharacterLevelCap + 1
|
||||
)
|
||||
|
||||
var automaticRobotNamePrefixes = [...]string{
|
||||
"晨星", "月影", "风语", "霜华", "赤焰", "青岚", "星尘", "流光",
|
||||
"云歌", "夜航", "银羽", "苍穹", "花火", "山岚", "晴空", "远星",
|
||||
}
|
||||
|
||||
var automaticRobotNameSuffixes = [...]string{
|
||||
"剑客", "旅人", "游侠", "勇者", "行者", "守望", "逐风", "寻梦",
|
||||
"猎手", "星使", "漫游", "冒险", "先锋", "旅团", "巡游", "歌者",
|
||||
}
|
||||
|
||||
type automaticRobotTownPlacement struct {
|
||||
Village uint8
|
||||
Area uint8
|
||||
X uint16
|
||||
Y uint16
|
||||
MinX uint16
|
||||
MaxX uint16
|
||||
MinY uint16
|
||||
MaxY uint16
|
||||
MapPath string
|
||||
DungeonGate bool
|
||||
StoreArea bool
|
||||
}
|
||||
|
||||
// EnsureAutomaticRobotProfile is the zero-touch fleet composition boundary.
|
||||
// Every index owns a dedicated account and deterministic identity, so a restart
|
||||
// reuses the same native character instead of manufacturing another hundred.
|
||||
func (s *Service) EnsureAutomaticRobotProfile(ctx context.Context, index int) (dnfrobotdomain.Profile, error) {
|
||||
if s == nil || index < 1 || index > dnfrobot.AutomaticFleetTarget {
|
||||
return dnfrobotdomain.Profile{}, fmt.Errorf("%w: invalid automatic robot index", ErrGMRequestInvalid)
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
accountID := fmt.Sprintf("%s-%03d", automaticRobotAccountPrefix, index)
|
||||
profileID := fmt.Sprintf("%s-%03d", automaticRobotProfilePrefix, index)
|
||||
seed := automaticRobotSeed(index)
|
||||
placement := s.automaticRobotTownPlacement(index)
|
||||
channels, err := s.automaticRobotChannelIDs()
|
||||
if err != nil {
|
||||
return dnfrobotdomain.Profile{}, err
|
||||
}
|
||||
channelID := channels[int(seed%uint64(len(channels)))]
|
||||
|
||||
repositories, ok := s.repositoryGroup()
|
||||
if !ok || repositories.Character == nil {
|
||||
return dnfrobotdomain.Profile{}, ErrGMRepositoryUnavailable
|
||||
}
|
||||
records, err := repositories.Character.ListByAccount(ctx, accountID, dnfrepo.MaxCharacterSlotLimit)
|
||||
if err != nil {
|
||||
return dnfrobotdomain.Profile{}, err
|
||||
}
|
||||
for _, record := range records {
|
||||
if record.Stats != nil && record.Stats["delete_flag"] != 0 {
|
||||
continue
|
||||
}
|
||||
record, err = s.ensureAutomaticRobotTownReady(ctx, repositories, record, placement)
|
||||
if err != nil {
|
||||
return dnfrobotdomain.Profile{}, fmt.Errorf("prepare automatic robot %d existing character %s: %w", index, record.CharacterID, err)
|
||||
}
|
||||
if profile, ok := automaticRobotProfileFromRecord(profileID, accountID, channelID, byte((seed>>40)&1), placement, record); ok {
|
||||
return profile, nil
|
||||
}
|
||||
}
|
||||
|
||||
// The current fitting-room PVF has complete sets for jobs 0..14. Job 15
|
||||
// exists in character.lst but has no authored eight-part set in this client,
|
||||
// so zero-touch creation stays inside the complete appearance pool.
|
||||
job := int((seed >> 8) % uint64(jobmap.MaxID))
|
||||
level := currentAdventureCharacterLevelCap
|
||||
var lastErr error
|
||||
for attempt := 0; attempt < 16; attempt++ {
|
||||
name := automaticRobotName(index, attempt, seed)
|
||||
if _, exists, findErr := repositories.Character.FindIDByName(ctx, name); findErr != nil {
|
||||
return dnfrobotdomain.Profile{}, findErr
|
||||
} else if exists {
|
||||
continue
|
||||
}
|
||||
result, provisionErr := s.GMProvisionRobotCharacter(ctx, GMRobotCharacterProvisionRequest{
|
||||
AccountID: accountID, Name: name, Job: job, GrowType: 0, Level: level,
|
||||
Village: int(placement.Village), Area: int(placement.Area), X: int(placement.X), Y: int(placement.Y),
|
||||
})
|
||||
if provisionErr == nil {
|
||||
created, found, loadErr := repositories.Character.Load(ctx, strconv.Itoa(int(result.CharacterID)))
|
||||
if loadErr != nil {
|
||||
return dnfrobotdomain.Profile{}, loadErr
|
||||
}
|
||||
if !found {
|
||||
return dnfrobotdomain.Profile{}, ErrGMRepositoryUnavailable
|
||||
}
|
||||
created, loadErr = s.ensureAutomaticRobotTownReady(ctx, repositories, created, placement)
|
||||
if loadErr != nil {
|
||||
return dnfrobotdomain.Profile{}, fmt.Errorf("prepare automatic robot %d created character %s: %w", index, created.CharacterID, loadErr)
|
||||
}
|
||||
profile, valid := automaticRobotProfileFromRecord(profileID, accountID, channelID, byte((seed>>40)&1), placement, created)
|
||||
if !valid {
|
||||
return dnfrobotdomain.Profile{}, fmt.Errorf("%w: automatic robot %d created profile projection", ErrGMRequestInvalid, index)
|
||||
}
|
||||
return profile, nil
|
||||
}
|
||||
lastErr = provisionErr
|
||||
if !errors.Is(provisionErr, ErrGMRequestInvalid) {
|
||||
break
|
||||
}
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = fmt.Errorf("%w: no unique automatic robot name is available", ErrGMRequestInvalid)
|
||||
}
|
||||
return dnfrobotdomain.Profile{}, lastErr
|
||||
}
|
||||
|
||||
func (s *Service) ensureAutomaticRobotTownReady(ctx context.Context, repositories dnfrepo.Group, record dnfrepo.CharacterRecord, placement automaticRobotTownPlacement) (dnfrepo.CharacterRecord, error) {
|
||||
if record.Level < currentAdventureCharacterLevelCap {
|
||||
if _, err := s.GMSetCharacterLevel(ctx, record.CharacterID, currentAdventureCharacterLevelCap); err != nil {
|
||||
return dnfrepo.CharacterRecord{}, err
|
||||
}
|
||||
current, found, err := repositories.Character.Load(ctx, record.CharacterID)
|
||||
if err != nil {
|
||||
return dnfrepo.CharacterRecord{}, err
|
||||
}
|
||||
if !found || current.AccountID != record.AccountID {
|
||||
return dnfrepo.CharacterRecord{}, fmt.Errorf("%w: automatic robot level migration identity", ErrGMRequestInvalid)
|
||||
}
|
||||
record = current
|
||||
}
|
||||
job, validJob := characterJobByte(record)
|
||||
if !validJob {
|
||||
return dnfrepo.CharacterRecord{}, fmt.Errorf("%w: automatic robot job %q", ErrGMRequestInvalid, record.Job)
|
||||
}
|
||||
avatarSet, err := s.automaticRobotAvatarSet(job, uint64(numericCharacterID(record)))
|
||||
if err != nil {
|
||||
return dnfrepo.CharacterRecord{}, err
|
||||
}
|
||||
appearanceReady := automaticRobotAvatarSetReady(dnfrepo.EquipmentRecord{}, false, avatarSet)
|
||||
if !appearanceReady {
|
||||
if repositories.Equipment == nil {
|
||||
return dnfrepo.CharacterRecord{}, ErrGMRepositoryUnavailable
|
||||
}
|
||||
equipment, found, equipmentErr := repositories.Equipment.Load(ctx, record.CharacterID)
|
||||
if equipmentErr != nil {
|
||||
return dnfrepo.CharacterRecord{}, equipmentErr
|
||||
}
|
||||
appearanceReady = automaticRobotAvatarSetReady(equipment, found, avatarSet)
|
||||
}
|
||||
storePermissionReady := false
|
||||
if repositories.Account != nil {
|
||||
account, found, accountErr := repositories.Account.Load(ctx, strings.TrimSpace(record.AccountID))
|
||||
if accountErr != nil {
|
||||
return dnfrepo.CharacterRecord{}, accountErr
|
||||
}
|
||||
storePermissionReady = found && premium.Active(account, premium.TypeAuction, time.Now().UTC()) &&
|
||||
premium.Active(account, premium.TypePrivateStore, time.Now().UTC())
|
||||
}
|
||||
if record.Stats != nil && record.Stats[currentDungeonTutorialCompletedKey] == currentDungeonTutorialCompleteFlag &&
|
||||
record.Stats["tutorial_reward_progress_38"] == 1 &&
|
||||
record.Stats[dnfrepo.CharacterStoryDigestLastLevelStatKey] >= int64(currentAdventureCharacterLevelCap) &&
|
||||
record.Stats[dnfrepo.CharacterStoryDigestMigrationVersionStatKey] >= int64(dnfrepo.CurrentCharacterStoryDigestMigrationVersion) &&
|
||||
appearanceReady &&
|
||||
record.Stats["ex_equip_slot_stat"]&gmAllEquipSlotsMask == gmAllEquipSlotsMask &&
|
||||
record.Stats["town_id"] == int64(placement.Village) &&
|
||||
record.Stats["area_id"] == int64(placement.Area) &&
|
||||
record.Stats["pos_x"] == int64(placement.X) &&
|
||||
record.Stats["pos_y"] == int64(placement.Y) &&
|
||||
storePermissionReady {
|
||||
return record, nil
|
||||
}
|
||||
if repositories.CharacterSettlement == nil {
|
||||
return dnfrepo.CharacterRecord{}, ErrGMRepositoryUnavailable
|
||||
}
|
||||
catalog, err := s.loadQuestCatalog(ctx)
|
||||
if err != nil {
|
||||
return dnfrepo.CharacterRecord{}, err
|
||||
}
|
||||
characterID, accountID := record.CharacterID, record.AccountID
|
||||
var updated dnfrepo.CharacterRecord
|
||||
err = repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
current, found, loadErr := tx.Character.Load(ctx, characterID)
|
||||
if loadErr != nil {
|
||||
return loadErr
|
||||
}
|
||||
if !found || current.AccountID != accountID || current.Level != currentAdventureCharacterLevelCap {
|
||||
return fmt.Errorf("%w: automatic robot baseline found=%t account_match=%t level=%d", ErrGMRequestInvalid, found, current.AccountID == accountID, current.Level)
|
||||
}
|
||||
if tx.Account == nil {
|
||||
return ErrGMRepositoryUnavailable
|
||||
}
|
||||
account, accountFound, accountErr := tx.Account.Load(ctx, accountID)
|
||||
if accountErr != nil {
|
||||
return accountErr
|
||||
}
|
||||
if !accountFound || strings.TrimSpace(account.AccountID) != accountID {
|
||||
return fmt.Errorf("%w: automatic robot account identity", ErrGMRequestInvalid)
|
||||
}
|
||||
if !premium.Active(account, premium.TypeAuction, time.Now().UTC()) ||
|
||||
!premium.Active(account, premium.TypePrivateStore, time.Now().UTC()) {
|
||||
account = dnfrepo.CloneAccount(account)
|
||||
now := time.Now().UTC()
|
||||
premium.Upsert(&account, premium.TypeAuction, automaticRobotStoreContractSeconds, 1, now)
|
||||
for _, premiumType := range premium.ActivationTypes(premium.TypeAuction) {
|
||||
key := premium.MetadataKey(premiumType)
|
||||
if saveErr := dnfrepo.SaveAccountMetadataEntry(ctx, tx.Account, account, key, account.Metadata[key], now); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
}
|
||||
current = dnfrepo.CloneCharacter(current)
|
||||
if current.Stats == nil {
|
||||
current.Stats = make(map[string]int64)
|
||||
}
|
||||
if current.Stats[dnfrepo.CharacterStoryDigestLastLevelStatKey] < int64(currentAdventureCharacterLevelCap) {
|
||||
currentJob, currentValidJob := characterJobByte(current)
|
||||
if !currentValidJob || currentJob != job {
|
||||
return fmt.Errorf("%w: automatic robot job %q", ErrGMRequestInvalid, current.Job)
|
||||
}
|
||||
quests, questFound, questErr := tx.Quest.Load(ctx, characterID)
|
||||
if questErr != nil {
|
||||
return questErr
|
||||
}
|
||||
if !questFound {
|
||||
quests = dnfrepo.QuestRecord{CharacterID: characterID}
|
||||
}
|
||||
plan, planErr := catalog.PlanAutoCompleteMainQuests(quests, dnfquest.CharacterEligibility{
|
||||
Level: automaticRobotStoryEligibilityLevel, Job: int(currentJob),
|
||||
GrowType: int(numericCharacterStatValue(current, "grow_type")),
|
||||
}, currentAdventureCharacterLevelCap, 0, time.Now().UTC())
|
||||
// A robot profile is filled in without a class change or an
|
||||
// awakening run, so the bulk pass may report "nothing eligible",
|
||||
// "class change first" or "awakening quest first". All three mean
|
||||
// the story digest simply stays where it is.
|
||||
if planErr != nil &&
|
||||
!errors.Is(planErr, dnfquest.ErrAutoCompleteTargetInvalid) &&
|
||||
!errors.Is(planErr, dnfquest.ErrAutoCompleteClassRequired) &&
|
||||
!errors.Is(planErr, dnfquest.ErrAutoCompleteAwakeningRequired) {
|
||||
return planErr
|
||||
}
|
||||
plan.Record.CharacterID = characterID
|
||||
if planErr == nil && len(plan.ChangedFields) > 0 {
|
||||
if saveErr := dnfrepo.SaveQuestFields(ctx, tx.Quest, plan.Record, plan.ChangedFields...); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
}
|
||||
current.Stats[currentDungeonTutorialCompletedKey] = currentDungeonTutorialCompleteFlag
|
||||
current.Stats["tutorial_reward_progress_38"] = 1
|
||||
current.Stats[dnfrepo.CharacterStoryDigestLastLevelStatKey] = int64(currentAdventureCharacterLevelCap)
|
||||
current.Stats[dnfrepo.CharacterStoryDigestMigrationVersionStatKey] = int64(dnfrepo.CurrentCharacterStoryDigestMigrationVersion)
|
||||
if !appearanceReady {
|
||||
if tx.Equipment == nil {
|
||||
return ErrGMRepositoryUnavailable
|
||||
}
|
||||
equipment, equipmentFound, equipmentErr := tx.Equipment.Load(ctx, characterID)
|
||||
if equipmentErr != nil {
|
||||
return equipmentErr
|
||||
}
|
||||
if !automaticRobotAvatarSetReady(equipment, equipmentFound, avatarSet) {
|
||||
if !equipmentFound {
|
||||
equipment = dnfrepo.EquipmentRecord{CharacterID: characterID}
|
||||
} else {
|
||||
equipment = dnfrepo.CloneEquipment(equipment)
|
||||
}
|
||||
if equipment.Entries == nil {
|
||||
equipment.Entries = make(map[string]dnfrepo.EquipmentEntry)
|
||||
}
|
||||
for index, itemID := range avatarSet {
|
||||
if itemID == 0 {
|
||||
continue
|
||||
}
|
||||
slot := currentAvatarFittingRoomEquipmentSlots[index]
|
||||
equipment.Entries[strconv.Itoa(int(slot))] = dnfrepo.EquipmentEntry{
|
||||
SlotIndex: slot,
|
||||
ItemID: int64(itemID),
|
||||
RawEntry: buildInitialEquipmentRawEntry(slot, int64(itemID), 0),
|
||||
Extra: map[string]string{
|
||||
"source": "automatic_robot_avatar_set",
|
||||
"avatar_set_part": currentAvatarFittingRoomPartNames[index],
|
||||
},
|
||||
}
|
||||
}
|
||||
equipment.UpdatedAt = time.Now().UTC()
|
||||
if saveErr := dnfrepo.SaveEquipmentFields(ctx, tx.Equipment, equipment, dnfrepo.EquipmentFieldEntries); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
}
|
||||
}
|
||||
current.Stats["ex_equip_slot_stat"] |= gmAllEquipSlotsMask
|
||||
current.Stats["town_id"] = int64(placement.Village)
|
||||
current.Stats["area_id"] = int64(placement.Area)
|
||||
current.Stats["pos_x"] = int64(placement.X)
|
||||
current.Stats["pos_y"] = int64(placement.Y)
|
||||
current.UpdatedAt = time.Now().UTC()
|
||||
if saveErr := dnfrepo.SaveCharacterFields(ctx, tx.Character, current, dnfrepo.CharacterFieldStats); saveErr != nil {
|
||||
return saveErr
|
||||
}
|
||||
updated = current
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return dnfrepo.CharacterRecord{}, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// automaticRobotAvatarSet selects one complete PVF-authored eight-part set for
|
||||
// the robot's native job. The choice is deterministic per character so a
|
||||
// restart or a failed transaction never rolls another appearance. Each item is
|
||||
// resolved through the active item catalog and must map back to the exact
|
||||
// current-client avatar slot expected by the fitting-room table.
|
||||
func (s *Service) automaticRobotAvatarSet(job byte, seed uint64) ([currentAvatarFittingRoomPartCount]uint32, error) {
|
||||
config, err := s.automaticRobotAvatarCatalog()
|
||||
if err != nil {
|
||||
return [currentAvatarFittingRoomPartCount]uint32{}, err
|
||||
}
|
||||
candidates := make([][currentAvatarFittingRoomPartCount]uint32, 0, len(config.Sets))
|
||||
for _, set := range config.Sets {
|
||||
items, ok := set.Jobs[job]
|
||||
if ok {
|
||||
candidates = append(candidates, items)
|
||||
}
|
||||
}
|
||||
if len(candidates) == 0 {
|
||||
// Existing installations can already contain a job introduced after the
|
||||
// current fitting-room table. Keep its native creation appearance and
|
||||
// finish the remaining robot migration instead of blocking the fleet.
|
||||
return [currentAvatarFittingRoomPartCount]uint32{}, nil
|
||||
}
|
||||
selected := candidates[int(automaticRobotMix(seed, 0x6176617461727365)%uint64(len(candidates)))]
|
||||
catalog, err := s.currentPVFItemCatalog()
|
||||
if err != nil {
|
||||
return [currentAvatarFittingRoomPartCount]uint32{}, err
|
||||
}
|
||||
for index, itemID := range selected {
|
||||
definition, resolveErr := catalog.ResolveItem(itemID)
|
||||
if resolveErr != nil {
|
||||
return [currentAvatarFittingRoomPartCount]uint32{}, resolveErr
|
||||
}
|
||||
rule, ok := currentEquipmentPlacementRuleForPVFType(definition.EquipmentType)
|
||||
if definition.Kind != dungeonDropItemEquipment || !ok || rule.class != currentEquipmentPlacementClassAvatar || rule.targetSlot != currentAvatarFittingRoomEquipmentSlots[index] {
|
||||
return [currentAvatarFittingRoomPartCount]uint32{}, fmt.Errorf(
|
||||
"%w: automatic robot job=%d part=%s item=%d type=%q target=%d",
|
||||
errCurrentAvatarFittingRoomUnavailable,
|
||||
job,
|
||||
currentAvatarFittingRoomPartNames[index],
|
||||
itemID,
|
||||
definition.EquipmentType,
|
||||
rule.targetSlot,
|
||||
)
|
||||
}
|
||||
}
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func automaticRobotAvatarSetReady(equipment dnfrepo.EquipmentRecord, found bool, set [currentAvatarFittingRoomPartCount]uint32) bool {
|
||||
required := 0
|
||||
for index, itemID := range set {
|
||||
if itemID == 0 {
|
||||
continue
|
||||
}
|
||||
required++
|
||||
if !found || equipment.Entries[strconv.Itoa(int(currentAvatarFittingRoomEquipmentSlots[index]))].ItemID != int64(itemID) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return required == 0 || found
|
||||
}
|
||||
|
||||
func (s *Service) automaticRobotAvatarCatalog() (currentAvatarFittingRoomConfig, error) {
|
||||
if s == nil {
|
||||
return currentAvatarFittingRoomConfig{}, errCurrentAvatarFittingRoomUnavailable
|
||||
}
|
||||
s.automaticRobotAppearanceMu.Lock()
|
||||
defer s.automaticRobotAppearanceMu.Unlock()
|
||||
if s.automaticRobotAvatarConfig != nil {
|
||||
return *s.automaticRobotAvatarConfig, nil
|
||||
}
|
||||
if s.automaticRobotAvatarLoadErr != nil {
|
||||
return currentAvatarFittingRoomConfig{}, s.automaticRobotAvatarLoadErr
|
||||
}
|
||||
catalog, err := s.currentPVFItemCatalog()
|
||||
if err == nil {
|
||||
var config currentAvatarFittingRoomConfig
|
||||
config, err = loadCurrentAvatarFittingRoomConfig(catalog.source)
|
||||
if err == nil {
|
||||
s.automaticRobotAvatarConfig = &config
|
||||
return config, nil
|
||||
}
|
||||
}
|
||||
s.automaticRobotAvatarLoadErr = err
|
||||
return currentAvatarFittingRoomConfig{}, err
|
||||
}
|
||||
|
||||
// EnsureRobotPrivateStoreStock keeps autonomous stores useful without letting
|
||||
// the robot client invent item ids. It fills only missing rows from real,
|
||||
// currently tradeable PVF stackables and commits through GMStockRobotCharacter.
|
||||
func (s *Service) EnsureRobotPrivateStoreStock(ctx context.Context, accountID string, characterID uint16, maximumRows int) error {
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if s == nil || accountID == "" || characterID == 0 || maximumRows <= 0 || maximumRows > currentPrivateStoreMaxRows {
|
||||
return fmt.Errorf("%w: invalid automatic robot stock request", ErrGMRequestInvalid)
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
existingRows := 0
|
||||
rows, err := s.RobotPrivateStoreRows(ctx, characterID, maximumRows, 1)
|
||||
if err == nil {
|
||||
existingRows = len(rows)
|
||||
if existingRows >= maximumRows {
|
||||
return nil
|
||||
}
|
||||
} else if !errors.Is(err, errRobotPrivateStoreInventoryEmpty) {
|
||||
return err
|
||||
}
|
||||
|
||||
repositories, ok := s.repositoryGroup()
|
||||
if !ok || repositories.Inventory == nil {
|
||||
return ErrGMRepositoryUnavailable
|
||||
}
|
||||
inventory, found, err := repositories.Inventory.Load(ctx, strconv.Itoa(int(characterID)))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrGMRepositoryUnavailable
|
||||
}
|
||||
excluded := make(map[uint32]struct{}, len(inventory.Slots))
|
||||
for _, stack := range inventory.Slots {
|
||||
if stack.ItemID > 0 && uint64(stack.ItemID) <= uint64(^uint32(0)) {
|
||||
excluded[uint32(stack.ItemID)] = struct{}{}
|
||||
}
|
||||
}
|
||||
items, err := s.automaticRobotStockItems(uint64(characterID), maximumRows-existingRows, excluded)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.GMStockRobotCharacter(ctx, GMRobotStockRequest{
|
||||
AccountID: accountID, CharacterID: characterID, Items: items,
|
||||
})
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *Service) automaticRobotStockItems(seed uint64, count int, excluded map[uint32]struct{}) ([]GMRobotStockItem, error) {
|
||||
if count <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
catalog, err := s.currentPVFItemCatalog()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
catalog.mu.Lock()
|
||||
ids := append([]uint32(nil), catalog.stackableIDs...)
|
||||
catalog.mu.Unlock()
|
||||
if len(ids) == 0 {
|
||||
return nil, fmt.Errorf("%w: automatic robot stock catalog is empty", ErrGMRequestInvalid)
|
||||
}
|
||||
start := int(automaticRobotMix(seed, 0x73746f636b) % uint64(len(ids)))
|
||||
items := make([]GMRobotStockItem, 0, count)
|
||||
for offset := 0; offset < len(ids) && len(items) < count; offset++ {
|
||||
itemID := ids[(start+offset)%len(ids)]
|
||||
if _, exists := excluded[itemID]; exists {
|
||||
continue
|
||||
}
|
||||
definition, resolveErr := catalog.ResolveItem(itemID)
|
||||
if resolveErr != nil || definition.Kind != dungeonDropItemStackable ||
|
||||
!currentDungeonDiscardTradeable(definition) || definition.StackLimit <= 0 ||
|
||||
!definition.ExpirationDate.IsZero() || definition.UsablePeriodDays > 0 {
|
||||
continue
|
||||
}
|
||||
limit := uint64(definition.StackLimit)
|
||||
amount := uint64(5) + automaticRobotMix(seed, uint64(itemID))%46
|
||||
if amount > limit {
|
||||
amount = limit
|
||||
}
|
||||
if amount == 0 || amount > uint64(^uint32(0)) {
|
||||
continue
|
||||
}
|
||||
items = append(items, GMRobotStockItem{ItemID: itemID, Count: uint32(amount)})
|
||||
excluded[itemID] = struct{}{}
|
||||
}
|
||||
if len(items) == 0 {
|
||||
return nil, fmt.Errorf("%w: no tradeable automatic robot stock candidate", ErrGMRequestInvalid)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (s *Service) automaticRobotChannelIDs() ([]int, error) {
|
||||
s.mu.Lock()
|
||||
catalog := s.catalog
|
||||
s.mu.Unlock()
|
||||
if catalog == nil {
|
||||
return nil, errors.New("dnf automatic robot channel catalog is not ready")
|
||||
}
|
||||
// The shipped local profile exposes channel 19 as its ordinary public town
|
||||
// entrance even though the legacy ETC type byte is 11. Prefer that concrete
|
||||
// deployment channel so autonomous residents are visible to local players
|
||||
// instead of being distributed across unrelated compatibility entrances.
|
||||
if preferred, ok := catalog.Channel(automaticRobotPreferredChannelID); ok && preferred.Port > 0 {
|
||||
return []int{preferred.ID}, nil
|
||||
}
|
||||
channels := catalog.Channels()
|
||||
ids := make([]int, 0, len(channels))
|
||||
for _, channel := range channels {
|
||||
group := strings.ToLower(strings.Trim(strings.TrimSpace(channel.Group), "[]`"))
|
||||
// The current town actor and resident-channel projections carry the
|
||||
// channel id in one byte. Higher ids are valid catalog entrances for
|
||||
// special modes, but cannot own an autonomous town resident.
|
||||
if channel.ID <= 0 || channel.ID > 0xff || channel.Port <= 0 || channel.Type == 2 || channel.Type == 3 ||
|
||||
channel.Type == dnfenum.DeathTowerChannelType || channel.Type == dnfenum.TradeChannelType ||
|
||||
channel.Type == dnfenum.RaidChannelType || channel.Type == dnfenum.HiddenRaidType ||
|
||||
group == dnfenum.GroupCrack || group == dnfenum.GroupDeathTower || group == dnfenum.GroupTrade ||
|
||||
group == dnfenum.GroupRaid || group == dnfenum.GroupAttackRaid {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, channel.ID)
|
||||
}
|
||||
if len(ids) == 0 && len(channels) > 0 {
|
||||
if resident, ok := catalog.ResidentFor(channels[0]); ok {
|
||||
ids = append(ids, resident.ID)
|
||||
}
|
||||
}
|
||||
sort.Ints(ids)
|
||||
if len(ids) == 0 {
|
||||
return nil, errors.New("dnf automatic robot has no ordinary town channel")
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func automaticRobotPublicTownPosition(identity string) (int, int) {
|
||||
parsed, _ := strconv.ParseUint(strings.TrimSpace(identity), 10, 32)
|
||||
if parsed == 0 {
|
||||
parsed = automaticRobotSeed(len(identity) + 1)
|
||||
}
|
||||
return 520 + int(parsed*37%1200), 225 + int(parsed*17%75)
|
||||
}
|
||||
|
||||
func (s *Service) automaticRobotTownPlacement(index int) automaticRobotTownPlacement {
|
||||
fallbackX, fallbackY := automaticRobotPublicTownPosition(strconv.Itoa(index))
|
||||
fallback := automaticRobotTownPlacement{
|
||||
Village: automaticRobotTownID, Area: automaticRobotTownAreaID,
|
||||
X: uint16(fallbackX), Y: uint16(fallbackY),
|
||||
MinX: uint16(maxInt(0, fallbackX-48)), MaxX: uint16(minInt(0xffff, fallbackX+48)),
|
||||
MinY: uint16(maxInt(0, fallbackY-6)), MaxY: uint16(minInt(0xffff, fallbackY+6)),
|
||||
MapPath: "fallback", StoreArea: true,
|
||||
}
|
||||
if s == nil {
|
||||
return fallback
|
||||
}
|
||||
s.townCatalogMu.RLock()
|
||||
table := s.townCatalog
|
||||
s.townCatalogMu.RUnlock()
|
||||
if table == nil {
|
||||
return fallback
|
||||
}
|
||||
placements := automaticRobotPublicTownPlacements(table.Towns())
|
||||
if len(placements) == 0 {
|
||||
return fallback
|
||||
}
|
||||
base := placements[(index-1)%len(placements)]
|
||||
ordinal := (index - 1) / len(placements)
|
||||
seed := automaticRobotSeed(index)
|
||||
base.X = automaticRobotCoordinateInRange(base.MinX, base.MaxX, uint64(ordinal)*53+(seed>>8))
|
||||
base.Y = automaticRobotCoordinateInRange(base.MinY, base.MaxY, uint64(ordinal)*7+(seed>>24))
|
||||
return base
|
||||
}
|
||||
|
||||
func automaticRobotCoordinateInRange(minimum, maximum uint16, salt uint64) uint16 {
|
||||
if maximum <= minimum {
|
||||
return minimum
|
||||
}
|
||||
return minimum + uint16(salt%uint64(uint32(maximum)-uint32(minimum)+1))
|
||||
}
|
||||
|
||||
func automaticRobotNPCCorridor(spawns []dnftown.NPCSpawn) (uint16, uint16, uint16, uint16, bool) {
|
||||
valid := make([]dnftown.NPCSpawn, 0, len(spawns))
|
||||
for _, spawn := range spawns {
|
||||
if spawn.X < 0 || spawn.X > 0xffff || spawn.Y < 0 || spawn.Y > 0xffff {
|
||||
continue
|
||||
}
|
||||
valid = append(valid, spawn)
|
||||
}
|
||||
if len(valid) == 0 {
|
||||
return 0, 0, 0, 0, false
|
||||
}
|
||||
sort.Slice(valid, func(i, j int) bool {
|
||||
if valid[i].Y != valid[j].Y {
|
||||
return valid[i].Y < valid[j].Y
|
||||
}
|
||||
return valid[i].X < valid[j].X
|
||||
})
|
||||
const floorTolerance = int64(20)
|
||||
clusters := make([][]dnftown.NPCSpawn, 0, len(valid))
|
||||
for _, spawn := range valid {
|
||||
if len(clusters) == 0 || spawn.Y-clusters[len(clusters)-1][0].Y > floorTolerance {
|
||||
clusters = append(clusters, []dnftown.NPCSpawn{spawn})
|
||||
continue
|
||||
}
|
||||
clusters[len(clusters)-1] = append(clusters[len(clusters)-1], spawn)
|
||||
}
|
||||
best := clusters[0]
|
||||
for _, candidate := range clusters[1:] {
|
||||
bestWidth := automaticRobotNPCClusterWidth(best)
|
||||
candidateWidth := automaticRobotNPCClusterWidth(candidate)
|
||||
if len(candidate) > len(best) || (len(candidate) == len(best) && candidateWidth > bestWidth) {
|
||||
best = candidate
|
||||
}
|
||||
}
|
||||
minX, maxX, totalY := best[0].X, best[0].X, int64(0)
|
||||
for _, spawn := range best {
|
||||
if spawn.X < minX {
|
||||
minX = spawn.X
|
||||
}
|
||||
if spawn.X > maxX {
|
||||
maxX = spawn.X
|
||||
}
|
||||
totalY += spawn.Y
|
||||
}
|
||||
centerY := totalY / int64(len(best))
|
||||
if maxX-minX >= 96 {
|
||||
minX += 24
|
||||
maxX -= 24
|
||||
} else {
|
||||
centerX := (minX + maxX) / 2
|
||||
minX = maxInt64(0, centerX-48)
|
||||
maxX = minInt64(0xffff, centerX+48)
|
||||
}
|
||||
minY := maxInt64(0, centerY-6)
|
||||
maxY := minInt64(0xffff, centerY+6)
|
||||
return uint16(minX), uint16(maxX), uint16(minY), uint16(maxY), true
|
||||
}
|
||||
|
||||
func automaticRobotNPCClusterWidth(spawns []dnftown.NPCSpawn) int64 {
|
||||
if len(spawns) == 0 {
|
||||
return 0
|
||||
}
|
||||
minimum, maximum := spawns[0].X, spawns[0].X
|
||||
for _, spawn := range spawns[1:] {
|
||||
if spawn.X < minimum {
|
||||
minimum = spawn.X
|
||||
}
|
||||
if spawn.X > maximum {
|
||||
maximum = spawn.X
|
||||
}
|
||||
}
|
||||
return maximum - minimum
|
||||
}
|
||||
|
||||
func minInt64(left, right int64) int64 {
|
||||
if left < right {
|
||||
return left
|
||||
}
|
||||
return right
|
||||
}
|
||||
|
||||
func automaticRobotPublicTownPlacements(towns []dnftown.Town) []automaticRobotTownPlacement {
|
||||
// Keep the autonomous population on current public hubs. For every hub we
|
||||
// retain its first ordinary plaza plus every declared dungeon entrance;
|
||||
// historical/event/mirror/private towns would otherwise dilute 500 robots
|
||||
// over more than one hundred barely visited area records.
|
||||
publicTowns := map[int64]struct{}{
|
||||
6: {}, 9: {}, 11: {}, 12: {}, 13: {}, 14: {}, 17: {}, 20: {}, 22: {},
|
||||
30: {}, 31: {}, 38: {}, 39: {}, 40: {}, 41: {}, 42: {}, 43: {},
|
||||
}
|
||||
excludedPaths := []string{"seria", "guildagit", "fair_pvp", "twin_raid", "fiendwar", "return_town", "death_tower", "tournament", "/test", "test/"}
|
||||
seenMaps := make(map[string]struct{})
|
||||
normalTownSelected := make(map[int64]bool)
|
||||
placements := make([]automaticRobotTownPlacement, 0, 128)
|
||||
for _, town := range towns {
|
||||
if town.ID < 0 || town.ID > 0xff {
|
||||
continue
|
||||
}
|
||||
if _, public := publicTowns[town.ID]; !public {
|
||||
continue
|
||||
}
|
||||
for _, area := range town.Areas {
|
||||
if area.ID < 0 || area.ID > 0xff || area.MinLevel > currentAdventureCharacterLevelCap ||
|
||||
(area.Kind != "normal" && area.Kind != "dungeon_gate") || len(area.NPCs) == 0 {
|
||||
continue
|
||||
}
|
||||
path := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(area.MapPath), "\\", "/"))
|
||||
if path == "" {
|
||||
continue
|
||||
}
|
||||
blocked := false
|
||||
for _, marker := range excludedPaths {
|
||||
if strings.Contains(path, marker) {
|
||||
blocked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if blocked {
|
||||
continue
|
||||
}
|
||||
if area.Kind == "normal" && normalTownSelected[town.ID] {
|
||||
continue
|
||||
}
|
||||
if _, duplicated := seenMaps[path]; duplicated {
|
||||
continue
|
||||
}
|
||||
minX, maxX, minY, maxY, ok := automaticRobotNPCCorridor(area.NPCs)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
seenMaps[path] = struct{}{}
|
||||
if area.Kind == "normal" {
|
||||
normalTownSelected[town.ID] = true
|
||||
}
|
||||
placements = append(placements, automaticRobotTownPlacement{
|
||||
Village: uint8(town.ID), Area: uint8(area.ID),
|
||||
X: automaticRobotCoordinateInRange(minX, maxX, uint64(town.ID)<<8|uint64(area.ID)),
|
||||
Y: automaticRobotCoordinateInRange(minY, maxY, uint64(town.ID)<<16|uint64(area.ID)),
|
||||
MinX: minX, MaxX: maxX, MinY: minY, MaxY: maxY, MapPath: path,
|
||||
DungeonGate: area.Kind == "dungeon_gate",
|
||||
// The current PVF marks the large Hendon Myre and West Coast
|
||||
// plazas as dungeon gates because they also own entrances. The
|
||||
// recovered robot service used an explicit store-area whitelist;
|
||||
// retain that semantic by allowing ordinary plazas and large
|
||||
// multi-NPC composite hubs while excluding small entrance maps.
|
||||
StoreArea: area.Kind == "normal" || len(area.NPCs) >= 5,
|
||||
})
|
||||
}
|
||||
}
|
||||
sort.Slice(placements, func(i, j int) bool {
|
||||
if placements[i].Village != placements[j].Village {
|
||||
return placements[i].Village < placements[j].Village
|
||||
}
|
||||
if placements[i].Area != placements[j].Area {
|
||||
return placements[i].Area < placements[j].Area
|
||||
}
|
||||
return placements[i].MapPath < placements[j].MapPath
|
||||
})
|
||||
return placements
|
||||
}
|
||||
|
||||
// currentAutomaticRobotPublicTownAreaAllowed limits the authenticated robot
|
||||
// bootstrap shortcut to the exact public-town catalog used by the zero-touch
|
||||
// scheduler. Real players must continue to prove cross-town travel through the
|
||||
// current PVF portal topology; this exception only lets a private HMAC-bound
|
||||
// robot materialize at its preassigned plaza or dungeon entrance after login.
|
||||
func (s *Service) currentAutomaticRobotPublicTownAreaAllowed(townID, areaID byte) bool {
|
||||
if s == nil {
|
||||
return false
|
||||
}
|
||||
s.townCatalogMu.RLock()
|
||||
table := s.townCatalog
|
||||
s.townCatalogMu.RUnlock()
|
||||
if table == nil {
|
||||
return false
|
||||
}
|
||||
for _, placement := range automaticRobotPublicTownPlacements(table.Towns()) {
|
||||
if placement.Village == townID && placement.Area == areaID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func automaticRobotProfileFromRecord(profileID, accountID string, channelID int, moveType byte, placement automaticRobotTownPlacement, record dnfrepo.CharacterRecord) (dnfrobotdomain.Profile, bool) {
|
||||
characterID, err := strconv.ParseUint(strings.TrimSpace(record.CharacterID), 10, 16)
|
||||
if err != nil || characterID == 0 || record.AccountID != accountID {
|
||||
return dnfrobotdomain.Profile{}, false
|
||||
}
|
||||
village := int64(automaticRobotTownID)
|
||||
area := int64(automaticRobotTownAreaID)
|
||||
defaultX, defaultY := automaticRobotPublicTownPosition(record.CharacterID)
|
||||
x := int64(defaultX)
|
||||
y := int64(defaultY)
|
||||
if record.Stats != nil {
|
||||
village = automaticRobotCoordinate(record.Stats["town_id"], village, 0xff)
|
||||
area = automaticRobotCoordinate(record.Stats["area_id"], area, 0xff)
|
||||
x = automaticRobotCoordinate(record.Stats["pos_x"], x, 0xffff)
|
||||
y = automaticRobotCoordinate(record.Stats["pos_y"], y, 0xffff)
|
||||
}
|
||||
return dnfrobotdomain.Profile{
|
||||
ID: profileID, AccountID: accountID, CharacterID: uint16(characterID),
|
||||
ChannelID: channelID, AutoStart: false,
|
||||
Village: uint8(village), Area: uint8(area), X: uint16(x), Y: uint16(y),
|
||||
MoveType: moveType,
|
||||
MoveBoundsSet: true, MoveMinX: placement.MinX, MoveMaxX: placement.MaxX,
|
||||
MoveMinY: placement.MinY, MoveMaxY: placement.MaxY,
|
||||
AutomaticStoreDisabled: !placement.StoreArea,
|
||||
}, true
|
||||
}
|
||||
|
||||
func automaticRobotCoordinate(value, fallback, maximum int64) int64 {
|
||||
if value < 0 || value > maximum || (value == 0 && fallback != 0) {
|
||||
return fallback
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func automaticRobotSeed(index int) uint64 {
|
||||
hash := fnv.New64a()
|
||||
_, _ = hash.Write([]byte(fmt.Sprintf("dnf90-automatic-robot-%03d", index)))
|
||||
return hash.Sum64()
|
||||
}
|
||||
|
||||
func automaticRobotName(index, attempt int, seed uint64) string {
|
||||
mixed := automaticRobotMix(seed, uint64(attempt+1))
|
||||
prefix := automaticRobotNamePrefixes[mixed%uint64(len(automaticRobotNamePrefixes))]
|
||||
suffix := automaticRobotNameSuffixes[(mixed>>8)%uint64(len(automaticRobotNameSuffixes))]
|
||||
return fmt.Sprintf("%s%s%03d", prefix, suffix, index)
|
||||
}
|
||||
|
||||
func automaticRobotMix(seed, salt uint64) uint64 {
|
||||
value := seed ^ salt*0x9e3779b97f4a7c15
|
||||
value ^= value >> 30
|
||||
value *= 0xbf58476d1ce4e5b9
|
||||
value ^= value >> 27
|
||||
value *= 0x94d049bb133111eb
|
||||
return value ^ (value >> 31)
|
||||
}
|
||||
|
||||
var _ dnfrobot.AutomaticFleetProvider = (*Service)(nil)
|
||||
var _ dnfrobot.PrivateStoreStocker = (*Service)(nil)
|
||||
Reference in new issue
Block a user