DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,206 @@
// 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
}
@@ -0,0 +1,92 @@
package darkknightcombo
import (
"bytes"
"context"
"errors"
"reflect"
"testing"
"time"
repomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestEncodeNotificationBodyOrdersNativePageBlocks(t *testing.T) {
page0 := Page{Index: 0, Roots: []Root{{SkillID: 118, Children: []uint16{8, 77}}}}
page1 := Page{Index: 1, Roots: []Root{{SkillID: 119, Children: []uint16{5}}}}
got, err := EncodeNotificationBody([]Page{page1, page0})
if err != nil {
t.Fatal(err)
}
block0, _ := EncodePageBlock(page0)
block1, _ := EncodePageBlock(page1)
want := append([]byte{0, 2}, block0...)
want = append(want, block1...)
if !bytes.Equal(got, want) {
t.Fatalf("notification=%x want=%x", got, want)
}
}
func TestEncodePreloadNotificationBodyKeepsRosterSlotsAndPageBlocks(t *testing.T) {
page0 := Page{Index: 0, Roots: []Root{{SkillID: 118, Children: []uint16{8}}}}
page1 := Page{Index: 1, Roots: []Root{{SkillID: 119, Children: []uint16{5}}}}
got, err := EncodePreloadNotificationBody([]PreloadEntry{{Slot: 3, Pages: []Page{page1, page0}}})
if err != nil {
t.Fatal(err)
}
block0, _ := EncodePageBlock(page0)
block1, _ := EncodePageBlock(page1)
want := append([]byte{1, 3, 0, 2}, block0...)
want = append(want, block1...)
if !bytes.Equal(got, want) {
t.Fatalf("preload=%x want=%x", got, want)
}
}
func TestNormalizePageKeepsLastChildOccurrence(t *testing.T) {
page, err := NormalizePage(Page{Index: 0, Roots: []Root{
{SkillID: 100, Children: []uint16{10, 11}},
{SkillID: 101, Children: []uint16{11, 12, 10}},
}})
if err != nil {
t.Fatal(err)
}
want := Page{Index: 0, Roots: []Root{
{SkillID: 100},
{SkillID: 101, Children: []uint16{11, 12, 10}},
}}
if !reflect.DeepEqual(page, want) {
t.Fatalf("page=%+v want=%+v", page, want)
}
}
func TestNormalizePageRejectsMoreThanFiveComboStages(t *testing.T) {
_, err := NormalizePage(Page{Index: 0, Roots: []Root{{
SkillID: 118,
Children: []uint16{1, 2, 3, 4, 5, 6},
}}})
if !errors.Is(err, ErrPageBlockInvalid) {
t.Fatalf("error=%v want=%v", err, ErrPageBlockInvalid)
}
}
func TestPagesRoundTripThroughSettingsRepository(t *testing.T) {
repositories := repomemory.NewMemoryGroup()
ctx := context.Background()
pages := []Page{{Index: 0, Roots: []Root{{SkillID: 100, Children: []uint16{10, 11}}}}}
if err := SavePages(ctx, repositories.Settings, "77", pages, time.Unix(100, 0)); err != nil {
t.Fatal(err)
}
loaded, found, err := LoadPages(ctx, repositories.Settings, "77")
if err != nil || !found || !reflect.DeepEqual(loaded, pages) {
t.Fatalf("loaded=%+v found=%t err=%v", loaded, found, err)
}
page1 := Page{Index: 1, Roots: []Root{{SkillID: 200, Children: []uint16{}}}}
if err := SavePage(ctx, repositories.Settings, "77", page1, time.Unix(200, 0)); err != nil {
t.Fatal(err)
}
loaded, found, err = LoadPages(ctx, repositories.Settings, "77")
if err != nil || !found || !reflect.DeepEqual(loaded, []Page{pages[0], page1}) {
t.Fatalf("updated pages=%+v found=%t err=%v", loaded, found, err)
}
}
@@ -0,0 +1,133 @@
package darkknightcombo
import (
"fmt"
"path"
"regexp"
"sort"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
)
var comboRootPathRE = regexp.MustCompile(`(?i)(^|/)comboset\d+\.skl$`)
type TextSource interface {
ReadText(relativePath string) (string, error)
}
// BuildDefaultPages derives combo roots and children only from the active PVF
// and learned skills. No root or child skill ID is hard-coded.
func BuildDefaultPages(source TextSource, catalog *dnfskill.Table, record dnfrepo.SkillRecord) ([]Page, error) {
if source == nil || catalog == nil {
return nil, fmt.Errorf("dark knight combo PVF resources are unavailable")
}
pages := make([]Page, 0, MaximumPageCount)
for pageIndex := 0; pageIndex < MaximumPageCount; pageIndex++ {
state, found := dnfrepo.SkillTree(record, pageIndex)
if !found || len(state.Skills) == 0 {
continue
}
page, ok, err := buildDefaultPage(source, catalog, byte(pageIndex), state, record.Layouts[pageIndex])
if err != nil {
return nil, err
}
if ok {
pages = append(pages, page)
}
}
return pages, nil
}
func buildDefaultPage(source TextSource, catalog *dnfskill.Table, pageIndex byte, state dnfrepo.SkillTreeState, layout dnfrepo.SkillLayout) (Page, bool, error) {
learned := make(map[uint16]struct{}, len(state.Skills))
for skillID, skillState := range state.Skills {
if skillState.Enabled && skillState.Level > 0 && skillID > 0 && skillID <= 0xffff {
learned[uint16(skillID)] = struct{}{}
}
}
if len(learned) == 0 {
return Page{}, false, nil
}
ordered := orderedLearnedSkillIDs(layout, learned)
page := Page{Index: pageIndex}
for _, skillID := range ordered {
definition, found := catalog.Find(Job, skillID)
if !found || !comboRootPathRE.MatchString(strings.ReplaceAll(definition.Path, `\`, "/")) {
continue
}
children, err := loadComboSet(source, definition.Path)
if err != nil {
return Page{}, false, fmt.Errorf("load dark knight combo root %d (%s): %w", skillID, definition.Path, err)
}
root := Root{SkillID: skillID}
for _, child := range children {
if _, found := learned[child]; found {
root.Children = append(root.Children, child)
}
}
page.Roots = append(page.Roots, root)
}
if len(page.Roots) == 0 {
return Page{}, false, nil
}
page, err := NormalizePage(page)
return page, err == nil, err
}
func orderedLearnedSkillIDs(layout dnfrepo.SkillLayout, learned map[uint16]struct{}) []uint16 {
slots := make([]int, 0, len(layout))
for slot := range layout {
slots = append(slots, slot)
}
sort.Ints(slots)
ordered := make([]uint16, 0, len(learned))
seen := make(map[uint16]struct{}, len(learned))
for _, slot := range slots {
skillID := layout[slot]
if _, ok := learned[skillID]; !ok {
continue
}
if _, ok := seen[skillID]; ok {
continue
}
seen[skillID] = struct{}{}
ordered = append(ordered, skillID)
}
missing := make([]int, 0, len(learned)-len(ordered))
for skillID := range learned {
if _, ok := seen[skillID]; !ok {
missing = append(missing, int(skillID))
}
}
sort.Ints(missing)
for _, skillID := range missing {
ordered = append(ordered, uint16(skillID))
}
return ordered
}
func loadComboSet(source TextSource, skillPath string) ([]uint16, error) {
clean := strings.TrimPrefix(strings.ReplaceAll(strings.TrimSpace(skillPath), `\`, "/"), "/")
if !strings.HasPrefix(strings.ToLower(clean), "skill/") {
clean = path.Join("skill", clean)
}
text, err := source.ReadText(clean)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(clean, text)
if err != nil {
return nil, err
}
values := doc.Ints("combo set")
children := make([]uint16, 0, len(values))
for _, value := range values {
if value > 0 && value <= 0xffff {
children = append(children, uint16(value))
}
}
return children, nil
}
@@ -0,0 +1,65 @@
package darkknightcombo
import (
"context"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
)
type comboTestSource map[string]string
func (source comboTestSource) ReadText(name string) (string, error) { return source[name], nil }
func TestBuildDefaultPagesUsesOnlyPVFLearnedSkillsAndDynamicRoots(t *testing.T) {
source := comboTestSource{
"skill/skilllist.lst": "9 `demonicswordman/skilllist.lst`\n",
"skill/demonicswordman/skilllist.lst": "1 `attack.skl`\n100 `comboset1.skl`\n101 `comboset2.skl`\n",
"skill/demonicswordman/attack.skl": "[name] `attack`\n[type] `active`",
"skill/demonicswordman/comboset1.skl": "[name] `combo 1`\n[combo set]\n1 2 3\n[/combo set]",
"skill/demonicswordman/comboset2.skl": "[name] `combo 2`\n[combo set]\n3 4\n[/combo set]",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatal(err)
}
record := dnfrepo.SkillRecord{
CharacterID: "77",
Skills: map[int64]dnfrepo.SkillState{
1: {Level: 1, Enabled: true}, 2: {Level: 1, Enabled: true},
100: {Level: 1, Enabled: true}, 101: {Level: 1, Enabled: true},
},
Layouts: map[int]dnfrepo.SkillLayout{0: {0: 100, 1: 1, 2: 101, 3: 2}},
}
pages, err := BuildDefaultPages(source, catalog, record)
if err != nil {
t.Fatal(err)
}
if len(pages) != 1 || len(pages[0].Roots) != 2 {
t.Fatalf("pages=%+v", pages)
}
if got := pages[0].Roots[0].Children; !equalUint16s(got, []uint16{1, 2}) {
t.Fatalf("first children=%v", got)
}
if got := pages[0].Roots[1].Children; len(got) != 0 {
t.Fatalf("second children=%v", got)
}
}
func equalUint16s(left, right []uint16) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
@@ -0,0 +1,217 @@
package darkknightcombo
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
)
var fixedComboRootPathRE = regexp.MustCompile(`(?i)(^|/)comboset([0-5])\.skl$`)
const (
ordinaryQuickSlotEnd = 6
extensionQuickSlotFrom = 198
extensionQuickSlotEnd = 204
nonShortcutSlotFrom = 12
nonShortcutSlotEnd = 198
preferredTreeSlotFrom = 150
)
func IsShortcutSlot(slot int) bool {
return (slot >= 0 && slot < ordinaryQuickSlotEnd) ||
(slot >= extensionQuickSlotFrom && slot < extensionQuickSlotEnd)
}
// IsExclusiveComboSlot reports the six ASDFGH coordinates owned by the
// Dark Knight combo manager. Ordinary skills remain allowed in the separate
// QWERTY extension coordinates (198..203).
func IsExclusiveComboSlot(slot int) bool {
return slot >= 0 && slot < ordinaryQuickSlotEnd
}
// NormalizeFixedComboRootSlots installs the learned PVF combo-set roots in the
// six ASDFGH coordinates owned by the Dark Knight combat HUD. The current
// client does not synthesize these roots from the combo-editor notification:
// they must be ordinary op19 skill rows at slots 0..5. Historical ordinary
// occupants are preserved in QWERTY (198..203) first and only overflow into a
// non-shortcut skill-tree coordinate.
func NormalizeFixedComboRootSlots(
catalog *dnfskill.Table,
states map[int64]dnfrepo.SkillState,
existing dnfrepo.SkillLayout,
) (dnfrepo.SkillLayout, int, error) {
if catalog == nil {
return nil, 0, fmt.Errorf("dark knight combo root catalog is unavailable")
}
layout := make(dnfrepo.SkillLayout, len(existing))
for slot, skillID := range existing {
layout[slot] = skillID
}
rootsBySlot := make(map[int]uint16, ordinaryQuickSlotEnd)
rootSkillIDs := make(map[uint16]struct{}, ordinaryQuickSlotEnd)
for rawSkillID, state := range states {
if !state.Enabled || state.Level <= 0 || rawSkillID <= 0 || rawSkillID > 0xffff {
continue
}
skillID := uint16(rawSkillID)
definition, found := catalog.Find(Job, skillID)
if !found {
continue
}
slot, root := fixedComboRootSlot(definition.Path)
if !root {
continue
}
if existingRoot, duplicate := rootsBySlot[slot]; duplicate && existingRoot != skillID {
return nil, 0, fmt.Errorf("dark knight combo slot %d has duplicate PVF roots %d and %d", slot, existingRoot, skillID)
}
rootsBySlot[slot] = skillID
rootSkillIDs[skillID] = struct{}{}
}
// Remove every stale/wrong coordinate for a learned combo root before
// displacing ordinary ASDFGH occupants. This also heals duplicate roots.
for slot, skillID := range layout {
if _, root := rootSkillIDs[skillID]; root {
delete(layout, slot)
}
}
moved := 0
for slot := 0; slot < ordinaryQuickSlotEnd; slot++ {
skillID, occupied := layout[slot]
if !occupied {
continue
}
to := firstFreeSlot(layout, extensionQuickSlotFrom, extensionQuickSlotEnd)
if to < 0 {
to = firstFreeNonShortcutSlot(layout)
}
if to < 0 {
return nil, 0, fmt.Errorf("cannot move ordinary dark knight skill %d from combo root slot %d", skillID, slot)
}
delete(layout, slot)
layout[to] = skillID
moved++
}
for slot, skillID := range rootsBySlot {
layout[slot] = skillID
}
return layout, moved, nil
}
func fixedComboRootSlot(skillPath string) (int, bool) {
normalized := strings.ReplaceAll(strings.TrimSpace(skillPath), `\`, "/")
match := fixedComboRootPathRE.FindStringSubmatch(normalized)
if len(match) != 3 {
return 0, false
}
slot, err := strconv.Atoi(match[2])
return slot, err == nil && slot >= 0 && slot < ordinaryQuickSlotEnd
}
// CleanAutoComboQuickSlots removes native combo candidates from shortcut
// coordinates while preserving every learned skill in a non-shortcut tree
// coordinate. Root skills are always excluded and saved children take
// priority even when their current PVF classification is incomplete.
func CleanAutoComboQuickSlots(
catalog *dnfskill.Table,
states map[int64]dnfrepo.SkillState,
existing dnfrepo.SkillLayout,
rootSkillIDs map[uint16]struct{},
savedChildSkillIDs map[uint16]struct{},
) (dnfrepo.SkillLayout, int, error) {
return cleanQuickSlots(existing, func(skillID uint16) bool {
if _, root := rootSkillIDs[skillID]; root {
return false
}
if _, saved := savedChildSkillIDs[skillID]; saved {
return true
}
state := states[int64(skillID)]
if !state.Enabled || state.Level <= 0 || catalog == nil {
return false
}
definition, found := catalog.Find(Job, skillID)
if !found || !definition.Active || definition.IsTPSkill() || definition.SkillClass < 0 || definition.SkillClass > 3 {
return false
}
path := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(definition.Path), `\`, "/"))
path = strings.TrimPrefix(path, "skill/")
return strings.HasPrefix(path, "demonicswordman/") && !strings.Contains(path, "/notapplicable/")
})
}
// CleanSavedChildrenQuickSlots performs the second half of an auto-combo
// commit. A manual combo save must not call this function.
func CleanSavedChildrenQuickSlots(
existing dnfrepo.SkillLayout,
rootSkillIDs map[uint16]struct{},
savedChildSkillIDs map[uint16]struct{},
) (dnfrepo.SkillLayout, int, error) {
return cleanQuickSlots(existing, func(skillID uint16) bool {
if _, root := rootSkillIDs[skillID]; root {
return false
}
_, saved := savedChildSkillIDs[skillID]
return saved
})
}
func cleanQuickSlots(existing dnfrepo.SkillLayout, shouldMove func(uint16) bool) (dnfrepo.SkillLayout, int, error) {
layout := make(dnfrepo.SkillLayout, len(existing))
for slot, skillID := range existing {
layout[slot] = skillID
}
shortcutSlots := make([]int, 0, len(layout))
for slot, skillID := range layout {
if IsShortcutSlot(slot) && shouldMove(skillID) {
shortcutSlots = append(shortcutSlots, slot)
}
}
sort.Ints(shortcutSlots)
moved := 0
for _, from := range shortcutSlots {
skillID, occupied := layout[from]
if !occupied {
continue
}
to := firstFreeNonShortcutSlot(layout)
if to < 0 {
return nil, 0, fmt.Errorf("cannot move dark knight combo skill %d from shortcut %d", skillID, from)
}
delete(layout, from)
layout[to] = skillID
moved++
}
return layout, moved, nil
}
func firstFreeNonShortcutSlot(layout dnfrepo.SkillLayout) int {
for slot := preferredTreeSlotFrom; slot < nonShortcutSlotEnd; slot++ {
if _, occupied := layout[slot]; !occupied {
return slot
}
}
for slot := nonShortcutSlotFrom; slot < nonShortcutSlotEnd; slot++ {
if _, occupied := layout[slot]; !occupied {
return slot
}
}
return -1
}
func firstFreeSlot(layout dnfrepo.SkillLayout, start int, end int) int {
for slot := start; slot < end; slot++ {
if _, occupied := layout[slot]; !occupied {
return slot
}
}
return -1
}
@@ -0,0 +1,76 @@
package darkknightcombo
import (
"context"
"os"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestRealScriptPVFDarkKnightFixedComboRootsOccupyASDFGH(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify Dark Knight combo roots")
}
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
if err != nil {
t.Fatal(err)
}
index, err := dnfpvf.Build(context.Background(), archive, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatal(err)
}
states := make(map[int64]dnfrepo.SkillState)
layout := make(dnfrepo.SkillLayout)
rootCount := 0
ordinaryCount := 0
for _, definition := range catalog.SkillsForJob(Job) {
if slot, root := fixedComboRootSlot(definition.Path); root {
states[int64(definition.ID)] = dnfrepo.SkillState{Level: 1, Enabled: true}
layout[150+slot] = definition.ID
rootCount++
continue
}
if definition.Active && ordinaryCount < 3 {
states[int64(definition.ID)] = dnfrepo.SkillState{Level: 1, Enabled: true}
layout[ordinaryCount] = definition.ID
ordinaryCount++
}
}
if rootCount != ordinaryQuickSlotEnd || ordinaryCount != 3 {
t.Fatalf("real PVF roots=%d ordinary fixtures=%d", rootCount, ordinaryCount)
}
normalized, moved, err := NormalizeFixedComboRootSlots(catalog, states, layout)
if err != nil {
t.Fatal(err)
}
if moved != ordinaryCount {
t.Fatalf("moved=%d want=%d layout=%v", moved, ordinaryCount, normalized)
}
for slot := 0; slot < ordinaryQuickSlotEnd; slot++ {
skillID, found := normalized[slot]
if !found {
t.Fatalf("real PVF combo slot %d is empty: %v", slot, normalized)
}
definition, found := catalog.Find(Job, skillID)
rootSlot, root := fixedComboRootSlot(definition.Path)
if !found || !root || rootSlot != slot {
t.Fatalf("slot %d skill=%d path=%q rootSlot=%d root=%t", slot, skillID, definition.Path, rootSlot, root)
}
}
for slot := extensionQuickSlotFrom; slot < extensionQuickSlotFrom+ordinaryCount; slot++ {
if _, found := normalized[slot]; !found {
t.Fatalf("ordinary Dark Knight skill was not preserved at QWERTY slot %d: %v", slot, normalized)
}
}
}
@@ -0,0 +1,142 @@
package darkknightcombo
import (
"context"
"reflect"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfskill "longheng.io/server/internal/modules/dnf/skill"
)
func TestCleanSavedChildrenQuickSlotsPreservesRootsAndMovesChildren(t *testing.T) {
layout := dnfrepo.SkillLayout{0: 100, 1: 46, 198: 47, 54: 9}
got, moved, err := CleanSavedChildrenQuickSlots(
layout,
map[uint16]struct{}{100: {}},
map[uint16]struct{}{46: {}, 47: {}},
)
if err != nil {
t.Fatal(err)
}
want := dnfrepo.SkillLayout{0: 100, 54: 9, 150: 46, 151: 47}
if moved != 2 || !reflect.DeepEqual(got, want) {
t.Fatalf("moved=%d layout=%v want=%v", moved, got, want)
}
if !reflect.DeepEqual(layout, dnfrepo.SkillLayout{0: 100, 1: 46, 198: 47, 54: 9}) {
t.Fatalf("input layout mutated: %v", layout)
}
}
func TestCleanSavedChildrenQuickSlotsUsesStockSixPlusSixBoundary(t *testing.T) {
layout := make(dnfrepo.SkillLayout)
savedChildren := make(map[uint16]struct{})
for slot := 0; slot < 12; slot++ {
skillID := uint16(100 + slot)
layout[slot] = skillID
savedChildren[skillID] = struct{}{}
}
for slot := 198; slot < 204; slot++ {
skillID := uint16(300 + slot - 198)
layout[slot] = skillID
savedChildren[skillID] = struct{}{}
}
got, moved, err := CleanSavedChildrenQuickSlots(
layout,
map[uint16]struct{}{},
savedChildren,
)
if err != nil {
t.Fatal(err)
}
if moved != 12 {
t.Fatalf("moved=%d want=12", moved)
}
for slot := 0; slot < 6; slot++ {
if _, occupied := got[slot]; occupied {
t.Fatalf("primary quick slot %d was not cleaned: %v", slot, got)
}
}
for slot := 198; slot < 204; slot++ {
if _, occupied := got[slot]; occupied {
t.Fatalf("extension quick slot %d was not cleaned: %v", slot, got)
}
}
for slot := 6; slot < 12; slot++ {
want := uint16(100 + slot)
if got[slot] != want {
t.Fatalf("ordinary tree slot %d=%d want=%d; layout=%v", slot, got[slot], want, got)
}
}
}
func TestNormalizeFixedComboRootSlotsInstallsRootsAndMovesOrdinarySkillsToQWERTY(t *testing.T) {
catalog := fixedComboRootTestCatalog(t)
states := map[int64]dnfrepo.SkillState{
5: {Level: 1, Enabled: true}, 8: {Level: 1, Enabled: true}, 46: {Level: 1, Enabled: true},
118: {Level: 1, Enabled: true}, 119: {Level: 1, Enabled: true},
}
layout := dnfrepo.SkillLayout{0: 5, 1: 8, 2: 46, 198: 99, 150: 118, 151: 119}
got, moved, err := NormalizeFixedComboRootSlots(catalog, states, layout)
if err != nil {
t.Fatal(err)
}
want := dnfrepo.SkillLayout{0: 118, 1: 119, 198: 99, 199: 5, 200: 8, 201: 46}
if moved != 3 || !reflect.DeepEqual(got, want) {
t.Fatalf("moved=%d layout=%v want=%v", moved, got, want)
}
if !reflect.DeepEqual(layout, dnfrepo.SkillLayout{0: 5, 1: 8, 2: 46, 198: 99, 150: 118, 151: 119}) {
t.Fatalf("input layout mutated: %v", layout)
}
}
func TestNormalizeFixedComboRootSlotsOverflowsOrdinarySkillsIntoSkillTree(t *testing.T) {
catalog := fixedComboRootTestCatalog(t)
states := map[int64]dnfrepo.SkillState{
5: {Level: 1, Enabled: true}, 8: {Level: 1, Enabled: true},
118: {Level: 1, Enabled: true}, 119: {Level: 1, Enabled: true},
}
layout := dnfrepo.SkillLayout{0: 5, 1: 8}
for slot := extensionQuickSlotFrom; slot < extensionQuickSlotEnd; slot++ {
layout[slot] = uint16(100 + slot - extensionQuickSlotFrom)
}
layout[150] = 118
layout[151] = 119
got, moved, err := NormalizeFixedComboRootSlots(catalog, states, layout)
if err != nil {
t.Fatal(err)
}
if moved != 2 || got[150] != 5 || got[151] != 8 || got[0] != 118 || got[1] != 119 {
t.Fatalf("moved=%d layout=%v", moved, got)
}
for slot := 2; slot < ordinaryQuickSlotEnd; slot++ {
if _, occupied := got[slot]; occupied {
t.Fatalf("unlearned combo root slot %d remained occupied: %v", slot, got)
}
}
}
func fixedComboRootTestCatalog(t *testing.T) *dnfskill.Table {
t.Helper()
source := comboTestSource{
"skill/skilllist.lst": "9 `demonicswordman/skilllist.lst`\n",
"skill/demonicswordman/skilllist.lst": "5 `five.skl`\n8 `eight.skl`\n46 `forty_six.skl`\n118 `comboset0.skl`\n119 `comboset1.skl`\n",
"skill/demonicswordman/five.skl": "[type] `[active]`\n",
"skill/demonicswordman/eight.skl": "[type] `[active]`\n",
"skill/demonicswordman/forty_six.skl": "[type] `[active]`\n",
"skill/demonicswordman/comboset0.skl": "[combo set]\n5 8\n[/combo set]\n",
"skill/demonicswordman/comboset1.skl": "[combo set]\n46\n[/combo set]\n",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfskill.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfskill.Load(context.Background(), index, dnfskill.Options{})
if err != nil {
t.Fatal(err)
}
return catalog
}
@@ -0,0 +1,125 @@
package darkknightcombo
import (
"context"
"encoding/base64"
"fmt"
"sort"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
settingsFormatKey = "format"
settingsFormatValue = "dark-knight-combo-pages-v1"
settingsPagePrefix = "page_"
)
func SettingsScope(characterID string) string {
return "character:" + strings.TrimSpace(characterID) + ":dark_knight_combo"
}
func LoadPages(ctx context.Context, repo dnfrepo.SettingsRepository, characterID string) ([]Page, bool, error) {
characterID = strings.TrimSpace(characterID)
if repo == nil || characterID == "" {
return nil, false, fmt.Errorf("dark knight combo settings repository is unavailable")
}
record, found, err := repo.Load(ctx, SettingsScope(characterID))
if err != nil || !found {
return nil, found, err
}
if record.Values[settingsFormatKey] != settingsFormatValue {
return nil, false, fmt.Errorf("unsupported dark knight combo settings format")
}
pages := make([]Page, 0, MaximumPageCount)
for pageIndex := 0; pageIndex < MaximumPageCount; pageIndex++ {
encoded := strings.TrimSpace(record.Values[fmt.Sprintf("%s%d", settingsPagePrefix, pageIndex)])
if encoded == "" {
continue
}
block, decodeErr := base64.StdEncoding.Strict().DecodeString(encoded)
if decodeErr != nil {
return nil, false, fmt.Errorf("decode dark knight combo page %d: %w", pageIndex, decodeErr)
}
page, parseErr := ParsePageBlock(block)
if parseErr != nil {
return nil, false, fmt.Errorf("decode dark knight combo page %d: %w", pageIndex, parseErr)
}
if int(page.Index) != pageIndex {
return nil, false, fmt.Errorf("decode dark knight combo page %d: page index=%d", pageIndex, page.Index)
}
pages = append(pages, page)
}
if len(pages) == 0 {
return nil, false, nil
}
return pages, true, nil
}
func SavePages(ctx context.Context, repo dnfrepo.SettingsRepository, characterID string, pages []Page, updatedAt time.Time) error {
record, err := BuildSettingsRecord(characterID, pages, updatedAt)
if err != nil {
return err
}
if repo == nil {
return ErrPageBlockInvalid
}
return repo.Save(ctx, record)
}
// BuildSettingsRecord encodes both independent combo pages without writing
// them. Character creation uses this builder so the combo state is committed
// in the same aggregate transaction as the new character and its skill state.
func BuildSettingsRecord(characterID string, pages []Page, updatedAt time.Time) (dnfrepo.SettingsRecord, error) {
characterID = strings.TrimSpace(characterID)
if characterID == "" || len(pages) == 0 || len(pages) > MaximumPageCount {
return dnfrepo.SettingsRecord{}, ErrPageBlockInvalid
}
values := map[string]string{settingsFormatKey: settingsFormatValue}
sorted := append([]Page(nil), pages...)
sort.Slice(sorted, func(left, right int) bool { return sorted[left].Index < sorted[right].Index })
for _, page := range sorted {
block, err := EncodePageBlock(page)
if err != nil {
return dnfrepo.SettingsRecord{}, err
}
key := fmt.Sprintf("%s%d", settingsPagePrefix, page.Index)
if _, exists := values[key]; exists {
return dnfrepo.SettingsRecord{}, ErrPageBlockInvalid
}
values[key] = base64.StdEncoding.EncodeToString(block)
}
return dnfrepo.SettingsRecord{
Scope: SettingsScope(characterID),
Values: values,
UpdatedAt: updatedAt.UTC(),
}, nil
}
func SavePage(ctx context.Context, repo dnfrepo.SettingsRepository, characterID string, page Page, updatedAt time.Time) error {
page, err := NormalizePage(page)
if err != nil {
return err
}
pages, found, err := LoadPages(ctx, repo, characterID)
if err != nil {
return err
}
if !found {
pages = nil
}
replaced := false
for index := range pages {
if pages[index].Index == page.Index {
pages[index] = page
replaced = true
break
}
}
if !replaced {
pages = append(pages, page)
}
return SavePages(ctx, repo, characterID, pages, updatedAt)
}
@@ -0,0 +1,44 @@
package darkknightcombo
import (
"context"
"testing"
"time"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestBuildSettingsRecordRoundTripsBothIndependentPages(t *testing.T) {
pages := []Page{
{Index: 1, Roots: []Root{{SkillID: 119, Children: []uint16{8}}}},
{Index: 0, Roots: []Root{{SkillID: 118, Children: []uint16{46}}}},
}
record, err := BuildSettingsRecord("77", pages, time.Unix(10, 0))
if err != nil {
t.Fatal(err)
}
if record.Scope != SettingsScope("77") {
t.Fatalf("scope=%q want=%q", record.Scope, SettingsScope("77"))
}
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Settings.Save(context.Background(), record); err != nil {
t.Fatal(err)
}
loaded, found, err := LoadPages(context.Background(), repositories.Settings, "77")
if err != nil || !found {
t.Fatalf("load found=%t err=%v", found, err)
}
if len(loaded) != MaximumPageCount || loaded[0].Index != 0 || loaded[1].Index != 1 {
t.Fatalf("loaded pages=%+v", loaded)
}
if loaded[0].Roots[0].Children[0] != 46 || loaded[1].Roots[0].Children[0] != 8 {
t.Fatalf("loaded pages changed=%+v", loaded)
}
}
func TestBuildSettingsRecordRejectsDuplicatePages(t *testing.T) {
_, err := BuildSettingsRecord("77", []Page{{Index: 0}, {Index: 0}}, time.Time{})
if err == nil {
t.Fatal("duplicate page indexes were accepted")
}
}