服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
317 lines
11 KiB
Go
317 lines
11 KiB
Go
package services
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
"pvfine/internal/pvf"
|
|
)
|
|
|
|
func semanticDeltaFixture(t *testing.T) (*core, *sqliteArchiveIndex) {
|
|
t.Helper()
|
|
a := pvf.New()
|
|
mustAddText(t, a, "equipment/first.equ", "[name]\n`First`\n[rarity]\n2", pvf.TypeScript)
|
|
mustAddText(t, a, "equipment/second.equ", "[name]\n`Second`", pvf.TypeScript)
|
|
mustAddText(t, a, "equipment/equipment.lst", "1\t`first.equ`\n2\t`second.equ`\n3\t`future.equ`", pvf.TypeScript)
|
|
mustAddText(t, a, "stackable/stackable.lst", "9\t`../equipment/first.equ`", pvf.TypeScript)
|
|
c := NewCore()
|
|
if err := c.setArchive(a); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
index, _, err := openSQLiteArchiveIndex(a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.mu.Lock()
|
|
c.diskIndex = index
|
|
c.installDiskArchiveIndexesLocked(a)
|
|
c.mu.Unlock()
|
|
t.Cleanup(c.closeArchive)
|
|
c.startSearchIndex()
|
|
waitForSearchIndex(t, c)
|
|
return c, index
|
|
}
|
|
|
|
func assertSemanticDeltaMatchesFull(t *testing.T, c *core, index *sqliteArchiveIndex) {
|
|
t.Helper()
|
|
waitForSearchIndex(t, c)
|
|
c.mu.RLock()
|
|
snapshot := c.archive.CloneForBatch()
|
|
specs := c.searchableListSpecsLocked()
|
|
c.mu.RUnlock()
|
|
oracle := deltaIndexFixture(t, snapshot)
|
|
t.Cleanup(oracle.close)
|
|
fake := &core{archive: snapshot, diskIndex: oracle, indexGen: 1, indexStatus: IndexStatus{State: IndexStateBuilding}}
|
|
if _, _, err := oracle.buildSemantic(context.Background(), fake, snapshot, specs, 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, query := range []string{
|
|
`SELECT file_index,name,record_id,path,category,list_path,size,data_type FROM records ORDER BY file_index,category,list_path,record_id`,
|
|
`SELECT file_index,tag_id,name,category FROM tags ORDER BY file_index,category,tag_id`,
|
|
`SELECT * FROM visuals ORDER BY file_index`,
|
|
`SELECT * FROM semantic_dependencies ORDER BY file_index,dependency_path`,
|
|
`SELECT value FROM meta WHERE key='semantic_count'`,
|
|
} {
|
|
c.mu.RLock()
|
|
actual := deltaQuery(t, index.db, query)
|
|
c.mu.RUnlock()
|
|
expected := deltaQuery(t, oracle.db, query)
|
|
if !reflect.DeepEqual(actual, expected) {
|
|
t.Fatalf("delta differs from full rebuild: %s\nactual=%v\nexpected=%v", query, actual, expected)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestSQLiteSemanticDeltaMatchesFullAndPreservesUntouchedRows(t *testing.T) {
|
|
c, index := semanticDeltaFixture(t)
|
|
originalDB := index.db
|
|
untouched := deltaQuery(t, index.db, `SELECT id FROM records WHERE path='equipment/second.equ'`)
|
|
first, _ := c.archive.Find("equipment/first.equ")
|
|
if err := NewEditorService(c).SetText(first, "[name]\n`Updated`\n[rarity]\n4"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertSemanticDeltaMatchesFull(t, c, index)
|
|
if index.db != originalDB || !reflect.DeepEqual(untouched, deltaQuery(t, index.db, `SELECT id FROM records WHERE path='equipment/second.equ'`)) {
|
|
t.Fatal("single-file update rebuilt unrelated semantic rows")
|
|
}
|
|
created, err := NewArchiveService(c).CreateFile("equipment/future.equ", pvf.TypeScript)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := NewEditorService(c).SetText(created.FileIndex, "[name]\n`Future`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertSemanticDeltaMatchesFull(t, c, index)
|
|
list, _ := c.archive.Find("equipment/equipment.lst")
|
|
if err := NewEditorService(c).SetText(list, "2\t`second.equ`\n3\t`future.equ`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertSemanticDeltaMatchesFull(t, c, index)
|
|
first, _ = c.archive.Find("equipment/first.equ")
|
|
if _, err := NewArchiveService(c).DeleteFiles([]int32{first}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertSemanticDeltaMatchesFull(t, c, index)
|
|
}
|
|
|
|
func TestSQLiteSemanticDeltaRollbackRetainsDirtyQueue(t *testing.T) {
|
|
c, index := semanticDeltaFixture(t)
|
|
first, _ := c.archive.Find("equipment/first.equ")
|
|
c.mu.Lock()
|
|
before := deltaQuery(t, index.db, `SELECT file_index,name FROM records ORDER BY id`)
|
|
if err := c.archive.SetText(first, "[name]\n`After failure`"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
index.queueSemanticMutations(c.archive.MutationsSince(0))
|
|
c.archive.ClearMutations()
|
|
c.indexGen++
|
|
gen := c.indexGen
|
|
c.indexStatus = IndexStatus{State: IndexStateBuilding}
|
|
specs := c.searchableListSpecsLocked()
|
|
if _, err := index.db.Exec(`CREATE TRIGGER fail_semantic BEFORE INSERT ON records WHEN NEW.name='After failure' BEGIN SELECT RAISE(ABORT,'injected failure'); END`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a := c.archive
|
|
c.mu.Unlock()
|
|
if _, _, err := index.buildSemanticDelta(context.Background(), c, a, specs, gen); err == nil {
|
|
t.Fatal("failed delta reported success")
|
|
}
|
|
if !reflect.DeepEqual(before, deltaQuery(t, index.db, `SELECT file_index,name FROM records ORDER BY id`)) || len(index.pendingPaths) == 0 {
|
|
t.Fatal("failed transaction changed records or lost pending work")
|
|
}
|
|
if _, err := index.db.Exec(`DROP TRIGGER fail_semantic`); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, _, err := index.buildSemanticDelta(context.Background(), c, a, specs, gen); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.mu.Lock()
|
|
c.indexStatus = IndexStatus{State: IndexStateReady}
|
|
c.mu.Unlock()
|
|
assertSemanticDeltaMatchesFull(t, c, index)
|
|
}
|
|
|
|
func TestSQLiteDirtySearchUsesPathsAndRejectsOldPagination(t *testing.T) {
|
|
c, index := semanticDeltaFixture(t)
|
|
svc := NewArchiveService(c)
|
|
first, err := svc.Search("equipment", 0, 1)
|
|
if err != nil || first.NextCursor < 0 {
|
|
t.Fatalf("first page=%#v err=%v", first, err)
|
|
}
|
|
c.mu.Lock()
|
|
index.queueSemanticMutations(pvf.MutationSummary{Files: []pvf.FileMutation{{Path: "equipment/first.equ", Kind: pvf.MutationModified}}})
|
|
c.indexStatus = IndexStatus{State: IndexStateBuilding}
|
|
c.mu.Unlock()
|
|
if name, err := index.indexedNames(0); err != ErrSearchIndexing || name != "" {
|
|
t.Fatalf("dirty semantic name=%q err=%v", name, err)
|
|
}
|
|
if _, err := svc.Search("equipment", first.NextCursor, 1); err == nil {
|
|
t.Fatal("old semantic cursor was accepted during path fallback")
|
|
}
|
|
partial, err := svc.Search("equipment/first", 0, 10)
|
|
if err != nil || len(partial.Hits) != 1 || partial.Hits[0].ID != "" || partial.Hits[0].Name != "first.equ" {
|
|
t.Fatalf("path fallback=%#v err=%v", partial, err)
|
|
}
|
|
if _, err := svc.SearchQuestTargets("dungeon", "1", 0, 10); err == nil {
|
|
t.Fatal("exact semantic query presented incomplete results as ready")
|
|
}
|
|
c.startSearchIndex()
|
|
waitForSearchIndex(t, c)
|
|
if _, err := svc.Search("equipment", partial.NextCursor, 10); partial.NextCursor > 0 && err == nil {
|
|
t.Fatal("path cursor survived publication of semantic rows")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteSemanticDeltaUpdatesLocalizedPlaceholderDependencies(t *testing.T) {
|
|
c, a, itemIndex, _ := paged110LayoutFixture(t)
|
|
index, _, err := openSQLiteArchiveIndex(a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c.mu.Lock()
|
|
c.diskIndex = index
|
|
c.installDiskArchiveIndexesLocked(a)
|
|
c.mu.Unlock()
|
|
t.Cleanup(c.closeArchive)
|
|
c.startSearchIndex()
|
|
waitForSearchIndex(t, c)
|
|
originalDB := index.db
|
|
if err := NewEditorService(c).SetPlaceholderText(itemIndex, 3, "name_514530375", "增量本地化名称"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
assertSemanticDeltaMatchesFull(t, c, index)
|
|
result, err := NewArchiveService(c).Search("增量本地化名称", 0, 10)
|
|
if err != nil || len(result.Hits) != 1 {
|
|
t.Fatalf("localized delta search=%#v err=%v", result, err)
|
|
}
|
|
if index.db != originalDB {
|
|
t.Fatal("string-table edit copied the full semantic database")
|
|
}
|
|
}
|
|
|
|
// Compare identical one-file updates on a synthetic 100,000-entry archive.
|
|
// Fixture preparation and initial indexing are outside the measured interval.
|
|
func BenchmarkSQLiteSemanticRefresh(b *testing.B) {
|
|
for _, mode := range []string{"full", "delta"} {
|
|
b.Run(mode, func(b *testing.B) {
|
|
a := pvf.New()
|
|
var list strings.Builder
|
|
for n := 0; n < 100000; n++ {
|
|
path := fmt.Sprintf("equipment/%06d.equ", n)
|
|
a.AddFile(path, nil, pvf.TypeScript)
|
|
fmt.Fprintf(&list, "%d\t`%06d.equ`\n", n, n)
|
|
}
|
|
if _, err := a.AddFileText("equipment/equipment.lst", list.String(), pvf.TypeScript); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
index := deltaIndexFixture(b, a)
|
|
b.Cleanup(index.close)
|
|
c := &core{archive: a, diskIndex: index, indexGen: 1, indexStatus: IndexStatus{State: IndexStateBuilding}}
|
|
specs := []searchableListSpec{{"equipment/equipment.lst", SearchCategoryEquipment}}
|
|
if _, _, err := index.buildSemantic(context.Background(), c, a, specs, 1); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
b.ResetTimer()
|
|
for n := 0; n < b.N; n++ {
|
|
b.StopTimer()
|
|
if err := a.SetText(0, fmt.Sprintf("[name]\n`updated %d`", n)); err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
index.queueSemanticMutations(a.MutationsSince(0))
|
|
a.ClearMutations()
|
|
b.StartTimer()
|
|
var err error
|
|
if mode == "full" {
|
|
_, _, err = index.buildSemantic(context.Background(), c, a, specs, 1)
|
|
} else {
|
|
_, _, err = index.buildSemanticDelta(context.Background(), c, a, specs, 1)
|
|
}
|
|
if err != nil {
|
|
b.Fatal(err)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSQLiteCacheIdentityChecksContentWithUnchangedFileMetadata(t *testing.T) {
|
|
path := filepath.Join(t.TempDir(), "identity.pvf")
|
|
a := pvf.New()
|
|
mustAddText(t, a, "equipment/a.equ", "[name]\n`Alpha`", pvf.TypeScript)
|
|
if err := a.SaveAs(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
before, _, err := archiveIndexIdentity(a)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stat, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
b := pvf.New()
|
|
mustAddText(t, b, "equipment/a.equ", "[name]\n`Bravo`", pvf.TypeScript)
|
|
if err := b.SaveAs(path); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chtimes(path, stat.ModTime(), stat.ModTime()); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
afterStat, err := os.Stat(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if afterStat.Size() != stat.Size() || a.Header().BodySize != b.Header().BodySize {
|
|
t.Fatal("fixture must retain the cheap cache identity metadata")
|
|
}
|
|
loaded, err := pvf.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
after, _, err := archiveIndexIdentity(loaded)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if before == after {
|
|
t.Fatal("content replacement reused an unrelated cache")
|
|
}
|
|
}
|
|
|
|
func TestSQLiteRapidEditsRetainLatestSemanticSnapshot(t *testing.T) {
|
|
c, index := semanticDeltaFixture(t)
|
|
first, _ := c.archive.Find("equipment/first.equ")
|
|
for n := 0; n < 40; n++ {
|
|
if err := NewEditorService(c).SetText(first, fmt.Sprintf("[name]\n`Latest %d`", n)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
assertSemanticDeltaMatchesFull(t, c, index)
|
|
result, err := NewArchiveService(c).Search("Latest 39", 0, 10)
|
|
if err != nil || len(result.Hits) != 2 {
|
|
t.Fatalf("latest queued snapshot=%#v err=%v", result, err)
|
|
}
|
|
}
|
|
|
|
func TestSQLiteForcedRebuildSurvivesConcurrentEdits(t *testing.T) {
|
|
c, index := semanticDeltaFixture(t)
|
|
before := index.db
|
|
if _, err := NewArchiveService(c).RebuildSearchIndex(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
first, _ := c.archive.Find("equipment/first.equ")
|
|
for n := 0; n < 12; n++ {
|
|
if err := NewEditorService(c).SetText(first, fmt.Sprintf("[name]\n`Forced latest %d`", n)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
assertSemanticDeltaMatchesFull(t, c, index)
|
|
if index.db == before {
|
|
t.Fatal("queued edits downgraded the explicit forced rebuild")
|
|
}
|
|
}
|