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