服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
173 lines
6.3 KiB
Go
173 lines
6.3 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/binary"
|
|
"testing"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
func TestBuildCurrentSceneOp9ActorRemovalBodyUsesKind3PrefixOnly(t *testing.T) {
|
|
body := buildCurrentSceneOp9ActorRemovalBody(0x1234)
|
|
want := []byte{1, 0, 0x0f, 0x27, 0x34, 0x12, 0, currentSceneObjectContext, 0, currentSceneOp9ActorRemoveKind}
|
|
if !bytes.Equal(body, want) {
|
|
t.Fatalf("op9 kind3 removal body=%x want=%x", body, want)
|
|
}
|
|
}
|
|
|
|
func assertCurrentPartylessTownOp9Body(t *testing.T, body []byte, ownerChannel byte) {
|
|
t.Helper()
|
|
if len(body) != 10 ||
|
|
binary.LittleEndian.Uint16(body[0:2]) != 1 ||
|
|
binary.LittleEndian.Uint16(body[2:4]) != currentSceneOp9StableSceneValue ||
|
|
binary.LittleEndian.Uint16(body[4:6]) == 0 ||
|
|
body[7] != ownerChannel ||
|
|
body[9] != currentSceneOp9ActorRemoveKind {
|
|
t.Fatalf("partyless town op9 body=%x want kind3 owner=%02x", body, ownerChannel)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentSceneOp9ActorPartyDisplayCarriesCurrentSlotTable(t *testing.T) {
|
|
body := buildCurrentSceneOp9ActorPartyDisplayBodyInContext(
|
|
1001,
|
|
dnfrepo.CharacterRecord{Name: "leader", Job: "1", Level: 90},
|
|
true,
|
|
"",
|
|
currentSceneObjectContext,
|
|
38,
|
|
[]currentSceneOp9PartyMemberProjection{
|
|
{State: alignedcmd.PartyMemberState{UserID: 1001, Slot: 0, UserState: 1}, Name: "leader", Job: 1, Level: 90},
|
|
{State: alignedcmd.PartyMemberState{UserID: 1002, Slot: 1, UserState: 2}, Name: "member", Job: 2, Level: 80, Grow: 1},
|
|
},
|
|
1,
|
|
)
|
|
outerName := rosterNameBytes("leader")
|
|
offset := 16 + len(outerName) + 13
|
|
if body[offset] != 2 {
|
|
t.Fatalf("op9 party slot count=%d want=2 body=% X", body[offset], body)
|
|
}
|
|
offset++
|
|
for i, want := range []struct {
|
|
id uint16
|
|
name string
|
|
job byte
|
|
level byte
|
|
grow byte
|
|
state byte
|
|
}{
|
|
{id: 1001, name: "leader", job: 1, level: 90, state: 100},
|
|
{id: 1002, name: "member", job: 2, level: 80, grow: 1, state: 2},
|
|
} {
|
|
if body[offset] != byte(i) || binary.LittleEndian.Uint16(body[offset+1:offset+3]) != want.id {
|
|
t.Fatalf("op9 party member[%d] header=% X want slot=%d id=%d", i, body[offset:offset+3], i, want.id)
|
|
}
|
|
offset += 3
|
|
if !bytes.Equal(body[offset:offset+4], []byte{want.job, want.level, want.grow, 0}) {
|
|
t.Fatalf("op9 party member[%d] identity=% X", i, body[offset:offset+4])
|
|
}
|
|
offset += 4
|
|
nameLen := int(binary.LittleEndian.Uint32(body[offset : offset+4]))
|
|
offset += 4
|
|
name := rosterNameBytes(want.name)
|
|
if nameLen != len(name) || !bytes.Equal(body[offset:offset+nameLen], name) {
|
|
t.Fatalf("op9 party member[%d] name len=%d bytes=% X want=% X", i, nameLen, body[offset:offset+nameLen], name)
|
|
}
|
|
offset += nameLen
|
|
if !bytes.Equal(body[offset:offset+3], []byte{0, want.state, 0}) {
|
|
t.Fatalf("op9 party member[%d] state=% X want=% X", i, body[offset:offset+3], []byte{0, want.state, 0})
|
|
}
|
|
offset += 3
|
|
}
|
|
if !bytes.Equal(body[offset:], []byte{0, 1, 0, 0}) {
|
|
t.Fatalf("op9 party post-slot fields=% X want selected member slot 1", body[offset:])
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentSceneOp9ActorPartyDisplaySelectsSoloMemberSlotZero(t *testing.T) {
|
|
body := buildCurrentSceneOp9ActorPartyDisplayBodyInContext(
|
|
1001,
|
|
dnfrepo.CharacterRecord{Name: "leader", Job: "1", Level: 90},
|
|
true,
|
|
"",
|
|
currentSceneObjectContext,
|
|
38,
|
|
[]currentSceneOp9PartyMemberProjection{
|
|
{State: alignedcmd.PartyMemberState{UserID: 1001, Slot: 0, UserState: 1}, Name: "leader", Job: 1, Level: 90},
|
|
},
|
|
0,
|
|
)
|
|
if !bytes.Equal(body[len(body)-4:], []byte{0, 0, 0, 0}) {
|
|
t.Fatalf("op9 solo party post-slot fields=% X want selected member slot 0", body[len(body)-4:])
|
|
}
|
|
}
|
|
|
|
func TestCurrentSceneOp9SelectedPartySlotRequiresLocalMember(t *testing.T) {
|
|
members := []currentSceneOp9PartyMemberProjection{
|
|
{State: alignedcmd.PartyMemberState{UserID: 1001}},
|
|
}
|
|
if got, ok := currentSceneOp9SelectedPartySlot(members, 1002); ok || got != 0 {
|
|
t.Fatalf("op9 missing selected member slot=%02x ok=%v want 00 false", got, ok)
|
|
}
|
|
}
|
|
|
|
func TestBuildCurrentSceneOp9ActorDisplayKeepsInlineIdentityWithNonzeroOuterOwner(t *testing.T) {
|
|
const owner = byte(0xce)
|
|
body := buildCurrentSceneOp9ActorDisplayBodyInContext(
|
|
0x1234,
|
|
dnfrepo.CharacterRecord{Name: "hero", Job: "11", Level: 90},
|
|
true,
|
|
"",
|
|
owner,
|
|
)
|
|
if len(body) < 18 ||
|
|
body[7] != owner ||
|
|
body[10] != currentSceneObjectRoute ||
|
|
body[11] != currentSceneObjectContext {
|
|
t.Fatalf("op9 owner/identity header=%x want outer=%02x nested=00", body[:minInt(len(body), 12)], owner)
|
|
}
|
|
name := rosterNameBytes("hero")
|
|
nameLength := int(binary.LittleEndian.Uint32(body[12:16]))
|
|
if nameLength != len(name) ||
|
|
len(body) < 16+nameLength+2 ||
|
|
!bytes.Equal(body[16:16+nameLength], name) {
|
|
t.Fatalf("op9 inline name length=%d body=%x want=%x", nameLength, body, name)
|
|
}
|
|
tail := body[16+nameLength:]
|
|
if tail[0] != 11 || tail[1] != 90 {
|
|
t.Fatalf("op9 inline identity tail=%x want job=11 level=90", tail)
|
|
}
|
|
}
|
|
|
|
func TestSendCurrentSceneOp9PreviewActorRemovalOnceClearsPreviewState(t *testing.T) {
|
|
conn := &bufferConn{}
|
|
service := &Service{options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain}}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
connID: "op9-preview-remove",
|
|
selectedCharacterID: 0x1234,
|
|
selectPreviewObjectStateSent: true,
|
|
currentSceneObjectListSent: true,
|
|
}
|
|
if err := service.sendCurrentSceneOp9PreviewActorRemovalOnce(session, "test_before_op27"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
packet, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
|
|
if packet.Header.Classification != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
|
|
!bytes.Equal(packet.Body, buildCurrentSceneOp9ActorRemovalBody(0x1234)) || len(rest) != 0 {
|
|
t.Fatalf("op9 removal packet=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
|
|
}
|
|
if !session.selectPreviewActorRemoved || session.currentSceneObjectListSent {
|
|
t.Fatalf("preview removal flags removed=%v object_list=%v", session.selectPreviewActorRemoved, session.currentSceneObjectListSent)
|
|
}
|
|
writes := conn.write.Len()
|
|
if err := service.sendCurrentSceneOp9PreviewActorRemovalOnce(session, "test_duplicate"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if conn.write.Len() != writes {
|
|
t.Fatalf("duplicate op9 removal wrote %d extra bytes", conn.write.Len()-writes)
|
|
}
|
|
}
|