服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1653 lines
51 KiB
Go
1653 lines
51 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"log"
|
|
"path"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"pvfine/internal/pvf"
|
|
pvfversion "pvfine/internal/version"
|
|
)
|
|
|
|
const (
|
|
IndexStateIdle = "idle"
|
|
IndexStateBuilding = "building"
|
|
IndexStateReady = "ready"
|
|
IndexStateError = "error"
|
|
|
|
SearchCategoryFile = "file"
|
|
SearchCategoryEquipment = "equipment"
|
|
SearchCategoryStackable = "stackable"
|
|
SearchCategorySkill = "skill"
|
|
)
|
|
|
|
const (
|
|
itemShopListPath = "itemshop/itemshop.lst"
|
|
npcListPath = "npc/npc.lst"
|
|
)
|
|
|
|
// IndexStatus is the current state of the semantic search index.
|
|
type IndexStatus struct {
|
|
State string `json:"state"`
|
|
Stage string `json:"stage"`
|
|
Done int `json:"done"`
|
|
Total int `json:"total"`
|
|
Skipped int `json:"skipped"`
|
|
Error string `json:"error"`
|
|
// Refreshing means an older ready snapshot is still serving queries while
|
|
// a newer candidate is being built in the background.
|
|
Refreshing bool `json:"refreshing"`
|
|
RefreshError string `json:"refreshError"`
|
|
CacheHit bool `json:"cacheHit"`
|
|
OpenDurationMs float64 `json:"openDurationMs"`
|
|
BuildDurationMs float64 `json:"buildDurationMs"`
|
|
StringTableIndex *pvf.StringTableIndexStats `json:"stringTableIndex,omitempty"`
|
|
}
|
|
|
|
// SearchHit is one searchable file/list record.
|
|
type SearchHit struct {
|
|
Name string `json:"name"`
|
|
ID string `json:"id"`
|
|
Path string `json:"path"`
|
|
Category string `json:"category"`
|
|
Size int32 `json:"size"`
|
|
DataType int32 `json:"dataType"`
|
|
FileIndex int32 `json:"fileIndex"`
|
|
Rarity int32 `json:"rarity"`
|
|
ChangeKind string `json:"changeKind,omitempty"`
|
|
Annotations []TreeAnnotation `json:"annotations,omitempty"`
|
|
PathAnnotations map[string][]TreeAnnotation `json:"pathAnnotations,omitempty"`
|
|
Icon *ImageReference `json:"icon,omitempty"`
|
|
FieldImage *ImageReference `json:"fieldImage,omitempty"`
|
|
}
|
|
|
|
// TreeTag is one list mapping displayed after a file name in the explorer.
|
|
type TreeTag struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Category string `json:"category"`
|
|
// Rarity is the target file's [rarity] value, or pvf.RarityUnknown when the
|
|
// file declares none. The explorer colors the tag with it.
|
|
Rarity int32 `json:"rarity"`
|
|
}
|
|
|
|
type searchRecord struct {
|
|
hit SearchHit
|
|
lowerName string
|
|
lowerID string
|
|
lowerPath string
|
|
}
|
|
|
|
type indexedMetadata struct {
|
|
name string
|
|
id string
|
|
category string
|
|
listPath string
|
|
path string
|
|
fileIndex int32
|
|
size int32
|
|
dataType int32
|
|
rarity int32
|
|
icon *ImageReference
|
|
fieldImage *ImageReference
|
|
}
|
|
|
|
type fileVisuals struct {
|
|
icon *ImageReference
|
|
fieldImage *ImageReference
|
|
// rarity is the file's [rarity] value, or pvf.RarityUnknown when the file
|
|
// declares none. Empty visuals must carry RarityUnknown, never 0.
|
|
rarity int32
|
|
}
|
|
|
|
// visualsFromMetadata projects a script's display metadata onto the per-file
|
|
// visual snapshot shared by the explorer, the search results and the stale
|
|
// checks of the mutation classifier.
|
|
func visualsFromMetadata(metadata pvf.ScriptMetadata) fileVisuals {
|
|
return fileVisuals{
|
|
icon: imageReferenceFromPVF(metadata.Icon),
|
|
fieldImage: imageReferenceFromPVF(metadata.FieldImage),
|
|
rarity: metadata.RarityValue(),
|
|
}
|
|
}
|
|
|
|
type searchableListSpec struct {
|
|
listPath string
|
|
category string
|
|
}
|
|
|
|
func (c *core) searchableListSpecs() []searchableListSpec {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.searchableListSpecsLocked()
|
|
}
|
|
|
|
func (c *core) searchableListSpecsLocked() []searchableListSpec {
|
|
specs := make([]searchableListSpec, 0, 16)
|
|
seen := make(map[string]struct{})
|
|
appendSpec := func(spec searchableListSpec) {
|
|
key := strings.ToLower(spec.category + "\x00" + spec.listPath)
|
|
if spec.listPath == "" {
|
|
return
|
|
}
|
|
if _, ok := seen[key]; ok {
|
|
return
|
|
}
|
|
seen[key] = struct{}{}
|
|
specs = append(specs, spec)
|
|
}
|
|
engine := c.annotationEngine
|
|
if engine != nil {
|
|
for name, relation := range engine.Document().Relations {
|
|
category := relationSearchCategory(name)
|
|
kind := relation.Kind
|
|
if kind == "" {
|
|
kind = "list"
|
|
}
|
|
if kind == "list" {
|
|
appendSpec(searchableListSpec{listPath: relation.ListPath, category: category})
|
|
continue
|
|
}
|
|
if kind != "contextual" {
|
|
continue
|
|
}
|
|
for _, listPath := range relation.ContextPaths {
|
|
appendSpec(searchableListSpec{listPath: listPath, category: category})
|
|
}
|
|
}
|
|
}
|
|
sort.SliceStable(specs, func(i, j int) bool {
|
|
if specs[i].category != specs[j].category {
|
|
return specs[i].category < specs[j].category
|
|
}
|
|
return specs[i].listPath < specs[j].listPath
|
|
})
|
|
return specs
|
|
}
|
|
|
|
func relationSearchCategory(name string) string {
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
case "装备", "equipment":
|
|
return SearchCategoryEquipment
|
|
case "道具", "stackable":
|
|
return SearchCategoryStackable
|
|
case "技能", "skill":
|
|
return SearchCategorySkill
|
|
default:
|
|
return "relation:" + strings.TrimSpace(name)
|
|
}
|
|
}
|
|
|
|
// startSearchIndex starts or queues a metadata indexing generation for the
|
|
// current archive. The path/tree index is already available when this runs.
|
|
func (c *core) startSearchIndex() {
|
|
c.startSearchIndexWithOptions(false)
|
|
}
|
|
|
|
func (c *core) startSearchIndexForced() {
|
|
c.startSearchIndexWithOptions(true)
|
|
}
|
|
|
|
func (c *core) startSearchIndexWithOptions(force bool) {
|
|
lockStartedAt := time.Now()
|
|
c.mu.Lock()
|
|
if waited := time.Since(lockStartedAt).Round(time.Millisecond); waited > 0 {
|
|
developmentLog("[pvfine:index] waited for core lock: elapsed=%s", waited)
|
|
}
|
|
if c.archive == nil {
|
|
c.mu.Unlock()
|
|
developmentLog("[pvfine:index] index request ignored: no archive")
|
|
return
|
|
}
|
|
dirty := c.indexDirty != nil && len(c.indexDirty) > 0
|
|
if c.diskIndex != nil {
|
|
dirty = c.diskIndex.dirty
|
|
}
|
|
developmentLog("[pvfine:index] index request: force=%t files=%d disk=%t ready=%t dirty=%t", force, c.archive.FileCount(), c.diskIndex != nil, c.indexStatus.State == IndexStateReady, dirty)
|
|
if c.indexCancel != nil {
|
|
// Do not cancel an in-flight build for ordinary archive mutations.
|
|
// The mutation classifier has already accumulated the newest dirty
|
|
// files/list relations; let this candidate finish and run one follow-up
|
|
// build from that accumulated state. Cancelling here can race with the
|
|
// publisher and lose the later mutation.
|
|
c.indexRefreshPending = true
|
|
c.indexRefreshPendingForce = c.indexRefreshPendingForce || force
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
// A completion path clears these before asking for the queued build. Clear
|
|
// them here as well so an external/manual request consumes any stale queue.
|
|
c.indexRefreshPending = false
|
|
c.indexRefreshPendingForce = false
|
|
c.indexGen++
|
|
gen := c.indexGen
|
|
a := c.archive
|
|
startedAt := time.Now()
|
|
if c.diskIndex != nil {
|
|
index := c.diskIndex
|
|
if index.ready {
|
|
index.semanticInitialized = true
|
|
var fingerprint string
|
|
if index.db.QueryRow(`SELECT value FROM meta WHERE key='specs'`).Scan(&fingerprint) != nil || fingerprint != searchIndexSpecFingerprint(c.searchableListSpecsLocked()) {
|
|
index.ready, index.dirty = false, true
|
|
}
|
|
}
|
|
hasSnapshot := index.ready && !force && !index.dirty
|
|
index.ready = hasSnapshot
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
c.indexCancel = cancel
|
|
c.indexGen++
|
|
gen := c.indexGen
|
|
c.indexStartedAt = startedAt
|
|
if hasSnapshot {
|
|
developmentLog("[pvfine:index] cache hit: files=%d elapsed=%s", a.FileCount(), time.Since(startedAt).Round(time.Millisecond))
|
|
c.indexStatus = IndexStatus{State: IndexStateReady, Stage: "ready-cache", Done: int(a.FileCount()), Total: int(a.FileCount()), CacheHit: true, OpenDurationMs: c.indexStatus.OpenDurationMs}
|
|
status := c.indexStatus
|
|
c.indexCancel = nil
|
|
c.mu.Unlock()
|
|
cancel()
|
|
emitEvent("archive:index-ready", status)
|
|
return
|
|
}
|
|
index.dirty = true
|
|
c.indexStatus = IndexStatus{State: IndexStateBuilding, Stage: "sqlite", OpenDurationMs: c.indexStatus.OpenDurationMs}
|
|
status := c.indexStatus
|
|
c.mu.Unlock()
|
|
finishTask, ok := c.archiveTasks.tryBegin()
|
|
if !ok {
|
|
cancel()
|
|
return
|
|
}
|
|
if !c.indexCurrent(a, gen, ctx) {
|
|
finishTask()
|
|
return
|
|
}
|
|
emitEvent("archive:index-progress", status)
|
|
go func() {
|
|
defer finishTask()
|
|
c.buildSearchIndexSQLite(ctx, gen, a, index, startedAt, force)
|
|
}()
|
|
return
|
|
}
|
|
openDurationMs := c.indexStatus.OpenDurationMs
|
|
hasSnapshot := c.indexStatus.State == IndexStateReady && c.searchRecords != nil
|
|
pendingDirty := c.indexDirty
|
|
cacheEligible := a.SourcePath() != "" && !a.Modified()
|
|
delta := !force && hasSnapshot && (c.searchIndexDeltaPending || len(c.searchIndexListPending) > 0)
|
|
listIndexes := make([]int32, 0, len(c.searchIndexListPending))
|
|
for index := range c.searchIndexListPending {
|
|
listIndexes = append(listIndexes, index)
|
|
}
|
|
c.searchIndexDeltaPending = false
|
|
c.searchIndexListPending = nil
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
c.indexCancel = cancel
|
|
if !hasSnapshot || pendingDirty == nil {
|
|
c.indexDirty = make(map[int32]struct{})
|
|
}
|
|
c.indexStartedAt = startedAt
|
|
state := IndexStateBuilding
|
|
stage := "preparing"
|
|
if hasSnapshot {
|
|
state = IndexStateReady
|
|
stage = "refreshing"
|
|
}
|
|
c.indexStatus = IndexStatus{
|
|
State: state,
|
|
Stage: stage,
|
|
OpenDurationMs: openDurationMs,
|
|
Refreshing: hasSnapshot,
|
|
}
|
|
status := c.indexStatus
|
|
c.mu.Unlock()
|
|
|
|
finishTask, ok := c.archiveTasks.tryBegin()
|
|
if !ok {
|
|
cancel()
|
|
return
|
|
}
|
|
if !c.indexCurrent(a, gen, ctx) {
|
|
finishTask()
|
|
return
|
|
}
|
|
emitEvent("archive:index-progress", status)
|
|
if delta {
|
|
go func() {
|
|
defer finishTask()
|
|
c.buildSearchIndexDelta(ctx, gen, a, startedAt, listIndexes)
|
|
}()
|
|
return
|
|
}
|
|
go func() {
|
|
defer finishTask()
|
|
c.buildSearchIndex(ctx, gen, a, startedAt, force, cacheEligible)
|
|
}()
|
|
}
|
|
|
|
func (c *core) buildSearchIndexSQLite(ctx context.Context, gen uint64, a *pvf.Archive, index *sqliteArchiveIndex, startedAt time.Time, force bool) {
|
|
specs := c.searchableListSpecs()
|
|
developmentLog("[pvfine:index] semantic build started: force=%t generation=%d files=%d specs=%d", force, gen, a.FileCount(), len(specs))
|
|
var total, skipped int
|
|
var err error
|
|
c.mu.RLock()
|
|
delta := !force && index.semanticInitialized
|
|
epoch := index.semanticEpoch
|
|
revision := c.batchRevision
|
|
c.mu.RUnlock()
|
|
if delta {
|
|
total, skipped, err = index.buildSemanticDelta(ctx, c, a, specs, gen)
|
|
} else {
|
|
total, skipped, err = index.buildSemantic(ctx, c, a, specs, gen)
|
|
}
|
|
if errors.Is(err, errIndexDeltaStale) {
|
|
c.mu.Lock()
|
|
if c.indexIsCurrentLocked(a, gen) {
|
|
retryForce := force || c.indexRefreshPendingForce
|
|
c.indexCancel = nil
|
|
c.mu.Unlock()
|
|
c.startSearchIndexWithOptions(retryForce)
|
|
} else {
|
|
c.mu.Unlock()
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
if ctx.Err() != nil {
|
|
developmentLog("[pvfine:index] semantic build cancelled: generation=%d elapsed=%s", gen, time.Since(startedAt).Round(time.Millisecond))
|
|
return
|
|
}
|
|
log.Printf("[pvfine:index] semantic build failed: generation=%d elapsed=%s error=%v", gen, time.Since(startedAt).Round(time.Millisecond), err)
|
|
c.mu.Lock()
|
|
if c.indexIsCurrentLocked(a, gen) {
|
|
pendingRefresh := c.indexRefreshPending
|
|
pendingForce := c.indexRefreshPendingForce
|
|
c.indexStatus = IndexStatus{State: IndexStateError, Stage: "error", Error: err.Error(), OpenDurationMs: c.indexStatus.OpenDurationMs, BuildDurationMs: elapsedMilliseconds(startedAt)}
|
|
c.indexCancel = nil
|
|
c.indexRefreshPending = false
|
|
c.indexRefreshPendingForce = false
|
|
status := c.indexStatus
|
|
c.mu.Unlock()
|
|
if pendingRefresh {
|
|
// The failed candidate is obsolete because a newer mutation is
|
|
// already queued. Retry the queued build directly so observers do
|
|
// not mistake the transient failure for the final index state.
|
|
c.startSearchIndexWithOptions(pendingForce)
|
|
return
|
|
}
|
|
emitEvent("archive:index-error", status)
|
|
return
|
|
}
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
pendingRefresh := false
|
|
pendingForce := false
|
|
c.mu.Lock()
|
|
if !c.indexIsCurrentLocked(a, gen) || c.diskIndex != index || ctx.Err() != nil {
|
|
c.mu.Unlock()
|
|
developmentLog("[pvfine:index] semantic build discarded: generation=%d elapsed=%s", gen, time.Since(startedAt).Round(time.Millisecond))
|
|
return
|
|
}
|
|
pendingRefresh = c.indexRefreshPending || index.semanticPublishedEpoch != index.semanticEpoch || index.semanticPublishedRevision != c.batchRevision
|
|
pendingForce = c.indexRefreshPendingForce
|
|
index.semanticInitialized = true
|
|
if !delta && index.semanticEpoch == epoch && c.batchRevision == revision {
|
|
index.pendingPaths, index.pendingIDs = nil, nil
|
|
index.pendingStructural = false
|
|
}
|
|
index.ready = true
|
|
if !pendingRefresh {
|
|
index.dirty = false
|
|
}
|
|
stage := "ready-sqlite"
|
|
if pendingRefresh {
|
|
stage = "refreshing"
|
|
}
|
|
state := IndexStateReady
|
|
if pendingRefresh {
|
|
state = IndexStateBuilding
|
|
index.ready = false
|
|
}
|
|
c.indexStatus = IndexStatus{State: state, Stage: stage, Done: total, Total: total, Skipped: skipped, Refreshing: pendingRefresh, OpenDurationMs: c.indexStatus.OpenDurationMs, BuildDurationMs: elapsedMilliseconds(startedAt)}
|
|
c.indexCancel = nil
|
|
c.indexRefreshPending = false
|
|
c.indexRefreshPendingForce = false
|
|
status := c.indexStatus
|
|
c.mu.Unlock()
|
|
developmentLog("[pvfine:index] semantic build finished: generation=%d records=%d skipped=%d elapsed=%s", gen, total, skipped, time.Since(startedAt).Round(time.Millisecond))
|
|
emitEvent("archive:index-ready", status)
|
|
if pendingRefresh {
|
|
c.startSearchIndexWithOptions(pendingForce)
|
|
} else if !delta {
|
|
// Full builds now publish into private session storage. Preserve the
|
|
// completed clean cache for the next open as well.
|
|
c.persistCurrentSQLiteIndexAsync()
|
|
}
|
|
}
|
|
|
|
func (c *core) buildSearchIndex(ctx context.Context, gen uint64, a *pvf.Archive, startedAt time.Time, force, cacheEligible bool) {
|
|
defer func() {
|
|
if recovered := recover(); recovered != nil {
|
|
c.failSearchIndex(a, gen, ctx, startedAt, fmt.Errorf("搜索索引构建失败: %v", recovered))
|
|
}
|
|
}()
|
|
|
|
paths, ok := c.snapshotPaths(a, gen, ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
|
|
specs := c.searchableListSpecs()
|
|
if !force && cacheEligible {
|
|
c.mu.RLock()
|
|
cached, err := loadSearchIndexCache(a, specs, c.searchIndexCachePath)
|
|
c.mu.RUnlock()
|
|
if err == nil {
|
|
metadata := cached.Metadata
|
|
byFile := make(map[int32][]int)
|
|
visuals := make(map[int32]fileVisuals)
|
|
for index := range metadata {
|
|
value := &metadata[index]
|
|
byFile[value.fileIndex] = append(byFile[value.fileIndex], index)
|
|
visuals[value.fileIndex] = fileVisuals{
|
|
icon: cloneImageReference(value.icon),
|
|
fieldImage: cloneImageReference(value.fieldImage),
|
|
rarity: value.rarity,
|
|
}
|
|
}
|
|
records, recordsByFile := buildSearchRecords(paths, metadata, byFile)
|
|
treeTagsByFile := buildTreeTags(records, recordsByFile)
|
|
if c.publishSearchCandidate(a, gen, ctx, startedAt, records, recordsByFile, metadata, treeTagsByFile, visuals, cached.Total, cached.Skipped, cached.SpecsFingerprint, false, true) {
|
|
return
|
|
}
|
|
return
|
|
}
|
|
}
|
|
|
|
refs := make([]indexedMetadata, 0)
|
|
skipped := 0
|
|
for _, spec := range specs {
|
|
if !c.indexCurrent(a, gen, ctx) {
|
|
return
|
|
}
|
|
listIndex, ok := c.findArchiveList(a, gen, ctx, spec.listPath)
|
|
if !ok {
|
|
continue
|
|
}
|
|
|
|
pairs, err := c.readListPairs(a, gen, ctx, listIndex)
|
|
if err != nil {
|
|
skipped++
|
|
continue
|
|
}
|
|
for _, pair := range pairs {
|
|
target, ok := c.findListTarget(a, gen, ctx, spec.listPath, pair.Path)
|
|
if !ok {
|
|
skipped++
|
|
continue
|
|
}
|
|
refs = append(refs, indexedMetadata{
|
|
id: pair.ID,
|
|
category: spec.category,
|
|
listPath: spec.listPath,
|
|
path: target,
|
|
})
|
|
}
|
|
}
|
|
|
|
npcNames := make(map[string]string)
|
|
for _, spec := range specs {
|
|
if !sameSearchPath(spec.listPath, itemShopListPath) {
|
|
continue
|
|
}
|
|
var ok bool
|
|
npcNames, ok = c.buildNPCNameIndex(a, gen, ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
break
|
|
}
|
|
|
|
if !c.publishIndexStatus(a, gen, ctx, IndexStatus{
|
|
State: IndexStateBuilding,
|
|
Stage: "metadata",
|
|
Total: len(refs),
|
|
Skipped: skipped,
|
|
}) {
|
|
return
|
|
}
|
|
|
|
metadata := make([]indexedMetadata, 0, len(refs))
|
|
byFile := make(map[int32][]int)
|
|
metadataByIndex := make(map[int32]pvf.ScriptMetadata)
|
|
lastProgress := time.Now()
|
|
for i, ref := range refs {
|
|
if !c.indexCurrent(a, gen, ctx) {
|
|
return
|
|
}
|
|
|
|
fileIndex, ok := c.findArchiveEntry(a, gen, ctx, ref.path)
|
|
if !ok {
|
|
skipped++
|
|
c.publishIndexProgress(a, gen, ctx, i+1, len(refs), skipped, &lastProgress)
|
|
continue
|
|
}
|
|
|
|
f, canonicalPath, ok := c.readFileMetadata(a, gen, ctx, fileIndex)
|
|
if !ok {
|
|
return
|
|
}
|
|
scriptMetadata, metadataCached := metadataByIndex[fileIndex]
|
|
var metadataErr error
|
|
if !metadataCached {
|
|
scriptMetadata, metadataErr = c.readIndexedMetadata(a, gen, ctx, fileIndex, ref.listPath, npcNames)
|
|
if metadataErr == nil {
|
|
metadataByIndex[fileIndex] = scriptMetadata
|
|
}
|
|
}
|
|
if metadataErr != nil {
|
|
// The id/path mapping remains useful even when the target is not
|
|
// a parseable script or has malformed content.
|
|
scriptMetadata = pvf.ScriptMetadata{}
|
|
}
|
|
ref.name = scriptMetadata.Name
|
|
ref.icon = imageReferenceFromPVF(scriptMetadata.Icon)
|
|
ref.fieldImage = imageReferenceFromPVF(scriptMetadata.FieldImage)
|
|
ref.rarity = scriptMetadata.RarityValue()
|
|
ref.fileIndex = fileIndex
|
|
ref.path = canonicalPath
|
|
ref.size = f.DataSize
|
|
ref.dataType = f.DataType
|
|
byFile[fileIndex] = append(byFile[fileIndex], len(metadata))
|
|
metadata = append(metadata, ref)
|
|
c.publishIndexProgress(a, gen, ctx, i+1, len(refs), skipped, &lastProgress)
|
|
}
|
|
|
|
records, recordsByFile := buildSearchRecords(paths, metadata, byFile)
|
|
treeTagsByFile := buildTreeTags(records, recordsByFile)
|
|
visualsByFile := make(map[int32]fileVisuals, len(metadataByIndex))
|
|
for fileIndex, scriptMetadata := range metadataByIndex {
|
|
visualsByFile[fileIndex] = visualsFromMetadata(scriptMetadata)
|
|
}
|
|
|
|
if !c.publishSearchCandidate(a, gen, ctx, startedAt, records, recordsByFile, metadata, treeTagsByFile, visualsByFile, len(refs), skipped, searchIndexSpecFingerprint(specs), cacheEligible, false) {
|
|
return
|
|
}
|
|
}
|
|
|
|
func (c *core) buildSearchIndexDelta(ctx context.Context, gen uint64, a *pvf.Archive, startedAt time.Time, listIndexes []int32) {
|
|
paths, ok := c.snapshotPaths(a, gen, ctx)
|
|
if !ok {
|
|
return
|
|
}
|
|
c.mu.RLock()
|
|
metadata := cloneIndexedMetadata(c.searchMetadata)
|
|
oldSkipped := c.indexStatus.Skipped
|
|
dirtyIndexes := make([]int32, 0, len(c.indexDirty))
|
|
for index := range c.indexDirty {
|
|
dirtyIndexes = append(dirtyIndexes, index)
|
|
}
|
|
c.mu.RUnlock()
|
|
|
|
specs := c.searchableListSpecs()
|
|
affectedLists := make(map[int32]struct{}, len(listIndexes))
|
|
for _, listIndex := range listIndexes {
|
|
affectedLists[listIndex] = struct{}{}
|
|
}
|
|
|
|
// A structural delta keeps existing list registrations and only rebinds
|
|
// their paths/file indexes against the new file table. A list delta replaces
|
|
// the rows for the affected relation specs below.
|
|
if len(affectedLists) == 0 {
|
|
filtered := make([]indexedMetadata, 0, len(metadata))
|
|
for _, value := range metadata {
|
|
if !c.indexCurrent(a, gen, ctx) {
|
|
return
|
|
}
|
|
fileIndex, found := c.findArchiveEntry(a, gen, ctx, value.path)
|
|
if !found {
|
|
continue
|
|
}
|
|
file, canonicalPath, found := c.readFileMetadata(a, gen, ctx, fileIndex)
|
|
if !found {
|
|
continue
|
|
}
|
|
value.fileIndex = fileIndex
|
|
value.path = canonicalPath
|
|
value.size = file.DataSize
|
|
value.dataType = file.DataType
|
|
filtered = append(filtered, value)
|
|
}
|
|
metadata = filtered
|
|
} else {
|
|
kept := make([]indexedMetadata, 0, len(metadata))
|
|
for _, value := range metadata {
|
|
listIndex, found := c.findArchiveList(a, gen, ctx, value.listPath)
|
|
if found {
|
|
if _, affected := affectedLists[listIndex]; affected {
|
|
continue
|
|
}
|
|
}
|
|
fileIndex, found := c.findArchiveEntry(a, gen, ctx, value.path)
|
|
if !found {
|
|
continue
|
|
}
|
|
file, canonicalPath, found := c.readFileMetadata(a, gen, ctx, fileIndex)
|
|
if !found {
|
|
continue
|
|
}
|
|
value.fileIndex = fileIndex
|
|
value.path = canonicalPath
|
|
value.size = file.DataSize
|
|
value.dataType = file.DataType
|
|
kept = append(kept, value)
|
|
}
|
|
metadata = kept
|
|
|
|
npcNames := map[string]string(nil)
|
|
for _, spec := range specs {
|
|
listIndex, found := c.findArchiveList(a, gen, ctx, spec.listPath)
|
|
if !found {
|
|
continue
|
|
}
|
|
if _, affected := affectedLists[listIndex]; !affected {
|
|
continue
|
|
}
|
|
if sameSearchPath(spec.listPath, itemShopListPath) {
|
|
var namesOK bool
|
|
npcNames, namesOK = c.buildNPCNameIndex(a, gen, ctx)
|
|
if !namesOK {
|
|
return
|
|
}
|
|
}
|
|
pairs, err := c.readListPairs(a, gen, ctx, listIndex)
|
|
if err != nil {
|
|
oldSkipped++
|
|
continue
|
|
}
|
|
metadataByPath := make(map[string]pvf.ScriptMetadata)
|
|
for _, pair := range pairs {
|
|
targetPath, targetIndex, file, found := c.findListTargetFile(a, gen, ctx, spec.listPath, pair.Path)
|
|
if !found {
|
|
oldSkipped++
|
|
continue
|
|
}
|
|
scriptMetadata, cached := metadataByPath[targetPath]
|
|
if !cached {
|
|
var metadataErr error
|
|
scriptMetadata, metadataErr = c.readIndexedMetadata(a, gen, ctx, targetIndex, spec.listPath, npcNames)
|
|
if metadataErr != nil {
|
|
scriptMetadata = pvf.ScriptMetadata{}
|
|
}
|
|
metadataByPath[targetPath] = scriptMetadata
|
|
}
|
|
metadata = append(metadata, indexedMetadata{
|
|
name: scriptMetadata.Name,
|
|
id: pair.ID,
|
|
category: spec.category,
|
|
listPath: spec.listPath,
|
|
path: targetPath,
|
|
fileIndex: targetIndex,
|
|
size: file.DataSize,
|
|
dataType: file.DataType,
|
|
rarity: scriptMetadata.RarityValue(),
|
|
icon: imageReferenceFromPVF(scriptMetadata.Icon),
|
|
fieldImage: imageReferenceFromPVF(scriptMetadata.FieldImage),
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Payload-only edits keep the record set but need fresh display metadata.
|
|
// Read each changed target once and apply it to every list registration of
|
|
// that target. The expensive work is intentionally outside the request that
|
|
// staged the edit.
|
|
for _, dirtyIndex := range dirtyIndexes {
|
|
file, canonicalPath, ok := c.readFileMetadata(a, gen, ctx, dirtyIndex)
|
|
if !ok {
|
|
continue
|
|
}
|
|
listPath := ""
|
|
for _, value := range metadata {
|
|
if value.fileIndex == dirtyIndex || value.path == canonicalPath {
|
|
listPath = value.listPath
|
|
break
|
|
}
|
|
}
|
|
if listPath == "" {
|
|
for index := range paths {
|
|
if paths[index].idx == dirtyIndex {
|
|
paths[index].size = file.DataSize
|
|
paths[index].typ = file.DataType
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
scriptMetadata, err := c.readIndexedMetadata(a, gen, ctx, dirtyIndex, listPath, nil)
|
|
if err != nil {
|
|
scriptMetadata = pvf.ScriptMetadata{}
|
|
}
|
|
visuals := visualsFromMetadata(scriptMetadata)
|
|
for index := range metadata {
|
|
if metadata[index].fileIndex != dirtyIndex && metadata[index].path != canonicalPath {
|
|
continue
|
|
}
|
|
metadata[index].path = canonicalPath
|
|
metadata[index].fileIndex = dirtyIndex
|
|
metadata[index].size = file.DataSize
|
|
metadata[index].dataType = file.DataType
|
|
metadata[index].name = scriptMetadata.Name
|
|
metadata[index].rarity = visuals.rarity
|
|
metadata[index].icon = cloneImageReference(visuals.icon)
|
|
metadata[index].fieldImage = cloneImageReference(visuals.fieldImage)
|
|
}
|
|
for index := range paths {
|
|
if paths[index].idx == dirtyIndex {
|
|
paths[index].size = file.DataSize
|
|
paths[index].typ = file.DataType
|
|
}
|
|
}
|
|
}
|
|
|
|
byFile := make(map[int32][]int)
|
|
visuals := make(map[int32]fileVisuals)
|
|
for index := range metadata {
|
|
value := &metadata[index]
|
|
byFile[value.fileIndex] = append(byFile[value.fileIndex], index)
|
|
visuals[value.fileIndex] = fileVisuals{
|
|
icon: cloneImageReference(value.icon),
|
|
fieldImage: cloneImageReference(value.fieldImage),
|
|
rarity: value.rarity,
|
|
}
|
|
}
|
|
records, recordsByFile := buildSearchRecords(paths, metadata, byFile)
|
|
treeTagsByFile := buildTreeTags(records, recordsByFile)
|
|
total := len(metadata) + oldSkipped
|
|
if !c.publishSearchCandidate(a, gen, ctx, startedAt, records, recordsByFile, metadata, treeTagsByFile, visuals, total, oldSkipped, searchIndexSpecFingerprint(specs), false, false) {
|
|
return
|
|
}
|
|
}
|
|
|
|
func cloneIndexedMetadata(values []indexedMetadata) []indexedMetadata {
|
|
if len(values) == 0 {
|
|
return nil
|
|
}
|
|
result := make([]indexedMetadata, len(values))
|
|
for index, value := range values {
|
|
result[index] = value
|
|
result[index].icon = cloneImageReference(value.icon)
|
|
result[index].fieldImage = cloneImageReference(value.fieldImage)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// publishSearchCandidate atomically swaps a completed candidate into the
|
|
// currently active snapshot. A ready snapshot remains queryable until this
|
|
// point, so a slow refresh never turns the normal UI flow into a loading gate.
|
|
func (c *core) publishSearchCandidate(
|
|
a *pvf.Archive,
|
|
gen uint64,
|
|
ctx context.Context,
|
|
startedAt time.Time,
|
|
records []searchRecord,
|
|
recordsByFile map[int32][]int,
|
|
metadata []indexedMetadata,
|
|
treeTagsByFile map[int32][]TreeTag,
|
|
visualsByFile map[int32]fileVisuals,
|
|
total, skipped int,
|
|
specsFingerprint string,
|
|
cacheEligible bool,
|
|
cacheHit bool,
|
|
) bool {
|
|
// Reconcile payloads before taking the publish lock. Parsing script
|
|
// metadata can touch decompression and string-table caches; doing that
|
|
// while holding core.mu used to make the resource tree wait for the whole
|
|
// reconciliation pass.
|
|
c.mu.RLock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
c.mu.RUnlock()
|
|
return false
|
|
}
|
|
dirtyIndexes := make([]int32, 0, len(c.indexDirty))
|
|
for fileIndex := range c.indexDirty {
|
|
dirtyIndexes = append(dirtyIndexes, fileIndex)
|
|
}
|
|
c.mu.RUnlock()
|
|
for _, fileIndex := range dirtyIndexes {
|
|
c.mu.RLock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil || fileIndex < 0 || fileIndex >= a.FileCount() {
|
|
c.mu.RUnlock()
|
|
return false
|
|
}
|
|
scriptMetadata, err := readIndexedMetadataFromArchive(a, fileIndex, a.Path(fileIndex), nil)
|
|
fileSize := a.File(fileIndex).DataSize
|
|
fileType := a.File(fileIndex).DataType
|
|
c.mu.RUnlock()
|
|
if err != nil {
|
|
scriptMetadata = pvf.ScriptMetadata{}
|
|
}
|
|
visuals := visualsFromMetadata(scriptMetadata)
|
|
visualsByFile[fileIndex] = visuals
|
|
for metadataIndex := range metadata {
|
|
if metadata[metadataIndex].fileIndex != fileIndex {
|
|
continue
|
|
}
|
|
metadata[metadataIndex].name = scriptMetadata.Name
|
|
metadata[metadataIndex].size = fileSize
|
|
metadata[metadataIndex].dataType = fileType
|
|
metadata[metadataIndex].rarity = visuals.rarity
|
|
metadata[metadataIndex].icon = cloneImageReference(visuals.icon)
|
|
metadata[metadataIndex].fieldImage = cloneImageReference(visuals.fieldImage)
|
|
}
|
|
for _, recordIndex := range recordsByFile[fileIndex] {
|
|
if recordIndex < 0 || recordIndex >= len(records) {
|
|
continue
|
|
}
|
|
record := &records[recordIndex]
|
|
record.hit.Size = fileSize
|
|
record.hit.DataType = fileType
|
|
if record.hit.Category == SearchCategoryFile {
|
|
continue
|
|
}
|
|
record.hit.Name = scriptMetadata.Name
|
|
record.lowerName = strings.ToLower(scriptMetadata.Name)
|
|
record.hit.Rarity = visuals.rarity
|
|
record.hit.Icon = cloneImageReference(visuals.icon)
|
|
record.hit.FieldImage = cloneImageReference(visuals.fieldImage)
|
|
}
|
|
}
|
|
|
|
c.mu.Lock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
c.mu.Unlock()
|
|
return false
|
|
}
|
|
|
|
c.searchRecords = records
|
|
c.searchByFile = recordsByFile
|
|
c.searchMetadata = cloneIndexedMetadata(metadata)
|
|
c.searchSpecFingerprint = specsFingerprint
|
|
c.treeTagsByFile = treeTagsByFile
|
|
c.visualsByFile = visualsByFile
|
|
pendingRefresh := c.indexRefreshPending
|
|
pendingForce := c.indexRefreshPendingForce
|
|
openDurationMs := c.indexStatus.OpenDurationMs
|
|
c.indexStatus = IndexStatus{
|
|
State: IndexStateReady,
|
|
Stage: "ready",
|
|
Done: total,
|
|
Total: total,
|
|
Skipped: skipped,
|
|
CacheHit: cacheHit,
|
|
OpenDurationMs: openDurationMs,
|
|
BuildDurationMs: elapsedMilliseconds(startedAt),
|
|
Refreshing: pendingRefresh,
|
|
}
|
|
if cacheHit {
|
|
c.indexStatus.Stage = "ready-cache"
|
|
}
|
|
if pendingRefresh {
|
|
// Mutations that arrived after this build captured its dirty set belong
|
|
// to the queued build. Keeping the map here is intentionally conservative
|
|
// and may rebuild one already reconciled file, but can never drop the
|
|
// newest content.
|
|
c.indexStatus.Stage = "refreshing"
|
|
} else {
|
|
c.indexDirty = make(map[int32]struct{})
|
|
}
|
|
c.indexCancel = nil
|
|
c.indexRefreshPending = false
|
|
c.indexRefreshPendingForce = false
|
|
status := c.indexStatus
|
|
c.mu.Unlock()
|
|
|
|
emitEvent("archive:index-ready", status)
|
|
if pendingRefresh {
|
|
c.startSearchIndexWithOptions(pendingForce)
|
|
} else if cacheEligible && !cacheHit {
|
|
c.persistSearchIndexCacheAsync(a, gen, metadata, total, skipped)
|
|
}
|
|
return true
|
|
}
|
|
|
|
func (c *core) failSearchIndex(a *pvf.Archive, gen uint64, ctx context.Context, startedAt time.Time, err error) {
|
|
if err == nil || ctx.Err() != nil {
|
|
return
|
|
}
|
|
c.mu.Lock()
|
|
if !c.indexIsCurrentLocked(a, gen) {
|
|
c.mu.Unlock()
|
|
return
|
|
}
|
|
pendingRefresh := c.indexRefreshPending
|
|
pendingForce := c.indexRefreshPendingForce
|
|
if c.indexStatus.State == IndexStateReady && c.searchRecords != nil {
|
|
c.indexStatus.Refreshing = false
|
|
c.indexStatus.Stage = "refresh-error"
|
|
c.indexStatus.RefreshError = err.Error()
|
|
c.indexStatus.BuildDurationMs = elapsedMilliseconds(startedAt)
|
|
} else {
|
|
c.indexStatus = IndexStatus{
|
|
State: IndexStateError,
|
|
Stage: "error",
|
|
Error: err.Error(),
|
|
OpenDurationMs: c.indexStatus.OpenDurationMs,
|
|
BuildDurationMs: elapsedMilliseconds(startedAt),
|
|
}
|
|
}
|
|
c.indexCancel = nil
|
|
c.indexRefreshPending = false
|
|
c.indexRefreshPendingForce = false
|
|
status := c.indexStatus
|
|
c.mu.Unlock()
|
|
if pendingRefresh {
|
|
// See the SQLite path above: a queued newer mutation should be retried
|
|
// without exposing the failed intermediate candidate as final state.
|
|
c.startSearchIndexWithOptions(pendingForce)
|
|
return
|
|
}
|
|
emitEvent("archive:index-error", status)
|
|
}
|
|
|
|
// setText updates an archive script and synchronizes its semantic search name
|
|
// when the file is already represented by the completed index.
|
|
func (c *core) setText(index int32, text string) (bool, string, error) {
|
|
c.mu.Lock()
|
|
if c.archive == nil {
|
|
c.mu.Unlock()
|
|
return false, "", ErrNoArchive
|
|
}
|
|
if err := c.ensureVersionReadyLocked(); err != nil {
|
|
c.mu.Unlock()
|
|
return false, "", err
|
|
}
|
|
if index < 0 || index >= c.archive.FileCount() {
|
|
err := fmt.Errorf("文件索引越界: %d", index)
|
|
c.mu.Unlock()
|
|
return false, "", err
|
|
}
|
|
path := c.archive.Path(index)
|
|
a := c.archive
|
|
mutationCheckpoint := a.MutationCheckpoint()
|
|
var before pvfversion.ContentSnapshot
|
|
if c.versionRepo != nil {
|
|
var err error
|
|
before, err = pvfversion.ContentSnapshotFromArchive(c.archive, []string{path})
|
|
if err != nil {
|
|
c.mu.Unlock()
|
|
return false, "", err
|
|
}
|
|
}
|
|
if err := c.archive.SetText(index, text); err != nil {
|
|
c.mu.Unlock()
|
|
return false, "", err
|
|
}
|
|
summary := a.MutationsSince(mutationCheckpoint)
|
|
if len(summary.Files) == 0 {
|
|
a.ClearMutations()
|
|
c.mu.Unlock()
|
|
return false, "", nil
|
|
}
|
|
if c.versionRepo != nil {
|
|
after, err := pvfversion.ContentSnapshotFromArchive(c.archive, []string{path})
|
|
if err != nil {
|
|
c.mu.Unlock()
|
|
return false, "", err
|
|
}
|
|
if err := c.recordVersionMutationLocked("编辑文件", before, after); err != nil {
|
|
c.mu.Unlock()
|
|
return false, "", err
|
|
}
|
|
}
|
|
c.batchRevision++
|
|
c.batchPlan = nil
|
|
c.invalidateScriptLocked()
|
|
versioned := c.versionRepo != nil
|
|
if c.editorText == nil {
|
|
c.editorText = make(map[int32]string)
|
|
}
|
|
c.editorText[index] = text
|
|
c.annotationRelations = make(map[string]map[string]*relationTarget)
|
|
c.editorAnnotation = editorAnnotationCache{}
|
|
c.markAdvancedSearchStaleLocked(c.archive)
|
|
if c.diskIndex != nil {
|
|
if err := c.diskIndex.refreshFileMetadata(a, map[int32]struct{}{index: {}}); err != nil {
|
|
a.ClearMutations()
|
|
c.mu.Unlock()
|
|
return false, "", err
|
|
}
|
|
} else {
|
|
refreshArchiveIndexMetadataLocked(c, map[int32]struct{}{index: {}})
|
|
}
|
|
delete(c.visualsByFile, index)
|
|
a.ClearMutations()
|
|
c.mu.Unlock()
|
|
if err := c.scheduleArchiveMutations(a, summary); err != nil {
|
|
return false, "", err
|
|
}
|
|
emitEvent("archive:advanced-search-stale", map[string]any{"fileIndex": index})
|
|
if versioned {
|
|
emitVersionState(c, "edited")
|
|
}
|
|
return false, "", nil
|
|
}
|
|
|
|
// setPlaceholderText rewrites the string-table text behind one `<table::key>`
|
|
// placeholder and refreshes the name of the file that carries it, so the
|
|
// explorer and the search results pick the new text up immediately.
|
|
//
|
|
// The script that holds the placeholder is untouched: only the `.str` payload
|
|
// changes, which is where this client keeps the display text.
|
|
func (c *core) setPlaceholderText(index int32, tableIndex int32, key, text string) error {
|
|
c.mu.Lock()
|
|
if c.archive == nil {
|
|
c.mu.Unlock()
|
|
return ErrNoArchive
|
|
}
|
|
if err := c.ensureVersionReadyLocked(); err != nil {
|
|
c.mu.Unlock()
|
|
return err
|
|
}
|
|
if index < 0 || index >= c.archive.FileCount() {
|
|
err := fmt.Errorf("文件索引越界: %d", index)
|
|
c.mu.Unlock()
|
|
return err
|
|
}
|
|
a := c.archive
|
|
mutationCheckpoint := a.MutationCheckpoint()
|
|
tableFileIndex, ok := c.archive.StringTableEntryIndex(int(tableIndex), key)
|
|
if !ok {
|
|
c.mu.Unlock()
|
|
return fmt.Errorf("找不到字符串表条目 <%d::%s>", tableIndex, key)
|
|
}
|
|
tablePath := c.archive.Path(tableFileIndex)
|
|
var before pvfversion.ContentSnapshot
|
|
if c.versionRepo != nil {
|
|
var err error
|
|
before, err = pvfversion.ContentSnapshotFromArchive(c.archive, []string{tablePath})
|
|
if err != nil {
|
|
c.mu.Unlock()
|
|
return err
|
|
}
|
|
}
|
|
if err := c.archive.SetStringTableEntryAt(tableFileIndex, key, text); err != nil {
|
|
c.mu.Unlock()
|
|
return err
|
|
}
|
|
summary := a.MutationsSince(mutationCheckpoint)
|
|
if len(summary.Files) == 0 {
|
|
a.ClearMutations()
|
|
c.mu.Unlock()
|
|
return nil
|
|
}
|
|
if c.versionRepo != nil {
|
|
after, err := pvfversion.ContentSnapshotFromArchive(c.archive, []string{tablePath})
|
|
if err != nil {
|
|
c.mu.Unlock()
|
|
return err
|
|
}
|
|
if err := c.recordVersionMutationLocked("编辑字符串表", before, after); err != nil {
|
|
c.mu.Unlock()
|
|
return err
|
|
}
|
|
}
|
|
c.batchRevision++
|
|
c.batchPlan = nil
|
|
c.invalidateScriptLocked()
|
|
versioned := c.versionRepo != nil
|
|
c.annotationRelations = make(map[string]map[string]*relationTarget)
|
|
c.editorAnnotation = editorAnnotationCache{}
|
|
c.markAdvancedSearchStaleLocked(c.archive)
|
|
delete(c.visualsByFile, index)
|
|
if c.diskIndex == nil {
|
|
refreshArchiveIndexMetadataLocked(c, map[int32]struct{}{tableFileIndex: {}})
|
|
}
|
|
a.ClearMutations()
|
|
c.mu.Unlock()
|
|
if err := c.scheduleArchiveMutations(a, summary); err != nil {
|
|
return err
|
|
}
|
|
emitEvent("archive:advanced-search-stale", map[string]any{"fileIndex": index})
|
|
if versioned {
|
|
emitVersionState(c, "edited")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// refreshIndexedRecordsLocked re-reads one file's name and images from the
|
|
// archive and updates its search records and tree tags. It reports whether
|
|
// anything changed. The caller must hold c.mu and must already have deleted any
|
|
// stale entry from c.visualsByFile.
|
|
func (c *core) refreshIndexedRecordsLocked(index int32, previousVisuals fileVisuals, hadPreviousVisuals bool) (bool, string, fileVisuals) {
|
|
recordIndexes := c.searchByFile[index]
|
|
metadata, err := readIndexedMetadataFromArchive(c.archive, index, c.archive.Path(index), nil)
|
|
if err != nil {
|
|
metadata = pvf.ScriptMetadata{}
|
|
}
|
|
name := metadata.Name
|
|
visuals := visualsFromMetadata(metadata)
|
|
c.visualsByFile[index] = visuals
|
|
updated := false
|
|
for _, recordIndex := range recordIndexes {
|
|
record := &c.searchRecords[recordIndex]
|
|
if record.hit.Category != SearchCategoryFile {
|
|
record.hit.Name = name
|
|
record.lowerName = strings.ToLower(name)
|
|
}
|
|
if !imageReferencesEqual(record.hit.Icon, visuals.icon) || !imageReferencesEqual(record.hit.FieldImage, visuals.fieldImage) {
|
|
updated = true
|
|
}
|
|
record.hit.Icon = cloneImageReference(visuals.icon)
|
|
record.hit.FieldImage = cloneImageReference(visuals.fieldImage)
|
|
if record.hit.Category != SearchCategoryFile {
|
|
record.hit.Rarity = visuals.rarity
|
|
updated = true
|
|
}
|
|
}
|
|
c.treeTagsByFile[index] = treeTagsForRecords(c.searchRecords, recordIndexes)
|
|
if !hadPreviousVisuals || !imageReferencesEqual(previousVisuals.icon, visuals.icon) || !imageReferencesEqual(previousVisuals.fieldImage, visuals.fieldImage) {
|
|
if visuals.icon != nil || visuals.fieldImage != nil || hadPreviousVisuals {
|
|
updated = true
|
|
}
|
|
}
|
|
if isNPCEntryPath(c.archive.Path(index)) && c.refreshItemShopNamesLocked() {
|
|
updated = true
|
|
}
|
|
return updated, name, visuals
|
|
}
|
|
|
|
func (c *core) publishIndexProgress(a *pvf.Archive, gen uint64, ctx context.Context, done, total, skipped int, last *time.Time) {
|
|
if done != total && done%1000 != 0 && time.Since(*last) < 100*time.Millisecond {
|
|
return
|
|
}
|
|
status := IndexStatus{
|
|
State: IndexStateBuilding,
|
|
Stage: "metadata",
|
|
Done: done,
|
|
Total: total,
|
|
Skipped: skipped,
|
|
}
|
|
if c.publishIndexStatus(a, gen, ctx, status) {
|
|
*last = time.Now()
|
|
}
|
|
}
|
|
|
|
func (c *core) publishIndexStatus(a *pvf.Archive, gen uint64, ctx context.Context, status IndexStatus) bool {
|
|
c.mu.Lock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
c.mu.Unlock()
|
|
return false
|
|
}
|
|
if c.indexStatus.State == IndexStateReady && c.indexStatus.Refreshing {
|
|
// Keep the old snapshot queryable while a candidate reports progress.
|
|
status.State = IndexStateReady
|
|
status.Refreshing = true
|
|
}
|
|
status.OpenDurationMs = c.indexStatus.OpenDurationMs
|
|
status.BuildDurationMs = elapsedMilliseconds(c.indexStartedAt)
|
|
c.indexStatus = status
|
|
c.mu.Unlock()
|
|
emitEvent("archive:index-progress", status)
|
|
return true
|
|
}
|
|
|
|
func (c *core) indexCurrent(a *pvf.Archive, gen uint64, ctx context.Context) bool {
|
|
if ctx.Err() != nil {
|
|
return false
|
|
}
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
return c.indexIsCurrentLocked(a, gen)
|
|
}
|
|
|
|
func (c *core) indexIsCurrentLocked(a *pvf.Archive, gen uint64) bool {
|
|
return c.archive == a && c.indexGen == gen &&
|
|
(c.indexStatus.State == IndexStateBuilding ||
|
|
(c.indexStatus.State == IndexStateReady && c.indexStatus.Refreshing))
|
|
}
|
|
|
|
func (c *core) readListPairs(a *pvf.Archive, gen uint64, ctx context.Context, index int32) ([]pvf.ListPair, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
return nil, context.Canceled
|
|
}
|
|
return a.ScriptListPairs(index)
|
|
}
|
|
|
|
func (c *core) findArchiveEntry(a *pvf.Archive, gen uint64, ctx context.Context, name string) (int32, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
return 0, false
|
|
}
|
|
return a.Find(name)
|
|
}
|
|
|
|
// findArchiveList resolves a `.lst` relation path, tolerating the list layout
|
|
// difference between client generations (see Archive.FindList).
|
|
func (c *core) findArchiveList(a *pvf.Archive, gen uint64, ctx context.Context, listPath string) (int32, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
return 0, false
|
|
}
|
|
return a.FindList(listPath)
|
|
}
|
|
|
|
func (c *core) findListTarget(a *pvf.Archive, gen uint64, ctx context.Context, listPath, relativePath string) (string, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
return "", false
|
|
}
|
|
targetPath, _, ok := findListTargetInArchive(a, listPath, relativePath)
|
|
return targetPath, ok
|
|
}
|
|
|
|
func (c *core) findListTargetFile(a *pvf.Archive, gen uint64, ctx context.Context, listPath, relativePath string) (string, int32, pvf.File, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
return "", 0, pvf.File{}, false
|
|
}
|
|
targetPath, index, ok := findListTargetInArchive(a, listPath, relativePath)
|
|
if !ok {
|
|
return "", 0, pvf.File{}, false
|
|
}
|
|
return targetPath, index, a.File(index), true
|
|
}
|
|
|
|
func (c *core) readFileMetadata(a *pvf.Archive, gen uint64, ctx context.Context, index int32) (pvf.File, string, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
return pvf.File{}, "", false
|
|
}
|
|
return a.File(index), a.Path(index), true
|
|
}
|
|
|
|
func (c *core) readIndexedMetadata(a *pvf.Archive, gen uint64, ctx context.Context, index int32, listPath string, npcNames map[string]string) (pvf.ScriptMetadata, error) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
return pvf.ScriptMetadata{}, context.Canceled
|
|
}
|
|
return readIndexedMetadataFromArchive(a, index, listPath, npcNames)
|
|
}
|
|
|
|
func (c *core) readIndexedName(a *pvf.Archive, gen uint64, ctx context.Context, index int32, listPath string, npcNames map[string]string) (string, bool, error) {
|
|
metadata, err := c.readIndexedMetadata(a, gen, ctx, index, listPath, npcNames)
|
|
return metadata.Name, metadata.HasName, err
|
|
}
|
|
|
|
func (c *core) buildNPCNameIndex(a *pvf.Archive, gen uint64, ctx context.Context) (map[string]string, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
return nil, false
|
|
}
|
|
return buildNPCNameIndexFromArchive(a), true
|
|
}
|
|
|
|
func readIndexedNameFromArchive(a *pvf.Archive, index int32, listPath string, npcNames map[string]string) (string, bool, error) {
|
|
metadata, err := readIndexedMetadataFromArchive(a, index, listPath, npcNames)
|
|
return metadata.Name, metadata.HasName, err
|
|
}
|
|
|
|
// markedName is the name shown by the explorer, the search results and the
|
|
// editor's tree tags. It is the placeholder-resolved text, flagged when this
|
|
// client's own localization has no text for the key and only a language overlay
|
|
// could answer, so a Korean name is never mistaken for the client's own text.
|
|
func markedName(metadata pvf.ScriptMetadata) string {
|
|
if metadata.Name == "" || !metadata.NameFallback {
|
|
return metadata.Name
|
|
}
|
|
return metadata.Name + untranslatedMark
|
|
}
|
|
|
|
func readIndexedMetadataFromArchive(a *pvf.Archive, index int32, listPath string, npcNames map[string]string) (pvf.ScriptMetadata, error) {
|
|
metadata, err := a.ScriptMetadata(index)
|
|
if err != nil {
|
|
return pvf.ScriptMetadata{}, err
|
|
}
|
|
metadata.Name = markedName(metadata)
|
|
if metadata.HasName && strings.TrimSpace(metadata.Name) != "" {
|
|
return metadata, nil
|
|
}
|
|
if !isItemShopEntry(listPath, a.Path(index)) {
|
|
return metadata, nil
|
|
}
|
|
if npcNames == nil {
|
|
npcNames = buildNPCNameIndexFromArchive(a)
|
|
}
|
|
text, err := a.Text(index)
|
|
if err != nil {
|
|
return metadata, err
|
|
}
|
|
npcID := firstSectionValue(text, "npc")
|
|
if npcID == "" {
|
|
return metadata, nil
|
|
}
|
|
name, ok := npcNames[npcID]
|
|
if ok && strings.TrimSpace(name) != "" {
|
|
metadata.Name = name
|
|
metadata.HasName = true
|
|
}
|
|
return metadata, nil
|
|
}
|
|
|
|
func imageReferenceFromPVF(reference *pvf.ScriptImageReference) *ImageReference {
|
|
if reference == nil || strings.TrimSpace(reference.Path) == "" || reference.Index < 0 {
|
|
return nil
|
|
}
|
|
return &ImageReference{Path: reference.Path, Index: reference.Index}
|
|
}
|
|
|
|
func cloneImageReference(reference *ImageReference) *ImageReference {
|
|
if reference == nil {
|
|
return nil
|
|
}
|
|
copy := *reference
|
|
return ©
|
|
}
|
|
|
|
func imageReferencesEqual(left, right *ImageReference) bool {
|
|
if left == nil || right == nil {
|
|
return left == right
|
|
}
|
|
return left.Path == right.Path && left.Index == right.Index
|
|
}
|
|
|
|
func (c *core) fileVisualsLocked(index int32) fileVisuals {
|
|
if c.diskIndex != nil {
|
|
return c.diskIndex.visuals(index)
|
|
}
|
|
if c.visualsByFile == nil {
|
|
c.visualsByFile = make(map[int32]fileVisuals)
|
|
}
|
|
if visuals, ok := c.visualsByFile[index]; ok {
|
|
return fileVisuals{
|
|
icon: cloneImageReference(visuals.icon),
|
|
fieldImage: cloneImageReference(visuals.fieldImage),
|
|
rarity: visuals.rarity,
|
|
}
|
|
}
|
|
if c.archive == nil || index < 0 || index >= c.archive.FileCount() || c.archive.File(index).DataType != pvf.TypeScript {
|
|
return fileVisuals{rarity: pvf.RarityUnknown}
|
|
}
|
|
metadata, err := c.archive.ScriptMetadata(index)
|
|
if err != nil {
|
|
return fileVisuals{rarity: pvf.RarityUnknown}
|
|
}
|
|
visuals := visualsFromMetadata(metadata)
|
|
c.visualsByFile[index] = visuals
|
|
return fileVisuals{
|
|
icon: cloneImageReference(visuals.icon),
|
|
fieldImage: cloneImageReference(visuals.fieldImage),
|
|
rarity: visuals.rarity,
|
|
}
|
|
}
|
|
|
|
func buildNPCNameIndexFromArchive(a *pvf.Archive) map[string]string {
|
|
result := make(map[string]string)
|
|
listIndex, ok := a.FindList(npcListPath)
|
|
if !ok {
|
|
return result
|
|
}
|
|
pairs, err := a.ScriptListPairs(listIndex)
|
|
if err != nil {
|
|
return result
|
|
}
|
|
for _, pair := range pairs {
|
|
_, targetIndex, ok := findListTargetInArchive(a, npcListPath, pair.Path)
|
|
if !ok {
|
|
continue
|
|
}
|
|
metadata, err := a.ScriptMetadata(targetIndex)
|
|
if err != nil || !metadata.HasName || strings.TrimSpace(metadata.Name) == "" {
|
|
continue
|
|
}
|
|
if _, exists := result[pair.ID]; !exists {
|
|
result[pair.ID] = markedName(metadata)
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func isItemShopEntry(listPath, entryPath string) bool {
|
|
if sameSearchPath(listPath, itemShopListPath) {
|
|
return true
|
|
}
|
|
entryPath = normalizeSearchPath(entryPath)
|
|
return strings.HasPrefix(entryPath, "itemshop/") && strings.EqualFold(path.Ext(entryPath), ".shp")
|
|
}
|
|
|
|
func isNPCEntryPath(entryPath string) bool {
|
|
entryPath = normalizeSearchPath(entryPath)
|
|
return entryPath != npcListPath && strings.HasPrefix(entryPath, "npc/")
|
|
}
|
|
|
|
func (c *core) refreshItemShopNamesLocked() bool {
|
|
npcNames := buildNPCNameIndexFromArchive(c.archive)
|
|
changed := false
|
|
affectedFiles := make(map[int32]struct{})
|
|
for index := range c.searchRecords {
|
|
record := &c.searchRecords[index]
|
|
if record.hit.Category == SearchCategoryFile || !isItemShopEntry("", record.hit.Path) {
|
|
continue
|
|
}
|
|
name, _, err := readIndexedNameFromArchive(c.archive, record.hit.FileIndex, itemShopListPath, npcNames)
|
|
if err != nil {
|
|
name = ""
|
|
}
|
|
if record.hit.Name == name {
|
|
continue
|
|
}
|
|
record.hit.Name = name
|
|
record.lowerName = strings.ToLower(name)
|
|
affectedFiles[record.hit.FileIndex] = struct{}{}
|
|
changed = true
|
|
}
|
|
for fileIndex := range affectedFiles {
|
|
c.treeTagsByFile[fileIndex] = treeTagsForRecords(c.searchRecords, c.searchByFile[fileIndex])
|
|
}
|
|
return changed
|
|
}
|
|
|
|
func sameSearchPath(left, right string) bool {
|
|
return normalizeSearchPath(left) == normalizeSearchPath(right)
|
|
}
|
|
|
|
func normalizeSearchPath(value string) string {
|
|
return strings.ToLower(strings.Trim(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"), "/"))
|
|
}
|
|
|
|
func (c *core) snapshotPaths(a *pvf.Archive, gen uint64, ctx context.Context) ([]pathEntry, bool) {
|
|
c.mu.RLock()
|
|
defer c.mu.RUnlock()
|
|
if !c.indexIsCurrentLocked(a, gen) || ctx.Err() != nil {
|
|
return nil, false
|
|
}
|
|
paths := make([]pathEntry, len(c.sortedPaths))
|
|
copy(paths, c.sortedPaths)
|
|
return paths, true
|
|
}
|
|
|
|
func buildSearchRecords(paths []pathEntry, metadata []indexedMetadata, metadataByFile map[int32][]int) ([]searchRecord, map[int32][]int) {
|
|
if paths == nil {
|
|
paths = make([]pathEntry, 0, len(metadata))
|
|
seen := make(map[int32]bool)
|
|
for _, entry := range metadata {
|
|
if seen[entry.fileIndex] {
|
|
continue
|
|
}
|
|
seen[entry.fileIndex] = true
|
|
paths = append(paths, pathEntry{
|
|
path: entry.path,
|
|
lower: strings.ToLower(entry.path),
|
|
idx: entry.fileIndex,
|
|
size: entry.size,
|
|
typ: entry.dataType,
|
|
})
|
|
}
|
|
sort.Slice(paths, func(i, j int) bool { return paths[i].path < paths[j].path })
|
|
}
|
|
|
|
records := make([]searchRecord, 0, len(paths)+len(metadata))
|
|
recordsByFile := make(map[int32][]int)
|
|
for _, p := range paths {
|
|
metadataIndexes := metadataByFile[p.idx]
|
|
if len(metadataIndexes) == 0 {
|
|
appendSearchRecord(&records, &recordsByFile, SearchHit{
|
|
Name: pathBase(p.path),
|
|
Path: p.path,
|
|
Category: SearchCategoryFile,
|
|
Size: p.size,
|
|
DataType: p.typ,
|
|
FileIndex: p.idx,
|
|
Rarity: pvf.RarityUnknown,
|
|
ChangeKind: p.changeKind,
|
|
})
|
|
continue
|
|
}
|
|
for _, metadataIndex := range metadataIndexes {
|
|
entry := metadata[metadataIndex]
|
|
appendSearchRecord(&records, &recordsByFile, SearchHit{
|
|
Name: entry.name,
|
|
ID: entry.id,
|
|
Path: entry.path,
|
|
Category: entry.category,
|
|
Size: entry.size,
|
|
DataType: entry.dataType,
|
|
FileIndex: entry.fileIndex,
|
|
Rarity: entry.rarity,
|
|
ChangeKind: p.changeKind,
|
|
Icon: cloneImageReference(entry.icon),
|
|
FieldImage: cloneImageReference(entry.fieldImage),
|
|
})
|
|
}
|
|
}
|
|
return records, recordsByFile
|
|
}
|
|
|
|
func appendSearchRecord(records *[]searchRecord, recordsByFile *map[int32][]int, hit SearchHit) {
|
|
record := searchRecord{
|
|
hit: hit,
|
|
lowerName: strings.ToLower(hit.Name),
|
|
lowerID: strings.ToLower(hit.ID),
|
|
lowerPath: strings.ToLower(hit.Path),
|
|
}
|
|
*records = append(*records, record)
|
|
index := len(*records) - 1
|
|
(*recordsByFile)[hit.FileIndex] = append((*recordsByFile)[hit.FileIndex], index)
|
|
}
|
|
|
|
func buildTreeTags(records []searchRecord, recordsByFile map[int32][]int) map[int32][]TreeTag {
|
|
tagsByFile := make(map[int32][]TreeTag)
|
|
for fileIndex, recordIndexes := range recordsByFile {
|
|
tags := treeTagsForRecords(records, recordIndexes)
|
|
if len(tags) > 0 {
|
|
tagsByFile[fileIndex] = tags
|
|
}
|
|
}
|
|
return tagsByFile
|
|
}
|
|
|
|
func treeTagsForRecords(records []searchRecord, recordIndexes []int) []TreeTag {
|
|
var tags []TreeTag
|
|
for _, recordIndex := range recordIndexes {
|
|
if recordIndex < 0 || recordIndex >= len(records) {
|
|
continue
|
|
}
|
|
hit := records[recordIndex].hit
|
|
if hit.Category == SearchCategoryFile {
|
|
continue
|
|
}
|
|
tags = append(tags, TreeTag{
|
|
ID: hit.ID,
|
|
Name: hit.Name,
|
|
Category: hit.Category,
|
|
Rarity: hit.Rarity,
|
|
})
|
|
}
|
|
return tags
|
|
}
|
|
|
|
func cloneTreeTags(tags []TreeTag) []TreeTag {
|
|
if len(tags) == 0 {
|
|
return nil
|
|
}
|
|
cloned := make([]TreeTag, len(tags))
|
|
copy(cloned, tags)
|
|
return cloned
|
|
}
|
|
|
|
func resolveListPath(listPath, relative string) (string, bool) {
|
|
relative = strings.TrimSpace(strings.ReplaceAll(relative, "\\", "/"))
|
|
if relative == "" || strings.HasPrefix(relative, "/") {
|
|
return "", false
|
|
}
|
|
resolved := path.Clean(path.Join(path.Dir(listPath), relative))
|
|
if resolved == "." || resolved == ".." || strings.HasPrefix(resolved, "../") {
|
|
return "", false
|
|
}
|
|
return resolved, true
|
|
}
|
|
|
|
func findListTargetInArchive(a *pvf.Archive, listPath, relative string) (string, int32, bool) {
|
|
candidates, ok := listPathCandidates(listPath, relative)
|
|
if !ok {
|
|
return "", 0, false
|
|
}
|
|
for _, candidate := range candidates {
|
|
index, ok := a.Find(candidate)
|
|
if ok {
|
|
return a.Path(index), index, true
|
|
}
|
|
}
|
|
return "", 0, false
|
|
}
|
|
|
|
// listPathCandidates returns the archive paths a `.lst` entry may denote. The
|
|
// 90US layout stores entry paths relative to the list's own directory
|
|
// (`equipment/equipment.lst` + `character/a.equ`), while the 110US layout
|
|
// stores archive-root-relative paths (`list/equipment.lst` +
|
|
// `equipment/character/a.equ`). Both readings are tried, each also with the
|
|
// `(r)` override file name the client falls back to.
|
|
func listPathCandidates(listPath, relative string) ([]string, bool) {
|
|
targetPath, ok := resolveListPath(listPath, relative)
|
|
if !ok {
|
|
return nil, false
|
|
}
|
|
candidates := []string{targetPath}
|
|
appendOverride := func(candidate string) {
|
|
base := path.Base(candidate)
|
|
if strings.HasPrefix(strings.ToLower(base), "(r)") {
|
|
return
|
|
}
|
|
candidates = append(candidates, path.Join(path.Dir(candidate), "(r)"+base))
|
|
}
|
|
appendOverride(targetPath)
|
|
root := strings.Trim(strings.ReplaceAll(strings.TrimSpace(relative), "\\", "/"), "/")
|
|
if root != "" && !strings.Contains(root, "..") {
|
|
if cleaned := path.Clean(root); cleaned != "." && cleaned != targetPath {
|
|
candidates = append(candidates, cleaned)
|
|
appendOverride(cleaned)
|
|
}
|
|
}
|
|
return candidates, true
|
|
}
|
|
|
|
func pathBase(p string) string {
|
|
if slash := strings.LastIndexByte(p, '/'); slash >= 0 {
|
|
return p[slash+1:]
|
|
}
|
|
return p
|
|
}
|