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

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

514 lines
15 KiB
Go

package services
import (
"fmt"
"path"
"sort"
"strconv"
"strings"
"pvfine/internal/pvf"
pvfversion "pvfine/internal/version"
)
// ListRegistrationTarget describes a list that can register one archive file.
type ListRegistrationTarget struct {
ListPath string `json:"listPath"`
Category string `json:"category"`
EntryPath string `json:"entryPath"`
SuggestedID string `json:"suggestedId"`
HasIndexHash bool `json:"hasIndexHash"`
}
// FileRegistrationOptions contains the list choices for the current file.
type FileRegistrationOptions struct {
FileIndex int32 `json:"fileIndex"`
FilePath string `json:"filePath"`
Targets []*ListRegistrationTarget `json:"targets"`
}
// IndexHashTarget describes a list whose companion index-hash file exists.
type IndexHashTarget struct {
ListPath string `json:"listPath"`
IndexHashPath string `json:"indexHashPath"`
}
// IndexHashRegistrationResult summarizes a batch hash registration.
type IndexHashRegistrationResult struct {
ListPath string `json:"listPath"`
IndexHashPath string `json:"indexHashPath"`
Requested int `json:"requested"`
Added int `json:"added"`
Existing int `json:"existing"`
InvalidIDs []string `json:"invalidIds,omitempty"`
}
// ListRegistrationOptions returns the configured lists that can point to a
// file. Existing registrations are supplied by the search index and displayed
// beside the editor file, so this endpoint only supplies a target list and a
// collision-free default id.
func (s *ArchiveService) ListRegistrationOptions(fileIndex int32) (*FileRegistrationOptions, error) {
specs := s.c.searchableListSpecs()
s.c.mu.RLock()
defer s.c.mu.RUnlock()
a := s.c.archive
if a == nil {
return nil, ErrNoArchive
}
if err := validateFileIndexes(a, []int32{fileIndex}); err != nil {
return nil, err
}
if s.c.indexStatus.State == IndexStateReady {
tags := s.c.treeTagsByFile[fileIndex]
if s.c.diskIndex != nil {
tags, _ = s.c.diskIndex.tags(fileIndex)
}
for _, tag := range tags {
if id := strings.TrimSpace(tag.ID); id != "" {
return nil, fmt.Errorf("当前文件已有索引 id: %s", id)
}
}
}
filePath := a.Path(fileIndex)
result := &FileRegistrationOptions{
FileIndex: fileIndex,
FilePath: filePath,
Targets: make([]*ListRegistrationTarget, 0, len(specs)),
}
seen := make(map[string]struct{}, len(specs))
for _, spec := range specs {
listIndex, ok := a.FindList(spec.listPath)
if !ok {
continue
}
listPath := a.Path(listIndex)
key := strings.ToLower(listPath)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
pairs, err := a.ListPairs(listIndex)
if err != nil {
continue
}
suggestedID, err := nextListID(pairs, a.ContentRules().RequiresIndexHash)
if err != nil {
continue
}
hasIndexHash := false
if companion, ok := pvf.IndexHashCompanionPath(listPath); ok {
_, hasIndexHash = a.Find(companion)
}
result.Targets = append(result.Targets, &ListRegistrationTarget{
ListPath: listPath,
Category: spec.category,
EntryPath: listEntryPath(listPath, filePath),
SuggestedID: suggestedID,
HasIndexHash: hasIndexHash,
})
}
if len(result.Targets) == 0 {
return result, fmt.Errorf("没有找到可用的 lst: %s", filePath)
}
sort.SliceStable(result.Targets, func(i, j int) bool {
left, right := result.Targets[i], result.Targets[j]
leftScore := listTargetScore(left.ListPath, filePath)
rightScore := listTargetScore(right.ListPath, filePath)
if leftScore != rightScore {
return leftScore > rightScore
}
return left.ListPath < right.ListPath
})
return result, nil
}
// RegisterFileToList registers one file in the selected list. Paged110 files
// also receive the generated companion index-hash entry in the same mutation.
func (s *ArchiveService) RegisterFileToList(fileIndex int32, listPath, id string) (*FileRegistration, error) {
id = strings.TrimSpace(id)
if id == "" {
return nil, fmt.Errorf("id 不能为空")
}
s.c.mu.Lock()
a := s.c.archive
if a == nil {
s.c.mu.Unlock()
return nil, ErrNoArchive
}
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.Unlock()
return nil, err
}
if err := validateFileIndexes(a, []int32{fileIndex}); err != nil {
s.c.mu.Unlock()
return nil, err
}
listIndex, ok := a.FindList(listPath)
if !ok {
s.c.mu.Unlock()
return nil, fmt.Errorf("lst 不存在: %s", listPath)
}
actualListPath := a.Path(listIndex)
entryPath := listEntryPath(actualListPath, a.Path(fileIndex))
pairs, err := a.ListPairs(listIndex)
if err != nil {
s.c.mu.Unlock()
return nil, err
}
for _, pair := range pairs {
if strings.EqualFold(strings.TrimSpace(pair.ID), id) {
_, targetIndex, found := findListTargetInArchive(a, actualListPath, pair.Path)
if found && targetIndex == fileIndex {
s.c.mu.Unlock()
return nil, fmt.Errorf("当前文件已经注册到 %s", actualListPath)
}
s.c.mu.Unlock()
return nil, fmt.Errorf("id 已在 %s 中使用: %s", actualListPath, id)
}
_, targetIndex, found := findListTargetInArchive(a, actualListPath, pair.Path)
if found && targetIndex == fileIndex {
s.c.mu.Unlock()
return nil, fmt.Errorf("当前文件已经注册到 %s", actualListPath)
}
}
var numericID uint32
var hashPath string
hashIndex := int32(-1)
if a.ContentRules().RequiresIndexHash {
parsed, parseErr := strconv.ParseUint(id, 10, 32)
if parseErr != nil {
s.c.mu.Unlock()
return nil, fmt.Errorf("此归档的 indexhash id 必须是 uint32 数字: %s", id)
}
numericID = uint32(parsed)
var hashOK bool
hashPath, hashOK = pvf.IndexHashCompanionPath(actualListPath)
if !hashOK {
s.c.mu.Unlock()
return nil, fmt.Errorf("无法确定 %s 的 indexhash 文件", actualListPath)
}
hashIndex, hashOK = a.Find(hashPath)
if !hashOK {
s.c.mu.Unlock()
return nil, fmt.Errorf("indexhash 不存在: %s", hashPath)
}
}
changedPaths := []string{actualListPath}
if hashPath != "" {
changedPaths = append(changedPaths, hashPath)
}
before, err := contentSnapshot(a, changedPaths)
if err != nil {
s.c.mu.Unlock()
return nil, err
}
mutationCheckpoint := a.MutationCheckpoint()
if err := a.SetListPair(listIndex, id, entryPath); err != nil {
s.c.mu.Unlock()
return nil, err
}
if hashPath != "" {
if err := a.SetIndexHashEntriesForListIDs(hashPath, actualListPath, []uint32{numericID}); err != nil {
s.c.mu.Unlock()
return nil, err
}
}
after, err := contentSnapshot(a, changedPaths)
if err != nil {
s.c.mu.Unlock()
return nil, err
}
if err := s.c.recordVersionMutationLocked("注册到列表", before, after); err != nil {
s.c.mu.Unlock()
return nil, err
}
mutationSummary := a.MutationsSince(mutationCheckpoint)
s.c.markAdvancedSearchStaleLocked(a)
a.ClearMutations()
refreshRegistrationEditorTextLocked(s.c, a, listIndex, hashIndex)
invalidateRegistrationIndexesLocked(s.c)
info := a.Info()
versioned := s.c.versionRepo != nil
registration := &FileRegistration{
ID: id,
FileIndex: fileIndex,
FilePath: a.Path(fileIndex),
ListFileIndex: listIndex,
ListPath: actualListPath,
EntryPath: entryPath,
}
s.c.mu.Unlock()
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
return nil, err
}
emitEvent("archive:registrations-changed", map[string]any{
"fileIndexes": changedRegistrationIndexes(listIndex, hashIndex, fileIndex),
})
emitEvent("archive:changed", info)
if versioned {
emitVersionState(s.c, "registered-to-list")
}
return registration, nil
}
// IndexHashTargets lists the available list/indexhash pairs in a Paged110
// archive. Older archive layouts intentionally return an error.
func (s *ArchiveService) IndexHashTargets() ([]*IndexHashTarget, error) {
s.c.mu.RLock()
defer s.c.mu.RUnlock()
a := s.c.archive
if a == nil {
return nil, ErrNoArchive
}
if !a.ContentRules().RequiresIndexHash {
return nil, fmt.Errorf("当前归档不使用 indexhash")
}
result := make([]*IndexHashTarget, 0)
for index := int32(0); index < a.FileCount(); index++ {
listPath := a.Path(index)
if !strings.EqualFold(path.Ext(listPath), ".lst") {
continue
}
hashPath, ok := pvf.IndexHashCompanionPath(listPath)
if !ok {
continue
}
if _, exists := a.Find(hashPath); !exists {
continue
}
result = append(result, &IndexHashTarget{ListPath: listPath, IndexHashPath: hashPath})
}
sort.Slice(result, func(i, j int) bool { return result[i].ListPath < result[j].ListPath })
return result, nil
}
// RegisterMissingIndexHashes writes generated hashes for ids that are absent
// from the selected companion file, and repairs entries written to the wrong
// string pool. Valid existing entries are left untouched.
func (s *ArchiveService) RegisterMissingIndexHashes(listPath string, rawIDs []string) (*IndexHashRegistrationResult, error) {
s.c.mu.Lock()
a := s.c.archive
if a == nil {
s.c.mu.Unlock()
return nil, ErrNoArchive
}
if !a.ContentRules().RequiresIndexHash {
s.c.mu.Unlock()
return nil, fmt.Errorf("当前归档不使用 indexhash")
}
listIndex, ok := a.FindList(listPath)
if !ok {
s.c.mu.Unlock()
return nil, fmt.Errorf("lst 不存在: %s", listPath)
}
actualListPath := a.Path(listIndex)
hashPath, ok := pvf.IndexHashCompanionPath(actualListPath)
if !ok {
s.c.mu.Unlock()
return nil, fmt.Errorf("无法确定 %s 的 indexhash 文件", actualListPath)
}
hashIndex, ok := a.Find(hashPath)
if !ok {
s.c.mu.Unlock()
return nil, fmt.Errorf("indexhash 不存在: %s", hashPath)
}
result := &IndexHashRegistrationResult{
ListPath: actualListPath,
IndexHashPath: hashPath,
InvalidIDs: []string{},
}
ids := make([]uint32, 0, len(rawIDs))
seen := make(map[uint32]struct{}, len(rawIDs))
for _, rawID := range rawIDs {
trimmed := strings.TrimSpace(rawID)
if trimmed == "" {
continue
}
id, parseErr := strconv.ParseUint(trimmed, 10, 32)
if parseErr != nil {
result.InvalidIDs = append(result.InvalidIDs, trimmed)
continue
}
value := uint32(id)
if _, duplicate := seen[value]; duplicate {
continue
}
seen[value] = struct{}{}
ids = append(ids, value)
}
result.Requested = len(ids)
if len(ids) == 0 {
s.c.mu.Unlock()
return result, nil
}
pairs, err := a.IndexHashPairs(hashPath)
if err != nil {
s.c.mu.Unlock()
return nil, err
}
have := make(map[uint32]struct{}, len(pairs))
for _, pair := range pairs {
have[pair.ID] = struct{}{}
}
updates, err := a.IndexHashIDsNeedingUpdate(hashPath, ids)
if err != nil {
s.c.mu.Unlock()
return nil, err
}
for _, id := range ids {
if _, exists := have[id]; exists {
continue
}
result.Added++
}
result.Existing = len(ids) - len(updates)
if len(updates) == 0 {
s.c.mu.Unlock()
return result, nil
}
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.Unlock()
return nil, err
}
before, err := contentSnapshot(a, []string{hashPath})
if err != nil {
s.c.mu.Unlock()
return nil, err
}
if err := a.SetIndexHashEntriesForListIDs(hashPath, actualListPath, updates); err != nil {
s.c.mu.Unlock()
return nil, err
}
after, err := contentSnapshot(a, []string{hashPath})
if err != nil {
s.c.mu.Unlock()
return nil, err
}
if err := s.c.recordVersionMutationLocked("注册 indexhash", before, after); err != nil {
s.c.mu.Unlock()
return nil, err
}
s.c.markAdvancedSearchStaleLocked(a)
a.ClearMutations()
refreshRegistrationEditorTextLocked(s.c, a, -1, hashIndex)
invalidateRegistrationIndexesLocked(s.c)
result.Added = len(updates)
info := a.Info()
versioned := s.c.versionRepo != nil
s.c.mu.Unlock()
emitEvent("archive:registrations-changed", map[string]any{"fileIndexes": []int32{hashIndex}})
emitEvent("archive:changed", info)
if versioned {
emitVersionState(s.c, "registered-indexhash")
}
return result, nil
}
func contentSnapshot(a *pvf.Archive, paths []string) (pvfversion.ContentSnapshot, error) {
unique := make([]string, 0, len(paths))
seen := make(map[string]struct{}, len(paths))
for _, filePath := range paths {
if filePath == "" {
continue
}
if _, exists := seen[filePath]; exists {
continue
}
seen[filePath] = struct{}{}
unique = append(unique, filePath)
}
return pvfversion.ContentSnapshotFromArchive(a, unique)
}
func listEntryPath(listPath, filePath string) string {
listPath = strings.Trim(strings.ReplaceAll(listPath, "\\", "/"), "/")
filePath = strings.Trim(strings.ReplaceAll(filePath, "\\", "/"), "/")
dir := path.Dir(listPath)
if dir != "." && !strings.EqualFold(dir, "list") {
prefix := dir + "/"
if len(filePath) >= len(prefix) && strings.EqualFold(filePath[:len(prefix)], prefix) {
return filePath[len(prefix):]
}
}
return filePath
}
func nextListID(pairs []pvf.ListPair, requiresIndexHash bool) (string, error) {
used := make(map[string]struct{}, len(pairs))
var next uint64 = 1
for _, pair := range pairs {
id := strings.TrimSpace(pair.ID)
used[id] = struct{}{}
if parsed, err := strconv.ParseUint(id, 10, 64); err == nil && parsed >= next {
next = parsed + 1
}
}
for {
if requiresIndexHash && next > uint64(^uint32(0)) {
return "", fmt.Errorf("列表没有可用的 uint32 id")
}
candidate := strconv.FormatUint(next, 10)
if _, exists := used[candidate]; !exists {
return candidate, nil
}
next++
}
}
func listTargetScore(listPath, filePath string) int {
listPath = strings.ToLower(strings.Trim(strings.ReplaceAll(listPath, "\\", "/"), "/"))
filePath = strings.ToLower(strings.Trim(strings.ReplaceAll(filePath, "\\", "/"), "/"))
base := strings.TrimSuffix(path.Base(listPath), path.Ext(listPath))
if strings.HasPrefix(filePath, base+"/") {
return 2
}
if strings.HasPrefix(filePath, path.Dir(listPath)+"/") {
return 1
}
return 0
}
func refreshRegistrationEditorTextLocked(c *core, a *pvf.Archive, listIndex, hashIndex int32) {
if c.editorText == nil {
c.editorText = make(map[int32]string)
}
for _, index := range []int32{listIndex, hashIndex} {
if index < 0 {
continue
}
if text, err := a.Text(index); err == nil {
c.editorText[index] = text
}
}
}
func changedRegistrationIndexes(listIndex, hashIndex, fileIndex int32) []int32 {
result := make([]int32, 0, 3)
seen := make(map[int32]struct{}, 3)
for _, index := range []int32{listIndex, hashIndex, fileIndex} {
if index < 0 {
continue
}
if _, exists := seen[index]; exists {
continue
}
seen[index] = struct{}{}
result = append(result, index)
}
return result
}
func invalidateRegistrationIndexesLocked(c *core) {
c.batchRevision++
c.batchPlan = nil
c.invalidateScriptLocked()
c.annotationRelations = make(map[string]map[string]*relationTarget)
c.editorAnnotation = editorAnnotationCache{}
c.markAdvancedSearchStaleLocked(c.archive)
}