Files
90CNServer/server/internal/services/dnfbridge/auction_category.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

741 lines
27 KiB
Go

package dnfbridge
import (
"encoding/binary"
"sort"
"strings"
)
// The current client's auction window builds a fixed category tree and sends the
// selected node's code in op187 body[5:7] (u16 LE); it never puts item ids there.
// Until this pass the server dropped that operand and answered every category with
// the same whole-market snapshot, so a bracelet listed under 武器 showed up in
// every tab.
//
// Evidence for the wire layout, the 172 node codes and their labels:
//
// 拍卖行分类搜索_根因与分类码表_20260927.md
// tools/_auction_codes_full.txt
//
// currentAuctionClientCategoryTree (a generated test fixture) is the client's own
// tree; TestCurrentAuctionCategoryTreeMatchesCurrentClient pins this table to it
// node for node.
//
// What the client does not say is which item belongs to which node, so every node
// carries a rule over the server's PVF item catalog:
//
// - equipment nodes key off [equipment type] plus the PVF path segments
// (character/<job>/weapon/<type>/, character/common/<slot>/<material>/),
// - stackable nodes key off [stackable type] and the item's PVF path,
// - 全部 (40119) and any code this build has never seen keep the historical
// "no category filter" behaviour, so an unmapped code cannot empty the market,
// - the last child of a tab (其他) is "the tab's scope minus its siblings" and
// the top-level 其他 is "everything eligible minus the other tabs", so an
// imperfect rule can never make an auctionable item unreachable,
// - nodes whose membership is not proven yet answer an empty page on purpose
// (currentAuctionCategoryUnpinned): empty is honest, wrong is not.
// currentAuctionCategoryAllCode is the tree root, drawn as 全部.
const currentAuctionCategoryAllCode = uint16(40119)
type currentAuctionCategoryNodeKind uint8
const (
// currentAuctionCategoryItems matches items by its own rule.
currentAuctionCategoryItems currentAuctionCategoryNodeKind = iota + 1
// currentAuctionCategoryChildren is a parent: its own scope rule (optional)
// plus every child.
currentAuctionCategoryChildren
// currentAuctionCategoryRemainder is a tab's 其他: the tab scope minus the
// other children of that tab.
currentAuctionCategoryRemainder
// currentAuctionCategoryGlobal is the top-level 其他: every eligible item no
// other top-level node claims.
currentAuctionCategoryGlobal
// currentAuctionCategoryAll is 全部: no filter at all.
currentAuctionCategoryAll
// currentAuctionCategoryUnpinned is a real client node whose membership is not
// proven yet; it answers nothing.
currentAuctionCategoryUnpinned
)
// currentAuctionCategoryRule is one membership test over a PVF item definition.
// Empty fields are ignored; every populated field must hold.
type currentAuctionCategoryRule struct {
kinds []dungeonDropItemKind
equipmentTypes []string
stackableTypes []string
// stackableNone excludes stackable types (the 消耗品 tab owns everything that
// is neither a 设计图 nor a 副职业材料).
stackableNone []string
// pathAll entries must all appear in the item's PVF path, pathNone entries must
// not. Paths are compared lower-cased with forward slashes.
pathAll []string
pathNone []string
}
func (r currentAuctionCategoryRule) matches(definition dungeonDropItemDefinition) bool {
if len(r.kinds) != 0 {
found := false
for _, kind := range r.kinds {
if definition.Kind == kind {
found = true
break
}
}
if !found {
return false
}
}
if len(r.equipmentTypes) != 0 && !currentAuctionCategoryStringIn(definition.EquipmentType, r.equipmentTypes) {
return false
}
stackableType := normalizeDungeonDropStackableType(definition.StackableType)
if len(r.stackableTypes) != 0 && !currentAuctionCategoryStringIn(stackableType, r.stackableTypes) {
return false
}
if len(r.stackableNone) != 0 && currentAuctionCategoryStringIn(stackableType, r.stackableNone) {
return false
}
if len(r.pathAll) != 0 || len(r.pathNone) != 0 {
pvfPath := currentAuctionCategoryPath(definition.PVFPath)
for _, want := range r.pathAll {
if !strings.Contains(pvfPath, want) {
return false
}
}
for _, unwanted := range r.pathNone {
if strings.Contains(pvfPath, unwanted) {
return false
}
}
}
return true
}
func currentAuctionCategoryStringIn(value string, wanted []string) bool {
if value == "" {
return false
}
normalized := strings.ToLower(strings.TrimSpace(value))
for _, candidate := range wanted {
if normalized == candidate {
return true
}
}
return false
}
func currentAuctionCategoryPath(value string) string {
return strings.ToLower(strings.ReplaceAll(strings.TrimSpace(value), "\\", "/"))
}
// currentAuctionCategoryNode is one node of the client's tree plus the server's
// membership rule for it.
type currentAuctionCategoryNode struct {
code uint16
label string
parent uint16
children []uint16
kind currentAuctionCategoryNodeKind
rule currentAuctionCategoryRule
}
type currentAuctionCategoryTree struct {
nodes map[uint16]currentAuctionCategoryNode
topLevel []uint16
unpinned []uint16
allCodes []uint16
}
var currentAuctionCategory = buildCurrentAuctionCategoryTree()
// currentAuctionCategoryNodeFor returns the client node for one code.
func currentAuctionCategoryNodeFor(code uint16) (currentAuctionCategoryNode, bool) {
node, found := currentAuctionCategory.nodes[code]
return node, found
}
// currentAuctionCategoryIsUnpinned reports whether a code is a known client node
// whose membership rule is still unproven.
func currentAuctionCategoryIsUnpinned(code uint16) bool {
node, found := currentAuctionCategory.nodes[code]
return found && node.kind == currentAuctionCategoryUnpinned
}
// currentAuctionCategoryItemMatches evaluates one node against one already
// resolved item definition.
func currentAuctionCategoryItemMatches(code uint16, definition dungeonDropItemDefinition) bool {
node, found := currentAuctionCategory.nodes[code]
if !found {
return false
}
switch node.kind {
case currentAuctionCategoryAll:
return true
case currentAuctionCategoryItems:
return node.rule.matches(definition)
case currentAuctionCategoryChildren:
if node.rule.matches(definition) {
return true
}
for _, child := range node.children {
if currentAuctionCategoryItemMatches(child, definition) {
return true
}
}
return false
case currentAuctionCategoryRemainder:
tab, found := currentAuctionCategory.nodes[node.parent]
if !found || !tab.rule.matches(definition) {
return false
}
for _, sibling := range tab.children {
if sibling == code {
continue
}
if currentAuctionCategoryItemMatches(sibling, definition) {
return false
}
}
return true
case currentAuctionCategoryGlobal:
for _, top := range currentAuctionCategory.topLevel {
if top == code {
continue
}
if currentAuctionCategoryItemMatches(top, definition) {
return false
}
}
return true
default:
// currentAuctionCategoryUnpinned answers nothing.
return false
}
}
// currentAuctionCategoryEligible reports whether one listing's item belongs to the
// category the client asked for.
//
// An unknown code and 全部 keep the historical "no filter" behaviour: a code this
// build has never seen must not empty the market. A code that is known but still
// unpinned answers false, which is the honest empty page for it.
func (s *Service) currentAuctionCategoryEligible(code uint16, itemID uint32) bool {
node, found := currentAuctionCategory.nodes[code]
if !found || node.kind == currentAuctionCategoryAll {
return true
}
// The node's own kind decides before any catalogue or test seam is consulted:
// an unpinned node answers nothing whatever the item is.
if node.kind == currentAuctionCategoryUnpinned {
return false
}
if s != nil && s.auctionCategoryItem != nil {
return s.auctionCategoryItem(code, itemID)
}
if s == nil || itemID == 0 {
return false
}
catalog, err := s.currentPVFItemCatalog()
if err != nil || catalog == nil {
return false
}
definition, err := catalog.ResolveItem(itemID)
if err != nil {
return false
}
return currentAuctionCategoryItemMatches(code, definition)
}
// currentAuctionCategoryRequestCode reads the u16 category the client wrote at
// op187 body[5:7]. A short body answers 0, which no node uses, so it falls back to
// the unfiltered behaviour.
func currentAuctionCategoryRequestCode(body []byte) uint16 {
if len(body) < 7 {
return 0
}
return binary.LittleEndian.Uint16(body[5:7])
}
type currentAuctionCategorySpec struct {
code uint16
parent uint16
label string
kind currentAuctionCategoryNodeKind
rule currentAuctionCategoryRule
}
// currentAuctionCategoryWeaponJobs is 武器's job level. dir is the PVF directory
// under equipment/character/, types are that job's weapon directories in the
// order the client draws them.
var currentAuctionCategoryWeaponJobs = []struct {
code uint16
label string
dir string
types []struct {
code uint16
label string
dir string
}
}{
{10101, "鬼剑士/黑暗武士/守护者", "swordman", []struct {
code uint16
label string
dir string
}{{10102, "短剑", "ssword"}, {10103, "太刀", "katana"}, {10104, "钝器", "club"}, {10105, "巨剑", "hsword"}, {10106, "光剑", "beamsword"}}},
// Two fighter/mage pairs are cross-wired in the PVF itself: the `knuckle`
// directory holds items named 「手套」 and `boxglove` holds 「拳套」, `pole` holds
// 「长棍/棍棒」 and `rod` holds 「魔杖」. The tab is what the player reads, so the
// directory is chosen by the names the items actually carry (see
// TestCurrentAuctionCategoryRealPVFNamesMatchTheirLabels).
{10201, "格斗家", "fighter", []struct {
code uint16
label string
dir string
}{{10202, "手套", "knuckle"}, {10203, "臂铠", "gauntlet"}, {10204, "爪", "claw"}, {10205, "拳套", "boxglove"}, {10206, "东方棍", "tonfa"}}},
{10301, "神枪手", "gunner", []struct {
code uint16
label string
dir string
}{{10302, "左轮枪", "revolver"}, {10303, "自动手枪", "automatic"}, {10304, "步枪", "musket"}, {10305, "手炮", "hcannon"}, {10306, "手弩", "bowgun"}}},
{10401, "魔法师/缔造者", "mage", []struct {
code uint16
label string
dir string
}{{10402, "矛", "spear"}, {10403, "棍棒", "pole"}, {10404, "魔杖", "rod"}, {10405, "法杖", "staff"}, {10406, "扫把", "broom"}}},
{10501, "圣职者", "priest", []struct {
code uint16
label string
dir string
}{{10502, "十字架", "cross"}, {10503, "念珠", "rosary"}, {10504, "图腾", "totem"}, {10505, "镰刀", "scythe"}, {10506, "战斧", "axe"}}},
{10601, "暗夜使者", "thief", []struct {
code uint16
label string
dir string
}{{10602, "匕首", "dagger"}, {10603, "双剑", "twinsword"}, {10604, "手杖", "wand"}, {10605, "苦无", "chakraweapon"}}},
{10801, "魔枪士", "demoniclancer", []struct {
code uint16
label string
dir string
}{{10802, "长枪", "pike"}, {10803, "战戟", "halberd"}, {10804, "光枪", "beamspear"}, {10805, "暗矛", "javelin"}}},
{10901, "枪剑士", "gunblader", []struct {
code uint16
label string
dir string
}{{10902, "长刀", "lblade"}, {10903, "小太刀", "sblade"}, {10904, "重剑", "mswd"}, {10905, "源力剑", "coreswd"}}},
}
// currentAuctionCategoryArmorMaterials is 防具's material level; each material's
// children are the same five parts in the same order (base+1 .. base+5).
var currentAuctionCategoryArmorMaterials = []struct {
code uint16
label string
dir string
}{
{11001, "布甲", "cloth"},
{11100, "皮甲", "leather"},
{11200, "轻甲", "larmor"},
{11300, "重甲", "harmor"},
{11400, "板甲", "plate"},
}
// currentAuctionCategoryArmorParts is the client's fixed part order inside one
// armor material: 上衣 / 头肩 / 下装 / 鞋 / 腰带.
var currentAuctionCategoryArmorParts = []struct {
label string
slot string
types []string
}{
{"上衣", "jacket", []string{"[coat]"}},
{"头肩", "shoulder", []string{"[shoulder]"}},
{"下装", "pants", []string{"[pants]"}},
{"鞋", "shoes", []string{"[shoes]"}},
{"腰带", "belt", []string{"[waist]"}},
}
var currentAuctionCategoryArmorTypes = []string{"[coat]", "[pants]", "[shoulder]", "[shoes]", "[waist]"}
func currentAuctionCategorySpecs() []currentAuctionCategorySpec {
specs := []currentAuctionCategorySpec{
{code: currentAuctionCategoryAllCode, parent: 0, label: "全部", kind: currentAuctionCategoryAll},
}
// 武器 > <职业> > <武器类型>
specs = append(specs, currentAuctionCategorySpec{
code: 10000, parent: currentAuctionCategoryAllCode, label: "武器", kind: currentAuctionCategoryChildren,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: []string{"[weapon]"},
},
})
for _, job := range currentAuctionCategoryWeaponJobs {
specs = append(specs, currentAuctionCategorySpec{
code: job.code, parent: 10000, label: job.label, kind: currentAuctionCategoryItems,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: []string{"[weapon]"},
pathAll: []string{"/character/" + job.dir + "/weapon/"},
},
})
for _, weaponType := range job.types {
specs = append(specs, currentAuctionCategorySpec{
code: weaponType.code, parent: job.code, label: weaponType.label, kind: currentAuctionCategoryItems,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: []string{"[weapon]"},
pathAll: []string{"/character/" + job.dir + "/weapon/" + weaponType.dir + "/"},
},
})
}
}
// 防具 > <护甲> > <部位>
specs = append(specs, currentAuctionCategorySpec{
code: 11000, parent: currentAuctionCategoryAllCode, label: "防具", kind: currentAuctionCategoryChildren,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: currentAuctionCategoryArmorTypes,
},
})
for _, material := range currentAuctionCategoryArmorMaterials {
specs = append(specs, currentAuctionCategorySpec{
code: material.code, parent: 11000, label: material.label, kind: currentAuctionCategoryItems,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: currentAuctionCategoryArmorTypes,
pathAll: []string{"/" + material.dir + "/"},
},
})
for offset, part := range currentAuctionCategoryArmorParts {
specs = append(specs, currentAuctionCategorySpec{
code: material.code + uint16(offset) + 1, parent: material.code, label: part.label,
kind: currentAuctionCategoryItems,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: part.types,
pathAll: []string{"/" + part.slot + "/" + material.dir + "/"},
},
})
}
}
// 首饰 > 项链 / 戒指 / 手镯 / 称号
specs = append(specs, currentAuctionCategorySpec{
code: 12000, parent: currentAuctionCategoryAllCode, label: "首饰", kind: currentAuctionCategoryChildren,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: []string{"[amulet]", "[ring]", "[wrist]", "[title name]"},
},
})
for _, node := range []struct {
code uint16
label string
types []string
}{
{12001, "项链", []string{"[amulet]"}},
{12002, "戒指", []string{"[ring]"}},
{12003, "手镯", []string{"[wrist]"}},
{12004, "称号", []string{"[title name]"}},
} {
specs = append(specs, currentAuctionCategorySpec{
code: node.code, parent: 12000, label: node.label, kind: currentAuctionCategoryItems,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: node.types,
},
})
}
// 称号 splits by job on the client (14 nodes). Which [usable job] value belongs
// to which node is not proven yet; the labels are the client's own.
for _, node := range []struct {
code uint16
label string
}{
{12005, "鬼剑士/黑暗武士"}, {12006, "鬼剑士(女)"}, {12007, "格斗家(女)"}, {12008, "格斗家(男)"},
{12009, "神枪手(男)"}, {12010, "神枪手(女)"}, {12011, "魔法师(女)/缔造者"}, {12012, "魔法师(男)"},
{12013, "圣职者 (男)"}, {12017, "圣职者 (女)"}, {12014, "暗夜使者"}, {12015, "守护者"},
{12016, "魔枪士"}, {12018, "枪剑士"},
} {
specs = append(specs, currentAuctionCategorySpec{
code: node.code, parent: 12004, label: node.label, kind: currentAuctionCategoryUnpinned,
})
}
// 特殊装备 > 辅助装备 / 魔法石 / 耳环
specs = append(specs, currentAuctionCategorySpec{
code: 32000, parent: currentAuctionCategoryAllCode, label: "特殊装备", kind: currentAuctionCategoryChildren,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: []string{"[support]", "[magic stone]", "[earring]"},
},
})
for _, node := range []struct {
code uint16
label string
types []string
}{
{32001, "辅助装备", []string{"[support]"}},
{32100, "魔法石", []string{"[magic stone]"}},
{32200, "耳环", []string{"[earring]"}},
} {
specs = append(specs, currentAuctionCategorySpec{
code: node.code, parent: 32000, label: node.label, kind: currentAuctionCategoryItems,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: node.types,
},
})
}
// 辅助装备 / 魔法石 split by job on the client (16 nodes each) - same open
// question as 称号, and again the labels are the client's.
for _, node := range []struct {
code uint16
label string
}{
{32002, "鬼剑士 (男)"}, {32003, "鬼剑士 (女)"}, {32004, "黑暗武士"}, {32013, "格斗家(男)"},
{32005, "格斗家(女)"}, {32006, "神枪手(男)"}, {32011, "神枪手(女)"}, {32009, "魔法师(男)"},
{32007, "魔法师(女)"}, {32008, "缔造者"}, {32010, "圣职者 (男)"}, {32016, "圣职者 (女)"},
{32012, "暗夜使者"}, {32014, "守护者"}, {32015, "魔枪士"}, {32017, "枪剑士"},
} {
specs = append(specs, currentAuctionCategorySpec{
code: node.code, parent: 32001, label: node.label, kind: currentAuctionCategoryUnpinned,
})
}
for _, node := range []struct {
code uint16
label string
}{
{32101, "鬼剑士 (男)"}, {32102, "鬼剑士 (女)"}, {32103, "黑暗武士"}, {32112, "格斗家(男)"},
{32104, "格斗家(女)"}, {32105, "神枪手(男)"}, {32110, "神枪手(女)"}, {32108, "魔法师(男)"},
{32106, "魔法师(女)"}, {32107, "缔造者"}, {32109, "圣职者 (男)"}, {32115, "圣职者 (女)"},
{32111, "暗夜使者"}, {32113, "守护者"}, {32114, "魔枪士"}, {32116, "枪剑士"},
} {
specs = append(specs, currentAuctionCategorySpec{
code: node.code, parent: 32100, label: node.label, kind: currentAuctionCategoryUnpinned,
})
}
// 设计图: [recipe]. Its four children split by the recipe's output item, which
// would mean parsing every recipe's [output item] inside the auction lock; that
// is deliberately left out of this pass, so they stay unpinned.
specs = append(specs, currentAuctionCategorySpec{
code: 31000, parent: currentAuctionCategoryAllCode, label: "设计图", kind: currentAuctionCategoryChildren,
rule: currentAuctionCategoryStackableRule("[recipe]"),
})
for _, node := range []struct {
code uint16
parent uint16
label string
}{
{31001, 31000, "武器"},
{31101, 31000, "防具"},
{31201, 31000, "首饰"},
{31301, 31000, "特殊装备"},
{31302, 31301, "辅助装备"},
{31303, 31301, "魔法石"},
// The job level under 设计图>武器/防具/首饰 mirrors the item tree's own job
// order; it needs the recipe output classification above, so it is unpinned
// for the same reason. Labels are the client's.
{31002, 31001, "鬼剑士/黑暗武士/守护者"},
{31003, 31001, "格斗家"},
{31004, 31001, "神枪手"},
{31005, 31001, "魔法师/缔造者"},
{31006, 31001, "圣职者"},
{31007, 31001, "暗夜使者"},
{31010, 31001, "魔枪士"},
{31011, 31001, "枪剑士"},
{31102, 31101, "布甲"},
{31103, 31101, "皮甲"},
{31104, 31101, "轻甲"},
{31105, 31101, "重甲"},
{31106, 31101, "板甲"},
{31202, 31201, "项链"},
{31203, 31201, "戒指"},
{31204, 31201, "手镯"},
} {
specs = append(specs, currentAuctionCategorySpec{
code: node.code, parent: node.parent, label: node.label, kind: currentAuctionCategoryUnpinned,
})
}
// 消耗品: every eligible stackable that is not a 设计图 or a 副职业材料.
specs = append(specs, currentAuctionCategorySpec{
code: 13000, parent: currentAuctionCategoryAllCode, label: "消耗品", kind: currentAuctionCategoryChildren,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemStackable},
stackableNone: []string{"[recipe]", "[material expert job]"},
},
})
for _, node := range []struct {
code uint16
label string
kind currentAuctionCategoryNodeKind
rule currentAuctionCategoryRule
}{
{13001, "消耗品", currentAuctionCategoryItems, currentAuctionCategoryConsumableRule()},
{13002, "材料", currentAuctionCategoryItems, currentAuctionCategoryStackableRule("[material]")},
{13003, "投掷/设置", currentAuctionCategoryItems, currentAuctionCategoryStackableRule("[throw]")},
{13004, "抽取", currentAuctionCategoryUnpinned, currentAuctionCategoryRule{}},
{13005, "任务", currentAuctionCategoryItems, currentAuctionCategoryStackableRule("[quest]", "[quest receive]")},
{13006, "其他", currentAuctionCategoryRemainder, currentAuctionCategoryRule{}},
} {
specs = append(specs, currentAuctionCategorySpec{
code: node.code, parent: 13000, label: node.label, kind: node.kind, rule: node.rule,
})
}
// 宠物: the pets themselves plus the three pet-equipment families.
specs = append(specs, currentAuctionCategorySpec{
code: 14000, parent: currentAuctionCategoryAllCode, label: "宠物", kind: currentAuctionCategoryChildren,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: []string{"[creature]", "[artifact red]", "[artifact blue]", "[artifact green]"},
},
})
for _, node := range []struct {
code uint16
label string
types []string
}{
{14001, "红色宠物装备", []string{"[artifact red]"}},
{14002, "蓝色宠物装备", []string{"[artifact blue]"}},
{14003, "绿色宠物装备", []string{"[artifact green]"}},
} {
specs = append(specs, currentAuctionCategorySpec{
code: node.code, parent: 14000, label: node.label, kind: currentAuctionCategoryItems,
rule: currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemEquipment},
equipmentTypes: node.types,
},
})
}
// 副职业: [material expert job]. Splitting it per profession needs the
// expert-job catalogs, which is a separate dependency; those three stay
// unpinned.
specs = append(specs, currentAuctionCategorySpec{
code: 33000, parent: currentAuctionCategoryAllCode, label: "副职业", kind: currentAuctionCategoryChildren,
rule: currentAuctionCategoryStackableRule("[material expert job]"),
})
for _, node := range []struct {
code uint16
label string
kind currentAuctionCategoryNodeKind
rule currentAuctionCategoryRule
}{
{33001, "炼金术师", currentAuctionCategoryUnpinned, currentAuctionCategoryRule{}},
{33002, "控偶师", currentAuctionCategoryUnpinned, currentAuctionCategoryRule{}},
{33003, "附魔师", currentAuctionCategoryUnpinned, currentAuctionCategoryRule{}},
{33004, "副职材料", currentAuctionCategoryItems, currentAuctionCategoryStackableRule("[material expert job]")},
} {
specs = append(specs, currentAuctionCategorySpec{
code: node.code, parent: 33000, label: node.label, kind: node.kind, rule: node.rule,
})
}
// 其他 is the top-level catch-all.
specs = append(specs, currentAuctionCategorySpec{
code: 34000, parent: currentAuctionCategoryAllCode, label: "其他", kind: currentAuctionCategoryGlobal,
})
return specs
}
func currentAuctionCategoryStackableRule(types ...string) currentAuctionCategoryRule {
return currentAuctionCategoryRule{
kinds: []dungeonDropItemKind{dungeonDropItemStackable},
stackableTypes: types,
}
}
// currentAuctionCategoryConsumableRule is the 消耗品>消耗品 leaf: the usable /
// booster / package [stackable type] families. Everything else inside the tab
// lands in 消耗品>其他, so an incomplete list here cannot hide an item.
func currentAuctionCategoryConsumableRule() currentAuctionCategoryRule {
return currentAuctionCategoryStackableRule(
"[town and dungeon]",
"[unlimited town and dungeon]",
"[expert town potion]",
"[teleport potion]",
"[npc teleport potion]",
"[dye]",
"[booster]",
"[booster selection]",
"[booster random]",
"[cera booster]",
"[usable cera package]",
"[cera package]",
"[auto enchant]",
"[auto use special buff]",
"[disguise]",
"[random disguise]",
"[coupon]",
"[contract]",
"[avatar gift]",
"[feed]",
"[creature expitem]",
"[quiz item]",
"[legacy]",
"[upgradable legacy]",
"[random upgradable legacy]",
"[multi upgradable legacy]",
"[multi upgradable legacy bonus cera]",
"[stackable legacy]",
"[apc legacy]",
"[upgrade limit cube]",
"[gold kaleido]",
"[set]",
"[item upgrad buff usual upgrade]",
"[item upgrad buff amplify upgrade]",
"[scale effect]",
"[only effect]",
"[global effect]",
"[roulette buff]",
"[equipment effect]",
"[virtual]",
"[material instant item]",
"[waste instant item]",
)
}
func buildCurrentAuctionCategoryTree() currentAuctionCategoryTree {
specs := currentAuctionCategorySpecs()
tree := currentAuctionCategoryTree{nodes: make(map[uint16]currentAuctionCategoryNode, len(specs))}
for _, spec := range specs {
if _, exists := tree.nodes[spec.code]; exists {
panic("dnfbridge: duplicate auction category code")
}
tree.nodes[spec.code] = currentAuctionCategoryNode{
code: spec.code, label: spec.label, parent: spec.parent, kind: spec.kind, rule: spec.rule,
}
tree.allCodes = append(tree.allCodes, spec.code)
}
// Second pass: children are only known once every node exists, so the spec list
// can stay parent-before-child free of ordering constraints.
for _, spec := range specs {
if spec.parent == 0 {
continue
}
parent, found := tree.nodes[spec.parent]
if !found {
panic("dnfbridge: auction category spec references a missing parent")
}
parent.children = append(parent.children, spec.code)
tree.nodes[spec.parent] = parent
}
for _, code := range tree.allCodes {
node := tree.nodes[code]
if node.parent == currentAuctionCategoryAllCode {
tree.topLevel = append(tree.topLevel, code)
}
if node.kind == currentAuctionCategoryUnpinned {
tree.unpinned = append(tree.unpinned, code)
}
}
sort.Slice(tree.allCodes, func(i, j int) bool { return tree.allCodes[i] < tree.allCodes[j] })
sort.Slice(tree.topLevel, func(i, j int) bool { return tree.topLevel[i] < tree.topLevel[j] })
sort.Slice(tree.unpinned, func(i, j int) bool { return tree.unpinned[i] < tree.unpinned[j] })
return tree
}