服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
240 lines
6.9 KiB
Go
240 lines
6.9 KiB
Go
package services
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"pvfine/internal/pvf"
|
|
)
|
|
|
|
func waitForSearchCacheFile(t *testing.T, path string) {
|
|
t.Helper()
|
|
deadline := time.Now().Add(5 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
if info, err := os.Stat(path); err == nil && info.Size() > 0 {
|
|
return
|
|
}
|
|
time.Sleep(time.Millisecond)
|
|
}
|
|
t.Fatalf("search index cache was not written: %s", path)
|
|
}
|
|
|
|
func TestSearchIndexCacheHit(t *testing.T) {
|
|
archivePath := writeSearchFixture(t, "cached-search.pvf")
|
|
cachePath := filepath.Join(t.TempDir(), "search-index.json.gz")
|
|
|
|
first := NewCore()
|
|
first.searchIndexCachePath = cachePath
|
|
firstService := NewArchiveService(first)
|
|
if _, err := firstService.Open(archivePath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitForSearchIndex(t, first)
|
|
waitForSearchCacheFile(t, cachePath)
|
|
first.closeArchive()
|
|
|
|
second := NewCore()
|
|
second.searchIndexCachePath = cachePath
|
|
secondService := NewArchiveService(second)
|
|
if _, err := secondService.Open(archivePath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(second.closeArchive)
|
|
status := waitForSearchIndex(t, second)
|
|
if !status.CacheHit || status.Stage != "ready-cache" {
|
|
t.Fatalf("cache status = %#v", status)
|
|
}
|
|
result, err := secondService.Search("烈火之心项链", 0, 10)
|
|
if err != nil || len(result.Hits) != 2 {
|
|
t.Fatalf("cached search = %d hits, err = %v", len(result.Hits), err)
|
|
}
|
|
}
|
|
|
|
func TestSearchIndexCacheInvalidatesOnSourceChange(t *testing.T) {
|
|
archivePath := writeSearchFixture(t, "invalidated-search.pvf")
|
|
cachePath := filepath.Join(t.TempDir(), "search-index.json.gz")
|
|
|
|
first := NewCore()
|
|
first.searchIndexCachePath = cachePath
|
|
if _, err := NewArchiveService(first).Open(archivePath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitForSearchIndex(t, first)
|
|
waitForSearchCacheFile(t, cachePath)
|
|
first.closeArchive()
|
|
|
|
file, err := os.OpenFile(archivePath, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := file.WriteString("x"); err != nil {
|
|
_ = file.Close()
|
|
t.Fatal(err)
|
|
}
|
|
if err := file.Close(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
second := NewCore()
|
|
second.searchIndexCachePath = cachePath
|
|
if _, err := NewArchiveService(second).Open(archivePath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(second.closeArchive)
|
|
status := waitForSearchIndex(t, second)
|
|
if status.CacheHit {
|
|
t.Fatalf("stale cache was used: %#v", status)
|
|
}
|
|
}
|
|
|
|
func TestSearchIndexCacheIgnoresUnsavedEdits(t *testing.T) {
|
|
archivePath := writeSearchFixture(t, "unsaved-search.pvf")
|
|
cachePath := filepath.Join(t.TempDir(), "search-index.json.gz")
|
|
|
|
seed := NewCore()
|
|
seed.searchIndexCachePath = cachePath
|
|
seedService := NewArchiveService(seed)
|
|
if _, err := seedService.Open(archivePath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitForSearchIndex(t, seed)
|
|
waitForSearchCacheFile(t, cachePath)
|
|
seed.closeArchive()
|
|
|
|
edited := NewCore()
|
|
edited.searchIndexCachePath = cachePath
|
|
editedService := NewArchiveService(edited)
|
|
if _, err := editedService.Open(archivePath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitForSearchIndex(t, edited)
|
|
hits, err := editedService.Search("烈火之心项链", 0, 10)
|
|
if err != nil || len(hits.Hits) == 0 {
|
|
t.Fatalf("seed search = %d hits, err = %v", len(hits.Hits), err)
|
|
}
|
|
if err := NewEditorService(edited).SetText(hits.Hits[0].FileIndex, "[name]\n`未保存名称`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
waitForSearchRefresh(t, edited)
|
|
edited.closeArchive()
|
|
|
|
reopened := NewCore()
|
|
reopened.searchIndexCachePath = cachePath
|
|
reopenedService := NewArchiveService(reopened)
|
|
if _, err := reopenedService.Open(archivePath); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(reopened.closeArchive)
|
|
status := waitForSearchIndex(t, reopened)
|
|
if !status.CacheHit {
|
|
t.Fatalf("cache was unexpectedly replaced by unsaved edit: %#v", status)
|
|
}
|
|
old, err := reopenedService.Search("烈火之心项链", 0, 10)
|
|
if err != nil || len(old.Hits) == 0 {
|
|
t.Fatalf("reopened old search = %d hits, err = %v", len(old.Hits), err)
|
|
}
|
|
}
|
|
|
|
func TestSearchIndexNewFileUsesAsyncDelta(t *testing.T) {
|
|
archivePath := writeSearchFixture(t, "delta-search.pvf")
|
|
a, err := pvf.Open(archivePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := NewCore()
|
|
if err := c.setArchive(a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(c.closeArchive)
|
|
c.startSearchIndex()
|
|
waitForSearchIndex(t, c)
|
|
|
|
service := NewArchiveService(c)
|
|
if _, err := service.CreateFile("misc/generated.txt", pvf.TypeUnicode); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
status := service.IndexStatus()
|
|
if status.State != IndexStateReady {
|
|
t.Fatalf("index became unavailable during delta: %#v", status)
|
|
}
|
|
old, err := service.Search("烈火之心项链", 0, 10)
|
|
if err != nil || len(old.Hits) != 2 {
|
|
t.Fatalf("old search during delta = %d hits, err = %v", len(old.Hits), err)
|
|
}
|
|
waitForSearchRefresh(t, c)
|
|
newHits, err := service.Search("generated.txt", 0, 10)
|
|
if err != nil || len(newHits.Hits) != 1 {
|
|
t.Fatalf("new path search = %d hits, err = %v", len(newHits.Hits), err)
|
|
}
|
|
}
|
|
|
|
func TestSearchIndexListRegistrationUsesAsyncDelta(t *testing.T) {
|
|
archivePath := writeSearchFixture(t, "registration-delta.pvf")
|
|
a, err := pvf.Open(archivePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := NewCore()
|
|
if err := c.setArchive(a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(c.closeArchive)
|
|
c.startSearchIndex()
|
|
waitForSearchIndex(t, c)
|
|
fileIndex, ok := a.Find("misc/readme.txt")
|
|
if !ok {
|
|
t.Fatal("unregistered file missing")
|
|
}
|
|
|
|
service := NewArchiveService(c)
|
|
if _, err := service.RegisterFileToList(fileIndex, "equipment/equipment.lst", "2000"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status := service.IndexStatus(); status.State != IndexStateReady {
|
|
t.Fatalf("index became unavailable during registration: %#v", status)
|
|
}
|
|
waitForSearchRefresh(t, c)
|
|
result, err := service.Search("2000", 0, 10)
|
|
if err != nil || len(result.Hits) != 1 || result.Hits[0].FileIndex != fileIndex {
|
|
t.Fatalf("registered search = %#v, err = %v", result.Hits, err)
|
|
}
|
|
}
|
|
|
|
func TestSearchIndexDeleteUsesAsyncDelta(t *testing.T) {
|
|
archivePath := writeSearchFixture(t, "delete-delta.pvf")
|
|
a, err := pvf.Open(archivePath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c := NewCore()
|
|
if err := c.setArchive(a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(c.closeArchive)
|
|
c.startSearchIndex()
|
|
waitForSearchIndex(t, c)
|
|
fileIndex, ok := a.Find("misc/readme.txt")
|
|
if !ok {
|
|
t.Fatal("file to delete missing")
|
|
}
|
|
|
|
service := NewArchiveService(c)
|
|
if _, err := service.DeleteFiles([]int32{fileIndex}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if status := service.IndexStatus(); status.State != IndexStateReady {
|
|
t.Fatalf("index became unavailable during delete: %#v", status)
|
|
}
|
|
waitForSearchRefresh(t, c)
|
|
deleted, err := service.Search("misc/readme.txt", 0, 10)
|
|
if err != nil || len(deleted.Hits) != 0 {
|
|
t.Fatalf("deleted path search = %#v, err = %v", deleted.Hits, err)
|
|
}
|
|
remaining, err := service.Search("烈火之心项链", 0, 10)
|
|
if err != nil || len(remaining.Hits) != 2 {
|
|
t.Fatalf("remaining search = %d hits, err = %v", len(remaining.Hits), err)
|
|
}
|
|
}
|