服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
356 lines
13 KiB
Go
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)
|
|
}
|
|
}
|