服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
465 lines
14 KiB
Go
465 lines
14 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/hex"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"pvfine/internal/pvf"
|
|
)
|
|
|
|
const (
|
|
AdvancedSearchModeBinary = "binary"
|
|
AdvancedSearchModeString = "string"
|
|
AdvancedSearchModeQuick = "quick"
|
|
AdvancedSearchModeQuickExact = "quick-exact"
|
|
|
|
AdvancedIndexStateIdle = "idle"
|
|
AdvancedIndexStateBuilding = "building"
|
|
AdvancedIndexStateReady = "ready"
|
|
AdvancedIndexStateError = "error"
|
|
)
|
|
|
|
var ErrAdvancedSearchIndexing = errors.New("高级搜索字符串索引正在构建")
|
|
|
|
// AdvancedSearchIndexStatus describes the lazy string reverse-index state.
|
|
type AdvancedSearchIndexStatus struct {
|
|
State string `json:"state"`
|
|
Stage string `json:"stage"`
|
|
Done int `json:"done"`
|
|
Total int `json:"total"`
|
|
Error string `json:"error"`
|
|
}
|
|
|
|
// AdvancedSearchHit is one file-level advanced-search result.
|
|
type AdvancedSearchHit struct {
|
|
Name string `json:"name,omitempty"`
|
|
Path string `json:"path"`
|
|
Size int32 `json:"size"`
|
|
DataType int32 `json:"dataType"`
|
|
FileIndex int32 `json:"fileIndex"`
|
|
Details []*AdvancedSearchDetail `json:"details,omitempty"`
|
|
}
|
|
|
|
// AdvancedSearchDetail explains why a file matched the query.
|
|
type AdvancedSearchDetail struct {
|
|
Kind string `json:"kind"`
|
|
Value string `json:"value,omitempty"`
|
|
Pool string `json:"pool,omitempty"`
|
|
PoolOffset int32 `json:"poolOffset,omitempty"`
|
|
Occurrences int `json:"occurrences"`
|
|
TokenTypes []int32 `json:"tokenTypes,omitempty"`
|
|
FileFields []string `json:"fileFields,omitempty"`
|
|
ByteOffsets []int `json:"byteOffsets,omitempty"`
|
|
TokenOffsets []int `json:"tokenOffsets,omitempty"`
|
|
Hex string `json:"hex,omitempty"`
|
|
OffsetsTruncated bool `json:"offsetsTruncated,omitempty"`
|
|
}
|
|
|
|
// AdvancedSearchResult is a paged file-level advanced-search response.
|
|
type AdvancedSearchResult struct {
|
|
Hits []*AdvancedSearchHit `json:"hits"`
|
|
NextCursor int `json:"nextCursor"`
|
|
Scanned int `json:"scanned"`
|
|
}
|
|
|
|
type binarySearchKey struct {
|
|
scope string
|
|
pattern string
|
|
}
|
|
|
|
type advancedFileMatch struct {
|
|
fileIndex int32
|
|
details []*AdvancedSearchDetail
|
|
}
|
|
|
|
// AdvancedIndexStatus returns the lazy string reverse-index status.
|
|
func (s *ArchiveService) AdvancedIndexStatus() AdvancedSearchIndexStatus {
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
|
|
return s.c.advancedStatus
|
|
}
|
|
|
|
// AdvancedSearch searches raw token bytes, string-pool references, or explorer metadata.
|
|
// Pass NextCursor back unchanged; string-mode cursors identify the query
|
|
// session and position, while binary mode retains its legacy offset. Limit is 1..1000.
|
|
func (s *ArchiveService) AdvancedSearch(mode, query, scopePath string, regex bool, cursor, limit int) (*AdvancedSearchResult, error) {
|
|
result := &AdvancedSearchResult{Hits: []*AdvancedSearchHit{}, NextCursor: -1}
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return result, nil
|
|
}
|
|
if limit <= 0 || limit > 1000 {
|
|
limit = 200
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
scope := normalizeAdvancedScope(scopePath)
|
|
|
|
switch strings.ToLower(strings.TrimSpace(mode)) {
|
|
case AdvancedSearchModeBinary:
|
|
return s.searchAdvancedBinary(query, scope, cursor, limit)
|
|
case AdvancedSearchModeQuick, AdvancedSearchModeQuickExact:
|
|
return s.searchAdvancedQuick(query, scope, strings.EqualFold(strings.TrimSpace(mode), AdvancedSearchModeQuickExact), cursor, limit)
|
|
case AdvancedSearchModeString:
|
|
return s.searchAdvancedString(query, scope, regex, cursor, limit)
|
|
default:
|
|
return nil, fmt.Errorf("未知高级搜索模式: %s", mode)
|
|
}
|
|
}
|
|
|
|
// Quick search shares explorer matching and filters directory scope before pagination.
|
|
func (s *ArchiveService) searchAdvancedQuick(query, scope string, exact bool, cursor, limit int) (*AdvancedSearchResult, error) {
|
|
page, err := s.searchScoped(query, cursor, limit, exact, false, "", scope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
result := &AdvancedSearchResult{Hits: []*AdvancedSearchHit{}, NextCursor: page.NextCursor, Scanned: page.Scanned}
|
|
for _, hit := range page.Hits {
|
|
details := []*AdvancedSearchDetail{}
|
|
if hit.ID != "" {
|
|
details = append(details, &AdvancedSearchDetail{Kind: "id", Value: hit.ID})
|
|
}
|
|
result.Hits = append(result.Hits, &AdvancedSearchHit{Name: hit.Name, Path: hit.Path, Size: hit.Size, DataType: hit.DataType, FileIndex: hit.FileIndex, Details: details})
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func (s *ArchiveService) searchAdvancedString(query, scope string, regex bool, cursor, limit int) (*AdvancedSearchResult, error) {
|
|
result, err := s.searchAdvancedStringSQLite(query, scope, regex, cursor, limit)
|
|
if cursor == 0 && errors.Is(err, errAdvancedPoolChanged) {
|
|
return s.searchAdvancedStringSQLite(query, scope, regex, cursor, limit)
|
|
}
|
|
return result, err
|
|
}
|
|
|
|
func (s *ArchiveService) searchAdvancedBinary(query, scope string, cursor, limit int) (*AdvancedSearchResult, error) {
|
|
s.c.mu.RLock()
|
|
if s.c.archive == nil {
|
|
s.c.mu.RUnlock()
|
|
return nil, ErrNoArchive
|
|
}
|
|
pattern, err := s.c.archive.EncodeScriptQuery(query)
|
|
s.c.mu.RUnlock()
|
|
if errors.Is(err, pvf.ErrQueryStringNotInPool) {
|
|
return &AdvancedSearchResult{Hits: []*AdvancedSearchHit{}, NextCursor: -1}, nil
|
|
}
|
|
if err != nil {
|
|
return nil, fmt.Errorf("编译二进制查询失败: %w", err)
|
|
}
|
|
if len(pattern) == 0 {
|
|
return &AdvancedSearchResult{Hits: []*AdvancedSearchHit{}, NextCursor: -1}, nil
|
|
}
|
|
s.c.mu.RLock()
|
|
disk := s.c.diskIndex != nil
|
|
s.c.mu.RUnlock()
|
|
if disk {
|
|
return s.searchAdvancedBinaryDisk(pattern, scope, cursor, limit)
|
|
}
|
|
|
|
matched, err := s.c.binarySearch(pattern, scope)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return s.paginateAdvancedFiles(matched, "", cursor, limit)
|
|
}
|
|
|
|
func (s *ArchiveService) searchAdvancedBinaryDisk(pattern []byte, scope string, cursor, limit int) (*AdvancedSearchResult, error) {
|
|
if limit <= 0 || limit > 1000 {
|
|
limit = 200
|
|
}
|
|
if cursor < 0 {
|
|
cursor = 0
|
|
}
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if s.c.archive == nil || s.c.diskIndex == nil {
|
|
return nil, ErrNoArchive
|
|
}
|
|
result := &AdvancedSearchResult{Hits: []*AdvancedSearchHit{}, NextCursor: -1}
|
|
matched := 0
|
|
var scanErr error
|
|
err := s.c.diskIndex.eachFileByPath(func(index int32, path string, size, dataType int32) bool {
|
|
if scope != "" && !advancedPathInScope(path, scope) {
|
|
return true
|
|
}
|
|
raw, err := s.c.archive.RawBytes(index)
|
|
if err != nil {
|
|
scanErr = err
|
|
return false
|
|
}
|
|
occurrences, byteOffsets, tokenOffsets := binaryMatchOffsets(raw, pattern)
|
|
if occurrences == 0 {
|
|
return true
|
|
}
|
|
if matched < cursor {
|
|
matched++
|
|
return true
|
|
}
|
|
hit := &AdvancedSearchHit{Name: "", Path: path, Size: size, DataType: dataType, FileIndex: index, Details: []*AdvancedSearchDetail{{Kind: "binary", Occurrences: occurrences, ByteOffsets: byteOffsets, TokenOffsets: tokenOffsets, Hex: formatHex(pattern)}}}
|
|
if name, nameErr := s.c.diskIndex.indexedNames(index); nameErr == nil {
|
|
hit.Name = name
|
|
}
|
|
result.Hits = append(result.Hits, hit)
|
|
matched++
|
|
return len(result.Hits) < limit
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if scanErr != nil {
|
|
return nil, scanErr
|
|
}
|
|
if len(result.Hits) >= limit {
|
|
result.NextCursor = matched
|
|
}
|
|
result.Scanned = matched
|
|
return result, nil
|
|
}
|
|
|
|
func (c *core) binarySearch(pattern []byte, scope string) ([]advancedFileMatch, error) {
|
|
key := binarySearchKey{scope: scope, pattern: string(pattern)}
|
|
c.mu.RLock()
|
|
if c.archive == nil {
|
|
c.mu.RUnlock()
|
|
return nil, ErrNoArchive
|
|
}
|
|
if cached, ok := c.binaryCache[key]; ok {
|
|
result := append([]advancedFileMatch(nil), cached...)
|
|
c.mu.RUnlock()
|
|
return result, nil
|
|
}
|
|
a := c.archive
|
|
matched := make([]advancedFileMatch, 0)
|
|
err := a.ForEachRawFile(context.Background(), func(index int32, path string, _ pvf.File, raw []byte) bool {
|
|
if !advancedPathInScope(path, scope) {
|
|
return true
|
|
}
|
|
occurrences, byteOffsets, tokenOffsets := binaryMatchOffsets(raw, pattern)
|
|
if occurrences > 0 {
|
|
matched = append(matched, advancedFileMatch{
|
|
fileIndex: index,
|
|
details: []*AdvancedSearchDetail{{
|
|
Kind: "binary",
|
|
Occurrences: occurrences,
|
|
ByteOffsets: byteOffsets,
|
|
TokenOffsets: tokenOffsets,
|
|
Hex: formatHex(pattern),
|
|
OffsetsTruncated: occurrences > len(byteOffsets),
|
|
}},
|
|
})
|
|
}
|
|
return true
|
|
})
|
|
if err != nil {
|
|
c.mu.RUnlock()
|
|
return nil, err
|
|
}
|
|
sort.SliceStable(matched, func(i, j int) bool {
|
|
return a.Path(matched[i].fileIndex) < a.Path(matched[j].fileIndex)
|
|
})
|
|
c.mu.RUnlock()
|
|
|
|
c.mu.Lock()
|
|
if c.archive == a {
|
|
if c.binaryCache == nil {
|
|
c.binaryCache = make(map[binarySearchKey][]advancedFileMatch)
|
|
}
|
|
c.binaryCache[key] = append([]advancedFileMatch(nil), matched...)
|
|
}
|
|
c.mu.Unlock()
|
|
return matched, nil
|
|
}
|
|
|
|
func (s *ArchiveService) paginateAdvancedFiles(matches []advancedFileMatch, scope string, cursor, limit int) (*AdvancedSearchResult, error) {
|
|
s.c.mu.RLock()
|
|
defer s.c.mu.RUnlock()
|
|
if s.c.archive == nil {
|
|
return nil, ErrNoArchive
|
|
}
|
|
|
|
filtered := make([]advancedFileMatch, 0, len(matches))
|
|
for _, match := range matches {
|
|
index := match.fileIndex
|
|
if index < 0 || index >= s.c.archive.FileCount() {
|
|
continue
|
|
}
|
|
if scope != "" && !advancedPathInScope(s.c.archive.Path(index), scope) {
|
|
continue
|
|
}
|
|
filtered = append(filtered, match)
|
|
}
|
|
sort.SliceStable(filtered, func(i, j int) bool {
|
|
return s.c.archive.Path(filtered[i].fileIndex) < s.c.archive.Path(filtered[j].fileIndex)
|
|
})
|
|
|
|
result := &AdvancedSearchResult{Hits: []*AdvancedSearchHit{}, NextCursor: -1}
|
|
if cursor > len(filtered) {
|
|
cursor = len(filtered)
|
|
}
|
|
i := cursor
|
|
for ; i < len(filtered) && len(result.Hits) < limit; i++ {
|
|
match := filtered[i]
|
|
index := match.fileIndex
|
|
file := s.c.archive.File(index)
|
|
result.Hits = append(result.Hits, &AdvancedSearchHit{
|
|
Name: s.c.indexedFileNameLocked(index),
|
|
Path: s.c.archive.Path(index),
|
|
Size: file.DataSize,
|
|
DataType: file.DataType,
|
|
FileIndex: index,
|
|
Details: match.details,
|
|
})
|
|
}
|
|
if i < len(filtered) {
|
|
result.NextCursor = i
|
|
}
|
|
result.Scanned = i
|
|
return result, nil
|
|
}
|
|
|
|
func (c *core) indexedFileNameLocked(index int32) string {
|
|
if c.diskIndex != nil {
|
|
name, _ := c.diskIndex.indexedNames(index)
|
|
return name
|
|
}
|
|
seen := make(map[string]struct{})
|
|
names := make([]string, 0, 1)
|
|
for _, recordIndex := range c.searchByFile[index] {
|
|
if recordIndex < 0 || recordIndex >= len(c.searchRecords) {
|
|
continue
|
|
}
|
|
record := c.searchRecords[recordIndex].hit
|
|
if record.Category == SearchCategoryFile || record.Name == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[record.Name]; ok {
|
|
continue
|
|
}
|
|
seen[record.Name] = struct{}{}
|
|
names = append(names, record.Name)
|
|
}
|
|
return strings.Join(names, " / ")
|
|
}
|
|
|
|
const maxAdvancedOffsets = 32
|
|
|
|
func binaryMatchOffsets(raw, pattern []byte) (int, []int, []int) {
|
|
if len(pattern) == 0 {
|
|
return 0, nil, nil
|
|
}
|
|
occurrences := 0
|
|
byteOffsets := make([]int, 0, maxAdvancedOffsets)
|
|
tokenOffsets := make([]int, 0, maxAdvancedOffsets)
|
|
for start := 0; start <= len(raw)-len(pattern); {
|
|
relative := bytes.Index(raw[start:], pattern)
|
|
if relative < 0 {
|
|
break
|
|
}
|
|
offset := start + relative
|
|
occurrences++
|
|
if len(byteOffsets) < maxAdvancedOffsets {
|
|
byteOffsets = append(byteOffsets, offset)
|
|
if offset%5 == 0 {
|
|
tokenOffsets = append(tokenOffsets, offset/5)
|
|
}
|
|
}
|
|
start = offset + 1
|
|
}
|
|
return occurrences, byteOffsets, tokenOffsets
|
|
}
|
|
|
|
func formatHex(raw []byte) string {
|
|
encoded := strings.ToUpper(hex.EncodeToString(raw))
|
|
parts := make([]string, 0, len(encoded)/2)
|
|
for i := 0; i+1 < len(encoded); i += 2 {
|
|
parts = append(parts, encoded[i:i+2])
|
|
}
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func (c *core) invalidateAdvancedSearchLocked() {
|
|
if disk := c.detachAdvancedSearchLocked(); disk != nil {
|
|
go disk.close()
|
|
}
|
|
}
|
|
|
|
// markAdvancedSearchStaleLocked cancels result generation but retains a ready
|
|
// immutable reference base. Only explicit cancel/close or an unfinished build
|
|
// destroys the session. Caller holds core.mu.
|
|
func (c *core) markAdvancedSearchStaleLocked(a *pvf.Archive) {
|
|
if c.diskIndex != nil && a != nil {
|
|
summary := a.MutationsSince(0)
|
|
if len(summary.Files) > 0 {
|
|
c.diskIndex.queueSemanticMutations(summary)
|
|
}
|
|
}
|
|
d := c.advancedDisk
|
|
if d == nil {
|
|
return
|
|
}
|
|
if !d.published.Load() {
|
|
c.invalidateAdvancedSearchLocked()
|
|
return
|
|
}
|
|
d.epoch++
|
|
if d.activeCancel != nil {
|
|
d.activeCancel()
|
|
}
|
|
if d.pendingIDs == nil {
|
|
d.pendingIDs = make(map[uint64]struct{})
|
|
}
|
|
if a != nil {
|
|
for _, mutation := range a.MutationsSince(0).Files {
|
|
if mutation.Identity != 0 {
|
|
d.pendingIDs[mutation.Identity] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
c.advancedStatus = AdvancedSearchIndexStatus{State: AdvancedIndexStateIdle, Stage: "stale"}
|
|
c.binaryCache = make(map[binarySearchKey][]advancedFileMatch)
|
|
}
|
|
|
|
// detachAdvancedSearchLocked cancels and detaches the current disk session.
|
|
// The caller owns closing the returned session after releasing core.mu.
|
|
func (c *core) detachAdvancedSearchLocked() *advancedSQLite {
|
|
disk := c.advancedDisk
|
|
if disk != nil {
|
|
disk.cancel()
|
|
c.advancedDisk = nil
|
|
}
|
|
if c.advancedCancel != nil {
|
|
c.advancedCancel()
|
|
c.advancedCancel = nil
|
|
}
|
|
c.advancedStatus = AdvancedSearchIndexStatus{State: AdvancedIndexStateIdle}
|
|
c.binaryCache = make(map[binarySearchKey][]advancedFileMatch)
|
|
return disk
|
|
}
|
|
|
|
func normalizeAdvancedScope(scope string) string {
|
|
scope = strings.TrimSpace(strings.ReplaceAll(scope, "\\", "/"))
|
|
for strings.HasPrefix(scope, "./") || strings.HasPrefix(scope, "/") {
|
|
if strings.HasPrefix(scope, "./") {
|
|
scope = scope[2:]
|
|
} else {
|
|
scope = scope[1:]
|
|
}
|
|
}
|
|
return strings.TrimRight(strings.ToLower(scope), "/")
|
|
}
|
|
|
|
func advancedPathInScope(path, scope string) bool {
|
|
if scope == "" {
|
|
return true
|
|
}
|
|
path = strings.ToLower(strings.TrimRight(strings.ReplaceAll(path, "\\", "/"), "/"))
|
|
return path == scope || strings.HasPrefix(path, scope+"/")
|
|
}
|