Files
90CNServer/pvf/internal/pvf/script_render_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

428 lines
12 KiB
Go

package pvf
import (
"bytes"
"os"
"strconv"
"strings"
"testing"
"pvfine/internal/rendering"
)
func TestScriptRenderingRuleCanTargetSectionByExtension(t *testing.T) {
a := New()
input := "[records]\n1 2 3 4 5\n[/records]"
encoded, err := a.encodeScript(input)
if err != nil {
t.Fatalf("encodeScript() error = %v", err)
}
engine, err := rendering.Parse([]byte(`{
"version": 1,
"rules": [
{
"id": "skl.file",
"match": {"extensions": [".skl"]},
"target": {"kind": "file"},
"format": {"tokensPerLine": 1}
},
{
"id": "skl.records",
"match": {"extensions": [".skl"]},
"target": {"kind": "section", "section": "records"},
"format": {"tokensPerLine": 2}
}
]
}`))
if err != nil {
t.Fatalf("rendering.Parse() error = %v", err)
}
a.SetScriptRenderer(engine)
want := "[records]\n\t1\t2\n\t3\t4\n\t5\n[/records]\n"
if got := a.decodeScriptForPath(encoded, "SKILLS/TEST.SKL"); got != want {
t.Fatalf("scoped rendering = %q, want %q", got, want)
}
if got := a.decodeScriptForPath(encoded, "skills/test.equ"); got != "[records]\n\t1\t2\t3\t4\t5\n[/records]\n" {
t.Fatalf("unmatched extension rendering = %q", got)
}
}
func TestScriptRenderingSupportsLeadingTokenOffset(t *testing.T) {
a := New()
encoded, err := a.encodeScript("[records]\n10 20 1 2 3 4 5\n[/records]")
if err != nil {
t.Fatal(err)
}
engine, err := rendering.Parse([]byte(`{
"version": 1,
"rules": [{
"id": "records.offset",
"target": {"kind": "section", "section": "records"},
"format": {"offset": 2, "tokensPerLine": 2}
}]
}`))
if err != nil {
t.Fatal(err)
}
a.SetScriptRenderer(engine)
want := "[records]\n\t10\n\t20\n\t1\t2\n\t3\t4\n\t5\n[/records]\n"
if got := a.decodeScriptForPath(encoded, "item.equ"); got != want {
t.Fatalf("offset rendering = %q, want %q", got, want)
}
}
func TestScriptRenderingSupportsLegacyUnpairedSection(t *testing.T) {
a := New()
encoded, err := a.encodeScript("[basis of rarity dicision]\n99 1 2 3 4 5 6 7 8\n[next]")
if err != nil {
t.Fatal(err)
}
engine, err := rendering.Parse([]byte(`{
"version": 1,
"rules": [{
"id": "etc.basis-of-rarity-dicision",
"match": {},
"target": {"kind": "section", "section": "basis of rarity dicision"},
"format": {"tokensPerLine": 7, "offset": 1}
}]
}`))
if err != nil {
t.Fatal(err)
}
a.SetScriptRenderer(engine)
want := "[basis of rarity dicision]\n\t99\n\t1\t2\t3\t4\t5\t6\t7\n\t8\n\n[next]\n"
if got := a.decodeScriptForPath(encoded, "etc/itemdropinfo_monster_hell.etc"); got != want {
t.Fatalf("unpaired section rendering = %q, want %q", got, want)
}
}
func TestScriptRenderingUsesTokenValueForLegacyUnpairedSection(t *testing.T) {
a := New()
encoded, err := a.encodeScript("[basis of rarity dicision]\n3 100 200 300 400 500\n[next]")
if err != nil {
t.Fatal(err)
}
engine, err := rendering.Parse([]byte(`{
"version": 1,
"rules": [{
"id": "etc.basis-of-rarity-dicision-dynamic",
"match": {},
"target": {"kind": "section", "section": "basis of rarity dicision"},
"format": {"offset": 1, "tokensPerLine": 1, "tokensPerLineIndex": 0}
}]
}`))
if err != nil {
t.Fatal(err)
}
a.SetScriptRenderer(engine)
want := "[basis of rarity dicision]\n\t3\n\t100\t200\t300\n\t400\t500\n\n[next]\n"
if got := a.decodeScriptForPath(encoded, "etc/itemdropinfo_monster_hell.etc"); got != want {
t.Fatalf("dynamic unpaired section rendering = %q, want %q", got, want)
}
}
func TestScriptRenderingUsesTokenValueForTokensPerLine(t *testing.T) {
a := New()
encoded, err := a.encodeScript("[records]\n2 100 200 300 400 500\n[/records]")
if err != nil {
t.Fatal(err)
}
engine, err := rendering.Parse([]byte(`{
"version": 1,
"rules": [{
"id": "records.dynamic",
"target": {"kind": "section", "section": "records"},
"format": {
"offset": 1,
"tokensPerLine": 1,
"tokensPerLineIndex": 0
}
}]
}`))
if err != nil {
t.Fatal(err)
}
a.SetScriptRenderer(engine)
want := "[records]\n\t2\n\t100\t200\n\t300\t400\n\t500\n[/records]\n"
if got := a.decodeScriptForPath(encoded, "item.equ"); got != want {
t.Fatalf("dynamic tokensPerLine rendering = %q, want %q", got, want)
}
}
func TestScriptRenderingStandaloneValuesResetGrouping(t *testing.T) {
a := New()
engine, err := rendering.Parse([]byte(`{
"version": 1,
"rules": [{
"id": "world.drop",
"target": {"kind": "section", "section": "world drop"},
"format": {"tokensPerLine": 2, "standaloneValues": [-1]}
}]
}`))
if err != nil {
t.Fatal(err)
}
a.SetScriptRenderer(engine)
for _, tt := range []struct {
name, input, want string
}{
{"partial row", "[world drop]\n100 200 300 -1 400 500 -1 600 700\n[next]", "[world drop]\n\t100\t200\n\t300\n\t-1\n\t400\t500\n\t-1\n\t600\t700\n\n[next]\n"},
{"leading and consecutive", "[world drop]\n-1 -1 1 2 -1 3\n[next]", "[world drop]\n\t-1\n\t-1\n\t1\t2\n\t-1\n\t3\n\n[next]\n"},
{"no sentinel", "[world drop]\n1 2 3\n[next]", "[world drop]\n\t1\t2\n\t3\n\n[next]\n"},
} {
t.Run(tt.name, func(t *testing.T) {
encoded, err := a.encodeScript(tt.input)
if err != nil {
t.Fatal(err)
}
if got := a.decodeScriptForPath(encoded, "item.equ"); got != tt.want {
t.Fatalf("rendering = %q, want %q", got, tt.want)
}
})
}
}
func TestScriptRenderingIndependentDropWithOptionalList(t *testing.T) {
a := New()
engine, err := rendering.Parse([]byte(`{
"version": 1,
"rules": [
{
"id": "independent.drop",
"target": {"kind": "section", "section": "independent drop"},
"format": {"tokensPerLine": 17, "nestedSections": ["list"]}
},
{
"id": "list",
"target": {"kind": "section", "section": "list"},
"format": {"tokensPerLine": 2}
}
]
}`))
if err != nil {
t.Fatal(err)
}
a.SetScriptRenderer(engine)
input := "[independent drop]\n" +
"1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17\n" +
"[list]\n100 200 300 400\n[/list]\n" +
"18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34\n" +
"35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51\n" +
"[next]"
want := "[independent drop]\n" +
"\t1\t2\t3\t4\t5\t6\t7\t8\t9\t10\t11\t12\t13\t14\t15\t16\t17\n" +
"\t[list]\n\t\t100\t200\n\t\t300\t400\n\t[/list]\n" +
"\t18\t19\t20\t21\t22\t23\t24\t25\t26\t27\t28\t29\t30\t31\t32\t33\t34\n" +
"\t35\t36\t37\t38\t39\t40\t41\t42\t43\t44\t45\t46\t47\t48\t49\t50\t51\n\n" +
"[next]\n"
encoded, err := a.encodeScript(input)
if err != nil {
t.Fatal(err)
}
if got := a.decodeScriptForPath(encoded, "item.equ"); got != want {
t.Fatalf("rendering = %q, want %q", got, want)
}
}
func TestCanonicalTextIgnoresUserRenderingOverride(t *testing.T) {
a := New()
index, err := a.AddFileText("skills/test.skl", "[records]\n1 2 3 4 5\n[/records]", TypeScript)
if err != nil {
t.Fatalf("AddFileText() error = %v", err)
}
canonical, err := a.CanonicalText(index)
if err != nil {
t.Fatalf("CanonicalText() error = %v", err)
}
engine, err := rendering.Parse([]byte(`{
"version": 1,
"rules": [{
"id": "skl.records",
"match": {"extensions": [".skl"]},
"target": {"kind": "section", "section": "records"},
"format": {"tokensPerLine": 2}
}]
}`))
if err != nil {
t.Fatalf("rendering.Parse() error = %v", err)
}
a.SetScriptRenderer(engine)
active, err := a.Text(index)
if err != nil {
t.Fatalf("Text() error = %v", err)
}
if active == canonical {
t.Fatalf("custom renderer did not change active text: %q", active)
}
stable, err := a.CanonicalText(index)
if err != nil {
t.Fatalf("CanonicalText() after override error = %v", err)
}
if stable != canonical {
t.Fatalf("canonical text changed after renderer override: got %q, want %q", stable, canonical)
}
reencoded, err := a.encodeScript(active)
if err != nil {
t.Fatalf("encodeScript(active) error = %v", err)
}
if !bytes.Equal(reencoded, encodedPayloadForTest(t, a, index)) {
t.Fatalf("rendered text changed token payload")
}
}
func encodedPayloadForTest(t *testing.T, a *Archive, index int32) []byte {
t.Helper()
raw, err := a.RawBytes(index)
if err != nil {
t.Fatal(err)
}
return append([]byte(nil), raw...)
}
func TestDropRateRenderingUsesClientGroupWidth(t *testing.T) {
for _, tc := range []struct {
profile formatProfile
width int
}{
{standardProfile, 7}, {alternateProfile, 7}, {twProfile, 6}, {paged100CNProfile, 8}, {paged110Profile, 9}, {paged115CNProfile, 9}, {recoveredProfile, 7},
} {
t.Run(tc.profile.id, func(t *testing.T) {
a := New()
a.format = tc.profile
for _, path := range []string{"etc/itemdropinfo_monster_hell.etc", "etc/itemdropinfo_monseter.etc"} {
hell := strings.Contains(path, "monster_hell")
var values []string
for i := 1; i <= tc.width*2; i++ {
values = append(values, strconv.Itoa(i))
}
input := "[basis of rarity dicision]\n"
if hell {
input += "2\n"
}
input += strings.Join(values, " ") + "\n[next]\n99"
raw, err := a.encodeScript(input)
if err != nil {
t.Fatal(err)
}
rendered := a.decodeScriptForPath(raw, path)
want := "[basis of rarity dicision]\n"
if hell {
want += "\t2\n"
}
for group := 0; group < 2; group++ {
want += "\t" + strings.Join(values[group*tc.width:(group+1)*tc.width], "\t") + "\n"
}
want += "\n[next]\n\t99"
if rendered != want {
t.Fatalf("%s: rendered %q, want %q", path, rendered, want)
}
back, err := a.encodeScript(rendered)
if err != nil || !bytes.Equal(raw, back) {
t.Fatal("rendering changed tokens")
}
}
})
}
}
func TestDropRateRenderingRealArchiveRows(t *testing.T) {
path := os.Getenv("PVF_TESTFILE")
if path == "" {
t.Skip("PVF_TESTFILE not set")
}
a, err := Open(path)
if err != nil {
t.Fatal(err)
}
defer a.Release()
widths := map[string]int{"90US": 7, "90CN": 7, "TW": 6, "100CN": 8, "110US": 9}
width, ok := widths[a.ClientVersion()]
if !ok {
t.Skip("known drop-rate client required")
}
for _, spec := range []struct {
path string
groups, offset int
}{
{"etc/itemdropinfo_monster_hell.etc", 2, 1}, {"etc/itemdropinfo_clearreward.etc", 1, 0},
{"etc/itemdropinfo_monseter.etc", 4, 0}, {"etc/itemdropinfo_monseter_extra.etc", 4, 0},
} {
index, ok := a.Find(spec.path)
if !ok {
t.Fatal("drop file missing")
}
text, err := a.Text(index)
if err != nil {
t.Fatal(err)
}
lines := strings.Split(text, "\n")
inside := false
rows := 0
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "[basis of rarity dicision]" {
inside = true
continue
}
if !inside {
continue
}
if strings.HasPrefix(line, "[") {
break
}
if line == "" {
continue
}
want := width
if rows < spec.offset {
want = 1
}
if count := len(strings.Fields(line)); count != want {
t.Fatalf("%s %s row %d width=%d want=%d", a.ClientVersion(), spec.path, rows, count, want)
}
rows++
}
if rows != spec.groups+spec.offset {
t.Fatalf("%s unexpected row count=%d", spec.path, rows)
}
raw, err := a.RawBytes(index)
if err != nil {
t.Fatal(err)
}
back, err := a.encodeScript(text)
if err != nil {
t.Fatal(err)
}
// Legacy archives can have duplicate string-table entries: re-encoding
// may choose an equivalent offset, so compare the numeric drop section.
originalDoc, err := a.ParseScriptDocument(raw)
if err != nil {
t.Fatal(err)
}
renderedDoc, err := a.ParseScriptDocument(back)
if err != nil {
t.Fatal(err)
}
originalSection, ok := originalDoc.Section([]string{"basis of rarity dicision"}, 0)
if !ok {
t.Fatal("original drop section missing")
}
renderedSection, ok := renderedDoc.Section([]string{"basis of rarity dicision"}, 0)
if !ok {
t.Fatal("rendered drop section missing")
}
originalValues, renderedValues := originalSection.Values(), renderedSection.Values()
if len(originalValues) != len(renderedValues) {
t.Fatal("rendered drop section length changed")
}
for i, value := range originalValues {
if value.Type != renderedValues[i].Type || value.Value != renderedValues[i].Value {
t.Fatalf("%s drop value %d changed after rendering", spec.path, i)
}
}
}
}