服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
1470 lines
57 KiB
Go
1470 lines
57 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/binary"
|
|
"testing"
|
|
|
|
"longheng.io/server/internal/modules/dnf/channelcatalog"
|
|
"longheng.io/server/internal/modules/dnf/dnfenum"
|
|
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
|
dnfmonster "longheng.io/server/internal/modules/dnf/monster"
|
|
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
dnfquest "longheng.io/server/internal/modules/dnf/quest"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
)
|
|
|
|
func TestHandleDungeonSetPlayResultDefersCardLayoutAfterClearReward(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
connection.write.Reset()
|
|
|
|
const clientRankPoint = byte(73)
|
|
plan := currentDungeonSettlementPacketPlanForTest(t, runtime, clientRankPoint)
|
|
runtime.settlementResultPlan = &plan
|
|
request := make([]byte, currentDungeonPlayResultBaseSize)
|
|
request[currentDungeonPlayResultClientRankPointOffset] = clientRankPoint
|
|
if err := service.handleDungeonSetPlayResult(session, request); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op34, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
op37, rest := splitGameServerUpperPacket(t, rest)
|
|
op35, rest := splitGameServerUpperPacket(t, rest)
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected immediate card stream=%x", rest)
|
|
}
|
|
if op34.Header.MsgID != currentDungeonPlayResultNoticeMsgID || op37.Header.MsgID != currentDungeonCharacterStateMsgID ||
|
|
op35.Header.MsgID != currentDungeonClearRewardMsgID {
|
|
t.Fatalf("settlement order op34=%d op37=%d op35=%d", op34.Header.MsgID, op37.Header.MsgID, op35.Header.MsgID)
|
|
}
|
|
if runtime.settlementCardScrollStateSent || runtime.settlementCardLayoutSent ||
|
|
runtime.settlementCardSelectionSent {
|
|
t.Fatalf("op46 must stop at rating result until client op69: %+v", runtime)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardRequestsAreDispatchedAfterSettlement(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
completeCurrentDungeonRatingForCardPhaseTest(t, service, runtime, session, connection)
|
|
|
|
for _, step := range []struct {
|
|
msgID uint16
|
|
body []byte
|
|
}{
|
|
{msgID: uint16(dnfenum.CmdPacketScoreScrollState)},
|
|
{msgID: uint16(dnfenum.CmdPacketCardSelectRightState)},
|
|
} {
|
|
packet, err := dnfproto.BuildChannelPacket(step.msgID, step.body, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.handleGameUpper(session, packet); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
response, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if response.Header.MsgID != step.msgID || len(rest) != 0 {
|
|
t.Fatalf("card step op%d response=%d rest=%x", step.msgID, response.Header.MsgID, rest)
|
|
}
|
|
connection.write.Reset()
|
|
}
|
|
|
|
packet, err := dnfproto.BuildChannelPacket(uint16(dnfenum.CmdPacketSelectCard), []byte{0}, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.handleGameUpper(session, packet); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
op71, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected select stream=%x", rest)
|
|
}
|
|
if op71.Header.MsgID != uint16(dnfenum.CmdPacketSelectCard) {
|
|
t.Fatalf("card select op=%d", op71.Header.MsgID)
|
|
}
|
|
if op71.Header.Classification != currentDungeonCardResponseClass || len(op71.Body) < 33 || op71.Body[0] != 1 {
|
|
t.Fatalf("op71 packet = %+v body=%x", op71.Header, op71.Body)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardSelectDeliversFreeGoldOnceAndRefreshesCurrentAssets(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
runtime.settlementClearRewardSent = true
|
|
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseResultShown)
|
|
plan, err := newDungeonCardRewardPlan(
|
|
dungeonCardPlanIdentity{CharacterID: "99", DungeonID: runtime.Dungeon.ID, MazeIndex: runtime.MazeIndex, RunSeed: runtime.Seed},
|
|
"test_current_runtime_free_gold",
|
|
dungeonCardRewardBundle{Gold: 33},
|
|
dungeonCardRewardBundle{},
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state, err := newDungeonCardState(plan)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runtime.settlementCardRewardState = state
|
|
|
|
if err := service.sendCurrentDungeonCardScrollStateLocked(session, runtime, "test_prepared_scroll"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.sendCurrentDungeonCardLayoutLocked(session, runtime, "test_prepared_layout"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection.write.Reset()
|
|
|
|
packet, err := dnfproto.BuildChannelPacket(uint16(dnfenum.CmdPacketSelectCard), []byte{0}, 0, dnfproto.DefaultChannelClassification)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.handleGameUpper(session, packet); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op71, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
op14, rest := splitGameServerUpperPacket(t, rest)
|
|
if len(rest) != 0 {
|
|
t.Fatalf("unexpected card reward stream=%x", rest)
|
|
}
|
|
if op71.Header.MsgID != uint16(dnfenum.CmdPacketSelectCard) ||
|
|
op71.Header.Classification != currentDungeonCardResponseClass ||
|
|
len(op71.Body) != 33 || op71.Body[0] != 1 {
|
|
t.Fatalf("op71 packet = %+v body=%x", op71.Header, op71.Body)
|
|
}
|
|
slots := decodeCurrentDungeonOp71SlotsForTest(t, op71.Body)
|
|
if slots[0].StateA != 0 || slots[0].StateB != 0xff ||
|
|
len(slots[0].Rewards) != 0 || slots[0].TerminalFlag != 0 {
|
|
t.Fatalf("free-row op71 snapshot=%+v", slots)
|
|
}
|
|
if op14.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
|
|
op14.Header.Classification != 0 ||
|
|
!bytes.Equal(op14.Body, buildCurrentGoldStateBody(33)) {
|
|
t.Fatalf("gold refresh packet = %+v body=%x", op14.Header, op14.Body)
|
|
}
|
|
|
|
repositories, ok := service.repositoryGroup()
|
|
if !ok {
|
|
t.Fatal("repository group unavailable")
|
|
}
|
|
character, _, err := repositories.Character.Load(context.Background(), "99")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if character.Stats["gold"] != 33 {
|
|
t.Fatalf("persisted gold=%d want=33", character.Stats["gold"])
|
|
}
|
|
|
|
connection.write.Reset()
|
|
if err := service.handleGameUpper(session, packet); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
replay71, replayRest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if replay71.Header.MsgID != uint16(dnfenum.CmdPacketSelectCard) ||
|
|
replay71.Header.Classification != currentDungeonCardResponseClass ||
|
|
len(replayRest) != 0 {
|
|
t.Fatalf("card replay response=%+v rest=%x", replay71.Header, replayRest)
|
|
}
|
|
character, _, err = repositories.Character.Load(context.Background(), "99")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if character.Stats["gold"] != 33 {
|
|
t.Fatalf("replay mutated gold=%d want=33", character.Stats["gold"])
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardSelectCommitsPVFPageItemAndPushesIncrementalRow(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
runtime.settlementClearRewardSent = true
|
|
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseResultShown)
|
|
plan, err := newDungeonCardRewardPlan(
|
|
dungeonCardPlanIdentity{
|
|
CharacterID: "99",
|
|
DungeonID: runtime.Dungeon.ID,
|
|
MazeIndex: runtime.MazeIndex,
|
|
RunSeed: runtime.Seed,
|
|
},
|
|
"test_current_runtime_free_item",
|
|
dungeonCardRewardBundle{
|
|
Gold: 19,
|
|
Items: []dungeonCardItemReward{{
|
|
ItemID: 5001, Count: 1, SlotStart: 9, SlotEnd: 64,
|
|
Extra: map[string]string{"item_kind": "equipment"},
|
|
}},
|
|
},
|
|
dungeonCardRewardBundle{},
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state, err := newDungeonCardState(plan)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runtime.settlementCardRewardState = state
|
|
if err := service.sendCurrentDungeonCardScrollStateLocked(session, runtime, "test_item_scroll"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.sendCurrentDungeonCardLayoutLocked(session, runtime, "test_item_layout"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection.write.Reset()
|
|
|
|
packet, err := dnfproto.BuildChannelPacket(
|
|
uint16(dnfenum.CmdPacketSelectCard),
|
|
[]byte{0, 0},
|
|
0,
|
|
dnfproto.DefaultChannelClassification,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.handleGameUpper(session, packet); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op71, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
goldUpdate, rest := splitGameServerUpperPacket(t, rest)
|
|
itemUpdate, rest := splitGameServerUpperPacket(t, rest)
|
|
if len(rest) != 0 ||
|
|
op71.Header.MsgID != uint16(dnfenum.CmdPacketSelectCard) ||
|
|
goldUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
|
|
itemUpdate.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) {
|
|
t.Fatalf(
|
|
"card item stream op71=%d gold=%d item=%d rest=%x",
|
|
op71.Header.MsgID,
|
|
goldUpdate.Header.MsgID,
|
|
itemUpdate.Header.MsgID,
|
|
rest,
|
|
)
|
|
}
|
|
if len(itemUpdate.Body) < 3+currentItemListEntryWireSize ||
|
|
itemUpdate.Body[0] != dnfrepo.MainInventoryListType ||
|
|
binary.LittleEndian.Uint16(itemUpdate.Body[1:3]) != 1 ||
|
|
binary.LittleEndian.Uint16(itemUpdate.Body[3:5]) != 9 ||
|
|
binary.LittleEndian.Uint32(itemUpdate.Body[5:9]) != 5001 {
|
|
t.Fatalf("incremental card item update=%x", itemUpdate.Body)
|
|
}
|
|
|
|
repositories, ok := service.repositoryGroup()
|
|
if !ok {
|
|
t.Fatal("repository group unavailable")
|
|
}
|
|
inventory, found, err := repositories.Inventory.Load(context.Background(), "99")
|
|
if err != nil || !found || inventory.Slots["0:9"].ItemID != 5001 {
|
|
t.Fatalf("persisted card item found=%t err=%v inventory=%+v", found, err, inventory.Slots)
|
|
}
|
|
if !runtime.settlementCardRewardCommitted ||
|
|
runtime.settlementPhase < currentDungeonSettlementPhaseRewardCommitted {
|
|
t.Fatalf("card reward state not committed: %+v", runtime)
|
|
}
|
|
|
|
connection.write.Reset()
|
|
if err := service.handleDungeonEplpCommand(session, []byte{1, 2}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
op72.Header.Classification != currentDungeonCardResponseClass ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(1, 2)) ||
|
|
len(rest) == 0 {
|
|
t.Fatalf("item reward return route op72=%+v body=%x rest=%x", op72.Header, op72.Body, rest)
|
|
}
|
|
if session.dungeon.runtime != nil || !runtime.townReturnOp24Sent {
|
|
t.Fatalf("item reward return route owner=%p runtime=%+v", session.dungeon.runtime, runtime)
|
|
}
|
|
}
|
|
|
|
func assertCurrentDungeonOp71SelectedReward(
|
|
t *testing.T,
|
|
body []byte,
|
|
selected int,
|
|
want currentDungeonOp71RewardTuple,
|
|
) {
|
|
t.Helper()
|
|
if selected < 0 || selected >= dungeonCardWireSlotCount || len(body) < 1 || body[0] != 1 {
|
|
t.Fatalf("op71 selected reward precondition selected=%d body=%x", selected, body)
|
|
}
|
|
offset := 1
|
|
for slot := 0; slot < dungeonCardWireSlotCount; slot++ {
|
|
if offset+4 > len(body) {
|
|
t.Fatalf("op71 slot %d truncated offset=%d body=%x", slot, offset, body)
|
|
}
|
|
stateA := body[offset]
|
|
stateB := body[offset+1]
|
|
rewardCount := int(body[offset+2])
|
|
offset += 3
|
|
rewards := make([]currentDungeonOp71RewardTuple, 0, rewardCount)
|
|
for rewardIndex := 0; rewardIndex < rewardCount; rewardIndex++ {
|
|
if offset+8 > len(body) {
|
|
t.Fatalf("op71 reward slot=%d index=%d truncated body=%x", slot, rewardIndex, body)
|
|
}
|
|
rewards = append(rewards, currentDungeonOp71RewardTuple{
|
|
ValueA: binary.LittleEndian.Uint32(body[offset : offset+4]),
|
|
ValueB: binary.LittleEndian.Uint32(body[offset+4 : offset+8]),
|
|
})
|
|
offset += 8
|
|
}
|
|
terminal := body[offset]
|
|
offset++
|
|
if slot != selected {
|
|
if stateA != 0xff || stateB != 0xff || len(rewards) != 0 || terminal != 0 {
|
|
t.Fatalf("op71 non-selected slot %d state=%d/%d rewards=%+v terminal=%d", slot, stateA, stateB, rewards, terminal)
|
|
}
|
|
continue
|
|
}
|
|
wantStateA, wantStateB := byte(0), byte(0xff)
|
|
if selected >= dungeonCardSlotsPerSide {
|
|
wantStateA, wantStateB = 0xff, 0
|
|
}
|
|
if stateA != wantStateA || stateB != wantStateB || terminal != 0 || len(rewards) != 1 || rewards[0] != want {
|
|
t.Fatalf("op71 selected slot %d state=%d/%d rewards=%+v terminal=%d want=%+v", slot, stateA, stateB, rewards, terminal, want)
|
|
}
|
|
}
|
|
if offset != len(body) {
|
|
t.Fatalf("op71 trailing bytes offset=%d len=%d body=%x", offset, len(body), body)
|
|
}
|
|
}
|
|
|
|
func decodeCurrentDungeonOp71SlotsForTest(
|
|
t *testing.T,
|
|
body []byte,
|
|
) [dungeonCardWireSlotCount]currentDungeonOp71Slot {
|
|
t.Helper()
|
|
var slots [dungeonCardWireSlotCount]currentDungeonOp71Slot
|
|
if len(body) < 1 || body[0] != 1 {
|
|
t.Fatalf("op71 success body invalid: %x", body)
|
|
}
|
|
offset := 1
|
|
for index := range slots {
|
|
if offset+4 > len(body) {
|
|
t.Fatalf("op71 slot %d truncated: %x", index, body)
|
|
}
|
|
slot := currentDungeonOp71Slot{
|
|
StateA: body[offset],
|
|
StateB: body[offset+1],
|
|
}
|
|
rewardCount := int(body[offset+2])
|
|
offset += 3
|
|
for range rewardCount {
|
|
if offset+8 > len(body) {
|
|
t.Fatalf("op71 slot %d reward truncated: %x", index, body)
|
|
}
|
|
slot.Rewards = append(slot.Rewards, currentDungeonOp71RewardTuple{
|
|
ValueA: binary.LittleEndian.Uint32(body[offset : offset+4]),
|
|
ValueB: binary.LittleEndian.Uint32(body[offset+4 : offset+8]),
|
|
})
|
|
offset += 8
|
|
}
|
|
slot.TerminalFlag = body[offset]
|
|
offset++
|
|
slots[index] = slot
|
|
}
|
|
if offset != len(body) {
|
|
t.Fatalf("op71 trailing bytes offset=%d len=%d body=%x", offset, len(body), body)
|
|
}
|
|
return slots
|
|
}
|
|
|
|
func TestDungeonCardPaidAndFreeRowsSelectAndCommitIndependently(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
queue := newFakeCurrentDungeonDeathTimerQueue()
|
|
service.gameplayTimers = queue
|
|
runtime.settlementClearRewardSent = true
|
|
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseResultShown)
|
|
plan, err := newDungeonCardRewardPlan(
|
|
dungeonCardPlanIdentity{
|
|
CharacterID: "99",
|
|
DungeonID: runtime.Dungeon.ID,
|
|
MazeIndex: runtime.MazeIndex,
|
|
RunSeed: runtime.Seed,
|
|
},
|
|
"test_current_runtime_two_rows",
|
|
dungeonCardRewardBundle{Gold: 11},
|
|
dungeonCardRewardBundle{Gold: 22},
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state, err := newDungeonCardState(plan)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runtime.settlementCardRewardState = state
|
|
if err := service.sendCurrentDungeonCardScrollStateLocked(session, runtime, "test_two_rows_scroll"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.sendCurrentDungeonCardLayoutLocked(session, runtime, "test_two_rows_layout"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
connection.write.Reset()
|
|
|
|
for _, request := range [][]byte{{1, 0}, {0, 0}} {
|
|
packet, err := dnfproto.BuildChannelPacket(
|
|
uint16(dnfenum.CmdPacketSelectCard),
|
|
request,
|
|
0,
|
|
dnfproto.DefaultChannelClassification,
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.handleGameUpper(session, packet); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
op71, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
op14, rest := splitGameServerUpperPacket(t, rest)
|
|
if op71.Header.MsgID != uint16(dnfenum.CmdPacketSelectCard) ||
|
|
op14.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
|
|
len(rest) != 0 {
|
|
t.Fatalf("row request=%x op71=%d op14=%d rest=%x", request, op71.Header.MsgID, op14.Header.MsgID, rest)
|
|
}
|
|
slots := decodeCurrentDungeonOp71SlotsForTest(t, op71.Body)
|
|
if request[0] == 1 {
|
|
if slots[0].StateA != 0xff || slots[0].StateB != 0 ||
|
|
len(slots[0].Rewards) != 1 || slots[0].Rewards[0].ValueB != 22 {
|
|
t.Fatalf("paid row snapshot=%+v", slots)
|
|
}
|
|
} else {
|
|
if slots[0].StateA != 0 || slots[0].StateB != 0 ||
|
|
len(slots[0].Rewards) != 1 || slots[0].Rewards[0].ValueB != 22 {
|
|
t.Fatalf("combined row snapshot=%+v", slots)
|
|
}
|
|
}
|
|
connection.write.Reset()
|
|
}
|
|
if !runtime.settlementCardSideRewardCommitted[dungeonCardSideFree] ||
|
|
!runtime.settlementCardSideRewardCommitted[dungeonCardSidePaid] ||
|
|
!runtime.settlementCardRewardCommitted {
|
|
t.Fatalf("row commits=%+v aggregate=%t", runtime.settlementCardSideRewardCommitted, runtime.settlementCardRewardCommitted)
|
|
}
|
|
if scheduled, cancelled, active := queue.counts(); scheduled != 1 || cancelled != 1 || active != 0 {
|
|
t.Fatalf("manual free selection timer scheduled=%d cancelled=%d active=%d", scheduled, cancelled, active)
|
|
}
|
|
repositories, ok := service.repositoryGroup()
|
|
if !ok {
|
|
t.Fatal("repository group unavailable")
|
|
}
|
|
character, found, err := repositories.Character.Load(context.Background(), "99")
|
|
if err != nil || !found || character.Stats["gold"] != 33 {
|
|
t.Fatalf("two-row durable gold=%d found=%t err=%v", character.Stats["gold"], found, err)
|
|
}
|
|
}
|
|
|
|
func TestCurrentDungeonCardRequestMapsBothPaidModesToPaidRow(t *testing.T) {
|
|
for value, want := range map[byte]dungeonCardSide{
|
|
0: dungeonCardSideFree,
|
|
1: dungeonCardSidePaid,
|
|
2: dungeonCardSidePaid,
|
|
} {
|
|
got, ok := currentDungeonCardRequestSide(value)
|
|
if !ok || got != want {
|
|
t.Fatalf("request value=%d side=%d ok=%t want=%d", value, got, ok, want)
|
|
}
|
|
}
|
|
if _, ok := currentDungeonCardRequestSide(3); ok {
|
|
t.Fatal("unknown card request mode was accepted")
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardLayoutSchedulesFreeRowAutoFlipOnGameplayQueue(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
queue := newFakeCurrentDungeonDeathTimerQueue()
|
|
service.gameplayTimers = queue
|
|
runtime.settlementClearRewardSent = true
|
|
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseResultShown)
|
|
plan, err := newDungeonCardRewardPlan(
|
|
dungeonCardPlanIdentity{
|
|
CharacterID: "99",
|
|
DungeonID: runtime.Dungeon.ID,
|
|
MazeIndex: runtime.MazeIndex,
|
|
RunSeed: runtime.Seed,
|
|
},
|
|
"test_current_runtime_auto_flip",
|
|
dungeonCardRewardBundle{Gold: 17},
|
|
dungeonCardRewardBundle{Gold: 19},
|
|
)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
state, err := newDungeonCardState(plan)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
runtime.settlementCardRewardState = state
|
|
if err := service.sendCurrentDungeonCardScrollStateLocked(session, runtime, "test_auto_flip_scroll"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := service.sendCurrentDungeonCardLayoutLocked(session, runtime, "test_auto_flip_layout"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
task := queue.task(t, 0)
|
|
if task.delay != currentDungeonCardAutoFlipDelay {
|
|
t.Fatalf("auto-flip delay=%s want=%s", task.delay, currentDungeonCardAutoFlipDelay)
|
|
}
|
|
connection.write.Reset()
|
|
queue.fire(task, false)
|
|
op71, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
op14, rest := splitGameServerUpperPacket(t, rest)
|
|
if op71.Header.MsgID != uint16(dnfenum.CmdPacketSelectCard) ||
|
|
op14.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
|
|
len(rest) != 0 ||
|
|
!runtime.settlementCardSideRewardCommitted[dungeonCardSideFree] {
|
|
t.Fatalf("auto-flip op71=%d op14=%d rest=%x runtime=%+v", op71.Header.MsgID, op14.Header.MsgID, rest, runtime)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardExitStateOneReturnsToTownAfterAck(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
markCurrentDungeonCardRewardCommittedForTest(runtime)
|
|
connection.write.Reset()
|
|
|
|
// Live plaintext evidence: the return-to-town button is {state=1, option=2}.
|
|
if err := service.handleDungeonEplpCommand(session, []byte{1, 2}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
op72.Header.Classification != currentDungeonCardResponseClass ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(1, 2)) {
|
|
t.Fatalf("op72 ack = %+v body=%x", op72.Header, op72.Body)
|
|
}
|
|
if len(rest) == 0 {
|
|
t.Fatal("op72 {1,2} emitted no completed town route")
|
|
}
|
|
foundTransition := false
|
|
for len(rest) > 0 {
|
|
packet, tail := splitGameServerUpperPacket(t, rest)
|
|
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSceneTransitionMsgID &&
|
|
bytes.Equal(packet.Body, wantTutorialCompletionTownTransitionBody()) {
|
|
foundTransition = true
|
|
}
|
|
rest = tail
|
|
}
|
|
if !foundTransition {
|
|
t.Fatalf("op72 {1,2} route did not include expected town transition stream=%x", connection.write.Bytes())
|
|
}
|
|
if session.dungeon.runtime != nil || !runtime.townReturnOp24Sent {
|
|
t.Fatalf("op72 {1,2} did not commit completed return owner=%p runtime=%+v", session.dungeon.runtime, runtime)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardExitStateOneBeforeOp71DoesNotAdvanceOrRetire(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
runtime.settlementClearRewardSent = true
|
|
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseResultShown)
|
|
connection.write.Reset()
|
|
|
|
if err := service.handleDungeonEplpCommand(session, []byte{1, 0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if connection.write.Len() != 0 {
|
|
t.Fatalf("pre-op71 op72 must not synthesize card or exit packets=%x", connection.write.Bytes())
|
|
}
|
|
if runtime.settlementCardLayoutSent || runtime.settlementCardSelectionSent ||
|
|
runtime.settlementCardExitAckSent || session.dungeon.runtime != runtime || runtime.townReturnOp24Sent {
|
|
t.Fatalf("pre-op71 op72 state layout=%t select=%t exit=%t runtime=%p return=%t",
|
|
runtime.settlementCardLayoutSent,
|
|
runtime.settlementCardSelectionSent,
|
|
runtime.settlementCardExitAckSent,
|
|
session.dungeon.runtime,
|
|
runtime.townReturnOp24Sent)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardExitLegacyRouteHonorsPreOp71Gate(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
runtime.settlementClearRewardSent = true
|
|
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseResultShown)
|
|
connection.write.Reset()
|
|
|
|
// The current client delivers op72 only through the legacy game decoder
|
|
// (live log: game-legacy type=72), so the exit owner must fire from
|
|
// handleGameCommand, not only from the upper dispatcher.
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), []byte{1, 0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if connection.write.Len() != 0 {
|
|
t.Fatalf("legacy pre-op71 op72 synthesized packets=%x", connection.write.Bytes())
|
|
}
|
|
if runtime.settlementCardLayoutSent || runtime.settlementCardSelectionSent ||
|
|
runtime.settlementCardExitAckSent || session.dungeon.runtime != runtime {
|
|
t.Fatalf("legacy pre-op71 op72 advanced state=%+v owner=%p", runtime, session.dungeon.runtime)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardExitOwnerTreatsSixteenByteBodyAsOpaqueProtected(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
markCurrentDungeonCardRewardCommittedForTest(runtime)
|
|
connection.write.Reset()
|
|
|
|
// Live unpatched clients deliver an opaque protected 16-byte body whose
|
|
// first bytes are ciphertext (live prefix 67 f6 -> request_value 103/246),
|
|
// never the semantic state/option. The owner must echo them without ever
|
|
// treating ciphertext as a return-to-town command.
|
|
body := append([]byte{0x67, 0xf6}, make([]byte, 14)...)
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(0x67, 0xf6)) {
|
|
t.Fatalf("opaque 16-byte op72 ack = %+v body=%x", op72.Header, op72.Body)
|
|
}
|
|
if len(rest) != 0 {
|
|
t.Fatalf("opaque op72 ciphertext triggered trailing packets rest=%x", rest)
|
|
}
|
|
if session.dungeon.runtime != runtime || runtime.townReturnOp24Sent {
|
|
t.Fatalf("opaque op72 ciphertext mutated runtime=%p return=%t", session.dungeon.runtime, runtime.townReturnOp24Sent)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardExitPlaintextCloneBodyReturnsToTown(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
markCurrentDungeonCardRewardCommittedForTest(runtime)
|
|
connection.write.Reset()
|
|
|
|
// The selective plaintext clone delivers {state, option} plus the private
|
|
// four-byte trailer; the legacy boundary strips the trailer before decode.
|
|
// Live evidence pins return-to-town at option 2.
|
|
cloneBody := append([]byte{1, 2}, 0xde, 0xad, 0xbe, 0xef)
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), cloneBody); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(1, 2)) {
|
|
t.Fatalf("plaintext-clone op72 ack = %+v body=%x", op72.Header, op72.Body)
|
|
}
|
|
foundTransition := false
|
|
for len(rest) > 0 {
|
|
packet, tail := splitGameServerUpperPacket(t, rest)
|
|
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSceneTransitionMsgID &&
|
|
bytes.Equal(packet.Body, wantTutorialCompletionTownTransitionBody()) {
|
|
foundTransition = true
|
|
}
|
|
rest = tail
|
|
}
|
|
if !foundTransition {
|
|
t.Fatalf("plaintext-clone {1,2} op72 emitted no town transition stream=%x", connection.write.Bytes())
|
|
}
|
|
if session.dungeon.runtime != nil || !runtime.townReturnOp24Sent {
|
|
t.Fatalf("plaintext-clone {1,2} op72 did not commit completed return owner=%p runtime=%+v", session.dungeon.runtime, runtime)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardExitUnknownOptionIsAckOnly(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
markCurrentDungeonCardRewardCommittedForTest(runtime)
|
|
connection.write.Reset()
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), []byte{1, 3}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
op72.Header.Classification != currentDungeonCardResponseClass ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(1, 3)) {
|
|
t.Fatalf("op72 ack = %+v body=%x want echo {1,3}", op72.Header, op72.Body)
|
|
}
|
|
if len(rest) != 0 {
|
|
t.Fatalf("op72 {1,3} must not emit a town route or any trailing packet rest=%x", rest)
|
|
}
|
|
if !runtime.settlementCardExitAckSent || session.dungeon.runtime != runtime || runtime.townReturnOp24Sent {
|
|
t.Fatalf("op72 {1,3} mutated exit=%t runtime=%p return=%t",
|
|
runtime.settlementCardExitAckSent, session.dungeon.runtime, runtime.townReturnOp24Sent)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardExitRetryReEntersSameDungeonAfterAck(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
runtime.settlementPlayResultReceived = true
|
|
runtime.settlementStatisticReceived = true
|
|
markCurrentDungeonCardRewardCommittedForTest(runtime)
|
|
// A previous explicit practice selection is one-shot. The result dialog's
|
|
// server-driven retry must not silently carry it into the next run.
|
|
runtime.Request.RuntimeState = byte(currentDungeonProtocolModePractice)
|
|
runtime.Request.RuntimeToken = 0
|
|
wantDungeon := runtime.Dungeon.ID
|
|
wantDifficulty := runtime.Request.Difficulty
|
|
connection.write.Reset()
|
|
|
|
// Live evidence: 再次挑战 is {state=1, option=0}; the client waits for a
|
|
// server-driven re-entry instead of sending op16.
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), []byte{1, 0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
op72.Header.Classification != currentDungeonCardResponseClass ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(1, 0)) {
|
|
t.Fatalf("op72 ack = %+v body=%x want echo {1,0}", op72.Header, op72.Body)
|
|
}
|
|
foundEntryAck := false
|
|
foundTownTransition := false
|
|
for len(rest) > 0 {
|
|
packet, tail := splitGameServerUpperPacket(t, rest)
|
|
if packet.Header.Classification == 1 && packet.Header.MsgID == uint16(dnfenum.CmdPacketSelectDungeon) {
|
|
foundEntryAck = true
|
|
}
|
|
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSceneTransitionMsgID {
|
|
foundTownTransition = true
|
|
}
|
|
rest = tail
|
|
}
|
|
if !foundEntryAck {
|
|
t.Fatalf("retry {1,0} emitted no op16 entry ack stream=%x", connection.write.Bytes())
|
|
}
|
|
if foundTownTransition {
|
|
t.Fatalf("retry {1,0} unexpectedly emitted a town transition stream=%x", connection.write.Bytes())
|
|
}
|
|
newRuntime := session.dungeon.runtime
|
|
if newRuntime == nil || newRuntime == runtime {
|
|
t.Fatalf("retry {1,0} did not attach a fresh runtime old=%p new=%p", runtime, newRuntime)
|
|
}
|
|
if newRuntime.Dungeon.ID != wantDungeon || newRuntime.Request.Difficulty != wantDifficulty {
|
|
t.Fatalf("retry re-entered wrong dungeon got=%d/%d want=%d/%d",
|
|
newRuntime.Dungeon.ID, newRuntime.Request.Difficulty, wantDungeon, wantDifficulty)
|
|
}
|
|
if newRuntime.ProtocolMode != currentDungeonProtocolModeNormal ||
|
|
newRuntime.Request.RuntimeState != 0 || newRuntime.Request.RuntimeToken != 0xffff ||
|
|
newRuntime.Request.SelectionMode != 0 || newRuntime.Request.SpecialMode != 0 {
|
|
t.Fatalf("retry leaked one-shot selector mode: protocol=%s request=%+v", newRuntime.ProtocolMode, newRuntime.Request)
|
|
}
|
|
if newRuntime.Session.Snapshot().Run.Status != worldmap.DungeonRunActive {
|
|
t.Fatalf("retry runtime status=%v want active", newRuntime.Session.Snapshot().Run.Status)
|
|
}
|
|
}
|
|
|
|
func TestNormalCurrentDungeonRetryRequestClearsPracticeAndHellChoices(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
request dungeoncmd.SelectDungeonRequest
|
|
}{
|
|
{
|
|
name: "practice",
|
|
request: dungeoncmd.SelectDungeonRequest{
|
|
DungeonID: 1000, Difficulty: 2, RuntimeState: 1, RuntimeToken: 0,
|
|
},
|
|
},
|
|
{
|
|
name: "dedicated hell",
|
|
request: dungeoncmd.SelectDungeonRequest{
|
|
DungeonID: 1000, Difficulty: 2, SelectionMode: 1, RuntimeToken: 0xffff,
|
|
},
|
|
},
|
|
{
|
|
name: "manual hell",
|
|
request: dungeoncmd.SelectDungeonRequest{
|
|
DungeonID: 1000, Difficulty: 2, RuntimeToken: 0xffff, SpecialMode: 1,
|
|
},
|
|
},
|
|
}
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
test.request.EntryOption = 9
|
|
test.request.PartyState = 2
|
|
test.request.LeaderObjectKey = 3145
|
|
got := normalCurrentDungeonRetryRequest(test.request)
|
|
if got.SelectionMode != 0 || got.RuntimeState != 0 || got.RuntimeToken != 0xffff || got.SpecialMode != 0 {
|
|
t.Fatalf("normalized mode fields=%+v", got)
|
|
}
|
|
if got.DungeonID != test.request.DungeonID || got.Difficulty != test.request.Difficulty ||
|
|
got.EntryOption != test.request.EntryOption || got.PartyState != test.request.PartyState ||
|
|
got.LeaderObjectKey != test.request.LeaderObjectKey {
|
|
t.Fatalf("normalization changed non-mode fields got=%+v want=%+v", got, test.request)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardExitSelectOtherOpensSelectorAfterAck(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
repositories, ok := service.repositoryGroup()
|
|
if !ok {
|
|
t.Fatal("completed settlement skill repository unavailable")
|
|
}
|
|
enableTownMoveSkillProjection(t, service, repositories, "99")
|
|
runtime.settlementPlayResultReceived = true
|
|
runtime.settlementStatisticReceived = true
|
|
markCurrentDungeonCardRewardCommittedForTest(runtime)
|
|
connection.write.Reset()
|
|
|
|
// Live evidence: 选择其它地下城 is {state=1, option=1}; the client waits
|
|
// for the selector context instead of sending op15.
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), []byte{1, 1}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
op72.Header.Classification != currentDungeonCardResponseClass ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(1, 1)) {
|
|
t.Fatalf("op72 ack = %+v body=%x want echo {1,1}", op72.Header, op72.Body)
|
|
}
|
|
foundSelectAck := false
|
|
foundFatigue := false
|
|
foundContext := false
|
|
foundTownTransition := false
|
|
for len(rest) > 0 {
|
|
packet, tail := splitGameServerUpperPacket(t, rest)
|
|
switch {
|
|
case packet.Header.MsgID == uint16(dnfenum.CmdPacketEnterSelectDungeon):
|
|
foundSelectAck = true
|
|
case packet.Header.MsgID == currentFatigueMsgID:
|
|
foundFatigue = true
|
|
case packet.Header.MsgID == currentDungeonContextMsgID:
|
|
foundContext = true
|
|
case packet.Header.Classification == 0 && packet.Header.MsgID == currentSceneTransitionMsgID:
|
|
foundTownTransition = true
|
|
}
|
|
rest = tail
|
|
}
|
|
if !foundSelectAck || !foundFatigue || !foundContext {
|
|
t.Fatalf("select-other {1,1} selector push incomplete select=%t fatigue=%t context=%t stream=%x",
|
|
foundSelectAck, foundFatigue, foundContext, connection.write.Bytes())
|
|
}
|
|
if foundTownTransition {
|
|
t.Fatalf("select-other {1,1} unexpectedly emitted a town transition stream=%x", connection.write.Bytes())
|
|
}
|
|
if session.dungeon.runtime != nil {
|
|
t.Fatalf("select-other {1,1} did not retire the completed runtime=%+v", session.dungeon.runtime)
|
|
}
|
|
}
|
|
|
|
func TestCompletedRaidDungeonSelectOtherSkipsOrdinaryCardBarrier(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
repositories, ok := service.repositoryGroup()
|
|
if !ok {
|
|
t.Fatal("completed raid skill repository unavailable")
|
|
}
|
|
enableTownMoveSkillProjection(t, service, repositories, "99")
|
|
session.channel.Type = 23
|
|
session.residentChannel.Type = 23
|
|
runtime.settlementPlayResultReceived = true
|
|
runtime.settlementStatisticReceived = true
|
|
runtime.settlementClearRewardSent = true
|
|
runtime.advanceSettlementPhase(currentDungeonSettlementPhaseResultShown)
|
|
runtime.raidDungeon = runtimeRaidDungeonBinding{
|
|
RaidKey: 0x10063,
|
|
Phase: runtimeRaidDungeonPhaseOne,
|
|
MapID: 5314,
|
|
LifecycleToken: runtime.lifecycleToken,
|
|
selectedNotified: true,
|
|
loadedNotified: true,
|
|
completedNotified: true,
|
|
}
|
|
service.raids = map[uint32]*runtimeRaidState{
|
|
runtime.raidDungeon.RaidKey: {
|
|
RaidKey: runtime.raidDungeon.RaidKey,
|
|
LeaderID: session.selectedCharacterID,
|
|
Started: true,
|
|
EntryCostPaid: true,
|
|
Members: []runtimeRaidMemberState{{
|
|
CharID: session.selectedCharacterID,
|
|
}},
|
|
FrozenMembers: []runtimeRaidMemberState{{
|
|
CharID: session.selectedCharacterID,
|
|
}},
|
|
Lifecycle: runtimeRaidLifecycleState{
|
|
Status: runtimeRaidLifecyclePhaseActive,
|
|
Phase: runtimeRaidDungeonPhaseOne,
|
|
},
|
|
},
|
|
}
|
|
service.onlinePlayers = newOnlinePlayerManager()
|
|
connection.write.Reset()
|
|
|
|
if err := service.handleGameCommand(
|
|
session,
|
|
byte(dnfenum.GameCmdCommand),
|
|
uint16(dnfenum.CmdPacketEplpCommand),
|
|
[]byte{1, 1},
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
op72.Header.Classification != currentDungeonCardResponseClass ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(1, 1)) {
|
|
t.Fatalf("raid op72 ack = %+v body=%x", op72.Header, op72.Body)
|
|
}
|
|
foundSelectAck := false
|
|
for len(rest) > 0 {
|
|
packet, tail := splitGameServerUpperPacket(t, rest)
|
|
if packet.Header.MsgID == uint16(dnfenum.CmdPacketEnterSelectDungeon) {
|
|
foundSelectAck = true
|
|
}
|
|
rest = tail
|
|
}
|
|
if !foundSelectAck {
|
|
t.Fatalf("raid select-other emitted no selector ACK stream=%x", connection.write.Bytes())
|
|
}
|
|
if runtime.settlementCardLayoutSent || runtime.settlementCardSelectionSent ||
|
|
runtime.settlementCardRewardCommitted {
|
|
t.Fatalf("raid exit synthesized ordinary cards: %+v", runtime)
|
|
}
|
|
if session.dungeon.runtime != nil {
|
|
t.Fatalf("raid completed runtime not retired: %+v", session.dungeon.runtime)
|
|
}
|
|
}
|
|
|
|
func TestCompletedNativeRaidDungeonReturnToWaitingTownSkipsOrdinarySettlementBarrier(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
session.channel.Type = dnfenum.RaidChannelType
|
|
session.residentChannel.Type = dnfenum.RaidChannelType
|
|
service.bindGameSessionCharacter(session, session.selectedCharacterID)
|
|
service.onlinePlayers = newOnlinePlayerManager()
|
|
// The final single-Raid movie/two-card owner suppresses ordinary op31/op35.
|
|
// Preserve only the authoritative completed dungeon run and state=6 marker.
|
|
runtime.settlementEntrySent = false
|
|
runtime.settlementPlayResultReceived = false
|
|
runtime.settlementStatisticReceived = false
|
|
runtime.settlementClearRewardSent = false
|
|
runtime.settlementPhase = 0
|
|
runtime.raidNativeSettlementFinished = true
|
|
runtime.raidDungeon = runtimeRaidDungeonBinding{
|
|
RaidKey: 0x10063,
|
|
Phase: runtimeRaidDungeonPhaseOne,
|
|
MapID: 5314,
|
|
LifecycleToken: runtime.lifecycleToken,
|
|
selectedNotified: true,
|
|
loadedNotified: true,
|
|
completedNotified: true,
|
|
}
|
|
runtime.Dungeon.ID = 5314
|
|
runtime.Dungeon.Metadata.TutorialDungeon = worldmap.OptionalInt{}
|
|
runtime.Request.DungeonID = 5314
|
|
runtime.townReturnOrigin = currentTownPositionSnapshot{
|
|
CharacterID: session.selectedCharacterID,
|
|
TownID: byte(currentAntonRaidTownID),
|
|
AreaID: currentAntonRaidWaitingAreaID,
|
|
PositionX: 987,
|
|
PositionY: 380,
|
|
MovementCode: 5,
|
|
PositionValid: true,
|
|
}
|
|
member := runtimeRaidMemberState{CharID: session.selectedCharacterID, GroupIndex: 1, SlotOrder: 0}
|
|
service.raids = map[uint32]*runtimeRaidState{
|
|
runtime.raidDungeon.RaidKey: {
|
|
RaidKey: runtime.raidDungeon.RaidKey,
|
|
LeaderID: session.selectedCharacterID,
|
|
TailFlag: 1,
|
|
Started: true,
|
|
EntryCostPaid: true,
|
|
Members: []runtimeRaidMemberState{member},
|
|
FrozenMembers: []runtimeRaidMemberState{member},
|
|
Lifecycle: runtimeRaidLifecycleState{
|
|
Status: runtimeRaidLifecyclePhaseActive,
|
|
Phase: runtimeRaidDungeonPhaseOne,
|
|
},
|
|
},
|
|
}
|
|
connection.write.Reset()
|
|
|
|
if err := service.handleGameCommand(
|
|
session,
|
|
byte(dnfenum.GameCmdCommand),
|
|
uint16(dnfenum.CmdPacketEplpCommand),
|
|
[]byte{1, 2},
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
op72.Header.Classification != currentDungeonCardResponseClass ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(1, 2)) {
|
|
t.Fatalf("raid return op72 ack = %+v body=%x", op72.Header, op72.Body)
|
|
}
|
|
foundTownTransition := false
|
|
for len(rest) > 0 {
|
|
packet, tail := splitGameServerUpperPacket(t, rest)
|
|
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSceneTransitionMsgID {
|
|
foundTownTransition = true
|
|
}
|
|
rest = tail
|
|
}
|
|
if !foundTownTransition {
|
|
t.Fatalf("raid return emitted no waiting-town transition stream=%x", connection.write.Bytes())
|
|
}
|
|
if runtime.settlementCardLayoutSent || runtime.settlementCardSelectionSent ||
|
|
runtime.settlementCardRewardCommitted {
|
|
t.Fatalf("raid return synthesized ordinary cards: %+v", runtime)
|
|
}
|
|
if session.dungeon.runtime != nil || !runtime.townReturnOp24Sent {
|
|
t.Fatalf("raid return did not retire completed runtime owner=%p return=%t", session.dungeon.runtime, runtime.townReturnOp24Sent)
|
|
}
|
|
presence, present := service.onlinePlayers.PlayerForCharacter(session.selectedCharacterID)
|
|
if !present || presence.Session != session ||
|
|
presence.TownID != byte(currentAntonRaidTownID) ||
|
|
presence.AreaID != currentAntonRaidWaitingAreaID ||
|
|
presence.InstanceKey != runtime.raidDungeon.RaidKey ||
|
|
presence.PositionX != 987 || presence.PositionY != 380 {
|
|
t.Fatalf("raid waiting presence not restored: present=%t presence=%+v", present, presence)
|
|
}
|
|
if _, allowed, reason := service.runtimeRaidSelectorAdmission(session); !allowed {
|
|
t.Fatalf("restored waiting presence did not pass selector admission: %s", reason)
|
|
}
|
|
if ready, reason := service.currentTownEnterSelectReady(session); !ready {
|
|
t.Fatalf("restored waiting town is not selector-ready: %s snapshot=%+v", reason, session.townPositionSnapshot)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardExitFocusNotificationKeepsGateForRealClick(t *testing.T) {
|
|
service, runtime, session, connection, _ := prepareCompletedSettlementRuntime(t)
|
|
markCurrentDungeonCardRewardCommittedForTest(runtime)
|
|
connection.write.Reset()
|
|
|
|
// Live sequence: hovering the return-to-town button sends {2,2}, then the
|
|
// click sends {1,2}. The focus ACK must not consume the one-shot exit
|
|
// gate, or the real click loses its ACK and the town route.
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), []byte{2, 2}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
op72.Header.Classification != currentDungeonCardResponseClass ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(2, 2)) {
|
|
t.Fatalf("focus op72 ack = %+v body=%x", op72.Header, op72.Body)
|
|
}
|
|
if len(rest) != 0 {
|
|
t.Fatalf("focus op72 emitted trailing packets rest=%x", rest)
|
|
}
|
|
if runtime.settlementCardExitAckSent {
|
|
t.Fatal("focus notification consumed the one-shot exit gate")
|
|
}
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), []byte{1, 2}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Skip the first (focus) packet; the second packet must be the click ACK.
|
|
first, tail := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
second, rest := splitGameServerUpperPacket(t, tail)
|
|
if !bytes.Equal(first.Body, buildCurrentDungeonOp72SuccessBody(2, 2)) ||
|
|
second.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
!bytes.Equal(second.Body, buildCurrentDungeonOp72SuccessBody(1, 2)) {
|
|
t.Fatalf("focus-then-click acks first=%+v second=%+v", first.Header, second.Header)
|
|
}
|
|
foundTransition := false
|
|
for len(rest) > 0 {
|
|
packet, tail2 := splitGameServerUpperPacket(t, rest)
|
|
if packet.Header.Classification == 0 && packet.Header.MsgID == currentSceneTransitionMsgID &&
|
|
bytes.Equal(packet.Body, wantTutorialCompletionTownTransitionBody()) {
|
|
foundTransition = true
|
|
}
|
|
rest = tail2
|
|
}
|
|
if !foundTransition {
|
|
t.Fatalf("click after focus emitted no town transition stream=%x", connection.write.Bytes())
|
|
}
|
|
if session.dungeon.runtime != nil || !runtime.townReturnOp24Sent {
|
|
t.Fatalf("click after focus did not commit completed return owner=%p runtime=%+v", session.dungeon.runtime, runtime)
|
|
}
|
|
}
|
|
|
|
func prepareOrdinaryCompletedSettlementRuntime(t *testing.T) (*Service, *runtimeDungeonState, *gameSession, *bufferConn) {
|
|
t.Helper()
|
|
table, resolver, monsters := loadBridgeDungeonStaticData(t, bridgeDungeonPVF(false))
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
|
CharacterID: "99",
|
|
AccountID: "account-1",
|
|
Level: 20,
|
|
Stats: map[string]int64{
|
|
"fatigue": 100,
|
|
"town_id": 38,
|
|
"area_id": 1,
|
|
"pos_x": 450,
|
|
"pos_y": 234,
|
|
"direction": 5,
|
|
"area_state": 3,
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := &Service{
|
|
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
worldMapTable: table,
|
|
worldMapResolver: resolver,
|
|
dungeonMonsterTable: monsters,
|
|
dungeonChoice: func(int) (int, error) { return 0, nil },
|
|
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
}
|
|
enableTownMoveSkillProjection(t, service, repositories, "99")
|
|
conn := &bufferConn{}
|
|
channel := channelcatalog.Channel{ServerID: 1, ID: 19, Type: 1, Name: "ch.19", Port: 10019}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
connID: "ordinary-completed-settlement-test",
|
|
channel: channel,
|
|
residentChannel: channel,
|
|
selectedCharacterID: 99,
|
|
}
|
|
runtime, _, err := service.prepareDungeonRuntime(
|
|
context.Background(),
|
|
session,
|
|
dungeoncmd.SelectDungeonRequest{DungeonID: 700, Difficulty: 1},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("prepare ordinary runtime: %v", err)
|
|
}
|
|
session.dungeon.runtime = runtime
|
|
// Mirror the live op16 entry: the selector origin is bound from the town
|
|
// scene snapshot and frozen into the run for its eventual town return.
|
|
session.townMu.Lock()
|
|
session.townSceneReadyCharacterID = 99
|
|
session.townPositionSnapshot = currentTownPositionSnapshot{
|
|
CharacterID: 99,
|
|
TownID: 38,
|
|
AreaID: 1,
|
|
PositionX: 450,
|
|
PositionY: 234,
|
|
MovementCode: 5,
|
|
PositionValid: true,
|
|
}
|
|
session.townMu.Unlock()
|
|
if _, bound := bindCurrentTownSelectorOrigin(session); !bound {
|
|
t.Fatal("fixture selector origin did not bind")
|
|
}
|
|
if err := freezeCurrentDungeonTownReturnOrigin(session, runtime); err != nil {
|
|
t.Fatalf("fixture origin freeze: %v", err)
|
|
}
|
|
completeSettlementRuntimeForTest(t, service, runtime, session)
|
|
return service, runtime, session, conn
|
|
}
|
|
|
|
func TestDungeonCardExitRetryReEntersOrdinaryDungeonWithOriginFreeze(t *testing.T) {
|
|
service, runtime, session, connection := prepareOrdinaryCompletedSettlementRuntime(t)
|
|
runtime.settlementPlayResultReceived = true
|
|
runtime.settlementStatisticReceived = true
|
|
markCurrentDungeonCardRewardCommittedForTest(runtime)
|
|
connection.write.Reset()
|
|
|
|
// Ordinary dungeons freeze the new run's town-return origin from the bound
|
|
// selector origin; the retry owner must re-bind it from the retirement's
|
|
// restored town snapshot, or the entry flow is rejected
|
|
// (ordinary_dungeon_town_return_origin_unavailable, live proof).
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), []byte{1, 0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
op72.Header.Classification != currentDungeonCardResponseClass ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(1, 0)) {
|
|
t.Fatalf("op72 ack = %+v body=%x want echo {1,0}", op72.Header, op72.Body)
|
|
}
|
|
foundEntryAck := false
|
|
for len(rest) > 0 {
|
|
packet, tail := splitGameServerUpperPacket(t, rest)
|
|
if packet.Header.Classification == 1 && packet.Header.MsgID == uint16(dnfenum.CmdPacketSelectDungeon) {
|
|
foundEntryAck = true
|
|
}
|
|
rest = tail
|
|
}
|
|
if !foundEntryAck {
|
|
t.Fatalf("ordinary retry {1,0} emitted no op16 entry ack stream=%x", connection.write.Bytes())
|
|
}
|
|
newRuntime := session.dungeon.runtime
|
|
if newRuntime == nil || newRuntime == runtime {
|
|
t.Fatalf("ordinary retry {1,0} did not attach a fresh runtime old=%p new=%p", runtime, newRuntime)
|
|
}
|
|
if newRuntime.Session.Snapshot().Run.Status != worldmap.DungeonRunActive {
|
|
t.Fatalf("ordinary retry runtime status=%v want active", newRuntime.Session.Snapshot().Run.Status)
|
|
}
|
|
if !newRuntime.townReturnOrigin.PositionValid || newRuntime.townReturnOrigin.TownID != 38 || newRuntime.townReturnOrigin.AreaID != 1 {
|
|
t.Fatalf("ordinary retry town-return origin=%+v want valid 38/1", newRuntime.townReturnOrigin)
|
|
}
|
|
}
|
|
|
|
func prepareStoryChainCompletedSettlementRuntime(t *testing.T) (*Service, *runtimeDungeonState, *gameSession, *bufferConn) {
|
|
t.Helper()
|
|
service, runtime, session, conn := prepareStoryChainRuntime(t)
|
|
completeSettlementRuntimeForTest(t, service, runtime, session)
|
|
return service, runtime, session, conn
|
|
}
|
|
|
|
func prepareStoryChainRuntime(t *testing.T) (*Service, *runtimeDungeonState, *gameSession, *bufferConn) {
|
|
t.Helper()
|
|
source := bridgePVFSource{
|
|
worldmap.DefaultMapList: "100 `dungeon/test/story_a.map`\n200 `dungeon/test/story_b.map`\n300 `dungeon/test/ordinary.map`\n",
|
|
"map/dungeon/test/story_a.map": "[map name]\n`story_a`\n" +
|
|
"[dungeon]\n700\n" +
|
|
"[type]\n`[start]`\n" +
|
|
"[monster]\n3001 10 0 100 200 0 0 0 `[fixed]` `[normal]`\n",
|
|
"map/dungeon/test/story_b.map": "[map name]\n`story_b`\n" +
|
|
"[dungeon]\n800\n" +
|
|
"[type]\n`[start]`\n" +
|
|
"[monster]\n3001 10 0 100 200 0 0 0 `[fixed]` `[normal]`\n",
|
|
"map/dungeon/test/ordinary.map": "[map name]\n`ordinary`\n" +
|
|
"[dungeon]\n900\n" +
|
|
"[type]\n`[start]`\n" +
|
|
"[monster]\n3001 10 0 100 200 0 0 0 `[fixed]` `[normal]`\n",
|
|
worldmap.DefaultDungeonList: "700 `story_a.dgn`\n800 `story_b.dgn`\n900 `ordinary.dgn`\n",
|
|
"dungeon/story_a.dgn": "[name]\n`Story Dungeon A`\n" +
|
|
"[minimum required level]\n10\n" +
|
|
"[basis level]\n20\n" +
|
|
"[limit party count]\n1\n" +
|
|
"[maze info]\n" +
|
|
"[quest connection]\n0 3148\n" +
|
|
"[size]\n1 1\n" +
|
|
"[greed]\n`A`\n" +
|
|
"[map specification]\n`map` 0 0 100\n" +
|
|
"[start map]\n0 0\n" +
|
|
"[boss map]\n0 0\n",
|
|
"dungeon/story_b.dgn": "[name]\n`Story Dungeon B`\n" +
|
|
"[minimum required level]\n10\n" +
|
|
"[basis level]\n20\n" +
|
|
"[limit party count]\n1\n" +
|
|
"[maze info]\n" +
|
|
"[quest connection]\n0 3149\n" +
|
|
"[size]\n1 1\n" +
|
|
"[greed]\n`A`\n" +
|
|
"[map specification]\n`map` 0 0 200\n" +
|
|
"[start map]\n0 0\n" +
|
|
"[boss map]\n0 0\n",
|
|
"dungeon/ordinary.dgn": "[name]\n`Ordinary Dungeon`\n" +
|
|
"[minimum required level]\n10\n" +
|
|
"[basis level]\n20\n" +
|
|
"[limit party count]\n1\n" +
|
|
"[maze info]\n" +
|
|
"[size]\n1 1\n" +
|
|
"[greed]\n`A`\n" +
|
|
"[map specification]\n`map` 0 0 300\n" +
|
|
"[start map]\n0 0\n" +
|
|
"[boss map]\n0 0\n",
|
|
worldmap.DefaultWorldMapList: "1 `test.wdm`\n",
|
|
"worldmap/test.wdm": "[name]\n`Synthetic Area`\n[dungeon]\n700 -1\n800 -1\n900 -1\n[/dungeon]\n",
|
|
dnfmonster.DefaultList: "3001 `test.gob`\n",
|
|
"monster/test.gob": "[name]\n`Synthetic Goblin`\n" +
|
|
"[level]\n10\n" +
|
|
"[hp]\n500\n" +
|
|
"[exp]\n25\n",
|
|
}
|
|
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
|
|
questSource := questListTestSource{
|
|
dnfquest.DefaultList: "3148 `story_a.qst`\n3149 `story_b.qst`\n",
|
|
"n_quest/story_a.qst": "[grade]\n`[epic]`\n[npc index]\n1\n[level]\n1 99\n[job]\n`[all]`\n" +
|
|
"[type]\n`[clear map]`\n[int data]\n76156\n",
|
|
"n_quest/story_b.qst": "[grade]\n`[epic]`\n[npc index]\n1\n[level]\n1 99\n[job]\n`[all]`\n" +
|
|
"[pre required quest]\n3148\n" +
|
|
"[type]\n`[clear map]`\n[int data]\n76166\n",
|
|
}
|
|
questIndex, err := dnfpvf.Build(context.Background(), questSource, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
catalog, err := dnfquest.Load(context.Background(), questIndex)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
repositories := dnfrepomemory.NewMemoryGroup()
|
|
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
|
|
CharacterID: "99",
|
|
AccountID: "account-1",
|
|
Job: "0",
|
|
Level: 20,
|
|
Stats: map[string]int64{
|
|
"fatigue": 100,
|
|
"town_id": 38,
|
|
"area_id": 1,
|
|
"pos_x": 450,
|
|
"pos_y": 234,
|
|
"direction": 5,
|
|
"area_state": 3,
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
|
|
CharacterID: "99",
|
|
States: map[int64]dnfrepo.QuestState{
|
|
3148: {Status: "completed"},
|
|
},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
service := &Service{
|
|
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
|
|
worldMapTable: table,
|
|
worldMapResolver: resolver,
|
|
dungeonMonsterTable: monsters,
|
|
questCatalog: catalog,
|
|
dungeonChoice: func(int) (int, error) { return 0, nil },
|
|
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
|
|
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
|
|
}
|
|
enableTownMoveSkillProjection(t, service, repositories, "99")
|
|
conn := &bufferConn{}
|
|
channel := channelcatalog.Channel{ServerID: 1, ID: 19, Type: 1, Name: "ch.19", Port: 10019}
|
|
session := &gameSession{
|
|
conn: conn,
|
|
connID: "story-chain-completed-settlement-test",
|
|
channel: channel,
|
|
residentChannel: channel,
|
|
selectedCharacterID: 99,
|
|
}
|
|
service.bindGameSessionCharacter(session, 99)
|
|
runtime, _, err := service.prepareDungeonRuntime(
|
|
context.Background(),
|
|
session,
|
|
dungeoncmd.SelectDungeonRequest{DungeonID: 700, Difficulty: 1},
|
|
)
|
|
if err != nil {
|
|
t.Fatalf("prepare story runtime: %v", err)
|
|
}
|
|
session.townMu.Lock()
|
|
session.townSceneReadyCharacterID = 99
|
|
session.townPositionSnapshot = currentTownPositionSnapshot{
|
|
CharacterID: 99,
|
|
TownID: 38,
|
|
AreaID: 1,
|
|
PositionX: 450,
|
|
PositionY: 234,
|
|
MovementCode: 5,
|
|
PositionValid: true,
|
|
}
|
|
session.townMu.Unlock()
|
|
if _, bound := bindCurrentTownSelectorOrigin(session); !bound {
|
|
t.Fatal("fixture selector origin did not bind")
|
|
}
|
|
if err := freezeCurrentDungeonTownReturnOrigin(session, runtime); err != nil {
|
|
t.Fatalf("fixture origin freeze: %v", err)
|
|
}
|
|
if err := service.commitDungeonRuntime(session, runtime); err != nil {
|
|
t.Fatalf("commit story runtime: %v", err)
|
|
}
|
|
return service, runtime, session, conn
|
|
}
|
|
|
|
func TestDungeonCardExitStoryNextQuestEntersNextDungeon(t *testing.T) {
|
|
service, runtime, session, connection := prepareStoryChainCompletedSettlementRuntime(t)
|
|
resolution := service.resolveNextStoryQuestDungeon(session, runtime)
|
|
if !resolution.QuestConnected || !resolution.ChainKnown || !resolution.HasDungeon ||
|
|
resolution.CurrentQuestID != 3148 || resolution.NextQuestID != 3149 || resolution.DungeonID != 800 {
|
|
t.Fatalf("eligible story successor resolution=%+v", resolution)
|
|
}
|
|
runtime.settlementPlayResultReceived = true
|
|
runtime.settlementStatisticReceived = true
|
|
markCurrentDungeonCardRewardCommittedForTest(runtime)
|
|
connection.write.Reset()
|
|
|
|
// 剧情副本模式's 开始下个任务 is the same {1,0} wire value as 再次挑战.
|
|
// Because the completed maze is quest-connected, the owner must follow the
|
|
// PVF quest chain (3148 -> 3149) into the successor's dungeon (800)
|
|
// instead of re-entering the finished one (700).
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), []byte{1, 0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
op72.Header.Classification != currentDungeonCardResponseClass ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(1, 0)) {
|
|
t.Fatalf("op72 ack = %+v body=%x want echo {1,0}", op72.Header, op72.Body)
|
|
}
|
|
foundEntryAck := false
|
|
for len(rest) > 0 {
|
|
packet, tail := splitGameServerUpperPacket(t, rest)
|
|
if packet.Header.Classification == 1 && packet.Header.MsgID == uint16(dnfenum.CmdPacketSelectDungeon) {
|
|
foundEntryAck = true
|
|
}
|
|
rest = tail
|
|
}
|
|
if !foundEntryAck {
|
|
t.Fatalf("story next-quest {1,0} emitted no op16 entry ack stream=%x", connection.write.Bytes())
|
|
}
|
|
newRuntime := session.dungeon.runtime
|
|
if newRuntime == nil || newRuntime == runtime {
|
|
t.Fatalf("story next-quest {1,0} did not attach a fresh runtime old=%p new=%p", runtime, newRuntime)
|
|
}
|
|
if newRuntime.Dungeon.ID != 800 {
|
|
t.Fatalf("story next-quest entered dungeon %d, want 800 (successor quest 3149)", newRuntime.Dungeon.ID)
|
|
}
|
|
if newRuntime.Session.Snapshot().Run.Status != worldmap.DungeonRunActive {
|
|
t.Fatalf("story next-quest runtime status=%v want active", newRuntime.Session.Snapshot().Run.Status)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardExitStoryNextQuestWithUnmetPrerequisiteReturnsToTown(t *testing.T) {
|
|
service, runtime, session, connection := prepareStoryChainCompletedSettlementRuntime(t)
|
|
repositories, ok := service.repositoryGroup()
|
|
if !ok || repositories.Quest == nil {
|
|
t.Fatal("story fixture quest repository unavailable")
|
|
}
|
|
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
|
|
CharacterID: "99",
|
|
States: map[int64]dnfrepo.QuestState{},
|
|
}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
resolution := service.resolveNextStoryQuestDungeon(session, runtime)
|
|
if !resolution.QuestConnected || !resolution.ChainKnown || resolution.HasDungeon ||
|
|
resolution.CurrentQuestID != 3148 || resolution.Reason != "direct_successor_not_currently_acceptable" {
|
|
t.Fatalf("unmet successor resolution=%+v", resolution)
|
|
}
|
|
runtime.settlementPlayResultReceived = true
|
|
runtime.settlementStatisticReceived = true
|
|
markCurrentDungeonCardRewardCommittedForTest(runtime)
|
|
connection.write.Reset()
|
|
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), []byte{1, 0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
op72, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
|
|
if op72.Header.MsgID != uint16(dnfenum.CmdPacketEplpCommand) ||
|
|
!bytes.Equal(op72.Body, buildCurrentDungeonOp72SuccessBody(1, 0)) {
|
|
t.Fatalf("op72 ack=%+v body=%x", op72.Header, op72.Body)
|
|
}
|
|
foundEntryAck := false
|
|
for len(rest) > 0 {
|
|
packet, tail := splitGameServerUpperPacket(t, rest)
|
|
if packet.Header.Classification == 1 && packet.Header.MsgID == uint16(dnfenum.CmdPacketSelectDungeon) {
|
|
foundEntryAck = true
|
|
}
|
|
rest = tail
|
|
}
|
|
if foundEntryAck {
|
|
t.Fatal("unmet story successor incorrectly entered a dungeon")
|
|
}
|
|
if session.dungeon.runtime != nil {
|
|
t.Fatalf("completed town route retained dungeon runtime: old=%p current=%p", runtime, session.dungeon.runtime)
|
|
}
|
|
}
|
|
|
|
func TestDungeonCardExitOrdinaryRetryStillEntersSameDungeon(t *testing.T) {
|
|
service, runtime, session, connection := prepareOrdinaryCompletedSettlementRuntime(t)
|
|
runtime.settlementPlayResultReceived = true
|
|
runtime.settlementStatisticReceived = true
|
|
markCurrentDungeonCardRewardCommittedForTest(runtime)
|
|
connection.write.Reset()
|
|
|
|
// The ordinary (non quest-connected) dungeon has no story successor, so
|
|
// {1,0} stays a same-dungeon retry.
|
|
if err := service.handleGameCommand(session, byte(dnfenum.GameCmdCommand), uint16(dnfenum.CmdPacketEplpCommand), []byte{1, 0}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
newRuntime := session.dungeon.runtime
|
|
if newRuntime == nil || newRuntime == runtime {
|
|
t.Fatalf("ordinary retry did not attach a fresh runtime old=%p new=%p", runtime, newRuntime)
|
|
}
|
|
if newRuntime.Dungeon.ID != runtime.Dungeon.ID {
|
|
t.Fatalf("ordinary retry entered dungeon %d, want same %d", newRuntime.Dungeon.ID, runtime.Dungeon.ID)
|
|
}
|
|
}
|