Files
90CNServer/pvf/internal/pvf/stringtable_test.go
T
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

356 lines
13 KiB
Go

package pvf
import (
"bytes"
"fmt"
"strings"
"testing"
)
// TestPaged110ItemNames resolves the `<table::key>` placeholders the newer
// clients use instead of storing display text in the item file.
func TestPaged110ItemNames(t *testing.T) {
a, _ := openPaged110Fixture(t)
// Unit level: the placeholder parser and the table map.
idx, key, ok := parsePlaceholder("<31::equip_name_1>")
if !ok || idx != 31 || key != "equip_name_1" {
t.Fatalf("parsePlaceholder = %d, %q, %v", idx, key, ok)
}
if _, _, ok := parsePlaceholder("not a placeholder"); ok {
t.Error("parsePlaceholder accepted plain text")
}
if entries := a.stringTableMap("list/n_string.lst"); len(entries) < 30 {
t.Errorf("n_string.lst yielded %d entries", len(entries))
} else {
found := false
for _, e := range entries {
if e.index == 13 && strings.Contains(strings.ToLower(e.path), "stackable") {
found = true
}
}
if !found {
t.Errorf("table 13 is not mapped to a stackable .str file: %v", entries[:3])
}
}
// End to end: names of a few known items come out as text, not placeholders.
// A few files reference keys that the shipped tables simply do not contain
// (e.g. aradadventure/equipment/equipment_1.equ -> <31::equip_name_1>), so
// the check is a ratio rather than all-or-nothing.
resolved := 0
checked := 0
for i := int32(0); i < a.FileCount() && checked < 40; i++ {
p := strings.ToLower(a.Path(i))
if !strings.HasSuffix(p, ".stk") && !strings.HasSuffix(p, ".equ") {
continue
}
text, err := a.Text(i)
if err != nil || !strings.Contains(text, "<") || !strings.Contains(text, "::") {
continue
}
checked++
name, ok := a.ItemName(i)
if !ok {
t.Errorf("%s: no [name] resolved", a.Path(i))
continue
}
if strings.Contains(name, "::") || strings.Contains(name, "<") {
t.Logf("%s: name not resolvable (missing table entry): %q", a.Path(i), name)
continue
}
resolved++
if resolved <= 5 {
t.Logf("%s -> %q", a.Path(i), name)
}
}
if checked < 5 {
t.Fatalf("only %d placeholder-bearing item files inspected", checked)
}
if resolved*10 < checked*9 {
t.Errorf("only %d of %d item names resolved", resolved, checked)
}
// The base localization must win over the Korean overlay. These two items
// exist in both `String/equipment.uv.str` (Chinese) and
// `String/equipment.kor.str` (Korean).
for _, tc := range []struct{ path, want string }{
{"equipment/character/archer/avatar/belt/117530002.equ", "稀有克隆装扮腰部"},
{"equipment/character/archer/avatar/belt/117530004.equ", "混沌极武碎空腰带"},
} {
i, ok := a.Find(tc.path)
if !ok {
t.Logf("%s absent from this archive", tc.path)
continue
}
name, ok := a.ItemName(i)
if !ok || name != tc.want {
t.Errorf("%s -> %q, want %q", tc.path, name, tc.want)
}
}
}
// TestStringTablePrecedence locks in the table ranking: the base localization
// (`list/n_string.lst` -> `*.uv.str`) answers first, language overlays only fill
// the keys whose base value is empty or absent. Ranking the overlays first made
// the 110US item names come out Korean although the base table has Chinese.
func TestStringTablePrecedence(t *testing.T) {
a := New()
encode := func(s string) []byte {
out := make([]byte, 0, len(s)*2)
for _, r := range s {
out = append(out, byte(r), byte(r>>8))
}
return out
}
if _, err := a.AddFileText("list/n_string.lst", "3 `String/Equipment.uv.str`", TypeScript); err != nil {
t.Fatal(err)
}
if _, err := a.AddFileText("list/n_string_kor.lst", "3 `String/Equipment.kor.str`", TypeScript); err != nil {
t.Fatal(err)
}
a.AddFile("String/Equipment.uv.str",
encode("name_1>稀有克隆装扮腰部\r\nname_2>\r\n"), TypeScript)
a.AddFile("String/Equipment.kor.str",
encode("name_1>레어 허리 클론 아바타\r\nname_2>포니 비즈 뱅글\r\n"), TypeScript)
if got, ok := a.LookupStringTable(3, "name_1"); !ok || got != "稀有克隆装扮腰部" {
t.Errorf("base value lost: %q, %v", got, ok)
}
if got, ok := a.LookupStringTable(3, "name_2"); !ok || got != "포니 비즈 뱅글" {
t.Errorf("empty base value did not fall through to the overlay: %q, %v", got, ok)
}
if got, ok := a.LookupStringTable(3, "name_3"); ok {
t.Errorf("unknown key resolved to %q", got)
}
// Provenance: the base answer is not a fallback, the overlay answer is.
res, ok := a.ResolveStringTable(3, "name_1")
if !ok || res.Fallback || res.Source != "String/Equipment.uv.str" {
t.Errorf("base resolution = %#v", res)
}
res, ok = a.ResolveStringTable(3, "name_2")
if !ok || !res.Fallback || res.Source != "String/Equipment.kor.str" {
t.Errorf("overlay resolution = %#v", res)
}
if got := a.ResolvePlaceholdersMarked("<3::name_1> / <3::name_2>", "(未翻译)"); got != "稀有克隆装扮腰部 / 포니 비즈 뱅글(未翻译)" {
t.Errorf("marked resolution = %q", got)
}
if got := a.ResolvePlaceholders("<3::name_2>"); got != "포니 비즈 뱅글" {
t.Errorf("plain resolution = %q", got)
}
}
func TestTargetedStringTableLookup(t *testing.T) {
raw := append([]byte{0xff, 0xfe}, utf16LEBytes("name_1>old\r\nname_1>new\r\nname_2>last")...)
if got, ok := findStringTableValue(raw, "name_1"); !ok || got != "new" {
t.Fatalf("duplicate lookup = %q, %v", got, ok)
}
if got, ok := findStringTableValue(raw, "name_2"); !ok || got != "last" {
t.Fatalf("unterminated lookup = %q, %v", got, ok)
}
if got, ok := findStringTableValue(raw, "missing"); ok || got != "" {
t.Fatalf("missing lookup = %q, %v", got, ok)
}
}
func TestTargetedStringTableReusesIndexForMultipleKeys(t *testing.T) {
a := New()
if _, err := a.AddFileText("list/n_string.lst", "3 `String/Equipment.str`", TypeScript); err != nil {
t.Fatal(err)
}
a.AddFile("String/Equipment.str", utf16LEBytes("name_1>old\r\nname_2>second\r\nname_1>new\r\n"), TypeScript)
for _, tc := range []struct {
key, want string
}{
{"name_1", "new"},
{"name_2", "second"},
{"missing", ""},
{"name_1", "new"},
} {
got, ok := a.LookupStringTable(3, tc.key)
if tc.want == "" {
if ok {
t.Errorf("%s unexpectedly resolved to %q", tc.key, got)
}
} else if !ok || got != tc.want {
t.Errorf("%s = %q, %v; want %q", tc.key, got, ok, tc.want)
}
}
st := a.ensureStringTables()
index := st.valueIndexes["string/equipment.str"]
if index == nil || !index.valid || len(index.values) != 3 {
t.Fatalf("cached table index = %#v", index)
}
}
func TestTargetedStringTableSiblingFallback(t *testing.T) {
a := New()
if _, err := a.AddFileText("list/n_string.lst", "3 `String/Equipment.str`", TypeScript); err != nil {
t.Fatal(err)
}
a.AddFile("String/Equipment.str", utf16LEBytes("this is not a string table"), TypeScript)
a.AddFile("String/Equipment.kor.str", utf16LEBytes("name_1>overlay\r\n"), TypeScript)
if got, ok := a.LookupStringTable(3, "name_1"); !ok || got != "overlay" {
t.Fatalf("malformed primary did not fall back: %q, %v", got, ok)
}
// A valid primary table that lacks the key must keep the old loader's
// behavior: sibling fallback is only for missing or malformed payloads.
a.AddFile("String/Equipment.str", utf16LEBytes("name_2>base\r\n"), TypeScript)
a.InvalidateStringTables()
if got, ok := a.LookupStringTable(3, "name_1"); ok {
t.Fatalf("valid primary unexpectedly used sibling: %q", got)
}
}
func BenchmarkStringTableValueIndex(b *testing.B) {
const entries = 622000
var text strings.Builder
text.Grow(entries * 32)
for i := 0; i < entries; i++ {
fmt.Fprintf(&text, "equip_name_%d>display name %d\r\n", i, i)
}
raw := utf16LEBytes(text.String())
keys := []string{"equip_name_1", "equip_name_155500", "equip_name_311000", "equip_name_466500", "equip_name_621999"}
b.ReportAllocs()
b.SetBytes(int64(len(raw)))
b.ResetTimer()
for i := 0; i < b.N; i++ {
values, valid := indexStringTableValues(raw)
if !valid || len(values) != entries {
b.Fatalf("indexed %d keys, valid=%v", len(values), valid)
}
for _, key := range keys {
valueRange, ok := lookupStringTableValueRange(raw, values, key)
if !ok || len(raw[valueRange.start:valueRange.end]) == 0 {
b.Fatalf("missing %s", key)
}
}
}
}
// TestSetStringTableEntry edits entries at the byte level: only the value of the
// addressed line changes, the terminator and every other byte stay as they are.
func TestSetStringTableEntry(t *testing.T) {
a := New()
if _, err := a.AddFileText("list/n_string.lst", "3 `String/Equipment.uv.str`", TypeScript); err != nil {
t.Fatal(err)
}
if _, err := a.AddFileText("list/n_string_kor.lst", "3 `String/Equipment.kor.str`", TypeScript); err != nil {
t.Fatal(err)
}
a.AddFile("String/Equipment.uv.str", utf16LEBytes("name_1>旧名字\r\nname_2>别的\r\n"), TypeScript)
a.AddFile("String/Equipment.kor.str", utf16LEBytes("name_9>韩文\r\n"), TypeScript)
path, err := a.SetStringTableEntry(3, "name_1", "新名字")
if err != nil {
t.Fatal(err)
}
if !strings.EqualFold(path, "String/Equipment.uv.str") {
t.Errorf("edited %s", path)
}
if got, ok := a.LookupStringTable(3, "name_1"); !ok || got != "新名字" {
t.Errorf("edited value = %q, %v", got, ok)
}
// Byte-exact: only the value bytes differ, the CRLF terminator survives.
raw, err := a.RawBytes(mustFind(a, "String/Equipment.uv.str"))
if err != nil {
t.Fatal(err)
}
if want := utf16LEBytes("name_1>新名字\r\nname_2>别的\r\n"); !bytes.Equal(raw, want) {
t.Errorf("payload = % x, want % x", raw, want)
}
// A key that only the overlay knows is edited there.
path, err = a.SetStringTableEntry(3, "name_9", "改过的韩文")
if err != nil {
t.Fatal(err)
}
if !strings.EqualFold(path, "String/Equipment.kor.str") {
t.Errorf("overlay edit went to %s", path)
}
if got, ok := a.LookupStringTable(3, "name_9"); !ok || got != "改过的韩文" {
t.Errorf("overlay value = %q, %v", got, ok)
}
// A brand new key is appended to the base table with its terminator.
if _, err := a.SetStringTableEntry(3, "name_new", "新增"); err != nil {
t.Fatal(err)
}
if got, ok := a.LookupStringTable(3, "name_new"); !ok || got != "新增" {
t.Errorf("appended value = %q, %v", got, ok)
}
raw, _ = a.RawBytes(mustFind(a, "String/Equipment.uv.str"))
if want := utf16LEBytes("name_1>新名字\r\nname_2>别的\r\nname_new>新增\r\n"); !bytes.Equal(raw, want) {
t.Errorf("payload after append = % x", raw)
}
// Unreadable payloads are refused, never overwritten.
if _, err := a.SetStringTableEntry(9, "whatever", "x"); err == nil {
t.Error("editing an unmapped table index succeeded")
}
if _, err := a.SetStringTableEntry(3, "", "x"); err == nil {
t.Error("empty key accepted")
}
}
// TestSetStringTableEntryAppendsBeforePadding covers payloads that end with NUL
// padding (the retail tables do): the reader stops at the first NUL, so a new
// line has to be inserted before the padding to be visible at all.
func TestSetStringTableEntryAppendsBeforePadding(t *testing.T) {
a := New()
if _, err := a.AddFileText("list/n_string.lst", "3 `String/Equipment.uv.str`", TypeScript); err != nil {
t.Fatal(err)
}
a.AddFile("String/Equipment.uv.str", utf16LEBytes("name_1>旧\r\n\x00\x00"), TypeScript)
path, err := a.SetStringTableEntry(3, "name_2", "新")
if err != nil {
t.Fatal(err)
}
if !strings.EqualFold(path, "String/Equipment.uv.str") {
t.Errorf("edited %s", path)
}
if got, ok := a.LookupStringTable(3, "name_2"); !ok || got != "新" {
t.Errorf("appended value = %q, %v", got, ok)
}
raw, _ := a.RawBytes(mustFind(a, "String/Equipment.uv.str"))
if want := utf16LEBytes("name_1>旧\r\nname_2>新\r\n\x00\x00"); !bytes.Equal(raw, want) {
t.Errorf("payload = % x, want % x", raw, want)
}
// An unterminated last line before the padding is still editable in place:
// only the value bytes change, the missing terminator stays missing.
a.AddFile("String/Equipment.uv.str", utf16LEBytes("name_1>旧\r\nname_2>无结尾\x00\x00"), TypeScript)
a.InvalidateStringTables()
if _, err := a.SetStringTableEntry(3, "name_2", "改"); err != nil {
t.Fatal(err)
}
if got, ok := a.LookupStringTable(3, "name_2"); !ok || got != "改" {
t.Errorf("unterminated value = %q, %v", got, ok)
}
raw, _ = a.RawBytes(mustFind(a, "String/Equipment.uv.str"))
if want := utf16LEBytes("name_1>旧\r\nname_2>改\x00\x00"); !bytes.Equal(raw, want) {
t.Errorf("unterminated payload = % x, want % x", raw, want)
}
}
// TestStringTableUnresolvedKept ensures unknown placeholders are preserved.
func TestStringTableUnresolvedKept(t *testing.T) {
a := New()
if got := a.ResolvePlaceholders("plain text"); got != "plain text" {
t.Errorf("plain text rewritten: %q", got)
}
in := "keep <99::missing_key> as is"
if got := a.ResolvePlaceholders(in); got != in {
t.Errorf("unresolved placeholder rewritten: %q", got)
}
}