服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
210 lines
7.8 KiB
Go
210 lines
7.8 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
|
|
"longheng.io/server/internal/modules/dnf/purchaselimit"
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
func TestParseCurrentPurchaseLimitSectionReadsNewField(t *testing.T) {
|
|
document := mustCurrentPurchaseLimitDocument(t, "[purchase limit]\n`account` `weekly` 10\n")
|
|
policy, found, err := parseCurrentPurchaseLimitSection(490004806, document)
|
|
if err != nil || !found {
|
|
t.Fatalf("found=%t err=%v", found, err)
|
|
}
|
|
if policy.ItemID != 490004806 || policy.Scope != purchaselimit.ScopeAccount ||
|
|
policy.Period != purchaselimit.PeriodWeekly || policy.Limit != 10 ||
|
|
policy.Section != currentPurchaseLimitSection {
|
|
t.Fatalf("policy=%+v", policy)
|
|
}
|
|
}
|
|
|
|
func TestParseCurrentPurchaseLimitSectionReadsLegacyDailyField(t *testing.T) {
|
|
document := mustCurrentPurchaseLimitDocument(t, "[daily purchase limit]\n`character` 1\n")
|
|
policy, found, err := parseCurrentPurchaseLimitSection(10154547, document)
|
|
if err != nil || !found {
|
|
t.Fatalf("found=%t err=%v", found, err)
|
|
}
|
|
if policy.Scope != purchaselimit.ScopeCharacter || policy.Period != purchaselimit.PeriodDaily ||
|
|
policy.Limit != 1 || policy.Section != currentDailyPurchaseLimitSection {
|
|
t.Fatalf("policy=%+v", policy)
|
|
}
|
|
}
|
|
|
|
func TestParseCurrentPurchaseLimitSectionRejectsUnknownShape(t *testing.T) {
|
|
document := mustCurrentPurchaseLimitDocument(t, "[purchase limit]\n`account` 10\n")
|
|
if _, found, err := parseCurrentPurchaseLimitSection(1, document); err == nil || found {
|
|
t.Fatalf("found=%t err=%v want malformed error", found, err)
|
|
}
|
|
document = mustCurrentPurchaseLimitDocument(t, "[purchase limit]\n`clan` `weekly` 10\n")
|
|
if _, _, err := parseCurrentPurchaseLimitSection(1, document); err == nil {
|
|
t.Fatal("unknown scope accepted")
|
|
}
|
|
document = mustCurrentPurchaseLimitDocument(t, "[purchase limit]\n`account` `fortnightly` 10\n")
|
|
if _, _, err := parseCurrentPurchaseLimitSection(1, document); err == nil {
|
|
t.Fatal("unknown period accepted")
|
|
}
|
|
}
|
|
|
|
func TestParseCurrentPurchaseLimitSectionAbsent(t *testing.T) {
|
|
document := mustCurrentPurchaseLimitDocument(t, "[stack limit]\n999\n")
|
|
if _, found, err := parseCurrentPurchaseLimitSection(1, document); found || err != nil {
|
|
t.Fatalf("found=%t err=%v", found, err)
|
|
}
|
|
}
|
|
|
|
// 真机 PVF:确认三件真实道具的限购字段解析结果(新字段 / 旧字段 / 装备)。
|
|
func TestRealPVFPurchaseLimitSections(t *testing.T) {
|
|
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
|
if pvfPath == "" {
|
|
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to the runtime Script.pvf")
|
|
}
|
|
archive, err := platformpvf.Open(pvfPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
items, err := newPVFDungeonDropCatalog(archive)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cases := []struct {
|
|
itemID uint32
|
|
scope purchaselimit.Scope
|
|
period purchaselimit.Period
|
|
limit uint32
|
|
section string
|
|
}{
|
|
{490004806, purchaselimit.ScopeAccount, purchaselimit.PeriodWeekly, 10, currentPurchaseLimitSection},
|
|
{10154547, purchaselimit.ScopeCharacter, purchaselimit.PeriodDaily, 1, currentDailyPurchaseLimitSection},
|
|
{400350073, purchaselimit.ScopeCharacter, purchaselimit.PeriodAccumulate, 1, currentPurchaseLimitSection},
|
|
}
|
|
for _, want := range cases {
|
|
definition, err := items.ResolveItem(want.itemID)
|
|
if err != nil {
|
|
t.Fatalf("resolve item=%d: %v", want.itemID, err)
|
|
}
|
|
if definition.PurchaseLimitError != "" {
|
|
t.Fatalf("item=%d purchase limit parse error: %s", want.itemID, definition.PurchaseLimitError)
|
|
}
|
|
if !definition.PurchaseLimitFound {
|
|
t.Fatalf("item=%d has no purchase limit (path=%s)", want.itemID, definition.PVFPath)
|
|
}
|
|
got := definition.PurchaseLimit
|
|
if got.Scope != want.scope || got.Period != want.period || got.Limit != want.limit || got.Section != want.section {
|
|
t.Fatalf("item=%d policy=%+v want scope=%s period=%s limit=%d section=%s",
|
|
want.itemID, got, want.scope, want.period, want.limit, want.section)
|
|
}
|
|
}
|
|
}
|
|
|
|
func mustCurrentPurchaseLimitDocument(t *testing.T, text string) *dnfpvf.Document {
|
|
t.Helper()
|
|
document, err := dnfpvf.Parse("test.stk", text)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return document
|
|
}
|
|
|
|
// 全库回归:Script.pvf 里每一个带限购字段的脚本都必须能被解析器读出来。
|
|
// 这是“畸形字段按不限购处理”这条兜底的守门人——真出现读不懂的写法时要在这里
|
|
// 暴露出来,而不是悄悄放行。
|
|
func TestRealPVFPurchaseLimitCorpusParses(t *testing.T) {
|
|
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
|
if pvfPath == "" {
|
|
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to the runtime Script.pvf")
|
|
}
|
|
archive, err := platformpvf.Open(pvfPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
files := 0
|
|
bothSections := 0
|
|
shapes := make(map[string]int)
|
|
buckets := make(map[string]int)
|
|
failures := make([]string, 0, 8)
|
|
for _, file := range archive.Files() {
|
|
// 只有道具脚本参与限购:stackable/equipment 才是道具目录解析的对象。
|
|
// etc/** 里的同名段落是别的系统(如 pvpcolosseum.etc 的两个数字)。
|
|
if !strings.HasPrefix(file.ArchivePath, "stackable/") && !strings.HasPrefix(file.ArchivePath, "equipment/") {
|
|
continue
|
|
}
|
|
text, readErr := archive.ReadText(file.ArchivePath)
|
|
if readErr != nil {
|
|
continue
|
|
}
|
|
lower := strings.ToLower(text)
|
|
hasNew := strings.Contains(lower, "["+currentPurchaseLimitSection+"]")
|
|
hasLegacy := strings.Contains(lower, "["+currentDailyPurchaseLimitSection+"]")
|
|
if !hasNew && !hasLegacy {
|
|
continue
|
|
}
|
|
files++
|
|
// 两套字段同时出现的道具:解析器只取新字段(旧字段被忽略)。这里统计规模,
|
|
// 一旦不是 0 就要确认"同一件道具是否需要同时受两条限购约束"。
|
|
if hasNew && hasLegacy {
|
|
bothSections++
|
|
}
|
|
document, parseErr := dnfpvf.Parse(file.ArchivePath, text)
|
|
if parseErr != nil {
|
|
t.Fatalf("parse %s: %v", file.ArchivePath, parseErr)
|
|
}
|
|
policy, found, err := parseCurrentPurchaseLimitSection(1, document)
|
|
if err != nil {
|
|
// 空的段落与语义不明的取值(如 `trade`)是“没有可执行限购”,
|
|
// 解析器已经把它记成 PurchaseLimitError 并放行;这里只要求它
|
|
// 不把可识别的写法判错,畸形写法单独归档。
|
|
raw := currentPurchaseLimitRawSection(text)
|
|
switch {
|
|
case strings.TrimSpace(raw) == "["+currentDailyPurchaseLimitSection+"]" ||
|
|
strings.TrimSpace(raw) == "["+currentPurchaseLimitSection+"]":
|
|
buckets["empty_section"]++
|
|
case strings.Contains(raw, "`"+currentPurchaseLimitUnknownTradeScope+"`"):
|
|
buckets["unknown_scope_trade"]++
|
|
default:
|
|
failures = append(failures, file.ArchivePath+" :: "+raw)
|
|
}
|
|
continue
|
|
}
|
|
if !found {
|
|
t.Fatalf("limit section of %s was not detected", file.ArchivePath)
|
|
}
|
|
shapes[fmt.Sprintf("%s %s %s %d", policy.Section, policy.Scope, policy.Period, policy.Limit)]++
|
|
}
|
|
if files < 400 {
|
|
t.Fatalf("only %d files declared a purchase limit; the corpus scan looks wrong", files)
|
|
}
|
|
t.Logf("purchase-limit corpus: files=%d shapes=%d both_sections=%d buckets=%v unparsed=%d", files, len(shapes), bothSections, buckets, len(failures))
|
|
for shape, count := range shapes {
|
|
t.Logf(" %4d %s", count, shape)
|
|
}
|
|
for _, failure := range failures {
|
|
t.Logf(" UNPARSED %s", failure)
|
|
}
|
|
if len(failures) > 0 {
|
|
t.Fatalf("%d purchase-limit sections could not be parsed", len(failures))
|
|
}
|
|
}
|
|
|
|
// currentPurchaseLimitRawSection 取限购字段的原文,便于诊断畸形写法。
|
|
func currentPurchaseLimitRawSection(text string) string {
|
|
lower := strings.ToLower(text)
|
|
index := strings.Index(lower, "["+currentPurchaseLimitSection+"]")
|
|
if index < 0 {
|
|
index = strings.Index(lower, "["+currentDailyPurchaseLimitSection+"]")
|
|
}
|
|
if index < 0 {
|
|
return ""
|
|
}
|
|
rest := text[index:]
|
|
if end := strings.Index(rest, "\n["); end > 0 {
|
|
rest = rest[:end]
|
|
}
|
|
return strings.Join(strings.Fields(rest), " ")
|
|
}
|