服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
207 lines
6.4 KiB
Go
207 lines
6.4 KiB
Go
// Package darkknightcombo owns Dark Knight combo pages independently from
|
|
// ordinary learned skills and quick-slot layouts.
|
|
package darkknightcombo
|
|
|
|
import (
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
)
|
|
|
|
const (
|
|
Job byte = 9
|
|
MaximumPageCount = 2
|
|
MaximumStageCount = 5
|
|
maximumEntryCount = 255
|
|
)
|
|
|
|
var ErrPageBlockInvalid = errors.New("dark knight combo page block is invalid")
|
|
|
|
type Root struct {
|
|
SkillID uint16
|
|
Children []uint16
|
|
}
|
|
|
|
type Page struct {
|
|
Index byte
|
|
Roots []Root
|
|
}
|
|
|
|
// PreloadEntry is one character's combo pages for the native character-select
|
|
// preloader. Slot is the client roster slot, not a database row ordinal.
|
|
type PreloadEntry struct {
|
|
Slot byte
|
|
Pages []Page
|
|
}
|
|
|
|
// ParsePageBlock decodes the native editor page payload:
|
|
// u8 page, u8 root_count, repeated(u16 root, u8 child_count, u16 children...).
|
|
func ParsePageBlock(body []byte) (Page, error) {
|
|
if len(body) < 2 {
|
|
return Page{}, fmt.Errorf("%w: length=%d", ErrPageBlockInvalid, len(body))
|
|
}
|
|
page := Page{Index: body[0]}
|
|
if page.Index >= MaximumPageCount {
|
|
return Page{}, fmt.Errorf("%w: page=%d", ErrPageBlockInvalid, page.Index)
|
|
}
|
|
offset := 2
|
|
rootCount := int(body[1])
|
|
page.Roots = make([]Root, 0, rootCount)
|
|
for rootIndex := 0; rootIndex < rootCount; rootIndex++ {
|
|
if offset+3 > len(body) {
|
|
return Page{}, fmt.Errorf("%w: truncated root=%d offset=%d", ErrPageBlockInvalid, rootIndex, offset)
|
|
}
|
|
root := Root{SkillID: binary.LittleEndian.Uint16(body[offset : offset+2])}
|
|
offset += 2
|
|
childCount := int(body[offset])
|
|
offset++
|
|
if offset+childCount*2 > len(body) {
|
|
return Page{}, fmt.Errorf("%w: truncated children root=%d count=%d", ErrPageBlockInvalid, rootIndex, childCount)
|
|
}
|
|
root.Children = make([]uint16, 0, childCount)
|
|
for childIndex := 0; childIndex < childCount; childIndex++ {
|
|
root.Children = append(root.Children, binary.LittleEndian.Uint16(body[offset:offset+2]))
|
|
offset += 2
|
|
}
|
|
page.Roots = append(page.Roots, root)
|
|
}
|
|
if offset != len(body) {
|
|
return Page{}, fmt.Errorf("%w: trailing=%d", ErrPageBlockInvalid, len(body)-offset)
|
|
}
|
|
return page, nil
|
|
}
|
|
|
|
// NormalizePage keeps the last occurrence of a child skill, matching the
|
|
// native editor when a skill is moved within or between combo columns.
|
|
func NormalizePage(page Page) (Page, error) {
|
|
if page.Index >= MaximumPageCount || len(page.Roots) > maximumEntryCount {
|
|
return Page{}, ErrPageBlockInvalid
|
|
}
|
|
type occurrence struct{ root, child int }
|
|
last := make(map[uint16]occurrence)
|
|
for rootIndex, root := range page.Roots {
|
|
if len(root.Children) > MaximumStageCount {
|
|
return Page{}, ErrPageBlockInvalid
|
|
}
|
|
for childIndex, skillID := range root.Children {
|
|
last[skillID] = occurrence{root: rootIndex, child: childIndex}
|
|
}
|
|
}
|
|
out := Page{Index: page.Index, Roots: make([]Root, 0, len(page.Roots))}
|
|
for rootIndex, root := range page.Roots {
|
|
copyRoot := Root{SkillID: root.SkillID}
|
|
for childIndex, skillID := range root.Children {
|
|
if last[skillID] == (occurrence{root: rootIndex, child: childIndex}) {
|
|
copyRoot.Children = append(copyRoot.Children, skillID)
|
|
}
|
|
}
|
|
out.Roots = append(out.Roots, copyRoot)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func EncodePageBlock(page Page) ([]byte, error) {
|
|
page, err := NormalizePage(page)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body := make([]byte, 0, 2+len(page.Roots)*3)
|
|
body = append(body, page.Index, byte(len(page.Roots)))
|
|
var scratch [2]byte
|
|
for _, root := range page.Roots {
|
|
binary.LittleEndian.PutUint16(scratch[:], root.SkillID)
|
|
body = append(body, scratch[:]...)
|
|
body = append(body, byte(len(root.Children)))
|
|
for _, child := range root.Children {
|
|
binary.LittleEndian.PutUint16(scratch[:], child)
|
|
body = append(body, scratch[:]...)
|
|
}
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// EncodeNotificationBody builds the current client's class-0/op447 combo
|
|
// initialization body. applyAndEcho is deliberately zero: the server is
|
|
// restoring durable state and must not make the client immediately echo an
|
|
// op508 save while the character is still entering the scene.
|
|
func EncodeNotificationBody(pages []Page) ([]byte, error) {
|
|
if len(pages) == 0 || len(pages) > MaximumPageCount {
|
|
return nil, ErrPageBlockInvalid
|
|
}
|
|
ordered := append([]Page(nil), pages...)
|
|
sort.Slice(ordered, func(left, right int) bool { return ordered[left].Index < ordered[right].Index })
|
|
body := []byte{0, byte(len(ordered))}
|
|
seen := make(map[byte]struct{}, len(ordered))
|
|
for _, page := range ordered {
|
|
if _, duplicate := seen[page.Index]; duplicate {
|
|
return nil, ErrPageBlockInvalid
|
|
}
|
|
seen[page.Index] = struct{}{}
|
|
block, err := EncodePageBlock(page)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body = append(body, block...)
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
// EncodePreloadNotificationBody builds the current client's class-0/op809
|
|
// character-select preloader. Each entry owns its own page-count and the same
|
|
// page blocks accepted by class-0/op447. The list is omitted by the bridge
|
|
// when no Dark Knight roster row can be populated.
|
|
func EncodePreloadNotificationBody(entries []PreloadEntry) ([]byte, error) {
|
|
if len(entries) == 0 || len(entries) > maximumEntryCount {
|
|
return nil, ErrPageBlockInvalid
|
|
}
|
|
body := make([]byte, 0, 2)
|
|
body = append(body, byte(len(entries)))
|
|
seenSlots := make(map[byte]struct{}, len(entries))
|
|
for _, entry := range entries {
|
|
if _, duplicate := seenSlots[entry.Slot]; duplicate {
|
|
return nil, ErrPageBlockInvalid
|
|
}
|
|
seenSlots[entry.Slot] = struct{}{}
|
|
if len(entry.Pages) == 0 || len(entry.Pages) > MaximumPageCount {
|
|
return nil, ErrPageBlockInvalid
|
|
}
|
|
ordered := append([]Page(nil), entry.Pages...)
|
|
sort.Slice(ordered, func(left, right int) bool { return ordered[left].Index < ordered[right].Index })
|
|
body = append(body, entry.Slot, 0, byte(len(ordered)))
|
|
seenPages := make(map[byte]struct{}, len(ordered))
|
|
for _, page := range ordered {
|
|
if _, duplicate := seenPages[page.Index]; duplicate {
|
|
return nil, ErrPageBlockInvalid
|
|
}
|
|
seenPages[page.Index] = struct{}{}
|
|
block, err := EncodePageBlock(page)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
body = append(body, block...)
|
|
}
|
|
}
|
|
return body, nil
|
|
}
|
|
|
|
func RootSkillIDs(pages []Page) map[uint16]struct{} {
|
|
result := make(map[uint16]struct{})
|
|
for _, page := range pages {
|
|
for _, root := range page.Roots {
|
|
result[root.SkillID] = struct{}{}
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
func ChildSkillIDs(page Page) map[uint16]struct{} {
|
|
result := make(map[uint16]struct{})
|
|
for _, root := range page.Roots {
|
|
for _, child := range root.Children {
|
|
result[child] = struct{}{}
|
|
}
|
|
}
|
|
return result
|
|
}
|