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,157 @@
|
||||
// 本文件定义技能命令计划。
|
||||
// 计划只记录技能协议解析后的意图,不直接修改技能等级、SP/TP 或技能指令配置。
|
||||
package skillcmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
)
|
||||
|
||||
// Command 是 skillcmd handler 交给后续 skill owner 的最小命令计划。
|
||||
// 技能学习、退还和指令配置会影响战斗表现,必须由 skill owner 持久化并按 EXE 证据刷新后再回包。
|
||||
type Command struct {
|
||||
Operation string
|
||||
AccountID string
|
||||
SelectedCharacterID uint16
|
||||
DojoSkills bool
|
||||
SkillTree byte
|
||||
From byte
|
||||
To byte
|
||||
ContextIndex int32
|
||||
Mode byte
|
||||
FinalMode byte
|
||||
DeclaredCount byte
|
||||
EntryCount int
|
||||
RefundCount int
|
||||
SkillIDs []int64
|
||||
BuyEntries []BuySkillEntry
|
||||
CommandRecords []SkillCommandRecord
|
||||
RecordCount int
|
||||
NeedsOwner string
|
||||
}
|
||||
|
||||
// NewSlotCommand 构造技能栏互换计划。
|
||||
func NewSlotCommand(req alignedcmd.Request, parsed ChangeSkillSlotRequest) Command {
|
||||
return Command{
|
||||
DojoSkills: req.DojoSkills,
|
||||
Operation: "change_skill_slot",
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
SkillTree: parsed.SkillTree,
|
||||
From: parsed.From,
|
||||
To: parsed.To,
|
||||
ContextIndex: parsed.ContextIndex,
|
||||
Mode: parsed.Mode,
|
||||
NeedsOwner: "skill owner + bar persistence + NOTI 0x13 order",
|
||||
}
|
||||
}
|
||||
|
||||
// NewBuyCommand 构造学习或退还技能计划。
|
||||
func NewBuyCommand(req alignedcmd.Request, parsed BuySkillRequest) Command {
|
||||
refunds := 0
|
||||
skillIDs := make([]int64, 0, len(parsed.Entries))
|
||||
entries := make([]BuySkillEntry, len(parsed.Entries))
|
||||
copy(entries, parsed.Entries)
|
||||
for _, entry := range parsed.Entries {
|
||||
skillIDs = append(skillIDs, int64(entry.SkillID))
|
||||
if entry.RefundFlag != 0 {
|
||||
refunds++
|
||||
}
|
||||
}
|
||||
return Command{
|
||||
DojoSkills: req.DojoSkills,
|
||||
Operation: "buy_skill",
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
SkillTree: parsed.RawSkillTree,
|
||||
FinalMode: parsed.FinalMode,
|
||||
DeclaredCount: parsed.Count,
|
||||
EntryCount: len(parsed.Entries),
|
||||
RefundCount: refunds,
|
||||
SkillIDs: skillIDs,
|
||||
BuyEntries: entries,
|
||||
NeedsOwner: "skill owner + SP/TP validation + skill-state persistence + NOTI 0x13 order",
|
||||
}
|
||||
}
|
||||
|
||||
// NewResetCommand constructs the persisted reset requested by the current
|
||||
// Skill(K) initialize confirmation dialog.
|
||||
func NewResetCommand(req alignedcmd.Request, parsed SkillInitRequest) Command {
|
||||
return Command{
|
||||
DojoSkills: req.DojoSkills,
|
||||
Operation: "skill_init",
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
SkillTree: parsed.SkillTree,
|
||||
Mode: parsed.Mode,
|
||||
NeedsOwner: "skill owner + PVF initial levels + full SP/TP refund + layout persistence",
|
||||
}
|
||||
}
|
||||
|
||||
// NewTreeCommand 构造切换技能树计划。
|
||||
func NewTreeCommand(req alignedcmd.Request, parsed ChangeAnotherSkillTreeRequest) Command {
|
||||
return Command{
|
||||
Operation: "change_another_skill_tree",
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
SkillTree: parsed.SkillTree,
|
||||
NeedsOwner: "skill owner + active tree persistence + op260 ACK order",
|
||||
}
|
||||
}
|
||||
|
||||
// NewSkillCommand 构造技能指令自定义计划。
|
||||
func NewSkillCommand(req alignedcmd.Request, parsed SkillCommandRequest) Command {
|
||||
return Command{
|
||||
Operation: "skill_command_customizing",
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
DeclaredCount: parsed.Count,
|
||||
CommandRecords: cloneSkillCommandRecords(parsed.Records),
|
||||
RecordCount: len(parsed.Records),
|
||||
NeedsOwner: "skill owner + command persistence + NOTI 0x13 order",
|
||||
}
|
||||
}
|
||||
|
||||
func cloneSkillCommandRecords(records []SkillCommandRecord) []SkillCommandRecord {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]SkillCommandRecord, len(records))
|
||||
for index, record := range records {
|
||||
cloned[index] = SkillCommandRecord{
|
||||
SkillID: record.SkillID,
|
||||
CommandBytes: append([]byte(nil), record.CommandBytes...),
|
||||
}
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
// NewSimpleCommand 构造无包体或包体未闭合命令计划。
|
||||
func NewSimpleCommand(req alignedcmd.Request, operation string, needs string) Command {
|
||||
return Command{
|
||||
Operation: operation,
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
NeedsOwner: needs,
|
||||
}
|
||||
}
|
||||
|
||||
func (c Command) String() string {
|
||||
base := fmt.Sprintf("account=%q char=%d", c.AccountID, c.SelectedCharacterID)
|
||||
switch c.Operation {
|
||||
case "change_skill_slot":
|
||||
return fmt.Sprintf("%s tree=%d from=%d to=%d context=%d mode=%d needs=%s", base, c.SkillTree, c.From, c.To, c.ContextIndex, c.Mode, c.NeedsOwner)
|
||||
case "buy_skill":
|
||||
return fmt.Sprintf("%s tree=%d declared=%d entries=%d refunds=%d mode=%d needs=%s", base, c.SkillTree, c.DeclaredCount, c.EntryCount, c.RefundCount, c.FinalMode, c.NeedsOwner)
|
||||
case "skill_init":
|
||||
return fmt.Sprintf("%s tree=%d mode=%d needs=%s", base, c.SkillTree, c.Mode, c.NeedsOwner)
|
||||
case "change_another_skill_tree":
|
||||
return fmt.Sprintf("%s tree=%d needs=%s", base, c.SkillTree, c.NeedsOwner)
|
||||
case "skill_command_customizing":
|
||||
return fmt.Sprintf("%s declared=%d records=%d needs=%s", base, c.DeclaredCount, c.RecordCount, c.NeedsOwner)
|
||||
default:
|
||||
return fmt.Sprintf("%s needs=%s", base, c.NeedsOwner)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package skillcmd 承接 DNF 技能栏、学习技能和技能指令类 C2S 协议。
|
||||
package skillcmd
|
||||
@@ -0,0 +1,76 @@
|
||||
package skillcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"maps"
|
||||
"time"
|
||||
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// Native dojo has its own two-page manager. Internal pages 2/3 keep its
|
||||
// persisted skills separate; the native protocol still uses page numbers 0/1.
|
||||
func (cmd Command) storageSkillTree() int {
|
||||
if cmd.DojoSkills {
|
||||
return int(cmd.SkillTree) + 2
|
||||
}
|
||||
return int(cmd.SkillTree)
|
||||
}
|
||||
|
||||
func PrepareDojoSkills(ctx context.Context, repos repo.Group, characterID string) (repo.SkillRecord, error) {
|
||||
if repos.CharacterSkills == nil {
|
||||
return repo.SkillRecord{}, ErrOwnerUnavailable
|
||||
}
|
||||
var result repo.SkillRecord
|
||||
err := repos.CharacterSkills.WithinCharacterSkill(ctx, characterID, func(store repo.SkillRepository) error {
|
||||
record, found, err := store.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrSkillTreeUnavailable
|
||||
}
|
||||
record = repo.CloneSkill(record)
|
||||
changed := false
|
||||
for tree := 0; tree < 2; tree++ {
|
||||
if _, found := repo.SkillTree(record, tree+2); found {
|
||||
continue
|
||||
}
|
||||
state, found := repo.SkillTree(record, tree)
|
||||
if !found {
|
||||
return ErrSkillTreeUnavailable
|
||||
}
|
||||
repo.SetSkillTree(&record, tree+2, state)
|
||||
if record.Layouts == nil {
|
||||
record.Layouts = make(map[int]repo.SkillLayout)
|
||||
}
|
||||
record.Layouts[tree+2] = maps.Clone(record.Layouts[tree])
|
||||
changed = true
|
||||
}
|
||||
if changed {
|
||||
record.UpdatedAt = time.Now().UTC()
|
||||
if err := repo.SaveSkillFields(ctx, store, record, repo.SkillFieldTrees, repo.SkillFieldLayouts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result = DojoSkillProjection(record)
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func DojoSkillProjection(record repo.SkillRecord) repo.SkillRecord {
|
||||
result := repo.CloneSkill(record)
|
||||
for tree := 0; tree < 2; tree++ {
|
||||
state, found := repo.SkillTree(record, tree+2)
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
repo.SetSkillTree(&result, tree, state)
|
||||
if result.Layouts == nil {
|
||||
result.Layouts = make(map[int]repo.SkillLayout)
|
||||
}
|
||||
result.Layouts[tree] = maps.Clone(record.Layouts[tree+2])
|
||||
}
|
||||
return result
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package skillcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestDojoResetBuyAndSlotsKeepOrdinarySkills(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
record, _, _ := repos.Skill.Load(ctx, "31")
|
||||
record.Layouts = map[int]dnfrepo.SkillLayout{0: {0: 46}, 1: {150: 46}}
|
||||
dnfrepo.SetSkillTree(&record, 1, dnfrepo.SkillTreeState{Skills: record.Skills, Points: record.Points})
|
||||
if err := repos.Skill.Save(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := PrepareDojoSkills(ctx, repos, "31"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repos, OwnerOptions{Catalog: skillRuleCatalog(t), InitialLevels: map[uint16]int{46: 1},
|
||||
PointBaseline: &dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 100, TotalTP: 10, RemainingTP: 10, SyncedLevel: 10}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := alignedRequest("acc-skill", 31)
|
||||
req.DojoSkills = true
|
||||
reset, err := owner.ApplyReset(ctx, NewResetCommand(req, SkillInitRequest{SkillTree: 0, Mode: 3}))
|
||||
if err != nil || reset.Points.RemainingSP != 100 {
|
||||
t.Fatalf("dojo reset=%+v %v", reset, err)
|
||||
}
|
||||
buy, err := owner.ApplyBuy(ctx, NewBuyCommand(req, BuySkillRequest{RawSkillTree: 0, Count: 1,
|
||||
FinalMode: 3, Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1}}}))
|
||||
if err != nil || buy.SkillTree != 0 || buy.FinalMode != 3 {
|
||||
t.Fatalf("dojo learn=%+v %v", buy, err)
|
||||
}
|
||||
_, err = owner.ApplySlot(ctx, NewSlotCommand(req, ChangeSkillSlotRequest{SkillTree: 0, From: 0, To: 1}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = owner.ApplyBuy(ctx, NewBuyCommand(req, BuySkillRequest{RawSkillTree: 0, Count: 1,
|
||||
FinalMode: 3, Entries: []BuySkillEntry{{SkillID: 46, LevelDelta: 1, RefundFlag: 1}}}))
|
||||
if err != nil {
|
||||
t.Fatalf("native dojo refund incorrectly requires paid contract: %v", err)
|
||||
}
|
||||
current, _, _ := repos.Skill.Load(ctx, "31")
|
||||
for tree := 0; tree < 2; tree++ {
|
||||
before, _ := dnfrepo.SkillTree(record, tree)
|
||||
after, _ := dnfrepo.SkillTree(current, tree)
|
||||
if !reflect.DeepEqual(before, after) || !reflect.DeepEqual(record.Layouts[tree], current.Layouts[tree]) {
|
||||
t.Fatalf("dojo overwrote ordinary tree %d", tree)
|
||||
}
|
||||
}
|
||||
projected, err := PrepareDojoSkills(ctx, repos, "31")
|
||||
if err != nil || projected.Layouts[0][1] != 46 || projected.Skills[46].Level != 1 {
|
||||
t.Fatalf("dojo relog=%+v %v", projected, err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
package skillcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||||
)
|
||||
|
||||
type handler struct{}
|
||||
|
||||
func NewHandler() alignedcmd.Handler {
|
||||
return handler{}
|
||||
}
|
||||
|
||||
func (handler) Domain() dnfenum.AlignedDomain {
|
||||
return dnfenum.AlignedDomainSkill
|
||||
}
|
||||
|
||||
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
switch dnfenum.CmdPacket(req.Opcode) {
|
||||
case dnfenum.CmdPacketChangeSkillslot:
|
||||
parsed, err := DecodeChangeSkillSlotRequest(req.Body)
|
||||
cmd := NewSlotCommand(req, parsed)
|
||||
return applySlotOwner(ctx, req, cmd, err), nil
|
||||
case dnfenum.CmdPacketBuySkill:
|
||||
parsed, err := DecodeBuySkillRequest(req.Body)
|
||||
cmd := NewBuyCommand(req, parsed)
|
||||
return applyBuyOwner(ctx, req, cmd, err), nil
|
||||
case dnfenum.CmdPacketSkillInit:
|
||||
parsed, err := DecodeSkillInitRequest(req.Body)
|
||||
cmd := NewResetCommand(req, parsed)
|
||||
return applyResetOwner(ctx, req, cmd, err), nil
|
||||
case dnfenum.CmdPacketChangeAnotherSkillTree:
|
||||
parsed, err := DecodeChangeAnotherSkillTreeRequest(req.Body)
|
||||
cmd := NewTreeCommand(req, parsed)
|
||||
return applyTreeOwner(ctx, req, cmd, err), nil
|
||||
case dnfenum.CmdPacketSkillCommandCustomizing:
|
||||
parsed, err := DecodeSkillCommandRequest(req.Body)
|
||||
cmd := NewSkillCommand(req, parsed)
|
||||
return applyCommandOwner(ctx, req, cmd, err), nil
|
||||
case dnfenum.CmdPacketSkillCommandAllDefault:
|
||||
cmd := NewSimpleCommand(req, "skill_command_all_default", "skill owner + command reset persistence + NOTI 0x13 order")
|
||||
if len(req.Body) != 0 {
|
||||
return blockedParsedResult(cmd.Operation, cmd.String(), fmt.Errorf("invalid body length: got %d want 0", len(req.Body))), nil
|
||||
}
|
||||
return applyCommandDefaultOwner(ctx, req, cmd), nil
|
||||
default:
|
||||
return alignedcmd.Result{
|
||||
Handled: false,
|
||||
ResponseAllowed: false,
|
||||
Operation: "unregistered",
|
||||
Reason: fmt.Sprintf("skill module has no opcode %d handler", req.Opcode),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func applyCommandOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
|
||||
if parseErr != nil {
|
||||
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
|
||||
}
|
||||
owner, err := NewOwner(req.Repositories)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, err)
|
||||
}
|
||||
result, err := owner.ApplyCommands(ctx, cmd)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf("applied %s: char=%s records=%d; persisted before current EXE ACK-only op331 response", cmd.Operation, result.CharacterID, result.RecordCount),
|
||||
UpperResponses: []alignedcmd.UpperResponse{{
|
||||
MsgID: req.Opcode,
|
||||
Body: BuildSkillCommandSuccess(req.Body),
|
||||
Classification: dnfproto.DefaultChannelClassification,
|
||||
AllowCodec: true,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func applyCommandDefaultOwner(ctx context.Context, req alignedcmd.Request, cmd Command) alignedcmd.Result {
|
||||
owner, err := NewOwner(req.Repositories)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, err)
|
||||
}
|
||||
result, err := owner.ApplyAllCommandsDefault(ctx, cmd)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf("applied %s: char=%s; persisted before current EXE ACK-only op332 response", cmd.Operation, result.CharacterID),
|
||||
UpperResponses: []alignedcmd.UpperResponse{{
|
||||
MsgID: req.Opcode,
|
||||
Body: BuildSkillCommandAllDefaultSuccess(),
|
||||
Classification: dnfproto.DefaultChannelClassification,
|
||||
AllowCodec: true,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func applyTreeOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
|
||||
if parseErr != nil {
|
||||
return treeSwitchFailureResult(req, cmd, parseErr)
|
||||
}
|
||||
owner, err := NewOwner(req.Repositories)
|
||||
if err != nil {
|
||||
return treeSwitchFailureResult(req, cmd, err)
|
||||
}
|
||||
result, err := owner.ApplyTreeSwitch(ctx, cmd)
|
||||
if err != nil {
|
||||
return treeSwitchFailureResult(req, cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
body, err := BuildChangeAnotherSkillTreeSuccess(result)
|
||||
if err != nil {
|
||||
return treeSwitchFailureResult(req, cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf(
|
||||
"applied %s: char=%s current=%d target=%d; persisted before current EXE op260 success",
|
||||
cmd.Operation, result.CharacterID, result.Current, result.Target,
|
||||
),
|
||||
UpperResponses: []alignedcmd.UpperResponse{{
|
||||
MsgID: req.Opcode,
|
||||
Body: body,
|
||||
Classification: dnfproto.DefaultChannelClassification,
|
||||
AllowCodec: true,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func treeSwitchFailureResult(req alignedcmd.Request, cmd Command, err error) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf("rejected %s: %v; current EXE op260 failure 19 returned", cmd.Operation, err),
|
||||
UpperResponses: []alignedcmd.UpperResponse{{
|
||||
MsgID: req.Opcode,
|
||||
Body: BuildChangeAnotherSkillTreeFailure(),
|
||||
Classification: dnfproto.DefaultChannelClassification,
|
||||
AllowCodec: true,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func applyResetOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
|
||||
if parseErr != nil {
|
||||
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
|
||||
}
|
||||
owner, err := NewOwner(req.Repositories, OwnerOptions{
|
||||
Catalog: req.SkillCatalog,
|
||||
InitialLevels: req.InitialSkillLevels,
|
||||
PointBaseline: req.SkillPointBaseline,
|
||||
})
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, err)
|
||||
}
|
||||
result, err := owner.ApplyReset(ctx, cmd)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
body, err := BuildSkillInitSuccess(result)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf(
|
||||
"applied %s: char=%s tree=%d mode=%d initial_skills=%d remainSP=%d remainTP=%d; current EXE op491 response opened",
|
||||
cmd.Operation, result.CharacterID, result.SkillTree, result.Mode, result.SkillCount,
|
||||
result.Points.RemainingSP, result.Points.RemainingTP,
|
||||
),
|
||||
UpperResponses: []alignedcmd.UpperResponse{{
|
||||
MsgID: req.Opcode,
|
||||
Body: body,
|
||||
Classification: dnfproto.DefaultChannelClassification,
|
||||
AllowCodec: true,
|
||||
}},
|
||||
PostActions: []alignedcmd.PostAction{alignedcmd.PostActionRefreshSelectedActorSkills},
|
||||
}
|
||||
}
|
||||
|
||||
func applySlotOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
|
||||
if parseErr != nil {
|
||||
return slotFailureResult(req, cmd, parseErr)
|
||||
}
|
||||
owner, err := NewOwner(req.Repositories, OwnerOptions{
|
||||
Catalog: req.SkillCatalog,
|
||||
InitialLevels: req.InitialSkillLevels,
|
||||
})
|
||||
if err != nil {
|
||||
return slotFailureResult(req, cmd, err)
|
||||
}
|
||||
result, err := owner.ApplySlot(ctx, cmd)
|
||||
if err != nil {
|
||||
return slotFailureResult(req, cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
body, err := BuildChangeSkillSlotSuccess(result)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf(
|
||||
"applied %s: char=%s tree=%d from=%d to=%d source_skill=%d destination_skill=%d destination_occupied=%t; current EXE op28 response opened",
|
||||
cmd.Operation,
|
||||
result.CharacterID,
|
||||
result.SkillTree,
|
||||
result.From,
|
||||
result.To,
|
||||
result.FromSkillID,
|
||||
result.ToSkillID,
|
||||
result.ToOccupied,
|
||||
),
|
||||
UpperResponses: []alignedcmd.UpperResponse{{
|
||||
MsgID: req.Opcode,
|
||||
Body: body,
|
||||
Classification: dnfproto.DefaultChannelClassification,
|
||||
AllowCodec: true,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func slotFailureResult(req alignedcmd.Request, cmd Command, err error) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf("rejected %s: %v; current EXE op28 failure returned to clear pending drag", cmd.Operation, err),
|
||||
UpperResponses: []alignedcmd.UpperResponse{{
|
||||
MsgID: req.Opcode,
|
||||
Body: BuildChangeSkillSlotFailure(),
|
||||
Classification: dnfproto.DefaultChannelClassification,
|
||||
AllowCodec: true,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func applyBuyOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
|
||||
if parseErr != nil {
|
||||
return buyFailureResult(req, cmd, parseErr)
|
||||
}
|
||||
if err := validateBuySkillTree(cmd.SkillTree); err != nil {
|
||||
return buyFailureResult(req, cmd, err)
|
||||
}
|
||||
owner, err := NewOwner(req.Repositories, OwnerOptions{
|
||||
Catalog: req.SkillCatalog,
|
||||
InitialLevels: req.InitialSkillLevels,
|
||||
PointBaseline: req.SkillPointBaseline,
|
||||
})
|
||||
if err != nil {
|
||||
return buyFailureResult(req, cmd, err)
|
||||
}
|
||||
result, err := owner.ApplyBuy(ctx, cmd)
|
||||
if err != nil {
|
||||
return buyFailureResult(req, cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
body, err := BuildBuySkillSuccess(result)
|
||||
if err != nil {
|
||||
return buyFailureResult(req, cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
postActions := []alignedcmd.PostAction{}
|
||||
if result.ConsumedRefundItem {
|
||||
// The 遗忘河之水 stack changed in the same transaction; the main list
|
||||
// must refresh or the client keeps showing the old count (86JP sends
|
||||
// SendUpdateItemList for the consumed water slot).
|
||||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedItemContainers)
|
||||
}
|
||||
// The current EXE op29 ACK applies only the incremental batch. Live mode-0
|
||||
// learn traffic proved that leaving the final successful batch there can
|
||||
// keep the dungeon-entry skill guard in its initialization state even
|
||||
// though the repository and visible skill UI contain the learned skills.
|
||||
// Rebuild the native manager from the committed full op19 projection after
|
||||
// every successful batch. This also covers an expired-contract sweep, whose
|
||||
// removals are not present in the incremental ACK.
|
||||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedActorSkills)
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf(
|
||||
"applied %s: char=%s tree=%d entries=%d remainSP=%d remainTP=%d refund_water=%t; current EXE op29 response opened",
|
||||
cmd.Operation,
|
||||
result.CharacterID,
|
||||
result.SkillTree,
|
||||
len(result.Entries),
|
||||
result.Points.RemainingSP,
|
||||
result.Points.RemainingTP,
|
||||
result.ConsumedRefundItem,
|
||||
),
|
||||
UpperResponses: []alignedcmd.UpperResponse{{
|
||||
MsgID: req.Opcode,
|
||||
Body: body,
|
||||
Classification: dnfproto.DefaultChannelClassification,
|
||||
AllowCodec: true,
|
||||
}},
|
||||
PostActions: postActions,
|
||||
}
|
||||
}
|
||||
|
||||
func buyFailureResult(req alignedcmd.Request, cmd Command, err error) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf(
|
||||
"rejected %s: %v; current EXE op29 failure 0x%02X returned to clear pending skill learn",
|
||||
cmd.Operation, err, currentEXEBuySkillFailureCode,
|
||||
),
|
||||
UpperResponses: []alignedcmd.UpperResponse{{
|
||||
MsgID: req.Opcode,
|
||||
Body: BuildBuySkillFailure(currentEXEBuySkillFailureCode),
|
||||
Classification: dnfproto.DefaultChannelClassification,
|
||||
AllowCodec: true,
|
||||
}},
|
||||
}
|
||||
}
|
||||
|
||||
func applyOwner(ctx context.Context, req alignedcmd.Request, cmd Command, parseErr error) alignedcmd.Result {
|
||||
if parseErr != nil {
|
||||
return blockedParsedResult(cmd.Operation, cmd.String(), parseErr)
|
||||
}
|
||||
owner, err := NewOwner(req.Repositories, OwnerOptions{
|
||||
Catalog: req.SkillCatalog,
|
||||
InitialLevels: req.InitialSkillLevels,
|
||||
})
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, err)
|
||||
}
|
||||
result, err := owner.Plan(ctx, cmd)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd, planError(cmd.Operation, err))
|
||||
}
|
||||
return ownerPlannedResult(cmd, result)
|
||||
}
|
||||
|
||||
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
|
||||
if err != nil {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf("%s body parse failed: %v; success ACK is blocked", operation, err),
|
||||
}
|
||||
}
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf("parsed %s: %s; skill state, SP/TP, and NOTI 0x13 refresh are not closed, success ACK is blocked", operation, summary),
|
||||
}
|
||||
}
|
||||
|
||||
func ownerBlockedResult(cmd Command, err error) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf("parsed %s: %s; skill owner preflight failed: %v; success ACK is blocked", cmd.Operation, cmd.String(), err),
|
||||
}
|
||||
}
|
||||
|
||||
func ownerPlannedResult(cmd Command, result PlanResult) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf(
|
||||
"parsed %s: %s; skill owner verified account=%s char=%s known=%t skillCount=%d cooldownCount=%d requested=%v refunds=%d; SP/TP persistence and raw 0x13 refresh are not closed, success ACK is blocked",
|
||||
cmd.Operation,
|
||||
cmd.String(),
|
||||
result.AccountID,
|
||||
result.CharacterID,
|
||||
result.Known,
|
||||
result.SkillCount,
|
||||
result.CooldownCount,
|
||||
result.RequestedSkillIDs,
|
||||
result.RefundCount,
|
||||
),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,615 @@
|
||||
package skillcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestDecodeBuySkillRequest(t *testing.T) {
|
||||
got, err := DecodeBuySkillRequest([]byte{1, 1, 0x34, 0x12, 0, 3, 3})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeBuySkillRequest error = %v", err)
|
||||
}
|
||||
if got.RawSkillTree != 1 || got.Count != 1 || len(got.Entries) != 1 {
|
||||
t.Fatalf("got = %+v", got)
|
||||
}
|
||||
if got.Entries[0].SkillID != 0x1234 || got.Entries[0].LevelDelta != 3 || got.FinalMode != 3 {
|
||||
t.Fatalf("entry = %+v", got.Entries[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeBuySkillRequestRejectsDeclaredCountMismatch(t *testing.T) {
|
||||
if _, err := DecodeBuySkillRequest([]byte{1, 2, 7, 0, 0, 1, 0}); err == nil {
|
||||
t.Fatal("DecodeBuySkillRequest accepted a truncated second entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSkillInitRequestMatchesLiveCurrentEXEBody(t *testing.T) {
|
||||
got, err := DecodeSkillInitRequest([]byte{0, 0})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeSkillInitRequest() error = %v", err)
|
||||
}
|
||||
if got.SkillTree != 0 || got.Mode != 0 {
|
||||
t.Fatalf("request = %+v", got)
|
||||
}
|
||||
if _, err := DecodeSkillInitRequest([]byte{0}); err == nil {
|
||||
t.Fatal("DecodeSkillInitRequest() accepted a truncated live body")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeChangeAnotherSkillTreeRequestMatchesLiveCurrentEXEBody(t *testing.T) {
|
||||
got, err := DecodeChangeAnotherSkillTreeRequest([]byte{0, 0x3e, 0x74, 0x5e, 0x7a})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeChangeAnotherSkillTreeRequest() error = %v", err)
|
||||
}
|
||||
if got.SkillTree != 0 || got.TransportTail != [4]byte{0x3e, 0x74, 0x5e, 0x7a} {
|
||||
t.Fatalf("request = %+v", got)
|
||||
}
|
||||
for _, body := range [][]byte{{0}, {2, 1, 2, 3, 4}, {0, 1, 2, 3, 4, 5}} {
|
||||
if _, err := DecodeChangeAnotherSkillTreeRequest(body); err == nil {
|
||||
t.Fatalf("DecodeChangeAnotherSkillTreeRequest(%x) accepted malformed body", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerSkillInitCommitsBeforeCurrentEXESuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
record, _, _ := repos.Skill.Load(ctx, "31")
|
||||
record.Skills[47] = dnfrepo.SkillState{Level: 1, Enabled: true}
|
||||
record.Points.RemainingSP = 50
|
||||
if err := repos.Skill.Save(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketSkillInit),
|
||||
Body: []byte{0, 0},
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
SkillCatalog: skillRuleCatalog(t),
|
||||
InitialSkillLevels: map[uint16]int{46: 1},
|
||||
SkillPointBaseline: &dnfrepo.SkillPointState{TotalSP: 100, RemainingSP: 100, TotalTP: 10, RemainingTP: 10, SyncedLevel: 10},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
if len(got.PostActions) != 1 || got.PostActions[0] != alignedcmd.PostActionRefreshSelectedActorSkills {
|
||||
t.Fatalf("post actions = %+v, want selected actor skill refresh", got.PostActions)
|
||||
}
|
||||
response := got.UpperResponses[0]
|
||||
if response.MsgID != uint16(dnfenum.CmdPacketSkillInit) || string(response.Body) != string([]byte{1, 0, 1}) {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
persisted, _, _ := repos.Skill.Load(ctx, "31")
|
||||
if len(persisted.Skills) != 1 || persisted.Skills[46].Level != 1 || persisted.Points.RemainingSP != 100 {
|
||||
t.Fatalf("persisted reset = %+v", persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerChangeAnotherSkillTreeCommitsBeforeCurrentEXESuccess(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketChangeAnotherSkillTree),
|
||||
Body: []byte{0, 0x3e, 0x74, 0x5e, 0x7a},
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 || len(got.PostActions) != 0 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
response := got.UpperResponses[0]
|
||||
if response.MsgID != uint16(dnfenum.CmdPacketChangeAnotherSkillTree) ||
|
||||
response.Classification != dnfproto.DefaultChannelClassification || !response.AllowCodec ||
|
||||
string(response.Body) != string([]byte{1, 1}) {
|
||||
t.Fatalf("response = %+v, want success/target", response)
|
||||
}
|
||||
character, ok, loadErr := repos.Character.Load(ctx, "31")
|
||||
if loadErr != nil || !ok || character.Stats[currentEXESkillTreeIndexStat] != 1 {
|
||||
t.Fatalf("persisted character ok=%t err=%v record=%+v", ok, loadErr, character)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerChangeAnotherSkillTreeAcceptsClientCurrentWhenPersistedMarkerIsStale(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketChangeAnotherSkillTree),
|
||||
Body: []byte{1, 0x3e, 0x74, 0x5e, 0x7a},
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
||||
string(got.UpperResponses[0].Body) != string([]byte{1, 0}) {
|
||||
t.Fatalf("result = %+v, want success/target-zero", got)
|
||||
}
|
||||
character, _, _ := repos.Character.Load(ctx, "31")
|
||||
if character.Stats[currentEXESkillTreeIndexStat] != 0 {
|
||||
t.Fatalf("client-current switch persisted tree = %d", character.Stats[currentEXESkillTreeIndexStat])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerChangeAnotherSkillTreeReturnsFailureForMalformedRequest(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketChangeAnotherSkillTree),
|
||||
Body: []byte{0},
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
||||
string(got.UpperResponses[0].Body) != string([]byte{0, 19}) {
|
||||
t.Fatalf("result = %+v, want failure/code19", got)
|
||||
}
|
||||
character, _, _ := repos.Character.Load(ctx, "31")
|
||||
if character.Stats[currentEXESkillTreeIndexStat] != 0 {
|
||||
t.Fatalf("failed switch mutated persisted tree = %d", character.Stats[currentEXESkillTreeIndexStat])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSkillCommandRequest(t *testing.T) {
|
||||
got, err := DecodeSkillCommandRequest([]byte{
|
||||
2, 9, 0, 2, 'A', 'B',
|
||||
10, 0, 1, 'C',
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeSkillCommandRequest error = %v", err)
|
||||
}
|
||||
if got.Count != 2 || len(got.Records) != 2 {
|
||||
t.Fatalf("got = %+v", got)
|
||||
}
|
||||
if got.Records[0].SkillID != 9 || string(got.Records[0].CommandBytes) != "AB" {
|
||||
t.Fatalf("first = %+v", got.Records[0])
|
||||
}
|
||||
if got.Records[1].SkillID != 10 || string(got.Records[1].CommandBytes) != "C" {
|
||||
t.Fatalf("second = %+v", got.Records[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerSkillCommandCustomizingPersistsCapturedNineByteBody(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
record, _, err := repos.Skill.Load(ctx, "31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record.Skills[20] = dnfrepo.SkillState{Level: 1, Enabled: true}
|
||||
record.Trees = map[int]dnfrepo.SkillTreeState{
|
||||
1: {
|
||||
Skills: map[int64]dnfrepo.SkillState{20: {Level: 1, Enabled: true}},
|
||||
Points: record.Points,
|
||||
},
|
||||
}
|
||||
if err := repos.Skill.Save(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Current character 3 capture: count + u16 skill + u8 command length + five bytes.
|
||||
body := []byte{1, 20, 0, 5, 0, 0, 0, 0, 4}
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketSkillCommandCustomizing),
|
||||
Body: body,
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
response := got.UpperResponses[0]
|
||||
if response.MsgID != uint16(dnfenum.CmdPacketSkillCommandCustomizing) ||
|
||||
response.Classification != dnfproto.DefaultChannelClassification || !response.AllowCodec ||
|
||||
string(response.Body) != string(append([]byte{1}, body...)) {
|
||||
t.Fatalf("response = %+v", response)
|
||||
}
|
||||
persisted, found, err := repos.Skill.Load(ctx, "31")
|
||||
if err != nil || !found || string(persisted.Commands[20]) != string([]byte{0, 0, 0, 0, 4}) {
|
||||
t.Fatalf("persisted found=%t err=%v commands=%x", found, err, persisted.Commands)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerSkillCommandCustomizingAcceptsLiveRecordCountForTreeZeroSkill(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
record, _, err := repos.Skill.Load(ctx, "31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record.Skills[248] = dnfrepo.SkillState{Level: 1, Enabled: true}
|
||||
if err := repos.Skill.Save(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// Live current-EXE capture from character 1: one record, skill 248, and
|
||||
// command bytes 03 01 08. Skill 248 belongs to repository tree 0.
|
||||
body := []byte{1, 248, 0, 3, 3, 1, 8}
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketSkillCommandCustomizing),
|
||||
Body: body,
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
if response := got.UpperResponses[0]; string(response.Body) != string(append([]byte{1}, body...)) {
|
||||
t.Fatalf("response body = % X", response.Body)
|
||||
}
|
||||
persisted, found, err := repos.Skill.Load(ctx, "31")
|
||||
if err != nil || !found || string(persisted.Commands[248]) != string([]byte{3, 1, 8}) {
|
||||
t.Fatalf("persisted found=%t err=%v command=% X", found, err, persisted.Commands[248])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeSkillCommandRequestRejectsCountMismatch(t *testing.T) {
|
||||
for _, body := range [][]byte{
|
||||
{2, 248, 0, 3, 3, 1, 8},
|
||||
{0, 248, 0, 3, 3, 1, 8},
|
||||
} {
|
||||
if _, err := DecodeSkillCommandRequest(body); err == nil {
|
||||
t.Fatalf("DecodeSkillCommandRequest(% X) accepted mismatched count", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerSkillCommandAllDefaultClearsOnlyCommands(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
record, _, err := repos.Skill.Load(ctx, "31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record.Commands = map[int64][]byte{46: {1, 2, 3}}
|
||||
if err := repos.Skill.Save(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketSkillCommandAllDefault),
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
||||
string(got.UpperResponses[0].Body) != string([]byte{1}) {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
persisted, found, err := repos.Skill.Load(ctx, "31")
|
||||
if err != nil || !found || len(persisted.Commands) != 0 || persisted.Skills[46].Level != 1 {
|
||||
t.Fatalf("persisted found=%t err=%v record=%+v", found, err, persisted)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerCommitsSkillSlotAndBuildsCurrentEXEResponse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketChangeSkillslot),
|
||||
Body: []byte{0, 0, 1, 0xff, 0xff, 0xff, 0xff, 2},
|
||||
AccountID: " acc-skill ",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
SkillCatalog: skillRuleCatalog(t),
|
||||
InitialSkillLevels: map[uint16]int{46: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 || len(got.PostActions) != 0 {
|
||||
t.Fatalf("result = %+v, want only the current op28 response", got)
|
||||
}
|
||||
if got.Operation != "change_skill_slot" {
|
||||
t.Fatalf("operation = %q", got.Operation)
|
||||
}
|
||||
response := got.UpperResponses[0]
|
||||
if response.MsgID != uint16(dnfenum.CmdPacketChangeSkillslot) || response.Classification != dnfproto.DefaultChannelClassification || !response.AllowCodec || string(response.Body) != string([]byte{1, 0, 0, 1}) {
|
||||
t.Fatalf("response = %+v, want success/tree/from/to", response)
|
||||
}
|
||||
record, ok, loadErr := repos.Skill.Load(ctx, "31")
|
||||
if loadErr != nil || !ok || record.Layouts[0][1] != 46 {
|
||||
t.Fatalf("persisted moved layout ok=%t err=%v record=%+v", ok, loadErr, record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerUsesCurrentEXESourceAndAcknowledgesClientSlots(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
record, _, _ := repos.Skill.Load(ctx, "31")
|
||||
record.Layouts = map[int]dnfrepo.SkillLayout{0: {107: 46}}
|
||||
if err := repos.Skill.Save(ctx, record); err != nil {
|
||||
t.Fatalf("save skill layout: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketChangeSkillslot),
|
||||
Body: []byte{0, 107, 198, 0xff, 0xff, 0xff, 0xff, 0},
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
SkillCatalog: skillRuleCatalog(t),
|
||||
InitialSkillLevels: map[uint16]int{46: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||||
t.Fatalf("result = %+v, want one current op28 response", got)
|
||||
}
|
||||
response := got.UpperResponses[0]
|
||||
if response.MsgID != uint16(dnfenum.CmdPacketChangeSkillslot) || response.Classification != dnfproto.DefaultChannelClassification || !response.AllowCodec || string(response.Body) != string([]byte{1, 0, 107, 198}) {
|
||||
t.Fatalf("response = %+v, want original client slots in success ACK", response)
|
||||
}
|
||||
persisted, ok, loadErr := repos.Skill.Load(ctx, "31")
|
||||
if loadErr != nil || !ok || persisted.Layouts[0][198] != 46 {
|
||||
t.Fatalf("persisted moved layout ok=%t err=%v record=%+v", ok, loadErr, persisted)
|
||||
}
|
||||
if _, exists := persisted.Layouts[0][107]; exists {
|
||||
t.Fatalf("persisted source slot 107 still occupied: %+v", persisted.Layouts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerAcknowledgesLowerQuickbarDrag(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
record, _, _ := repos.Skill.Load(ctx, "31")
|
||||
record.Skills[49] = dnfrepo.SkillState{Level: 1, Enabled: true}
|
||||
record.Layouts = map[int]dnfrepo.SkillLayout{0: {7: 49}}
|
||||
if err := repos.Skill.Save(ctx, record); err != nil {
|
||||
t.Fatalf("save live-shaped layout: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketChangeSkillslot),
|
||||
Body: []byte{0, 7, 198, 0xff, 0xff, 0xff, 0xff, 0},
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
SkillCatalog: skillRuleCatalog(t),
|
||||
InitialSkillLevels: map[uint16]int{46: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
||||
string(got.UpperResponses[0].Body) != string([]byte{1, 0, 7, 198}) {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
persisted, _, _ := repos.Skill.Load(ctx, "31")
|
||||
if persisted.Layouts[0][198] != 49 {
|
||||
t.Fatalf("persisted layout = %+v", persisted.Layouts[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerChangeSkillSlotReturnsFailureAndPreservesLayoutOnRejectedDrag(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
setup func(t *testing.T, repos dnfrepo.Group)
|
||||
}{
|
||||
{
|
||||
name: "passive quickbar destination",
|
||||
body: []byte{0, 54, 198, 0xff, 0xff, 0xff, 0xff, 0},
|
||||
setup: func(t *testing.T, repos dnfrepo.Group) {
|
||||
record, _, err := repos.Skill.Load(ctx, "31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
record.Skills[50] = dnfrepo.SkillState{Level: 1, Enabled: true}
|
||||
record.Layouts = map[int]dnfrepo.SkillLayout{0: {54: 50}}
|
||||
if err := repos.Skill.Save(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "malformed request",
|
||||
body: []byte{0},
|
||||
setup: func(t *testing.T, repos dnfrepo.Group) {},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
test.setup(t, repos)
|
||||
before, _, err := repos.Skill.Load(ctx, "31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketChangeSkillslot),
|
||||
Body: test.body,
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
SkillCatalog: skillRuleCatalog(t),
|
||||
InitialSkillLevels: map[uint16]int{46: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
||||
got.UpperResponses[0].MsgID != uint16(dnfenum.CmdPacketChangeSkillslot) ||
|
||||
string(got.UpperResponses[0].Body) != string([]byte{0}) {
|
||||
t.Fatalf("result = %+v, want one op28 failure response", got)
|
||||
}
|
||||
after, _, err := repos.Skill.Load(ctx, "31")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(after.Layouts, before.Layouts) {
|
||||
t.Fatalf("rejected drag mutated layouts: before=%+v after=%+v", before.Layouts, after.Layouts)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerBuySkillReturnsNativeFailureWithoutPVFRules(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "31",
|
||||
AccountID: "acc-skill",
|
||||
}); err != nil {
|
||||
t.Fatalf("save character: %v", err)
|
||||
}
|
||||
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{
|
||||
CharacterID: "31",
|
||||
Skills: map[int64]dnfrepo.SkillState{
|
||||
7: {Level: 3, Enabled: true},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save skill: %v", err)
|
||||
}
|
||||
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketBuySkill),
|
||||
Body: []byte{0, 1, 7, 0, 0, 3, 0},
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||||
t.Fatalf("result = %+v, want one native failure response", got)
|
||||
}
|
||||
if response := got.UpperResponses[0]; response.MsgID != uint16(dnfenum.CmdPacketBuySkill) ||
|
||||
response.Classification != dnfproto.DefaultChannelClassification || !response.AllowCodec ||
|
||||
string(response.Body) != string([]byte{0, currentEXEBuySkillFailureCode}) {
|
||||
t.Fatalf("failure response = %+v", response)
|
||||
}
|
||||
if !strings.Contains(got.Reason, "skill PVF rules are unavailable") {
|
||||
t.Fatalf("reason should name missing PVF rules, got %q", got.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerBuySkillCommitsAndBuildsCurrentEXEResponse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
body := []byte{
|
||||
0, 2,
|
||||
47, 0, 0, 1,
|
||||
46, 0, 0, 1,
|
||||
3,
|
||||
}
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketBuySkill),
|
||||
Body: body,
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
SkillCatalog: skillRuleCatalog(t),
|
||||
InitialSkillLevels: map[uint16]int{46: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 {
|
||||
t.Fatalf("result = %+v, want one real response", got)
|
||||
}
|
||||
response := got.UpperResponses[0]
|
||||
if response.MsgID != uint16(dnfenum.CmdPacketBuySkill) || response.Classification != dnfproto.DefaultChannelClassification || !response.AllowCodec {
|
||||
t.Fatalf("response metadata = %+v", response)
|
||||
}
|
||||
want := []byte{
|
||||
1, 0,
|
||||
50, 0, 10, 0,
|
||||
2,
|
||||
1, 47, 0, 1, 0,
|
||||
0, 46, 0, 2, 0,
|
||||
3,
|
||||
}
|
||||
if string(response.Body) != string(want) {
|
||||
t.Fatalf("response body = % X, want % X", response.Body, want)
|
||||
}
|
||||
record, _, _ := repos.Skill.Load(ctx, "31")
|
||||
if record.Skills[46].Level != 2 || record.Skills[47].Level != 1 || record.Points.RemainingSP != 50 {
|
||||
t.Fatalf("persisted state = %+v", record)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerBuySkillRejectsOutOfRangeTreeWithNativeFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := seededSkillOwnerRepositories(t, ctx)
|
||||
unitOfWork := &countingCharacterSkillUnitOfWork{delegate: repos.CharacterSkills}
|
||||
repos.CharacterSkills = unitOfWork
|
||||
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketBuySkill),
|
||||
Body: []byte{2, 1, 46, 0, 0, 1, 0},
|
||||
AccountID: "acc-skill",
|
||||
SelectedCharacterID: 31,
|
||||
Repositories: repos,
|
||||
SkillCatalog: skillRuleCatalog(t),
|
||||
InitialSkillLevels: map[uint16]int{46: 1},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle error = %v", err)
|
||||
}
|
||||
if !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 1 ||
|
||||
string(got.UpperResponses[0].Body) != string([]byte{0, currentEXEBuySkillFailureCode}) {
|
||||
t.Fatalf("result = %+v, want handled native failure response", got)
|
||||
}
|
||||
if unitOfWork.calls != 0 {
|
||||
t.Fatalf("CharacterSkillUnitOfWork calls = %d, want 0", unitOfWork.calls)
|
||||
}
|
||||
if !strings.Contains(got.Reason, ErrSkillTree.Error()) {
|
||||
t.Fatalf("reason should identify out-of-range tree, got %q", got.Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuyCommandRecordsSkillOwnerGap(t *testing.T) {
|
||||
cmd := NewBuyCommand(alignedcmd.Request{
|
||||
AccountID: " acc-skill-2 ",
|
||||
SelectedCharacterID: 44,
|
||||
}, BuySkillRequest{
|
||||
RawSkillTree: 2,
|
||||
Count: 2,
|
||||
FinalMode: 3,
|
||||
Entries: []BuySkillEntry{
|
||||
{SkillID: 7, LevelDelta: 3},
|
||||
{SkillID: 8, RefundFlag: 1, LevelDelta: 1},
|
||||
},
|
||||
})
|
||||
if cmd.AccountID != "acc-skill-2" || cmd.SelectedCharacterID != 44 || cmd.SkillTree != 2 || cmd.FinalMode != 3 || cmd.DeclaredCount != 2 || cmd.EntryCount != 2 || cmd.RefundCount != 1 {
|
||||
t.Fatalf("cmd = %+v", cmd)
|
||||
}
|
||||
if !strings.Contains(cmd.String(), "SP/TP validation") || !strings.Contains(cmd.String(), "NOTI 0x13 order") {
|
||||
t.Fatalf("command plan must name skill write gap: %s", cmd.String())
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,196 @@
|
||||
// Package skillcmd decodes skill-related C2S bodies for the current NoPack EXE.
|
||||
package skillcmd
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
changeSkillSlotBodySize = 8
|
||||
buySkillHeaderSize = 2
|
||||
buySkillEntrySize = 4
|
||||
buySkillTailSize = 1
|
||||
skillInitBodySize = 2
|
||||
changeSkillTreeBodySize = 5
|
||||
)
|
||||
|
||||
// ChangeSkillSlotRequest is the current EXE type-28 request.
|
||||
// ContextIndex is intentionally neutral until its runtime owner is proven.
|
||||
type ChangeSkillSlotRequest struct {
|
||||
SkillTree byte
|
||||
From byte
|
||||
To byte
|
||||
ContextIndex int32
|
||||
Mode byte
|
||||
}
|
||||
|
||||
// BuySkillRequest is the current EXE type-29 learn/refund request.
|
||||
type BuySkillRequest struct {
|
||||
RawSkillTree byte
|
||||
Count byte
|
||||
Entries []BuySkillEntry
|
||||
FinalMode byte
|
||||
}
|
||||
|
||||
// BuySkillEntry carries a job-scoped uint16 skill ID and a level delta.
|
||||
type BuySkillEntry struct {
|
||||
SkillID uint16
|
||||
RefundFlag byte
|
||||
LevelDelta byte
|
||||
}
|
||||
|
||||
// SkillInitRequest is the current EXE type-491 reset request. The live
|
||||
// Skill(K) initialize confirmation path writes exactly u8 tree, u8 mode.
|
||||
type SkillInitRequest struct {
|
||||
SkillTree byte
|
||||
Mode byte
|
||||
}
|
||||
|
||||
// SignedDelta returns the requested level change without assigning policy to
|
||||
// non-zero refund flag values.
|
||||
func (e BuySkillEntry) SignedDelta() int {
|
||||
if e.RefundFlag != 0 {
|
||||
return -int(e.LevelDelta)
|
||||
}
|
||||
return int(e.LevelDelta)
|
||||
}
|
||||
|
||||
// ChangeAnotherSkillTreeRequest is the current EXE type-260 request.
|
||||
type ChangeAnotherSkillTreeRequest struct {
|
||||
// SkillTree is the currently selected tree. The server switches to the
|
||||
// other tree only after matching this value against persisted state.
|
||||
SkillTree byte
|
||||
// TransportTail is the four-byte transport-owned suffix observed in the
|
||||
// live current-EXE writer. It is retained for diagnostics and has no domain
|
||||
// authority.
|
||||
TransportTail [4]byte
|
||||
}
|
||||
|
||||
// SkillCommandRequest is the current EXE type-331 command customization body.
|
||||
type SkillCommandRequest struct {
|
||||
Count byte
|
||||
Records []SkillCommandRecord
|
||||
}
|
||||
|
||||
// SkillCommandRecord is one command customization record.
|
||||
type SkillCommandRecord struct {
|
||||
SkillID uint16
|
||||
CommandBytes []byte
|
||||
}
|
||||
|
||||
// DecodeChangeSkillSlotRequest decodes:
|
||||
// u8 tree, u8 from, u8 to, i32 context index, u8 mode.
|
||||
func DecodeChangeSkillSlotRequest(body []byte) (ChangeSkillSlotRequest, error) {
|
||||
if len(body) != changeSkillSlotBodySize {
|
||||
return ChangeSkillSlotRequest{}, fmt.Errorf("invalid body length: got %d want %d", len(body), changeSkillSlotBodySize)
|
||||
}
|
||||
return ChangeSkillSlotRequest{
|
||||
SkillTree: body[0],
|
||||
From: body[1],
|
||||
To: body[2],
|
||||
ContextIndex: int32(binary.LittleEndian.Uint32(body[3:7])),
|
||||
Mode: body[7],
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DecodeBuySkillRequest decodes:
|
||||
// u8 tree, u8 count, count * (u16 skill ID, u8 refund flag, u8 delta), u8 mode.
|
||||
func DecodeBuySkillRequest(body []byte) (BuySkillRequest, error) {
|
||||
if len(body) < buySkillHeaderSize+buySkillTailSize {
|
||||
return BuySkillRequest{}, fmt.Errorf("body too short: got %d want >= %d", len(body), buySkillHeaderSize+buySkillTailSize)
|
||||
}
|
||||
req := BuySkillRequest{RawSkillTree: body[0], Count: body[1]}
|
||||
expected := buySkillHeaderSize + int(req.Count)*buySkillEntrySize + buySkillTailSize
|
||||
if len(body) != expected {
|
||||
return BuySkillRequest{}, fmt.Errorf("invalid body length: got %d want %d for count %d", len(body), expected, req.Count)
|
||||
}
|
||||
|
||||
req.Entries = make([]BuySkillEntry, 0, int(req.Count))
|
||||
offset := buySkillHeaderSize
|
||||
for i := 0; i < int(req.Count); i++ {
|
||||
req.Entries = append(req.Entries, BuySkillEntry{
|
||||
SkillID: binary.LittleEndian.Uint16(body[offset : offset+2]),
|
||||
RefundFlag: body[offset+2],
|
||||
LevelDelta: body[offset+3],
|
||||
})
|
||||
offset += buySkillEntrySize
|
||||
}
|
||||
req.FinalMode = body[offset]
|
||||
return req, nil
|
||||
}
|
||||
|
||||
// DecodeSkillInitRequest decodes the two bytes written by current EXE
|
||||
// sub_1FE6070/sub_2209BC0.
|
||||
func DecodeSkillInitRequest(body []byte) (SkillInitRequest, error) {
|
||||
if len(body) != skillInitBodySize {
|
||||
return SkillInitRequest{}, fmt.Errorf("invalid body length: got %d want %d", len(body), skillInitBodySize)
|
||||
}
|
||||
return SkillInitRequest{SkillTree: body[0], Mode: body[1]}, nil
|
||||
}
|
||||
|
||||
// DecodeChangeAnotherSkillTreeRequest decodes the fixed five-byte live body.
|
||||
// Only byte zero belongs to the writer; the remaining bytes are transport tail.
|
||||
func DecodeChangeAnotherSkillTreeRequest(body []byte) (ChangeAnotherSkillTreeRequest, error) {
|
||||
if len(body) != changeSkillTreeBodySize {
|
||||
return ChangeAnotherSkillTreeRequest{}, fmt.Errorf("invalid body length: got %d want %d", len(body), changeSkillTreeBodySize)
|
||||
}
|
||||
if body[0] > 1 {
|
||||
return ChangeAnotherSkillTreeRequest{}, fmt.Errorf("invalid current skill tree: got %d want 0 or 1", body[0])
|
||||
}
|
||||
request := ChangeAnotherSkillTreeRequest{SkillTree: body[0]}
|
||||
copy(request.TransportTail[:], body[1:])
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// DecodeSkillCommandRequest decodes:
|
||||
// u8 record count, then count records: u16 skill ID, u8 length, raw command
|
||||
// bytes. Current EXE sub_2689980 writes the size at object+12 before iterating
|
||||
// the command map; sub_268A100 clears that same size after a successful ACK.
|
||||
func DecodeSkillCommandRequest(body []byte) (SkillCommandRequest, error) {
|
||||
if len(body) < 1 {
|
||||
return SkillCommandRequest{}, fmt.Errorf("body too short: got %d want >= 1", len(body))
|
||||
}
|
||||
req := SkillCommandRequest{Count: body[0]}
|
||||
offset := 1
|
||||
for index := 0; index < int(req.Count); index++ {
|
||||
if len(body)-offset < 3 {
|
||||
return SkillCommandRequest{}, fmt.Errorf("record %d header too short: offset %d body %d", index, offset, len(body))
|
||||
}
|
||||
skillID := binary.LittleEndian.Uint16(body[offset : offset+2])
|
||||
commandLen := int(body[offset+2])
|
||||
offset += 3
|
||||
if len(body)-offset < commandLen {
|
||||
return SkillCommandRequest{}, fmt.Errorf("record %d command too short: offset %d len %d body %d", index, offset, commandLen, len(body))
|
||||
}
|
||||
req.Records = append(req.Records, SkillCommandRecord{
|
||||
SkillID: skillID,
|
||||
CommandBytes: append([]byte(nil), body[offset:offset+commandLen]...),
|
||||
})
|
||||
offset += commandLen
|
||||
}
|
||||
if offset != len(body) {
|
||||
return SkillCommandRequest{}, fmt.Errorf("trailing command bytes: parsed %d body %d count %d", offset, len(body), req.Count)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (r ChangeSkillSlotRequest) String() string {
|
||||
return fmt.Sprintf("tree=%d from=%d to=%d context=%d mode=%d", r.SkillTree, r.From, r.To, r.ContextIndex, r.Mode)
|
||||
}
|
||||
|
||||
func (r BuySkillRequest) String() string {
|
||||
return fmt.Sprintf("tree=%d declared=%d entries=%d mode=%d", r.RawSkillTree, r.Count, len(r.Entries), r.FinalMode)
|
||||
}
|
||||
|
||||
func (r SkillInitRequest) String() string {
|
||||
return fmt.Sprintf("tree=%d mode=%d", r.SkillTree, r.Mode)
|
||||
}
|
||||
|
||||
func (r ChangeAnotherSkillTreeRequest) String() string {
|
||||
return fmt.Sprintf("tree=%d", r.SkillTree)
|
||||
}
|
||||
|
||||
func (r SkillCommandRequest) String() string {
|
||||
return fmt.Sprintf("declared=%d records=%d", r.Count, len(r.Records))
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package skillcmd
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrResponsePointRange = errors.New("skill response point value is out of uint16 range")
|
||||
ErrResponseEntryRange = errors.New("skill response entry is out of range")
|
||||
ErrResponseSlot = errors.New("skill response slot is unresolved")
|
||||
)
|
||||
|
||||
// currentEXEBuySkillFailureCode is a native, bounded op29 rejection understood
|
||||
// by the current NoPack.exe. sub_1D1E080 enters its failure branch when the
|
||||
// common dispatcher consumes success=0 and passes the following byte as a4;
|
||||
// 0x12 is one of the explicitly handled codes and, importantly, releases the
|
||||
// client's pending skill-learn operation.
|
||||
const currentEXEBuySkillFailureCode byte = 0x12
|
||||
|
||||
// BuildChangeSkillSlotSuccess matches current class1/op28 sub_1D0A4B0. The
|
||||
// dispatcher consumes the leading success byte; the handler then reads exactly
|
||||
// tree, from, and to before swapping the client-side vector.
|
||||
func BuildChangeSkillSlotSuccess(result SlotMutationResult) ([]byte, error) {
|
||||
cmd := Command{
|
||||
SkillTree: result.SkillTree,
|
||||
From: result.From,
|
||||
To: result.To,
|
||||
ContextIndex: -1,
|
||||
Mode: 0,
|
||||
}
|
||||
if err := validateSkillSlotCommand(cmd); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte{1, result.SkillTree, result.From, result.To}, nil
|
||||
}
|
||||
|
||||
// BuildChangeSkillSlotFailure matches the common current-EXE dispatcher path:
|
||||
// a zero success byte closes the pending drag without invoking sub_1D0A4B0's
|
||||
// local slot-swap reader.
|
||||
func BuildChangeSkillSlotFailure() []byte {
|
||||
return []byte{0}
|
||||
}
|
||||
|
||||
// BuildChangeAnotherSkillTreeSuccess matches current class1/op260
|
||||
// sub_1D0C8F0: the dispatcher consumes success and the reader consumes exactly
|
||||
// one target-tree byte.
|
||||
func BuildChangeAnotherSkillTreeSuccess(result TreeSwitchMutationResult) ([]byte, error) {
|
||||
if result.Current >= currentEXESkillTreeCount || result.Target >= currentEXESkillTreeCount || result.Target == result.Current {
|
||||
return nil, fmt.Errorf("%w: current=%d target=%d", ErrSkillTree, result.Current, result.Target)
|
||||
}
|
||||
return []byte{1, result.Target}, nil
|
||||
}
|
||||
|
||||
// BuildChangeAnotherSkillTreeFailure is the live server's bounded op260
|
||||
// rejection: failure followed by resource/error code 19.
|
||||
func BuildChangeAnotherSkillTreeFailure() []byte {
|
||||
return []byte{0, 19}
|
||||
}
|
||||
|
||||
// BuildBuySkillSuccess encodes the complete current upper body. The leading
|
||||
// success byte is consumed by the dispatcher before sub_1D1E080 reads tree.
|
||||
func BuildBuySkillSuccess(result MutationResult) ([]byte, error) {
|
||||
if err := validateBuySkillTree(result.SkillTree); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if result.Points.RemainingSP < 0 || result.Points.RemainingSP > math.MaxUint16 ||
|
||||
result.Points.RemainingTP < 0 || result.Points.RemainingTP > math.MaxUint16 {
|
||||
return nil, ErrResponsePointRange
|
||||
}
|
||||
if len(result.Entries) > math.MaxUint8 {
|
||||
return nil, ErrResponseEntryRange
|
||||
}
|
||||
size := 8
|
||||
for _, entry := range result.Entries {
|
||||
if entry.Slot < 0 || entry.Slot > math.MaxUint8 {
|
||||
return nil, fmt.Errorf("%w: skill=%d slot=%d", ErrResponseSlot, entry.SkillID, entry.Slot)
|
||||
}
|
||||
if entry.Level < 0 || entry.Level > math.MaxUint8 || len(entry.CommandData) > math.MaxUint8 {
|
||||
return nil, fmt.Errorf("%w: skill=%d level=%d command=%d", ErrResponseEntryRange, entry.SkillID, entry.Level, len(entry.CommandData))
|
||||
}
|
||||
size += 5
|
||||
if len(entry.CommandData) > 0 {
|
||||
size += 1 + len(entry.CommandData)
|
||||
}
|
||||
}
|
||||
body := make([]byte, 0, size)
|
||||
body = append(body, 1, result.SkillTree)
|
||||
var number [2]byte
|
||||
binary.LittleEndian.PutUint16(number[:], uint16(result.Points.RemainingSP))
|
||||
body = append(body, number[:]...)
|
||||
binary.LittleEndian.PutUint16(number[:], uint16(result.Points.RemainingTP))
|
||||
body = append(body, number[:]...)
|
||||
body = append(body, byte(len(result.Entries)))
|
||||
for _, entry := range result.Entries {
|
||||
body = append(body, byte(entry.Slot))
|
||||
binary.LittleEndian.PutUint16(number[:], entry.SkillID)
|
||||
body = append(body, number[:]...)
|
||||
body = append(body, byte(entry.Level))
|
||||
if len(entry.CommandData) == 0 {
|
||||
body = append(body, 0)
|
||||
continue
|
||||
}
|
||||
body = append(body, 1, byte(len(entry.CommandData)))
|
||||
body = append(body, entry.CommandData...)
|
||||
}
|
||||
body = append(body, result.FinalMode)
|
||||
return body, nil
|
||||
}
|
||||
|
||||
func BuildBuySkillFailure(code byte) []byte {
|
||||
return []byte{0, code}
|
||||
}
|
||||
|
||||
// BuildSkillInitSuccess matches current class1/op491 sub_1FEC390. The common
|
||||
// dispatcher consumes byte 0, then the handler reads tree and a non-zero
|
||||
// success/refresh flag.
|
||||
func BuildSkillInitSuccess(result ResetMutationResult) ([]byte, error) {
|
||||
if err := validateBuySkillTree(result.SkillTree); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []byte{1, result.SkillTree, 1}, nil
|
||||
}
|
||||
|
||||
// BuildSkillCommandSuccess mirrors the current EXE ACK-only reader: success
|
||||
// byte followed by the raw C2S command body.
|
||||
func BuildSkillCommandSuccess(body []byte) []byte {
|
||||
result := make([]byte, 1, len(body)+1)
|
||||
result[0] = 1
|
||||
return append(result, body...)
|
||||
}
|
||||
|
||||
func BuildSkillCommandAllDefaultSuccess() []byte {
|
||||
return []byte{1}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
package skillcmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestBuildChangeSkillSlotSuccessMatchesCurrentEXEReader(t *testing.T) {
|
||||
body, err := BuildChangeSkillSlotSuccess(SlotMutationResult{SkillTree: 0, From: 3, To: 198})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildChangeSkillSlotSuccess() error = %v", err)
|
||||
}
|
||||
if string(body) != string([]byte{1, 0, 3, 198}) {
|
||||
t.Fatalf("body = % X", body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChangeSkillSlotFailureMatchesCurrentEXEDispatcher(t *testing.T) {
|
||||
if got := BuildChangeSkillSlotFailure(); string(got) != string([]byte{0}) {
|
||||
t.Fatalf("failure body = % X", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildChangeAnotherSkillTreeResponsesMatchCurrentEXEReader(t *testing.T) {
|
||||
body, err := BuildChangeAnotherSkillTreeSuccess(TreeSwitchMutationResult{Current: 0, Target: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildChangeAnotherSkillTreeSuccess() error = %v", err)
|
||||
}
|
||||
if string(body) != string([]byte{1, 1}) {
|
||||
t.Fatalf("success body = % X", body)
|
||||
}
|
||||
if string(BuildChangeAnotherSkillTreeFailure()) != string([]byte{0, 19}) {
|
||||
t.Fatalf("failure body = % X", BuildChangeAnotherSkillTreeFailure())
|
||||
}
|
||||
if _, err := BuildChangeAnotherSkillTreeSuccess(TreeSwitchMutationResult{Current: 1, Target: 1}); !errors.Is(err, ErrSkillTree) {
|
||||
t.Fatalf("same-tree success error = %v, want %v", err, ErrSkillTree)
|
||||
}
|
||||
if _, err := BuildChangeAnotherSkillTreeSuccess(TreeSwitchMutationResult{Current: 2, Target: 1}); !errors.Is(err, ErrSkillTree) {
|
||||
t.Fatalf("invalid-current success error = %v, want %v", err, ErrSkillTree)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBuySkillSuccessMatchesCurrentEXEReader(t *testing.T) {
|
||||
body, err := BuildBuySkillSuccess(MutationResult{
|
||||
SkillTree: 0,
|
||||
FinalMode: 3,
|
||||
Points: dnfrepo.SkillPointState{RemainingSP: 0x1234, RemainingTP: 0x5678},
|
||||
Entries: []MutationEntry{
|
||||
{Slot: 6, SkillID: 0x1122, Level: 4},
|
||||
{Slot: 54, SkillID: 0x3344, Level: 5, CommandData: []byte{'A', 'B'}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("BuildBuySkillSuccess() error = %v", err)
|
||||
}
|
||||
want := []byte{
|
||||
1, 0,
|
||||
0x34, 0x12, 0x78, 0x56,
|
||||
2,
|
||||
6, 0x22, 0x11, 4, 0,
|
||||
54, 0x44, 0x33, 5, 1, 2, 'A', 'B',
|
||||
3,
|
||||
}
|
||||
if string(body) != string(want) {
|
||||
t.Fatalf("body = % X, want % X", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBuySkillSuccessRejectsUnprovenTree(t *testing.T) {
|
||||
_, err := BuildBuySkillSuccess(MutationResult{SkillTree: 2})
|
||||
if !errors.Is(err, ErrSkillTree) {
|
||||
t.Fatalf("BuildBuySkillSuccess() error = %v, want ErrSkillTree", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBuySkillSuccessRejectsUnresolvedSlot(t *testing.T) {
|
||||
_, err := BuildBuySkillSuccess(MutationResult{
|
||||
Points: dnfrepo.SkillPointState{},
|
||||
Entries: []MutationEntry{{Slot: -1, SkillID: 46, Level: 2}},
|
||||
})
|
||||
if !errors.Is(err, ErrResponseSlot) {
|
||||
t.Fatalf("BuildBuySkillSuccess() error = %v, want ErrResponseSlot", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildBuySkillFailure(t *testing.T) {
|
||||
if got := BuildBuySkillFailure(0x12); string(got) != string([]byte{0, 0x12}) {
|
||||
t.Fatalf("failure body = % X", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildSkillInitSuccessMatchesCurrentEXEReader(t *testing.T) {
|
||||
body, err := BuildSkillInitSuccess(ResetMutationResult{SkillTree: 0})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(body) != string([]byte{1, 0, 1}) {
|
||||
t.Fatalf("body = % X", body)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user