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

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

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

No files matched your search

@@ -0,0 +1,88 @@
// 本文件定义副本类命令计划。
// 副本命令会影响房间、掉落、经验和进场门闸,当前只记录意图和缺口,不伪造成功响应。
package dungeoncmd
import (
"fmt"
"strings"
"longheng.io/server/internal/modules/dnf/alignedcmd"
)
// Command 是 dungeoncmd handler 交给后续 dungeon/room owner 的最小命令计划。
type Command struct {
Operation string
AccountID string
SelectedCharacterID uint16
DungeonID uint32
Difficulty byte
EntryOption uint16
SelectionMode byte
DropObjectKey uint32
NextX byte
NextY byte
TutorialProgress uint32
TutorialCommit byte
RawLen int
NeedsOwner string
}
// NewRawCommand 构造尚未闭合包体字段的副本命令计划。
func NewRawCommand(req alignedcmd.Request, operation string, needs string) Command {
return Command{
Operation: operation,
AccountID: strings.TrimSpace(req.AccountID),
SelectedCharacterID: req.SelectedCharacterID,
RawLen: len(req.Body),
NeedsOwner: needs,
}
}
// NewSelectCommand 构造选择副本命令计划。
func NewSelectCommand(req alignedcmd.Request, parsed SelectDungeonRequest) Command {
cmd := NewRawCommand(req, "select_dungeon", "dungeon owner + party/entry validation + scene bootstrap response order")
cmd.DungeonID = parsed.DungeonID
cmd.Difficulty = parsed.Difficulty
cmd.EntryOption = parsed.EntryOption
cmd.SelectionMode = parsed.SelectionMode
return cmd
}
// NewGetItemCommand 构造副本内拾取掉落命令计划。
func NewGetItemCommand(req alignedcmd.Request, parsed GetItemRequest) Command {
cmd := NewRawCommand(req, "get_item", "room drop owner + inventory owner + mutation id + drop/backpack notify order")
cmd.DropObjectKey = parsed.DropObjectKey
return cmd
}
// NewMoveMapCommand 构造切换房间命令计划。
func NewMoveMapCommand(req alignedcmd.Request, parsed MoveMapRequest) Command {
cmd := NewRawCommand(req, "move_map", "active DungeonSession + current op45 ACK + proven target-room actor/object packet chain")
cmd.NextX = parsed.NextX
cmd.NextY = parsed.NextY
return cmd
}
// NewTutorialCommand 构造教程标记命令计划。
func NewTutorialCommand(req alignedcmd.Request, parsed ChangeTutorialFlagRequest) Command {
cmd := NewRawCommand(req, "change_tutorial_flag", "tutorial owner + profile persistence + current EXE response evidence")
cmd.TutorialProgress = parsed.Progress
cmd.TutorialCommit = parsed.CommitFlag
return cmd
}
func (c Command) String() string {
base := fmt.Sprintf("account=%q char=%d", c.AccountID, c.SelectedCharacterID)
switch c.Operation {
case "select_dungeon":
return fmt.Sprintf("%s dungeon=%d difficulty=%d entryOption=%d selectionMode=%d needs=%s", base, c.DungeonID, c.Difficulty, c.EntryOption, c.SelectionMode, c.NeedsOwner)
case "get_item":
return fmt.Sprintf("%s dropObject=%d needs=%s", base, c.DropObjectKey, c.NeedsOwner)
case "move_map":
return fmt.Sprintf("%s next=(%d,%d) rawLen=%d needs=%s", base, c.NextX, c.NextY, c.RawLen, c.NeedsOwner)
case "change_tutorial_flag":
return fmt.Sprintf("%s progress=%d commit=%d rawLen=%d needs=%s", base, c.TutorialProgress, c.TutorialCommit, c.RawLen, c.NeedsOwner)
default:
return fmt.Sprintf("%s rawLen=%d needs=%s", base, c.RawLen, c.NeedsOwner)
}
}
@@ -0,0 +1,5 @@
// Package dungeoncmd 承接 DNF 地下城进入、房间移动、拾取和结算类 C2S 协议。
//
// 现有 dnf/dungeon 包只负责 PVF 地下城静态表;本包只做客户端命令入口,
// 后续真实校验和奖励写入必须交给 dungeon/battle owner。
package dungeoncmd
@@ -0,0 +1,149 @@
// 本文件负责副本命令的协议分发。
// 当前只把已经对齐的 C2S 解析成命令计划;场景 owner、结算和加载门闸闭合前不回成功 ACK。
package dungeoncmd
import (
"context"
"fmt"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
)
type handler struct{}
// NewHandler 创建副本协议处理器。
func NewHandler() alignedcmd.Handler {
return handler{}
}
// Domain 返回副本业务域。
func (handler) Domain() dnfenum.AlignedDomain {
return dnfenum.AlignedDomainDungeon
}
// Handle 解析副本请求,并在场景 owner 和 S2C 顺序闭合前禁止成功 ACK。
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
switch dnfenum.CmdPacket(req.Opcode) {
case dnfenum.CmdPacketEnterSelectDungeon:
cmd := NewRawCommand(req, "enter_select_dungeon", "scene bootstrap owner + channel/party gate + response order")
return applyOwner(ctx, req, cmd, nil), nil
case dnfenum.CmdPacketSelectDungeon:
parsed, err := DecodeSelectDungeonRequest(req.Body)
cmd := NewSelectCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketGetItem:
parsed, err := DecodeGetItemRequest(req.Body)
cmd := NewGetItemCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketMoveMap:
parsed, err := DecodeMoveMapRequest(req.Body)
cmd := NewMoveMapCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketDieMonster:
parsed, err := DecodeDieMonsterRequest(req.Body)
cmd := NewRawCommand(req, "die_monster", parsed.String())
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketDieCharacter:
cmd := NewRawCommand(req, "die_character", "room combat owner + death/revive state + current EXE response order")
return applyOwner(ctx, req, cmd, nil), nil
case dnfenum.CmdPacketUseCoin:
cmd := NewRawCommand(req, "use_coin", "account wallet/coin owner + revive state + scene refresh order")
return applyOwner(ctx, req, cmd, nil), nil
case dnfenum.CmdPacketSetPlayResult:
cmd := NewRawCommand(req, "set_play_result", "dungeon settlement owner + reward owner + back-to-town flush order")
return applyOwner(ctx, req, cmd, nil), nil
case dnfenum.CmdPacketChangeTutorialFlag:
parsed, err := DecodeChangeTutorialFlagRequest(req.Body)
cmd := NewTutorialCommand(req, parsed)
return applyOwner(ctx, req, cmd, err), nil
case dnfenum.CmdPacketDungeonEventStoryPause:
cmd := NewRawCommand(req, "dungeon_event_story_pause", "dungeon story owner + pause state + current EXE response evidence")
return applyOwner(ctx, req, cmd, nil), nil
case dnfenum.CmdPacketRequestDisjointItem:
cmd := NewRawCommand(req, "request_disjoint_item", "dungeon disjoint machine context + inventory owner + response evidence")
return applyOwner(ctx, req, cmd, nil), nil
case dnfenum.CmdPacketDropItem:
cmd := NewRawCommand(req, "drop_item", "inventory owner + scene drop notification + pickup registration")
return applyOwner(ctx, req, cmd, nil), nil
default:
return alignedcmd.Result{
Handled: false,
ResponseAllowed: false,
Operation: "unregistered",
Reason: fmt.Sprintf("dungeon 模块未登记 opcode %d", req.Opcode),
}, nil
}
}
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)
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 请求体解析失败:%v;禁止回成功包", operation, err),
}
}
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: operation,
Reason: fmt.Sprintf("已解析 %s:%s;副本场景状态、奖励和加载门闸闭合前禁止回包", operation, summary),
}
}
func ownerBlockedResult(cmd Command, err error) alignedcmd.Result {
return alignedcmd.Result{
Handled: true,
ResponseAllowed: false,
Operation: cmd.Operation,
Reason: fmt.Sprintf("已解析 %s:%s;dungeon owner 预检未通过:%v;禁止回成功包", 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(
"已解析 %s:%s;dungeon owner verified account=%s char=%s level=%d fatigue=%d location=(town=%d dungeon=%d room=%q) inventory=%t slots=%d warehouse=%d requestDungeon=%d difficulty=%d dropObject=%d next=(%d,%d) tutorialProgress=%d tutorialCommit=%d rawLen=%d;房间/掉落/结算/加载门闸未闭合,禁止成功 ACK",
cmd.Operation,
cmd.String(),
result.AccountID,
result.CharacterID,
result.Level,
result.Fatigue,
result.TownID,
result.DungeonID,
result.RoomID,
result.InventoryKnown,
result.InventorySlotCount,
result.WarehouseSlotCount,
result.RequestedDungeonID,
result.Difficulty,
result.DropObjectKey,
result.NextX,
result.NextY,
result.TutorialProgress,
result.TutorialCommit,
result.RawLen,
),
}
}
@@ -0,0 +1,372 @@
package dungeoncmd
import (
"context"
"encoding/binary"
"encoding/hex"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestDecodeSelectDungeonRequest(t *testing.T) {
body := make([]byte, 23)
binary.LittleEndian.PutUint32(body[0:4], 0x78563412)
body[4] = 3
binary.LittleEndian.PutUint16(body[5:7], 0x0504)
body[7] = 6
body[8] = 7
binary.LittleEndian.PutUint16(body[9:11], 0x0908)
binary.LittleEndian.PutUint32(body[11:15], 0x0d0c0b0a)
body[15] = 0x0e
binary.LittleEndian.PutUint32(body[16:20], 0x1211100f)
body[20] = 0x13
body[21], body[22] = 0xDE, 0xAD
got, err := DecodeSelectDungeonRequest(body)
if err != nil {
t.Fatalf("DecodeSelectDungeonRequest error = %v", err)
}
if got.DungeonID != 0x78563412 || got.Difficulty != 3 || got.EntryOption != 0x0504 ||
got.SelectionMode != 6 || got.RuntimeState != 7 || got.RuntimeToken != 0x0908 ||
got.Reserved != 0x0d0c0b0a || got.PartyState != 0x0e ||
got.LeaderObjectKey != 0x1211100f || got.SpecialMode != 0x13 ||
len(got.OpaqueTail) != 2 || got.OpaqueTail[0] != 0xDE {
t.Fatalf("got = %+v", got)
}
body[21] = 0
if got.OpaqueTail[0] != 0xDE {
t.Fatal("opaque select tail aliases request body")
}
if _, err := DecodeSelectDungeonRequest(make([]byte, 20)); err == nil {
t.Fatal("20-byte select request accepted")
}
}
func TestDecodeSelectDungeonRequestRejectsTruncatedFiveByteBody(t *testing.T) {
if _, err := DecodeSelectDungeonRequest(make([]byte, 5)); err == nil {
t.Fatal("truncated five-byte op16 request accepted")
}
}
func TestDecodeGetItemRequestMatchesCurrentEXEOrdinaryTwentyByteWriter(t *testing.T) {
body := make([]byte, GetItemRequestSize)
binary.LittleEndian.PutUint32(body[0:4], 0x44332211)
body[4] = 0
body[5] = 0x66
for index, value := range []uint16{101, 102, 103, 201, 202, 301, 302} {
binary.LittleEndian.PutUint16(body[6+index*2:], value)
}
request, err := DecodeGetItemRequest(body)
if err != nil {
t.Fatal(err)
}
if request.DropObjectKey != 0x44332211 || request.FixedZero != 0 || request.ObjectContext != 0x66 ||
request.PlayerX != 101 || request.PlayerY != 102 || request.Token0 != 103 ||
request.DropX != 201 || request.DropY != 202 || request.Token1 != 301 || request.Token2 != 302 {
t.Fatalf("request = %+v", request)
}
for _, size := range []int{2, 18, GetItemRequestSize - 1, GetItemRequestSize + 1} {
if _, err := DecodeGetItemRequest(make([]byte, size)); err == nil {
t.Fatalf("op43 body size %d accepted", size)
}
}
}
func TestDecodeGetItemRequestMatchesLiveOrdinaryPickupCapture(t *testing.T) {
// Full C2S body after the legacy game header from a live accepted op43.
// Keep the byte at offset 4: an earlier truncated transcription omitted it
// and incorrectly shifted ObjectContext into FixedZero.
body, err := hex.DecodeString("ae01000000016502f500d8094b02e500876c9a10")
if err != nil {
t.Fatal(err)
}
request, err := DecodeGetItemRequest(body)
if err != nil {
t.Fatal(err)
}
if request.DropObjectKey != 430 || request.FixedZero != 0 || request.ObjectContext != 1 ||
request.PlayerX != 613 || request.PlayerY != 245 || request.Token0 != 2520 ||
request.DropX != 587 || request.DropY != 229 || request.Token1 != 27783 || request.Token2 != 4250 {
t.Fatalf("request = %+v", request)
}
}
func TestDecodeMoveMapRequestCurrentEXELayoutAndBoundary(t *testing.T) {
if _, err := DecodeMoveMapRequest(make([]byte, MoveMapRequestSize-1)); err == nil {
t.Fatal("99-byte move-map request accepted")
}
if _, err := DecodeMoveMapRequest(make([]byte, MoveMapRequestSize+1)); err == nil {
t.Fatal("101-byte move-map request accepted")
}
body := make([]byte, MoveMapRequestSize)
body[0], body[1] = 1, 2
binary.LittleEndian.PutUint32(body[2:6], 0x11223344)
binary.LittleEndian.PutUint32(body[6:10], 0x55667788)
body[10] = 4
binary.LittleEndian.PutUint16(body[11:13], 0x1234)
offset := 13
for index := 0; index < 8; index++ {
binary.LittleEndian.PutUint16(body[offset:offset+2], uint16(100+index))
offset += 2
}
for index := 0; index < 8; index++ {
binary.LittleEndian.PutUint32(body[offset:offset+4], uint32(1000+index))
offset += 4
}
binary.LittleEndian.PutUint16(body[61:63], 0xBEEF)
for index := 63; index < 99; index++ {
body[index] = byte(index)
}
body[99] = 9
full, err := DecodeMoveMapRequest(body)
if err != nil {
t.Fatalf("DecodeMoveMapRequest full error = %v", err)
}
if full.NextX != 1 || full.NextY != 2 || full.PositionX != 0x11223344 ||
full.PositionY != 0x55667788 || full.MoveKind != 4 || full.TimingToken != 0x1234 ||
full.ShortState[7] != 107 || full.IntegerState[7] != 1007 ||
full.SequenceToken != 0xBEEF || full.RuntimeState != 9 ||
full.RuntimeTail[0] != 63 || full.RuntimeTail[35] != 98 || len(full.OpaqueTail) != 0 {
t.Fatalf("full = %+v", full)
}
body[63] = 0
if full.RuntimeTail[0] != 63 {
t.Fatal("runtime tail aliases request body")
}
}
func TestDecodeDieMonsterRequestCurrentEXELayoutAndBoundary(t *testing.T) {
if _, err := DecodeDieMonsterRequest(make([]byte, 61)); err == nil {
t.Fatal("61-byte die-monster request accepted")
}
body := make([]byte, DieMonsterRequestSize)
binary.LittleEndian.PutUint32(body[0:4], 0x11223344)
binary.LittleEndian.PutUint16(body[4:6], 0x5566)
for index := 0; index < 4; index++ {
binary.LittleEndian.PutUint32(body[6+index*4:], uint32(100+index))
}
body[22] = 7
binary.LittleEndian.PutUint16(body[23:25], 201)
binary.LittleEndian.PutUint16(body[25:27], 202)
copy(body[27:31], []byte{8, 9, 10, 11})
binary.LittleEndian.PutUint16(body[31:33], 0x7788)
body[33] = 1
binary.LittleEndian.PutUint32(body[34:38], 0x99AABBCC)
binary.LittleEndian.PutUint64(body[38:46], 0x0102030405060708)
for index := 0; index < 7; index++ {
binary.LittleEndian.PutUint16(body[46+index*2:], uint16(300+index))
}
body[60], body[61] = 12, 13
got, err := DecodeDieMonsterRequest(body)
if err != nil {
t.Fatal(err)
}
if got.RuntimeObjectKey != 0x11223344 || got.OwnerObjectKey != 0x5566 ||
got.CombatState[3] != 103 || got.ShortState[1] != 202 || got.ByteState[3] != 11 ||
got.RandomState != 0x7788 || got.HasActorState != 1 || got.ActorState != 0x99AABBCC ||
got.ActorValue != 0x0102030405060708 || got.ActorShortState[6] != 306 ||
got.Layout != DieMonsterRequestLayoutFixed62 || got.TailState != [2]byte{12, 13} || len(got.OpaqueTail) != 0 {
t.Fatalf("got = %+v", got)
}
if _, err := DecodeDieMonsterRequest(append(append([]byte(nil), body...), 0xFA)); err == nil {
t.Fatal("unsupported 63-byte die-monster boundary accepted")
}
}
func TestDecodeDieMonsterRequestCurrentTutorialAPCVariableLayout(t *testing.T) {
body, err := hex.DecodeString("9301000094010000000000000000910d0000130000000211019401bf0b00000d001101130094010000060064024d01000000001e3c0001000000430d00000000000064024d0164024d0112002f0003000000300280d6")
if err != nil {
t.Fatal(err)
}
got, err := DecodeDieMonsterRequest(body)
if err != nil {
t.Fatal(err)
}
if got.Layout != DieMonsterRequestLayoutVariableCombat ||
got.RuntimeObjectKey != 403 || got.OwnerObjectKey != 404 ||
got.CombatEntryCount != 2 || len(got.CombatEntries) != 20 ||
len(got.RuntimeTail) != DieMonsterVariableRuntimeTailSize || len(got.OpaqueTail) != 0 {
t.Fatalf("got = %+v", got)
}
body[23] = 0
if got.CombatEntries[0] != 0x11 {
t.Fatal("variable combat entries alias request body")
}
withTail := append(append([]byte(nil), body...), 0xFA, 0xCE)
got, err = DecodeDieMonsterRequest(withTail)
if err != nil {
t.Fatal(err)
}
if len(got.OpaqueTail) != 2 || got.OpaqueTail[0] != 0xFA || got.OpaqueTail[1] != 0xCE {
t.Fatalf("opaque tail = %x", got.OpaqueTail)
}
truncated := append([]byte(nil), body[:len(body)-1]...)
truncated[22] = 2
if _, err := DecodeDieMonsterRequest(truncated); err == nil {
t.Fatal("truncated variable die-monster body accepted")
}
}
func TestDecodeBossDieCheckRequestCurrentEXELayoutAndBoundary(t *testing.T) {
for _, size := range []int{BossDieCheckRequestSize - 1, BossDieCheckRequestSize + 1} {
if _, err := DecodeBossDieCheckRequest(make([]byte, size)); err == nil {
t.Fatalf("%d-byte boss-die-check request accepted", size)
}
}
body := make([]byte, BossDieCheckRequestSize)
binary.LittleEndian.PutUint16(body[0:2], 0x1122)
binary.LittleEndian.PutUint16(body[2:4], 0x3344)
binary.LittleEndian.PutUint32(body[4:8], 0)
binary.LittleEndian.PutUint32(body[8:12], 0x55667788)
body[12] = 0
binary.LittleEndian.PutUint32(body[13:17], 0x99aabbcc)
binary.LittleEndian.PutUint64(body[17:25], 0x0102030405060708)
for index := 0; index < 7; index++ {
binary.LittleEndian.PutUint16(body[25+index*2:], uint16(0x1200+index))
}
got, err := DecodeBossDieCheckRequest(body)
if err != nil {
t.Fatal(err)
}
if got.RelatedActorObjectKey != 0x1122 || got.TargetObjectKey != 0x3344 ||
got.ReservedZero != 0 || got.Field08 != 0x55667788 || got.Field12 != 0 ||
got.Field13 != 0x99aabbcc || got.Field17 != 0x0102030405060708 ||
got.Field25[0] != 0x1200 || got.Field25[6] != 0x1206 {
t.Fatalf("got = %+v", got)
}
}
func TestHandlerBlocksDungeonAck(t *testing.T) {
body := make([]byte, GetItemRequestSize)
binary.LittleEndian.PutUint32(body[0:4], 0x11223344)
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketGetItem),
Body: body,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
if got.Operation != "get_item" {
t.Fatalf("operation = %q", got.Operation)
}
}
func TestHandlerUsesOwnerPreflightAndStillBlocksDungeonAck(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "99",
AccountID: "acc",
Level: 86,
Stats: map[string]int64{"fatigue": 121},
Location: dnfrepo.CharacterLocation{TownID: 3, DungeonID: 4001, RoomID: "2:3"},
}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "99",
Slots: map[string]dnfrepo.ItemStack{
"0:9": {ItemID: 1001, Count: 2},
},
}); err != nil {
t.Fatalf("Inventory.Save error = %v", err)
}
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketSelectDungeon),
SelectedCharacterID: 99,
Repositories: repos,
Body: func() []byte {
body := make([]byte, 21)
binary.LittleEndian.PutUint32(body, 4001)
body[4] = 3
return body
}(),
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed {
t.Fatalf("result = %+v, want handled without response", got)
}
for _, want := range []string{"dungeon owner verified", "level=86", "fatigue=121", "requestDungeon=4001", "difficulty=3", `room="2:3"`} {
if !strings.Contains(got.Reason, want) {
t.Fatalf("reason %q missing %q", got.Reason, want)
}
}
}
func TestHandlerMoveMapRequiresLiveRuntimeBeforeResponse(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "99", AccountID: "acc"}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
body := make([]byte, MoveMapRequestSize)
body[0], body[1] = 1, 2
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketMoveMap),
SelectedCharacterID: 99,
Repositories: repos,
Body: body,
})
if err != nil {
t.Fatalf("Handle error = %v", err)
}
if !got.Handled || got.ResponseAllowed || got.Operation != "move_map" {
t.Fatalf("result = %+v, want blocked move_map", got)
}
for _, want := range []string{"active dungeon runtime session required", "target-room actor/object packet chain is not proven"} {
if !strings.Contains(got.Reason, want) {
t.Fatalf("reason %q missing %q", got.Reason, want)
}
}
}
func TestDecodeChangeTutorialFlagRequestFromCurrentEXEWriter(t *testing.T) {
got, err := DecodeChangeTutorialFlagRequest([]byte{
0x00,
0x1f, 0x00, 0x00, 0x00,
0x01,
})
if err != nil {
t.Fatalf("DecodeChangeTutorialFlagRequest error = %v", err)
}
if got.Prefix != 0 || got.Progress != 31 || got.CommitFlag != 1 {
t.Fatalf("request = %+v", got)
}
}
func TestDecodeChangeTutorialFlagRequestRequiresExactBoundary(t *testing.T) {
for _, body := range [][]byte{make([]byte, 5), make([]byte, 7), make([]byte, 16)} {
if _, err := DecodeChangeTutorialFlagRequest(body); err == nil {
t.Fatalf("body len %d unexpectedly decoded", len(body))
}
}
}
func TestTutorialCommandRecordsMCPOwnerGap(t *testing.T) {
cmd := NewTutorialCommand(alignedcmd.Request{
AccountID: " acc ",
SelectedCharacterID: 99,
Body: make([]byte, ChangeTutorialFlagRequestSize),
}, ChangeTutorialFlagRequest{
Progress: 31,
CommitFlag: 1,
})
summary := cmd.String()
for _, want := range []string{`account="acc"`, "char=99", "progress=31", "commit=1", "rawLen=6", "tutorial owner", "EXE"} {
if !strings.Contains(summary, want) {
t.Fatalf("summary %q missing %q", summary, want)
}
}
}
@@ -0,0 +1,140 @@
// 本文件提供副本命令的只读 owner 预检。
// 当前只读取角色和背包快照,房间、掉落、结算和进场门闸闭合前不会写库或开放成功 ACK。
package dungeoncmd
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrOwnerUnavailable = errors.New("dungeon owner unavailable")
ErrCharacterRequired = errors.New("selected character id required")
ErrCharacterNotFound = errors.New("character record not found")
ErrInventoryNotFound = errors.New("inventory record not found")
ErrRuntimeSessionRequired = errors.New("active dungeon runtime session required")
ErrCombatAuthorityNeeded = errors.New("server combat authority required")
)
// Owner 是副本命令进入真实场景 owner 前的只读校验边界。
type Owner struct {
characters dnfrepo.CharacterRepository
inventory dnfrepo.InventoryRepository
}
// PlanResult 描述副本命令当前能从玩家读模型确认到的上下文。
type PlanResult struct {
AccountID string
CharacterID string
Operation string
Level int
Fatigue int64
TownID int64
DungeonID int64
RoomID string
InventoryKnown bool
InventorySlotCount int
WarehouseSlotCount int
RequestedDungeonID uint32
Difficulty byte
DropObjectKey uint32
NextX byte
NextY byte
TutorialProgress uint32
TutorialCommit byte
RawLen int
}
// NewOwner 创建副本命令 owner;缺少角色或背包仓储时拒绝预检。
func NewOwner(repos dnfrepo.Group) (*Owner, error) {
if repos.Character == nil || repos.Inventory == nil {
return nil, ErrOwnerUnavailable
}
return &Owner{characters: repos.Character, inventory: repos.Inventory}, nil
}
// Plan 只读取角色、位置和背包快照;真实房间状态、掉落和结算写路径仍等待后续 owner。
func (o *Owner) Plan(ctx context.Context, cmd Command) (PlanResult, error) {
if o == nil || o.characters == nil || o.inventory == nil {
return PlanResult{}, ErrOwnerUnavailable
}
if cmd.SelectedCharacterID == 0 {
return PlanResult{}, ErrCharacterRequired
}
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
character, ok, err := o.characters.Load(ctx, characterID)
if err != nil {
return PlanResult{}, err
}
if !ok {
return PlanResult{}, ErrCharacterNotFound
}
character = dnfrepo.CloneCharacter(character)
// The repository owner cannot prove live room ownership. Keep these paths
// blocked before inventory/reward reads or any future mutation is attempted.
switch cmd.Operation {
case "move_map":
return PlanResult{}, fmt.Errorf(
"%w: current op45 success reads two coordinates, but the target-room actor/object packet chain is not proven",
ErrRuntimeSessionRequired,
)
case "die_monster":
return PlanResult{}, fmt.Errorf(
"%w: a client op39 report is not a server-owned defeat or reward decision",
ErrCombatAuthorityNeeded,
)
}
inventory, inventoryKnown, err := o.inventory.Load(ctx, characterID)
if err != nil {
return PlanResult{}, err
}
if cmd.Operation == "get_item" && !inventoryKnown {
return PlanResult{}, ErrInventoryNotFound
}
inventory = dnfrepo.CloneInventory(inventory)
return PlanResult{
AccountID: character.AccountID,
CharacterID: characterID,
Operation: cmd.Operation,
Level: character.Level,
Fatigue: statValue(character.Stats, "fatigue", "fp", "疲劳"),
TownID: character.Location.TownID,
DungeonID: character.Location.DungeonID,
RoomID: character.Location.RoomID,
InventoryKnown: inventoryKnown,
InventorySlotCount: len(inventory.Slots),
WarehouseSlotCount: len(inventory.Warehouse),
RequestedDungeonID: cmd.DungeonID,
Difficulty: cmd.Difficulty,
DropObjectKey: cmd.DropObjectKey,
NextX: cmd.NextX,
NextY: cmd.NextY,
TutorialProgress: cmd.TutorialProgress,
TutorialCommit: cmd.TutorialCommit,
RawLen: cmd.RawLen,
}, nil
}
func statValue(stats map[string]int64, names ...string) int64 {
for _, name := range names {
if value, ok := stats[name]; ok {
return value
}
}
return 0
}
func planError(operation string, err error) error {
if err == nil {
return nil
}
return fmt.Errorf("%s: %w", strings.TrimSpace(operation), err)
}
@@ -0,0 +1,110 @@
package dungeoncmd
import (
"context"
"errors"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerPlansSelectDungeonContext(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: "99",
AccountID: "acc",
Level: 86,
Stats: map[string]int64{"fatigue": 121},
Location: dnfrepo.CharacterLocation{TownID: 3, DungeonID: 4001, RoomID: "2:3"},
}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
CharacterID: "99",
Slots: map[string]dnfrepo.ItemStack{
"0:9": {ItemID: 1001, Count: 2},
},
}); err != nil {
t.Fatalf("Inventory.Save error = %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
got, err := owner.Plan(ctx, Command{
Operation: "select_dungeon",
SelectedCharacterID: 99,
DungeonID: 4001,
Difficulty: 3,
})
if err != nil {
t.Fatalf("Plan error = %v", err)
}
if got.AccountID != "acc" || got.CharacterID != "99" || got.Level != 86 || got.Fatigue != 121 {
t.Fatalf("identity/stat = %+v", got)
}
if got.TownID != 3 || got.DungeonID != 4001 || got.RoomID != "2:3" {
t.Fatalf("location = %+v", got)
}
if !got.InventoryKnown || got.InventorySlotCount != 1 {
t.Fatalf("inventory = known %t slots %d", got.InventoryKnown, got.InventorySlotCount)
}
if got.RequestedDungeonID != 4001 || got.Difficulty != 3 {
t.Fatalf("request = %+v", got)
}
}
func TestOwnerRejectsGetItemWithoutInventory(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "99", AccountID: "acc"}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
_, err = owner.Plan(ctx, Command{
Operation: "get_item",
SelectedCharacterID: 99,
DropObjectKey: 7007,
})
if !errors.Is(err, ErrInventoryNotFound) {
t.Fatalf("Plan error = %v, want ErrInventoryNotFound", err)
}
}
func TestOwnerBlocksLiveRoomCommandsWithoutRuntimeAuthority(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "99", AccountID: "acc"}); err != nil {
t.Fatalf("Character.Save error = %v", err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
tests := []struct {
operation string
want error
}{
{operation: "move_map", want: ErrRuntimeSessionRequired},
{operation: "die_monster", want: ErrCombatAuthorityNeeded},
}
for _, test := range tests {
t.Run(test.operation, func(t *testing.T) {
_, planErr := owner.Plan(ctx, Command{
Operation: test.operation,
SelectedCharacterID: 99,
})
if !errors.Is(planErr, test.want) {
t.Fatalf("Plan error = %v, want %v", planErr, test.want)
}
})
}
}
@@ -0,0 +1,396 @@
// 本文件解析副本类 C2S 请求体。
// 只记录当前已经对齐的请求字段,复杂场景状态交给后续 dungeon owner 处理。
package dungeoncmd
import (
"encoding/binary"
"fmt"
)
const (
SelectDungeonRequestSize = 21
GetItemRequestSize = 20
MoveMapRequestSize = 100
DieMonsterRequestSize = 62
DieMonsterVariableBaseSize = 66
DieMonsterVariableCombatEntrySize = 10
DieMonsterVariableRuntimeTailSize = 43
DieMonsterVariableMaxCombatCount = 254
BossDieCheckRequestSize = 39
ChangeTutorialFlagRequestSize = 6
)
// SelectDungeonRequest 描述选择副本请求。
type SelectDungeonRequest struct {
DungeonID uint32
Difficulty byte
EntryOption uint16
SelectionMode byte
RuntimeState byte
RuntimeToken uint16
Reserved uint32
PartyState byte
// LeaderObjectKey is the legacy name for the u32 at offset 16. Its
// semantics are not proved for the current EXE, so owners must retain it
// as opaque request state and must not use it as an authority boundary.
LeaderObjectKey uint32
SpecialMode byte
OpaqueTail []byte
}
// GetItemRequest is the exact 20-byte ordinary scene-item pickup body written
// by the current EXE sub_2326920. Only DropObjectKey has proved ownership
// semantics; the remaining coordinates/tokens are retained by wire position.
// The separate 18-byte op43 writer is a script/special-object variant and is
// deliberately rejected by DecodeGetItemRequest.
type GetItemRequest struct {
DropObjectKey uint32
FixedZero byte
ObjectContext byte
PlayerX uint16
PlayerY uint16
Token0 uint16
DropX uint16
DropY uint16
Token1 uint16
Token2 uint16
}
// MoveMapRequest 描述切换房间请求。
type MoveMapRequest struct {
NextX byte
NextY byte
PositionX uint32
PositionY uint32
MoveKind byte
TimingToken uint16
ShortState [8]uint16
IntegerState [8]uint32
SequenceToken uint16
RuntimeTail [36]byte
RuntimeState byte
OpaqueTail []byte
}
// DieMonsterRequest is the current EXE op39 body. Unknown combat scalars are
// retained by position so the room owner can use the proven runtime object key
// without inventing semantics for the remaining fields.
type DieMonsterRequest struct {
Layout DieMonsterRequestLayout
RuntimeObjectKey uint32
OwnerObjectKey uint16
CombatState [4]uint32
StateFlag byte
ShortState [2]uint16
ByteState [4]byte
RandomState uint16
HasActorState byte
ActorState uint32
ActorValue uint64
ActorShortState [7]uint16
TailState [2]byte
CombatEntryCount byte
CombatEntries []byte
RuntimeTail []byte
OpaqueTail []byte
}
// BossDieCheckRequest is the exact 39-byte plaintext body written by the
// current EXE sub_24351D0 for class-1/op117. Only the two object keys have
// proven ownership semantics. The remaining values are retained by their
// native offsets so the bridge does not invent names for unproven fields.
type BossDieCheckRequest struct {
RelatedActorObjectKey uint16
TargetObjectKey uint16
ReservedZero uint32
Field08 uint32
Field12 byte
Field13 uint32
Field17 uint64
Field25 [7]uint16
}
type DieMonsterRequestLayout byte
const (
DieMonsterRequestLayoutFixed62 DieMonsterRequestLayout = iota + 1
DieMonsterRequestLayoutVariableCombat
)
// ChangeTutorialFlagRequest is the exact six-byte C2S body written by the
// current EXE sub_33BDAF0: u8 prefix, u32 progress, u8 commit flag. The
// count/entry layout belongs to the S2C success callback and must not be used
// to decode this request direction.
type ChangeTutorialFlagRequest struct {
Prefix byte
Progress uint32
CommitFlag byte
}
// DecodeSelectDungeonRequest 解析选择副本请求。
func DecodeSelectDungeonRequest(body []byte) (SelectDungeonRequest, error) {
if len(body) < SelectDungeonRequestSize {
return SelectDungeonRequest{}, fmt.Errorf("body too short: got %d want >= %d", len(body), SelectDungeonRequestSize)
}
request := SelectDungeonRequest{
DungeonID: binary.LittleEndian.Uint32(body[0:4]),
Difficulty: body[4],
EntryOption: binary.LittleEndian.Uint16(body[5:7]),
SelectionMode: body[7],
RuntimeState: body[8],
RuntimeToken: binary.LittleEndian.Uint16(body[9:11]),
Reserved: binary.LittleEndian.Uint32(body[11:15]),
PartyState: body[15],
LeaderObjectKey: binary.LittleEndian.Uint32(body[16:20]),
SpecialMode: body[20],
}
request.OpaqueTail = append([]byte(nil), body[SelectDungeonRequestSize:]...)
return request, nil
}
// DecodeGetItemRequest accepts only the current ordinary-drop writer boundary.
func DecodeGetItemRequest(body []byte) (GetItemRequest, error) {
if len(body) != GetItemRequestSize {
return GetItemRequest{}, fmt.Errorf("invalid body length: got %d want %d", len(body), GetItemRequestSize)
}
return GetItemRequest{
DropObjectKey: binary.LittleEndian.Uint32(body[0:4]),
FixedZero: body[4],
ObjectContext: body[5],
PlayerX: binary.LittleEndian.Uint16(body[6:8]),
PlayerY: binary.LittleEndian.Uint16(body[8:10]),
Token0: binary.LittleEndian.Uint16(body[10:12]),
DropX: binary.LittleEndian.Uint16(body[12:14]),
DropY: binary.LittleEndian.Uint16(body[14:16]),
Token1: binary.LittleEndian.Uint16(body[16:18]),
Token2: binary.LittleEndian.Uint16(body[18:20]),
}, nil
}
// DecodeMoveMapRequest parses the fixed 100-byte plaintext body written by the
// current EXE sub_17C9D80. Its first 63 bytes are followed by the exact 36-byte
// sub_17C8490 runtime block and one final state byte. The native opcode-45
// transport normally Twofish-pads this plaintext to 112 wire bytes; the client
// compatibility path must bypass that process-local cipher before this decoder.
func DecodeMoveMapRequest(body []byte) (MoveMapRequest, error) {
if len(body) != MoveMapRequestSize {
return MoveMapRequest{}, fmt.Errorf("invalid body length: got %d want %d", len(body), MoveMapRequestSize)
}
request := MoveMapRequest{
NextX: body[0],
NextY: body[1],
PositionX: binary.LittleEndian.Uint32(body[2:6]),
PositionY: binary.LittleEndian.Uint32(body[6:10]),
MoveKind: body[10],
TimingToken: binary.LittleEndian.Uint16(body[11:13]),
RuntimeState: body[99],
}
offset := 13
for index := range request.ShortState {
request.ShortState[index] = binary.LittleEndian.Uint16(body[offset : offset+2])
offset += 2
}
for index := range request.IntegerState {
request.IntegerState[index] = binary.LittleEndian.Uint32(body[offset : offset+4])
offset += 4
}
request.SequenceToken = binary.LittleEndian.Uint16(body[offset : offset+2])
copy(request.RuntimeTail[:], body[63:99])
return request, nil
}
// DecodeDieMonsterRequest parses both current EXE death-writer families.
// sub_2276500/sub_22767C0 emit the fixed 62-byte form. Tutorial APC writer
// sub_2570F20 emits 66+10*count bytes: a 23-byte prefix, count ten-byte combat
// entries, and a 43-byte runtime tail which includes sub_226F700 output.
// Unknown values are retained by exact boundary; extra bytes remain opaque so
// the runtime owner can reject an unproven extension before changing state.
func DecodeDieMonsterRequest(body []byte) (DieMonsterRequest, error) {
if len(body) < DieMonsterRequestSize {
return DieMonsterRequest{}, fmt.Errorf("body too short: got %d want >= %d", len(body), DieMonsterRequestSize)
}
if len(body) != DieMonsterRequestSize {
return decodeVariableDieMonsterRequest(body)
}
request := DieMonsterRequest{
Layout: DieMonsterRequestLayoutFixed62,
RuntimeObjectKey: binary.LittleEndian.Uint32(body[0:4]),
OwnerObjectKey: binary.LittleEndian.Uint16(body[4:6]),
StateFlag: body[22],
ShortState: [2]uint16{
binary.LittleEndian.Uint16(body[23:25]),
binary.LittleEndian.Uint16(body[25:27]),
},
ByteState: [4]byte{body[27], body[28], body[29], body[30]},
RandomState: binary.LittleEndian.Uint16(body[31:33]),
HasActorState: body[33],
ActorState: binary.LittleEndian.Uint32(body[34:38]),
ActorValue: binary.LittleEndian.Uint64(body[38:46]),
TailState: [2]byte{body[60], body[61]},
OpaqueTail: append([]byte(nil), body[DieMonsterRequestSize:]...),
}
for index := range request.CombatState {
offset := 6 + index*4
request.CombatState[index] = binary.LittleEndian.Uint32(body[offset : offset+4])
}
for index := range request.ActorShortState {
offset := 46 + index*2
request.ActorShortState[index] = binary.LittleEndian.Uint16(body[offset : offset+2])
}
return request, nil
}
// DecodeBossDieCheckRequest parses only the current EXE's exact plaintext
// boundary. The ordinary protected path pads the same body to 40 wire bytes;
// that ciphertext must not be accepted by this decoder.
func DecodeBossDieCheckRequest(body []byte) (BossDieCheckRequest, error) {
if len(body) != BossDieCheckRequestSize {
return BossDieCheckRequest{}, fmt.Errorf(
"invalid body length: got %d want %d",
len(body),
BossDieCheckRequestSize,
)
}
request := BossDieCheckRequest{
RelatedActorObjectKey: binary.LittleEndian.Uint16(body[0:2]),
TargetObjectKey: binary.LittleEndian.Uint16(body[2:4]),
ReservedZero: binary.LittleEndian.Uint32(body[4:8]),
Field08: binary.LittleEndian.Uint32(body[8:12]),
Field12: body[12],
Field13: binary.LittleEndian.Uint32(body[13:17]),
Field17: binary.LittleEndian.Uint64(body[17:25]),
}
for index := range request.Field25 {
offset := 25 + index*2
request.Field25[index] = binary.LittleEndian.Uint16(body[offset : offset+2])
}
return request, nil
}
func decodeVariableDieMonsterRequest(body []byte) (DieMonsterRequest, error) {
if len(body) < DieMonsterVariableBaseSize {
return DieMonsterRequest{}, fmt.Errorf(
"unsupported die-monster boundary: got %d want %d or >= %d",
len(body), DieMonsterRequestSize, DieMonsterVariableBaseSize,
)
}
count := int(body[22])
if count > DieMonsterVariableMaxCombatCount {
return DieMonsterRequest{}, fmt.Errorf(
"die-monster combat count exceeds current writer limit: got %d want <= %d",
count, DieMonsterVariableMaxCombatCount,
)
}
expected := DieMonsterVariableBaseSize + count*DieMonsterVariableCombatEntrySize
if len(body) < expected {
return DieMonsterRequest{}, fmt.Errorf(
"variable die-monster body too short: got %d want >= %d for count %d",
len(body), expected, count,
)
}
entriesStart := 23
entriesEnd := entriesStart + count*DieMonsterVariableCombatEntrySize
runtimeTailEnd := entriesEnd + DieMonsterVariableRuntimeTailSize
request := DieMonsterRequest{
Layout: DieMonsterRequestLayoutVariableCombat,
RuntimeObjectKey: binary.LittleEndian.Uint32(body[0:4]),
OwnerObjectKey: binary.LittleEndian.Uint16(body[4:6]),
CombatEntryCount: byte(count),
CombatEntries: append([]byte(nil), body[entriesStart:entriesEnd]...),
RuntimeTail: append([]byte(nil), body[entriesEnd:runtimeTailEnd]...),
OpaqueTail: append([]byte(nil), body[runtimeTailEnd:]...),
}
for index := range request.CombatState {
offset := 6 + index*4
request.CombatState[index] = binary.LittleEndian.Uint32(body[offset : offset+4])
}
return request, nil
}
// DecodeChangeTutorialFlagRequest parses the current EXE C2S writer. The
// boundary is exact so a protected/padded body cannot be mistaken for the
// plaintext tutorial-finish handshake.
func DecodeChangeTutorialFlagRequest(body []byte) (ChangeTutorialFlagRequest, error) {
if len(body) != ChangeTutorialFlagRequestSize {
return ChangeTutorialFlagRequest{}, fmt.Errorf(
"body length mismatch: got %d want %d",
len(body),
ChangeTutorialFlagRequestSize,
)
}
return ChangeTutorialFlagRequest{
Prefix: body[0],
Progress: binary.LittleEndian.Uint32(body[1:5]),
CommitFlag: body[5],
}, nil
}
func (r SelectDungeonRequest) String() string {
return fmt.Sprintf(
"dungeon=%d difficulty=%d entryOption=%d selectionMode=%d runtimeState=%d runtimeToken=%d reserved=%d partyState=%d leader=%d specialMode=%d tail=%d",
r.DungeonID, r.Difficulty, r.EntryOption, r.SelectionMode, r.RuntimeState,
r.RuntimeToken, r.Reserved, r.PartyState, r.LeaderObjectKey, r.SpecialMode, len(r.OpaqueTail),
)
}
func (r GetItemRequest) String() string {
return fmt.Sprintf(
"dropObject=%d fixed=%d context=%d player=(%d,%d) token0=%d drop=(%d,%d) token1=%d token2=%d",
r.DropObjectKey,
r.FixedZero,
r.ObjectContext,
r.PlayerX,
r.PlayerY,
r.Token0,
r.DropX,
r.DropY,
r.Token1,
r.Token2,
)
}
func (r MoveMapRequest) String() string {
return fmt.Sprintf(
"next=(%d,%d) position=(%d,%d) kind=%d sequence=%d tail=%d",
r.NextX, r.NextY, r.PositionX, r.PositionY, r.MoveKind, r.SequenceToken, len(r.OpaqueTail),
)
}
func (r DieMonsterRequest) String() string {
return fmt.Sprintf(
"layout=%d runtimeObject=%d ownerObject=%d actorState=%d combatEntries=%d runtimeTail=%d tail=%d",
r.Layout, r.RuntimeObjectKey, r.OwnerObjectKey, r.ActorState,
r.CombatEntryCount, len(r.RuntimeTail), len(r.OpaqueTail),
)
}
func (r BossDieCheckRequest) String() string {
return fmt.Sprintf(
"relatedActor=%d target=%d reserved=%d field08=%d field12=%d field13=%d field17=%d",
r.RelatedActorObjectKey,
r.TargetObjectKey,
r.ReservedZero,
r.Field08,
r.Field12,
r.Field13,
r.Field17,
)
}
func (r ChangeTutorialFlagRequest) String() string {
return fmt.Sprintf("prefix=%d progress=%d commit=%d", r.Prefix, r.Progress, r.CommitFlag)
}
func readU16(body []byte, offset int) uint16 {
if offset+2 <= len(body) {
return binary.LittleEndian.Uint16(body[offset:])
}
return 0
}
func readU32(body []byte, offset int) uint32 {
if offset+4 <= len(body) {
return binary.LittleEndian.Uint32(body[offset:])
}
return 0
}