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