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

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

1367 lines
57 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"testing"
"time"
"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"
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 TestCurrentDungeonAltarEntryUsesOp211AndWaitsForReady(t *testing.T) {
_, _, runtime, scene, _ := prepareCurrentDungeonAltarRuntimeTest(t)
packets, err := buildCurrentDungeonEntryPackets(runtime, scene)
if err != nil {
t.Fatal(err)
}
if currentDungeonStartMapNotificationForRuntime(runtime) != currentDungeonAltarStartMapMsgID {
t.Fatalf("altar start msg=%d", currentDungeonStartMapNotificationForRuntime(runtime))
}
if len(packets.StartMap) != 15 || packets.StartMap[7] != 1 || binary.LittleEndian.Uint32(packets.StartMap[8:12]) != 17030 {
t.Fatalf("altar initial op211=%x", packets.StartMap)
}
if runtime.altar.progress != currentDungeonAltarProgressAwaitingReady || runtime.altar.readyAccepted || runtime.altar.schedule != nil {
t.Fatalf("altar did not wait for op258: %+v", runtime.altar)
}
ordinary := *runtime
ordinary.altar = nil
if currentDungeonStartMapNotificationForRuntime(&ordinary) != currentDungeonStartNotification {
t.Fatal("ordinary dungeon start notification changed")
}
target := scene
target.Coordinate = worldmap.RoomCoordinate{X: 1, Y: 0}
target.Map.Map.ID = 17031
plan := currentDungeonMovePlan{Target: target, TargetRoom: &runtimeDungeonRoom{}, Seed: 0x87654321}
moveBody, err := buildCurrentDungeonMoveStartMapBody(runtime, plan)
if err != nil {
t.Fatal(err)
}
if len(moveBody) != 15 || moveBody[7] != 1 || binary.LittleEndian.Uint32(moveBody[8:12]) != 17031 {
t.Fatalf("altar new-room op211=%x", moveBody)
}
plan.Revisit = true
revisitBody, err := buildCurrentDungeonMoveStartMapBody(runtime, plan)
if err != nil {
t.Fatal(err)
}
if len(revisitBody) != 8 || revisitBody[7] != 0 {
t.Fatalf("altar revisit op211=%x", revisitBody)
}
}
func TestCurrentDungeonEndlessAltarAnnouncesCrazyMonsterAfterRoundInterval(t *testing.T) {
service, session, runtime := prepareCurrentDungeonEndlessAltarRoomMoveTest(t)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
connection, ok := session.conn.(*bufferConn)
if !ok {
t.Fatalf("connection=%T", session.conn)
}
if runtime.altar.crazyMonster == nil {
t.Fatal("Endless Altar crazy monster was not selected")
}
runtime.altar.crazyMonster.RoundIndex = 0
runtime.altar.crazyMonster.PhaseIndex = 0
runtime.altar.crazyMonster.MonsterCode = 61010
runtime.altar.crazyMonster.Variant = 18
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
interval, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if interval.Header.Classification != 0 || interval.Header.MsgID != currentDungeonAltarRoundIntervalMsgID {
t.Fatalf("first packet=%+v body=%x", interval.Header, interval.Body)
}
crazy, rest := splitGameServerUpperPacket(t, rest)
if crazy.Header.Classification != 0 || crazy.Header.MsgID != currentDungeonAltarCrazyMonsterMsgID ||
!bytes.Equal(crazy.Body, []byte{0x52, 0xee, 0, 0, 18}) || len(rest) != 0 {
t.Fatalf("crazy packet=%+v body=%x rest=%x", crazy.Header, crazy.Body, rest)
}
if scheduled, _, active := queue.counts(); scheduled != 1 || active != 1 {
t.Fatalf("wave timers scheduled=%d active=%d", scheduled, active)
}
}
func TestCurrentDungeonAltarReadyDeathAndDifficultyRace(t *testing.T) {
service, session, runtime, _, connection := prepareCurrentDungeonAltarRuntimeTest(t)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
interval, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if interval.Header.Classification != 0 || interval.Header.MsgID != currentDungeonAltarRoundIntervalMsgID ||
!bytes.Equal(interval.Body, []byte{1, 0, 0, 0, 0}) || len(rest) != 0 {
t.Fatalf("ready interval header=%+v body=%x rest=%x", interval.Header, interval.Body, rest)
}
if scheduled, _, active := queue.counts(); scheduled != 1 || active != 1 {
t.Fatalf("ready timers scheduled=%d active=%d", scheduled, active)
}
bytesAfterFirstReady := connection.write.Len()
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
if scheduled, _, active := queue.counts(); scheduled != 1 || active != 1 || connection.write.Len() != bytesAfterFirstReady {
t.Fatalf("duplicate ready timers scheduled=%d active=%d bytes_before=%d bytes_after=%d", scheduled, active, bytesAfterFirstReady, connection.write.Len())
}
waveTask := queue.task(t, 0)
beforeWave := connection.write.Len()
queue.fire(waveTask, false)
stream := connection.write.Bytes()[beforeWave:]
spawn, rest := splitGameServerUpperPacket(t, stream)
if spawn.Header.Classification != 0 || spawn.Header.MsgID != currentDungeonAltarMonsterSpawnMsgID || len(rest) != 0 {
t.Fatalf("spawn header=%+v body=%x rest=%x", spawn.Header, spawn.Body, rest)
}
if len(spawn.Body) != 21 || binary.LittleEndian.Uint16(spawn.Body[:2]) != 1 ||
binary.LittleEndian.Uint16(spawn.Body[11:13]) != 1000 || binary.LittleEndian.Uint16(spawn.Body[19:21]) != 1 {
t.Fatalf("spawn body=%x", spawn.Body)
}
sequence := binary.LittleEndian.Uint16(spawn.Body[3:5])
if sequence == 0 {
t.Fatal("spawn sequence is zero")
}
beforeDeath := connection.write.Len()
deathBody := []byte{1, byte(sequence), byte(sequence >> 8)}
if err := service.handleCurrentDungeonAltarDeaths(session, deathBody); err != nil {
t.Fatal(err)
}
prompt, rest := splitGameServerUpperPacket(t, connection.write.Bytes()[beforeDeath:])
if prompt.Header.Classification != 0 || prompt.Header.MsgID != currentDungeonAltarDifficultyUIMsgID ||
!bytes.Equal(prompt.Body, []byte{0, 1, 10, 0}) || len(rest) != 0 {
t.Fatalf("difficulty prompt header=%+v body=%x rest=%x", prompt.Header, prompt.Body, rest)
}
if runtime.altar.progress != currentDungeonAltarProgressAwaitingDifficulty || runtime.altar.completedRounds != 1 {
t.Fatalf("after death progress=%d completed=%d", runtime.altar.progress, runtime.altar.completedRounds)
}
difficultyTask := queue.task(t, 1)
beforeChoice := connection.write.Len()
if err := service.handleCurrentDungeonAltarDifficulty(session, []byte{2}); err != nil {
t.Fatal(err)
}
ack, rest := splitGameServerUpperPacket(t, connection.write.Bytes()[beforeChoice:])
if ack.Header.Classification != dnfproto.DefaultChannelClassification ||
ack.Header.MsgID != uint16(dnfenum.CmdPacketSelectUltimateDifficulty) ||
!bytes.Equal(ack.Body, []byte{1, 2}) {
t.Fatalf("op317 ACK header=%+v body=%x", ack.Header, ack.Body)
}
secondInterval, rest := splitGameServerUpperPacket(t, rest)
if secondInterval.Header.Classification != 0 || secondInterval.Header.MsgID != currentDungeonAltarRoundIntervalMsgID || len(rest) != 0 {
t.Fatalf("second interval header=%+v body=%x rest=%x", secondInterval.Header, secondInterval.Body, rest)
}
if runtime.altar.schedule == nil || runtime.altar.schedule.Difficulty != worldmap.BloodAltarDifficultyB {
t.Fatalf("manual difficulty did not own next round: %+v", runtime.altar.schedule)
}
bytesBeforeStale := connection.write.Len()
queue.fire(difficultyTask, true)
if connection.write.Len() != bytesBeforeStale || runtime.altar.schedule == nil || runtime.altar.schedule.Difficulty != worldmap.BloodAltarDifficultyB {
t.Fatalf("stale timeout changed state/body: before=%d after=%d schedule=%+v", bytesBeforeStale, connection.write.Len(), runtime.altar.schedule)
}
}
func TestCancelCurrentDungeonAltarTimersActivelyCancelsScheduledWave(t *testing.T) {
service, session, runtime, _, _ := prepareCurrentDungeonAltarRuntimeTest(t)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
task := queue.task(t, 0)
session.dungeon.mu.Lock()
service.cancelCurrentDungeonAltarTimersLocked(runtime)
session.dungeon.mu.Unlock()
if !task.cancelled {
t.Fatal("altar wave timer was not actively canceled")
}
if scheduled, cancelled, active := queue.counts(); scheduled != 1 || cancelled != 1 || active != 0 {
t.Fatalf("timers scheduled=%d cancelled=%d active=%d", scheduled, cancelled, active)
}
}
func TestCancelCurrentDungeonAltarTimersActivelyCancelsTimedMonsterExpiry(t *testing.T) {
service, session, runtime, _, _ := prepareCurrentDungeonAltarRuntimeTest(t)
queue := service.gameplayTimers.(*fakeCurrentDungeonDeathTimerQueue)
runtime.altar.boundMonsters[0].DurationMilliseconds = 3000
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
queue.fire(queue.task(t, 0), false)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
if len(runtime.altar.expiryTimerNames) != 1 {
t.Fatalf("expiry timers=%v", runtime.altar.expiryTimerNames)
}
var timerName string
for name := range runtime.altar.expiryTimerNames {
timerName = name
}
queue.mu.Lock()
expiryTask := queue.active[timerName]
queue.mu.Unlock()
if expiryTask == nil {
t.Fatal("timed monster expiry task is missing")
}
session.dungeon.mu.Lock()
service.cancelCurrentDungeonAltarTimersLocked(runtime)
session.dungeon.mu.Unlock()
if !expiryTask.cancelled || len(runtime.altar.expiryTimerNames) != 0 {
t.Fatalf("expiry task cancelled=%t timers=%v", expiryTask.cancelled, runtime.altar.expiryTimerNames)
}
queue.fire(expiryTask, true)
if _, active := runtime.altar.activeSequences[sequence]; !active || runtime.altar.completedRounds != 0 {
t.Fatalf("stale cancelled expiry mutated altar: active=%v completed=%d",
runtime.altar.activeSequences, runtime.altar.completedRounds)
}
}
func TestCurrentDungeonAltarPVFDurationExpiryAdvancesEndlessAndUltimate(t *testing.T) {
tests := []struct {
name string
prepare func(*testing.T) (*Service, *gameSession, *runtimeDungeonState)
wantProgress currentDungeonAltarProgress
wantSchedule bool
}{
{
name: "endless",
prepare: func(t *testing.T) (*Service, *gameSession, *runtimeDungeonState) {
return prepareCurrentDungeonEndlessAltarRoomMoveTest(t)
},
wantProgress: currentDungeonAltarProgressRoundActive,
wantSchedule: true,
},
{
name: "ultimate",
prepare: func(t *testing.T) (*Service, *gameSession, *runtimeDungeonState) {
service, session, runtime, _, _ := prepareCurrentDungeonAltarRuntimeTest(t)
return service, session, runtime
},
wantProgress: currentDungeonAltarProgressAwaitingDifficulty,
wantSchedule: false,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service, session, runtime := test.prepare(t)
queue := service.gameplayTimers.(*fakeCurrentDungeonDeathTimerQueue)
if runtime.altar == nil || len(runtime.altar.boundMonsters) == 0 {
t.Fatal("altar monster table unavailable")
}
runtime.altar.boundMonsters[0].DurationMilliseconds = 3000
runtime.altar.boundMonsters[0].SpawnIntervalMilliseconds = 0
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
waveTask := queue.task(t, 0)
queue.fire(waveTask, false)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
if len(runtime.altar.expiryTimerNames) != 1 {
t.Fatalf("expiry timers=%v", runtime.altar.expiryTimerNames)
}
var expiryTimerName string
for name := range runtime.altar.expiryTimerNames {
expiryTimerName = name
}
queue.mu.Lock()
expiryTask := queue.active[expiryTimerName]
queue.mu.Unlock()
if expiryTask == nil || expiryTask.delay != 3*time.Second {
t.Fatalf("expiry task=%+v", expiryTask)
}
queue.fire(expiryTask, false)
if runtime.altar.completedRounds != 1 || runtime.altar.progress != test.wantProgress ||
(runtime.altar.schedule != nil) != test.wantSchedule || len(runtime.altar.activeSequences) != 0 {
t.Fatalf("after expiry completed=%d progress=%d schedule=%+v active=%v",
runtime.altar.completedRounds, runtime.altar.progress, runtime.altar.schedule, runtime.altar.activeSequences)
}
if _, retired := runtime.altar.retiredSequences[sequence]; !retired {
t.Fatalf("expired sequence %d was not retired", sequence)
}
completedBeforeStale := runtime.altar.completedRounds
queue.fire(expiryTask, true)
if runtime.altar.completedRounds != completedBeforeStale {
t.Fatalf("stale expiry advanced twice: before=%d after=%d", completedBeforeStale, runtime.altar.completedRounds)
}
})
}
}
func TestCurrentDungeonAltarDifficultyTimeoutWinsAgainstLateSelection(t *testing.T) {
service, session, runtime, _, connection := prepareCurrentDungeonAltarRuntimeTest(t)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
service.dungeonChoice = func(limit int) (int, error) {
if limit != 2 {
t.Fatalf("difficulty choice limit=%d", limit)
}
return 1, nil
}
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
queue.fire(queue.task(t, 0), false)
var sequence uint16
for candidate := range runtime.altar.activeSequences {
sequence = candidate
}
if sequence == 0 {
t.Fatal("first round did not own a live sequence")
}
if err := service.handleCurrentDungeonAltarDeaths(session, []byte{1, byte(sequence), byte(sequence >> 8)}); err != nil {
t.Fatal(err)
}
timeout := queue.task(t, 1)
beforeTimeout := connection.write.Len()
queue.fire(timeout, false)
resolved, rest := splitGameServerUpperPacket(t, connection.write.Bytes()[beforeTimeout:])
if resolved.Header.Classification != 0 || resolved.Header.MsgID != currentDungeonAltarDifficultyUIMsgID ||
!bytes.Equal(resolved.Body, []byte{2}) {
t.Fatalf("timeout resolution header=%+v body=%x", resolved.Header, resolved.Body)
}
interval, rest := splitGameServerUpperPacket(t, rest)
if interval.Header.Classification != 0 || interval.Header.MsgID != currentDungeonAltarRoundIntervalMsgID || len(rest) != 0 {
t.Fatalf("timeout interval header=%+v body=%x rest=%x", interval.Header, interval.Body, rest)
}
if runtime.altar.schedule == nil || runtime.altar.schedule.Difficulty != worldmap.BloodAltarDifficultyB {
t.Fatalf("timeout did not select B: %+v", runtime.altar.schedule)
}
bytesBeforeLateSelection := connection.write.Len()
if err := service.handleCurrentDungeonAltarDifficulty(session, []byte{1}); err != nil {
t.Fatal(err)
}
if connection.write.Len() != bytesBeforeLateSelection || runtime.altar.schedule == nil ||
runtime.altar.schedule.Difficulty != worldmap.BloodAltarDifficultyB {
t.Fatalf("late manual selection changed timeout winner: bytes_before=%d bytes_after=%d schedule=%+v",
bytesBeforeLateSelection, connection.write.Len(), runtime.altar.schedule)
}
}
func TestCurrentDungeonEndlessAltarDynamicOp39FailsClosedWithoutUniqueNextRoom(t *testing.T) {
service, session, runtime := prepareCurrentDungeonEndlessAltarCompletionTest(t)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
connection := session.conn.(*bufferConn)
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
for localRound := 0; localRound < 5; localRound++ {
queue.fire(queue.task(t, localRound), false)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
owner := runtime.altar.activeSequences[sequence]
if got, want := owner.FinalRoomWave, localRound == 4; got != want {
t.Fatalf("local round %d final-room-wave=%t want=%t", localRound, got, want)
}
beforeDeath := connection.write.Len()
if err := service.handleDungeonMonsterDeathLocal(
session,
currentDungeonAltarDynamicOp39Body(sequence, currentSceneActorObjectKey(session.selectedCharacterID), 1),
); err != nil {
t.Fatalf("local round %d dynamic op39: %v", localRound, err)
}
rest := assertCurrentDungeonAltarDynamicOp38(t, connection.write.Bytes()[beforeDeath:], sequence)
if localRound < 4 {
interval, trailing := splitGameServerUpperPacket(t, rest)
trailing = assertOptionalCurrentDungeonEndlessAltarCrazyPacket(t, runtime, localRound+1, trailing)
if interval.Header.Classification != 0 || interval.Header.MsgID != currentDungeonAltarRoundIntervalMsgID ||
len(interval.Body) != 5 || interval.Body[0] != byte(localRound+2) || len(trailing) != 0 {
t.Fatalf("local round %d next interval header=%+v body=%x trailing=%x", localRound, interval.Header, interval.Body, trailing)
}
} else if len(rest) != 0 {
t.Fatalf("fifth-round dynamic death emitted non-op38 packets: %x", rest)
}
}
scene, ok := runtime.Session.Scene()
if !ok || !scene.Cleared || runtime.altar.completedRounds != 5 ||
runtime.altar.progress != currentDungeonAltarProgressRoomComplete || len(runtime.altar.activeSequences) != 0 ||
runtime.altar.roomTransitionTimerName == "" {
t.Fatalf("fifth round did not release room: scene=%+v completed=%d progress=%d active=%d",
scene, runtime.altar.completedRounds, runtime.altar.progress, len(runtime.altar.activeSequences))
}
if _, err := currentDungeonEndlessAltarNextRoom(runtime); err == nil {
t.Fatal("malformed one-room endless altar invented a next-room target")
}
}
func TestCurrentDungeonEndlessAltarRoomCompleteActivelyMovesToNextRoom(t *testing.T) {
tests := []struct {
name string
dynamic bool
}{
{name: "op255", dynamic: false},
{name: "op39", dynamic: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service, session, runtime := prepareCurrentDungeonEndlessAltarRoomMoveTest(t)
queue := service.gameplayTimers.(*fakeCurrentDungeonDeathTimerQueue)
connection := session.conn.(*bufferConn)
initialGeneration := runtime.altar.Room.Generation
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
connection.write.Reset()
for localRound := 0; localRound < 5; localRound++ {
scheduled, _, _ := queue.counts()
queue.fire(queue.task(t, scheduled-1), false)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
connection.write.Reset()
var err error
if test.dynamic {
err = service.handleDungeonMonsterDeathLocal(
session,
currentDungeonAltarDynamicOp39Body(
sequence,
currentSceneActorObjectKey(session.selectedCharacterID),
1,
),
)
} else {
err = service.handleCurrentDungeonAltarDeaths(
session,
[]byte{1, byte(sequence), byte(sequence >> 8)},
)
}
if err != nil {
t.Fatalf("local round %d death: %v", localRound, err)
}
stream := connection.write.Bytes()
if test.dynamic {
stream = assertCurrentDungeonAltarDynamicOp38(t, stream, sequence)
}
if len(stream) == 0 {
scene, _ := runtime.Session.Scene()
scheduled, cancelled, active := queue.counts()
t.Fatalf("local round %d emitted no progress packet: room=%s/%d progress=%d transition_timer=%q timers=%d/%d/%d",
localRound, scene.Coordinate, scene.Map.Map.ID, runtime.altar.progress,
runtime.altar.roomTransitionTimerName, scheduled, cancelled, active)
}
packet, rest := splitGameServerUpperPacket(t, stream)
if localRound < 4 {
rest = assertOptionalCurrentDungeonEndlessAltarCrazyPacket(t, runtime, localRound+1, rest)
if packet.Header.Classification != 0 || packet.Header.MsgID != currentDungeonAltarRoundIntervalMsgID ||
len(packet.Body) != 5 || packet.Body[0] != byte(localRound+2) || len(rest) != 0 {
t.Fatalf("local round %d interval header=%+v body=%x rest=%x", localRound, packet.Header, packet.Body, rest)
}
continue
}
if packet.Header.Classification != 0 || packet.Header.MsgID != currentDungeonAltarStartMapMsgID ||
len(packet.Body) != 15 || packet.Body[0] != 1 || packet.Body[1] != 0 || packet.Body[7] != 1 ||
binary.LittleEndian.Uint32(packet.Body[8:12]) != 17021 || len(rest) != 0 {
t.Fatalf("fifth-round op211 header=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
}
}
scene, ok := runtime.Session.Scene()
if !ok || scene.Coordinate != (worldmap.RoomCoordinate{X: 1, Y: 0}) || scene.Map.Map.ID != 17021 ||
runtime.altar.Room.Key != runtimeDungeonRoomKeyFromScene(scene) ||
runtime.altar.Room.Generation == initialGeneration ||
runtime.altar.progress != currentDungeonAltarProgressAwaitingReady ||
runtime.altar.completedRounds != 5 || len(runtime.altar.completedRooms) != 1 {
t.Fatalf("next room scene=%+v altar=%+v", scene, runtime.altar)
}
connection.write.Reset()
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
interval, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
rest = assertOptionalCurrentDungeonEndlessAltarCrazyPacket(t, runtime, 0, rest)
if interval.Header.Classification != 0 || interval.Header.MsgID != currentDungeonAltarRoundIntervalMsgID ||
len(interval.Body) != 5 || interval.Body[0] != 6 || len(rest) != 0 {
t.Fatalf("second-room first interval header=%+v body=%x rest=%x", interval.Header, interval.Body, rest)
}
})
}
}
func assertOptionalCurrentDungeonEndlessAltarCrazyPacket(
t *testing.T,
runtime *runtimeDungeonState,
localRound int,
stream []byte,
) []byte {
t.Helper()
if runtime == nil || runtime.altar == nil || runtime.altar.crazyMonster == nil ||
runtime.altar.crazyMonster.RoundIndex != localRound {
return stream
}
packet, rest := splitGameServerUpperPacket(t, stream)
if packet.Header.Classification != 0 || packet.Header.MsgID != currentDungeonAltarCrazyMonsterMsgID ||
len(packet.Body) != 5 || binary.LittleEndian.Uint32(packet.Body[:4]) != runtime.altar.crazyMonster.MonsterCode ||
packet.Body[4] != runtime.altar.crazyMonster.Variant {
t.Fatalf("local round %d crazy packet header=%+v body=%x", localRound, packet.Header, packet.Body)
}
return rest
}
func TestCurrentDungeonEndlessAltarRoomMoveWriteFailureRetriesOnce(t *testing.T) {
service, session, runtime := prepareCurrentDungeonEndlessAltarRoomMoveTest(t)
queue := service.gameplayTimers.(*fakeCurrentDungeonDeathTimerQueue)
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
for localRound := 0; localRound < 4; localRound++ {
scheduled, _, _ := queue.counts()
queue.fire(queue.task(t, scheduled-1), false)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
if err := service.handleCurrentDungeonAltarDeaths(
session,
[]byte{1, byte(sequence), byte(sequence >> 8)},
); err != nil {
t.Fatalf("local round %d death: %v", localRound, err)
}
}
scheduled, _, _ := queue.counts()
queue.fire(queue.task(t, scheduled-1), false)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
sourceRoom := runtime.altar.Room
wantWriteErr := errors.New("altar op211 write failed")
session.conn = &failNthDungeonWriteConn{failAt: 1, err: wantWriteErr}
if err := service.handleCurrentDungeonAltarDeaths(
session,
[]byte{1, byte(sequence), byte(sequence >> 8)},
); err != nil {
t.Fatal(err)
}
scene, ok := runtime.Session.Scene()
if !ok || runtimeDungeonRoomKeyFromScene(scene) != sourceRoom.Key || runtime.altar.Room != sourceRoom ||
runtime.altar.progress != currentDungeonAltarProgressRoomComplete ||
runtime.altar.roomTransitionTimerName == "" {
t.Fatalf("failed write committed or lost retry: scene=%+v altar=%+v", scene, runtime.altar)
}
scheduled, _, active := queue.counts()
if active != 1 {
t.Fatalf("room-move retry timers scheduled=%d active=%d", scheduled, active)
}
retryTask := queue.task(t, scheduled-1)
timerName := runtime.altar.roomTransitionTimerName
retryVersion := runtime.altar.roomTransitionVersion
if err := service.fireCurrentDungeonAltarRoomMoveRetry(
session,
runtime,
runtime.lifecycleToken+1,
sourceRoom,
retryVersion,
timerName,
queue.Now(),
); err != nil {
t.Fatal(err)
}
if runtime.altar.Room != sourceRoom || runtime.altar.roomTransitionTimerName != timerName {
t.Fatalf("stale lifecycle token changed retry owner: room=%+v timer=%q", runtime.altar.Room, runtime.altar.roomTransitionTimerName)
}
retryConnection := &bufferConn{}
session.conn = retryConnection
queue.fire(retryTask, false)
if retryConnection.write.Len() == 0 {
scene, _ = runtime.Session.Scene()
scheduled, cancelled, active := queue.counts()
t.Fatalf("room-move retry emitted no op211: scene=%+v altar=%+v timers=%d/%d/%d",
scene, runtime.altar, scheduled, cancelled, active)
}
packet, rest := splitGameServerUpperPacket(t, retryConnection.write.Bytes())
if packet.Header.Classification != 0 || packet.Header.MsgID != currentDungeonAltarStartMapMsgID ||
len(packet.Body) != 15 || packet.Body[0] != 1 || packet.Body[1] != 0 ||
binary.LittleEndian.Uint32(packet.Body[8:12]) != 17021 || len(rest) != 0 {
t.Fatalf("retry op211 header=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
}
scene, ok = runtime.Session.Scene()
if !ok || scene.Coordinate != (worldmap.RoomCoordinate{X: 1, Y: 0}) || scene.Map.Map.ID != 17021 ||
runtime.altar.Room.Generation == sourceRoom.Generation ||
runtime.altar.progress != currentDungeonAltarProgressAwaitingReady ||
runtime.altar.roomTransitionTimerName != "" {
t.Fatalf("retry did not commit exactly one next room: scene=%+v altar=%+v", scene, runtime.altar)
}
committedRoom := runtime.altar.Room
bytesBeforeStale := retryConnection.write.Len()
queue.fire(retryTask, true)
if retryConnection.write.Len() != bytesBeforeStale || runtime.altar.Room != committedRoom {
t.Fatalf("stale retry replayed op211/commit: bytes=%d->%d room=%+v->%+v",
bytesBeforeStale, retryConnection.write.Len(), committedRoom, runtime.altar.Room)
}
}
func TestCurrentDungeonEndlessAltarDynamicOp39RetriesOnlyOp211AfterSecondWriteFails(t *testing.T) {
service, session, runtime := prepareCurrentDungeonEndlessAltarRoomMoveTest(t)
queue := service.gameplayTimers.(*fakeCurrentDungeonDeathTimerQueue)
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
for localRound := 0; localRound < 4; localRound++ {
fireCurrentDungeonAltarNamedTimerTest(t, queue, runtime.altar.waveTimerName)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
if err := service.handleDungeonMonsterDeathLocal(
session,
currentDungeonAltarDynamicOp39Body(
sequence,
currentSceneActorObjectKey(session.selectedCharacterID),
1,
),
); err != nil {
t.Fatalf("local round %d dynamic death: %v", localRound, err)
}
}
fireCurrentDungeonAltarNamedTimerTest(t, queue, runtime.altar.waveTimerName)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
sourceRoom := runtime.altar.Room
wantWriteErr := errors.New("altar dynamic op211 second write failed")
failing := &failNthDungeonWriteConn{failAt: 2, err: wantWriteErr}
session.conn = failing
if err := service.handleDungeonMonsterDeathLocal(
session,
currentDungeonAltarDynamicOp39Body(
sequence,
currentSceneActorObjectKey(session.selectedCharacterID),
1,
),
); err != nil {
t.Fatal(err)
}
if failing.writes != 2 {
t.Fatalf("dynamic final writes=%d want op38 success then op211 failure", failing.writes)
}
if rest := assertCurrentDungeonAltarDynamicOp38(t, failing.write.Bytes(), sequence); len(rest) != 0 {
t.Fatalf("failed op211 retained packets after op38: %x", rest)
}
scene, ok := runtime.Session.Scene()
if !ok || runtimeDungeonRoomKeyFromScene(scene) != sourceRoom.Key || runtime.altar.Room != sourceRoom ||
runtime.altar.progress != currentDungeonAltarProgressRoomComplete ||
runtime.altar.roomTransitionTimerName == "" {
t.Fatalf("second-write failure committed or lost retry: scene=%+v altar=%+v", scene, runtime.altar)
}
scheduled, _, active := queue.counts()
if active != 1 {
t.Fatalf("dynamic room-move retry timers scheduled=%d active=%d", scheduled, active)
}
retryTask := queue.task(t, scheduled-1)
retryConnection := &bufferConn{}
session.conn = retryConnection
queue.fire(retryTask, false)
packet, rest := splitGameServerUpperPacket(t, retryConnection.write.Bytes())
if packet.Header.Classification != 0 || packet.Header.MsgID != currentDungeonAltarStartMapMsgID ||
len(packet.Body) != 15 || packet.Body[0] != 1 || packet.Body[1] != 0 ||
binary.LittleEndian.Uint32(packet.Body[8:12]) != 17021 || len(rest) != 0 {
t.Fatalf("dynamic retry packet header=%+v body=%x rest=%x", packet.Header, packet.Body, rest)
}
scene, ok = runtime.Session.Scene()
if !ok || scene.Coordinate != (worldmap.RoomCoordinate{X: 1, Y: 0}) || scene.Map.Map.ID != 17021 ||
runtime.altar.Room.Generation == sourceRoom.Generation ||
runtime.altar.progress != currentDungeonAltarProgressAwaitingReady {
t.Fatalf("dynamic retry did not commit once: scene=%+v altar=%+v", scene, runtime.altar)
}
committedRoom := runtime.altar.Room
bytesBeforeStale := retryConnection.write.Len()
queue.fire(retryTask, true)
if retryConnection.write.Len() != bytesBeforeStale || runtime.altar.Room != committedRoom {
t.Fatalf("dynamic stale retry replayed packet/commit: bytes=%d->%d room=%+v->%+v",
bytesBeforeStale, retryConnection.write.Len(), committedRoom, runtime.altar.Room)
}
}
func TestCurrentDungeonEndlessAltarCompletesFifteenRoundsAcrossThreeRooms(t *testing.T) {
service, session, runtime := prepareCurrentDungeonEndlessAltarRoomMoveTest(t)
queue := service.gameplayTimers.(*fakeCurrentDungeonDeathTimerQueue)
connection := session.conn.(*bufferConn)
prepareCurrentDungeonAltarEmptySettlementTestState(t, runtime)
op211Count, rankingCount, rewardCount := 0, 0, 0
for roomIndex := 0; roomIndex < 3; roomIndex++ {
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatalf("room %d ready: %v", roomIndex, err)
}
connection.write.Reset()
for localRound := 0; localRound < 5; localRound++ {
fireCurrentDungeonAltarNamedTimerTest(t, queue, runtime.altar.waveTimerName)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
connection.write.Reset()
if err := service.handleCurrentDungeonAltarDeaths(
session,
[]byte{1, byte(sequence), byte(sequence >> 8)},
); err != nil {
t.Fatalf("room %d local round %d death: %v", roomIndex, localRound, err)
}
stream := connection.write.Bytes()
for len(stream) != 0 {
packet, rest := splitGameServerUpperPacket(t, stream)
switch packet.Header.MsgID {
case currentDungeonAltarStartMapMsgID:
op211Count++
case currentDungeonAltarRankingMsgID:
rankingCount++
case currentDungeonAltarRewardMsgID:
rewardCount++
}
stream = rest
}
}
}
snapshot := runtime.Session.Snapshot()
if runtime.altar.completedRounds != 15 || len(runtime.altar.completedRooms) != 3 ||
runtime.altar.progress != currentDungeonAltarProgressDungeonComplete ||
snapshot.Run.Status != worldmap.DungeonRunCompleted || op211Count != 2 ||
rankingCount != 1 || rewardCount != 1 ||
!completedCurrentDungeonAltarSettlementExitReady(runtime, session.selectedCharacterID) {
t.Fatalf("endless completion rounds=%d rooms=%d progress=%d status=%s op211=%d op208=%d op209=%d settlement=%+v",
runtime.altar.completedRounds, len(runtime.altar.completedRooms), runtime.altar.progress,
snapshot.Run.Status, op211Count, rankingCount, rewardCount, runtime.altar.settlement)
}
}
func TestCurrentDungeonUltimateAltarCompletesSevenRoundsWithoutRoomMove(t *testing.T) {
service, session, runtime, _, connection := prepareCurrentDungeonAltarRuntimeTest(t)
queue := service.gameplayTimers.(*fakeCurrentDungeonDeathTimerQueue)
prepareCurrentDungeonAltarEmptySettlementTestState(t, runtime)
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
op211Count, rankingCount, rewardCount := 0, 0, 0
for runtime.altar.completedRounds < runtime.altar.MaxRounds {
fireCurrentDungeonAltarNamedTimerTest(t, queue, runtime.altar.waveTimerName)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
connection.write.Reset()
if err := service.handleCurrentDungeonAltarDeaths(
session,
[]byte{1, byte(sequence), byte(sequence >> 8)},
); err != nil {
t.Fatalf("round %d death: %v", runtime.altar.completedRounds+1, err)
}
stream := connection.write.Bytes()
for len(stream) != 0 {
packet, rest := splitGameServerUpperPacket(t, stream)
switch packet.Header.MsgID {
case currentDungeonAltarStartMapMsgID:
op211Count++
case currentDungeonAltarRankingMsgID:
rankingCount++
case currentDungeonAltarRewardMsgID:
rewardCount++
}
stream = rest
}
if runtime.altar.completedRounds == runtime.altar.MaxRounds {
break
}
switch runtime.altar.progress {
case currentDungeonAltarProgressAwaitingDifficulty:
connection.write.Reset()
if err := service.handleCurrentDungeonAltarDifficulty(session, []byte{1}); err != nil {
t.Fatal(err)
}
case currentDungeonAltarProgressFinalRoundDelay:
connection.write.Reset()
fireCurrentDungeonAltarNamedTimerTest(t, queue, runtime.altar.finalTimerName)
default:
t.Fatalf("unexpected ultimate progress after round %d: %d", runtime.altar.completedRounds, runtime.altar.progress)
}
}
snapshot := runtime.Session.Snapshot()
if runtime.altar.completedRounds != 7 || runtime.altar.progress != currentDungeonAltarProgressDungeonComplete ||
snapshot.Run.Status != worldmap.DungeonRunCompleted || op211Count != 0 ||
rankingCount != 1 || rewardCount != 1 ||
!completedCurrentDungeonAltarSettlementExitReady(runtime, session.selectedCharacterID) {
t.Fatalf("ultimate completion rounds=%d progress=%d status=%s op211=%d op208=%d op209=%d settlement=%+v",
runtime.altar.completedRounds, runtime.altar.progress, snapshot.Run.Status,
op211Count, rankingCount, rewardCount, runtime.altar.settlement)
}
}
func TestCurrentDungeonUltimateAltarDynamicOp39DuplicateResendsOnlyOp38(t *testing.T) {
service, session, runtime, _, connection := prepareCurrentDungeonAltarRuntimeTest(t)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
queue.fire(queue.task(t, 0), false)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
body := currentDungeonAltarDynamicOp39Body(sequence, currentSceneActorObjectKey(session.selectedCharacterID), 1)
beforeFirst := connection.write.Len()
if err := service.handleDungeonMonsterDeathLocal(session, body); err != nil {
t.Fatal(err)
}
rest := assertCurrentDungeonAltarDynamicOp38(t, connection.write.Bytes()[beforeFirst:], sequence)
prompt, trailing := splitGameServerUpperPacket(t, rest)
if prompt.Header.Classification != 0 || prompt.Header.MsgID != currentDungeonAltarDifficultyUIMsgID || len(trailing) != 0 {
t.Fatalf("ultimate dynamic death prompt=%+v body=%x trailing=%x", prompt.Header, prompt.Body, trailing)
}
if _, active := runtime.altar.activeSequences[sequence]; active {
t.Fatal("ultimate dynamic sequence remained active")
}
if _, retired := runtime.altar.retiredSequences[sequence]; !retired || runtime.altar.completedRounds != 1 {
t.Fatalf("ultimate dynamic sequence retired=%t completed=%d", retired, runtime.altar.completedRounds)
}
beforeDuplicate := connection.write.Len()
if err := service.handleDungeonMonsterDeathLocal(session, body); err != nil {
t.Fatal(err)
}
duplicateRest := assertCurrentDungeonAltarDynamicOp38(t, connection.write.Bytes()[beforeDuplicate:], sequence)
if len(duplicateRest) != 0 || runtime.altar.completedRounds != 1 ||
runtime.altar.progress != currentDungeonAltarProgressAwaitingDifficulty {
t.Fatalf("duplicate dynamic op39 rest=%x completed=%d progress=%d",
duplicateRest, runtime.altar.completedRounds, runtime.altar.progress)
}
}
func TestCurrentDungeonAltarDynamicOp39AcceptsNativeVariableShapes(t *testing.T) {
tests := []struct {
name string
body func(sequence uint16, owner uint16) []byte
}{
{
name: "count_zero",
body: func(sequence uint16, owner uint16) []byte {
return currentDungeonAltarDynamicOp39Body(sequence, owner, 0)
},
},
{
name: "count_greater_than_one",
body: func(sequence uint16, owner uint16) []byte {
return currentDungeonAltarDynamicOp39Body(sequence, owner, 2)
},
},
{
name: "non_player_owner",
body: func(sequence uint16, owner uint16) []byte {
return currentDungeonAltarDynamicOp39Body(sequence, owner+1, 1)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service, session, runtime, _, connection := prepareCurrentDungeonAltarRuntimeTest(t)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
queue.fire(queue.task(t, 0), false)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
before := connection.write.Len()
if err := service.handleDungeonMonsterDeathLocal(
session,
test.body(sequence, currentSceneActorObjectKey(session.selectedCharacterID)),
); err != nil {
t.Fatal(err)
}
rest := assertCurrentDungeonAltarDynamicOp38(t, connection.write.Bytes()[before:], sequence)
prompt, trailing := splitGameServerUpperPacket(t, rest)
if prompt.Header.MsgID != currentDungeonAltarDifficultyUIMsgID || len(trailing) != 0 {
t.Fatalf("native variable altar op39 prompt=%+v body=%x trailing=%x", prompt.Header, prompt.Body, trailing)
}
if _, active := runtime.altar.activeSequences[sequence]; active || runtime.altar.completedRounds != 1 {
t.Fatalf("native variable altar op39 active=%t completed=%d", active, runtime.altar.completedRounds)
}
})
}
}
func TestCurrentDungeonAltarDynamicOp39RejectsUnownedBoundaries(t *testing.T) {
tests := []struct {
name string
body func(sequence uint16, owner uint16) []byte
}{
{
name: "fixed62_layout",
body: func(sequence uint16, owner uint16) []byte {
body := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(body[0:4], uint32(sequence))
binary.LittleEndian.PutUint16(body[4:6], owner)
return body
},
},
{
name: "opaque_tail",
body: func(sequence uint16, owner uint16) []byte {
return append(currentDungeonAltarDynamicOp39Body(sequence, owner, 1), 0xaa)
},
},
{
name: "wrong_sequence",
body: func(sequence uint16, owner uint16) []byte {
return currentDungeonAltarDynamicOp39Body(sequence+1000, owner, 1)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service, session, runtime, _, connection := prepareCurrentDungeonAltarRuntimeTest(t)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
queue.fire(queue.task(t, 0), false)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
before := connection.write.Len()
if err := service.handleDungeonMonsterDeathLocal(
session,
test.body(sequence, currentSceneActorObjectKey(session.selectedCharacterID)),
); err != nil {
t.Fatal(err)
}
if connection.write.Len() != before {
t.Fatalf("unowned altar op39 emitted bytes: %x", connection.write.Bytes()[before:])
}
if _, active := runtime.altar.activeSequences[sequence]; !active || runtime.altar.completedRounds != 0 {
t.Fatalf("unowned altar op39 active=%t completed=%d", active, runtime.altar.completedRounds)
}
})
}
}
func TestCurrentDungeonAltarDynamicOp39WriteFailureRetriesBeforeRetirement(t *testing.T) {
service, session, runtime, _, _ := prepareCurrentDungeonAltarRuntimeTest(t)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
queue.fire(queue.task(t, 0), false)
sequence := onlyCurrentDungeonAltarActiveSequence(t, runtime)
wantWriteErr := errors.New("altar dynamic op38 write failed")
session.conn = &failNthDungeonWriteConn{failAt: 1, err: wantWriteErr}
if err := service.handleDungeonMonsterDeathLocal(
session,
currentDungeonAltarDynamicOp39Body(sequence, currentSceneActorObjectKey(session.selectedCharacterID), 1),
); err != nil {
t.Fatal(err)
}
if _, active := runtime.altar.activeSequences[sequence]; !active {
t.Fatal("failed dynamic op38 retired altar sequence")
}
if runtime.altar.questDropRetry == nil || !runtime.altar.questDropRetry.DynamicDeathNotification ||
runtime.altar.completedRounds != 0 {
t.Fatalf("failed dynamic op38 retry=%+v completed=%d", runtime.altar.questDropRetry, runtime.altar.completedRounds)
}
retryConnection := &bufferConn{}
session.conn = retryConnection
queue.fire(queue.task(t, 1), false)
rest := assertCurrentDungeonAltarDynamicOp38(t, retryConnection.write.Bytes(), sequence)
prompt, trailing := splitGameServerUpperPacket(t, rest)
if prompt.Header.MsgID != currentDungeonAltarDifficultyUIMsgID || len(trailing) != 0 {
t.Fatalf("retry prompt=%+v body=%x trailing=%x", prompt.Header, prompt.Body, trailing)
}
if _, active := runtime.altar.activeSequences[sequence]; active || runtime.altar.questDropRetry != nil ||
runtime.altar.completedRounds != 1 {
t.Fatalf("retry active=%t pending=%+v completed=%d", active, runtime.altar.questDropRetry, runtime.altar.completedRounds)
}
}
func TestCurrentDungeonAltarPendingRetryQueuesLaterDynamicDeathsWithoutLoss(t *testing.T) {
service, session, runtime, _, connection := prepareCurrentDungeonAltarRuntimeTest(t)
queue := newFakeCurrentDungeonDeathTimerQueue()
service.gameplayTimers = queue
runtime.altar.boundMonsters[0].BaseSpawnCount = 3
repositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("repository group unavailable")
}
counter := &countingCurrentDungeonAltarSettlement{base: repositories.CharacterSettlement}
availableRepositories := repositories
availableRepositories.CharacterSettlement = counter
unavailableRepositories := availableRepositories
unavailableRepositories.CharacterSettlement = nil
service.repositoryProvider = func() (dnfrepo.Group, bool) { return unavailableRepositories, true }
if err := service.handleCurrentDungeonAltarReady(session, nil); err != nil {
t.Fatal(err)
}
queue.fire(queue.task(t, 0), false)
sequences := make([]uint16, 0, len(runtime.altar.activeSequences))
for sequence := range runtime.altar.activeSequences {
sequences = append(sequences, sequence)
}
if len(sequences) != 3 {
t.Fatalf("active sequences=%v", sequences)
}
for left := 0; left < len(sequences); left++ {
for right := left + 1; right < len(sequences); right++ {
if sequences[left] > sequences[right] {
sequences[left], sequences[right] = sequences[right], sequences[left]
}
}
}
beforeFailure := connection.write.Len()
firstDynamicBody := currentDungeonAltarDynamicOp39Body(sequences[0], 700, 0)
if err := service.handleDungeonMonsterDeathLocal(session, firstDynamicBody); err != nil {
t.Fatalf("queue first dynamic sequence %d: %v", sequences[0], err)
}
if err := service.handleCurrentDungeonAltarDeaths(
session,
[]byte{1, byte(sequences[1]), byte(sequences[1] >> 8)},
); err != nil {
t.Fatalf("queue op255 sequence %d: %v", sequences[1], err)
}
lastDynamicBody := currentDungeonAltarDynamicOp39Body(sequences[2], 701, 2)
if err := service.handleDungeonMonsterDeathLocal(session, lastDynamicBody); err != nil {
t.Fatalf("queue last dynamic sequence %d: %v", sequences[2], err)
}
if err := service.handleDungeonMonsterDeathLocal(session, lastDynamicBody); err != nil {
t.Fatalf("duplicate queued dynamic sequence %d: %v", sequences[2], err)
}
if connection.write.Len() != beforeFailure {
t.Fatalf("pending deaths emitted packets before retry: %x", connection.write.Bytes()[beforeFailure:])
}
if runtime.altar.questDropRetry == nil || len(runtime.altar.questDropRetryQueue) != 2 ||
len(runtime.altar.activeSequences) != 3 {
t.Fatalf("pending head=%+v queue=%+v active=%d",
runtime.altar.questDropRetry, runtime.altar.questDropRetryQueue, len(runtime.altar.activeSequences))
}
if !runtime.altar.questDropRetry.DynamicDeathNotification ||
runtime.altar.questDropRetryQueue[0].DynamicDeathNotification ||
!runtime.altar.questDropRetryQueue[1].DynamicDeathNotification {
t.Fatalf("pending dynamic markers head=%t queue=%t,%t",
runtime.altar.questDropRetry.DynamicDeathNotification,
runtime.altar.questDropRetryQueue[0].DynamicDeathNotification,
runtime.altar.questDropRetryQueue[1].DynamicDeathNotification)
}
pendingBatches := []*currentDungeonAltarQuestDropRetry{
runtime.altar.questDropRetry,
runtime.altar.questDropRetryQueue[0],
runtime.altar.questDropRetryQueue[1],
}
for index, pendingBatch := range pendingBatches {
owner := runtime.altar.activeSequences[sequences[index]]
if len(pendingBatch.Deaths) != 1 || pendingBatch.Deaths[0].Sequence != sequences[index] ||
pendingBatch.Deaths[0].Owner != owner {
t.Fatalf("pending batch %d=%+v owner=%+v", index, pendingBatch, owner)
}
}
retryTask := queue.task(t, 1)
pending := runtime.altar.questDropRetry
staleRoom := pending.Room
staleRoom.Generation++
if err := service.fireCurrentDungeonAltarQuestDropRetry(
session,
runtime,
runtime.lifecycleToken,
staleRoom,
pending.Version,
retryTask.name,
queue.Now(),
); err != nil {
t.Fatal(err)
}
if len(runtime.altar.activeSequences) != 3 || connection.write.Len() != beforeFailure {
t.Fatalf("stale room generation mutated queue: active=%d bytes=%d",
len(runtime.altar.activeSequences), connection.write.Len()-beforeFailure)
}
service.repositoryProvider = func() (dnfrepo.Group, bool) { return availableRepositories, true }
queue.fire(retryTask, false)
stream := connection.write.Bytes()[beforeFailure:]
stream = assertCurrentDungeonAltarDynamicOp38(t, stream, sequences[0])
stream = assertCurrentDungeonAltarDynamicOp38(t, stream, sequences[2])
prompt, trailing := splitGameServerUpperPacket(t, stream)
if prompt.Header.Classification != 0 || prompt.Header.MsgID != currentDungeonAltarDifficultyUIMsgID || len(trailing) != 0 {
t.Fatalf("queued dynamic prompt=%+v body=%x trailing=%x", prompt.Header, prompt.Body, trailing)
}
if counter.calls != 3 || len(runtime.altar.questDropResults) != 3 {
t.Fatalf("queued dynamic quest commits=%d cached=%d", counter.calls, len(runtime.altar.questDropResults))
}
if len(runtime.altar.activeSequences) != 0 || runtime.altar.completedRounds != 1 ||
runtime.altar.questDropRetry != nil || len(runtime.altar.questDropRetryQueue) != 0 ||
runtime.altar.questDropRetryProgress != currentDungeonAltarProgressNone {
t.Fatalf("queued dynamic drain active=%d completed=%d head=%+v queue=%+v progress=%d",
len(runtime.altar.activeSequences), runtime.altar.completedRounds, runtime.altar.questDropRetry,
runtime.altar.questDropRetryQueue, runtime.altar.questDropRetryProgress)
}
beforeStale := connection.write.Len()
queue.fire(retryTask, true)
if connection.write.Len() != beforeStale || counter.calls != 3 {
t.Fatalf("stale completed retry bytes=%d quest commits=%d", connection.write.Len()-beforeStale, counter.calls)
}
}
func TestCurrentDungeonAltarDynamicOp39DoesNotChangeOrdinaryDeath(t *testing.T) {
service, runtime, _ := prepareCurrentDungeonMonsterExperienceTest(t)
connection := &bufferConn{}
session := &gameSession{
conn: connection,
connID: "altar-dynamic-op39-ordinary-control",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
if runtime.altar != nil {
t.Fatal("ordinary control unexpectedly owns altar runtime")
}
monster := runtime.Room.Snapshot().Monsters[0]
body := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(body[0:4], monster.ObjectKey)
binary.LittleEndian.PutUint16(body[4:6], currentSceneActorObjectKey(session.selectedCharacterID))
if err := service.handleDungeonMonsterDeathLocal(session, body); err != nil {
t.Fatal(err)
}
death, _ := splitGameServerUpperPacket(t, connection.write.Bytes())
if death.Header.Classification != 0 || death.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) {
t.Fatalf("ordinary op39 response=%+v body=%x", death.Header, death.Body)
}
if got := runtime.Room.Snapshot().Monsters[0].State; got != runtimeDungeonMonsterDefeated {
t.Fatalf("ordinary op39 monster state=%s", got)
}
}
func currentDungeonAltarDynamicOp39Body(sequence uint16, owner uint16, combatEntryCount byte) []byte {
body := make([]byte, dungeoncmd.DieMonsterVariableBaseSize+int(combatEntryCount)*dungeoncmd.DieMonsterVariableCombatEntrySize)
binary.LittleEndian.PutUint32(body[0:4], uint32(sequence))
binary.LittleEndian.PutUint16(body[4:6], owner)
body[22] = combatEntryCount
return body
}
func onlyCurrentDungeonAltarActiveSequence(t *testing.T, runtime *runtimeDungeonState) uint16 {
t.Helper()
if runtime == nil || runtime.altar == nil || len(runtime.altar.activeSequences) != 1 {
t.Fatalf("altar active sequences=%v", runtime)
}
for sequence := range runtime.altar.activeSequences {
return sequence
}
return 0
}
func assertCurrentDungeonAltarDynamicOp38(t *testing.T, stream []byte, sequence uint16) []byte {
t.Helper()
packet, rest := splitGameServerUpperPacket(t, stream)
wantBody, err := buildCurrentDungeonDeathNotificationBody(uint32(sequence), currentDungeonDeathResponseMonster)
if err != nil {
t.Fatal(err)
}
if packet.Header.Classification != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
!bytes.Equal(packet.Body, wantBody) {
t.Fatalf("altar dynamic op38 header=%+v body=%x want=%x", packet.Header, packet.Body, wantBody)
}
return rest
}
func fireCurrentDungeonAltarNamedTimerTest(
t *testing.T,
queue *fakeCurrentDungeonDeathTimerQueue,
timerName string,
) {
t.Helper()
if queue == nil || timerName == "" {
t.Fatalf("altar timer unavailable: queue=%p name=%q", queue, timerName)
}
queue.mu.Lock()
task := queue.active[timerName]
queue.mu.Unlock()
if task == nil {
t.Fatalf("altar timer %q is not active", timerName)
}
queue.fire(task, false)
}
func prepareCurrentDungeonAltarEmptySettlementTestState(t *testing.T, runtime *runtimeDungeonState) {
t.Helper()
if runtime == nil || runtime.altar == nil {
t.Fatal("altar runtime unavailable")
}
plan, err := newDungeonCardRewardPlan(
dungeonCardPlanIdentity{
CharacterID: "99",
DungeonID: runtime.Dungeon.ID,
MazeIndex: runtime.MazeIndex,
RunSeed: runtime.Seed,
},
"altar-full-path-test",
dungeonCardRewardBundle{},
dungeonCardRewardBundle{},
)
if err != nil {
t.Fatal(err)
}
rewardState, err := newDungeonCardState(plan)
if err != nil {
t.Fatal(err)
}
runtime.altar.settlement = &currentDungeonAltarSettlementRuntime{
phase: currentDungeonAltarSettlementPrepared,
plan: currentDungeonAltarSettlementPlan{
CurrentRound: uint32(runtime.altar.MaxRounds),
PlayTimeMilliseconds: uint32(runtime.altar.MaxRounds * 100),
BestRound: uint32(runtime.altar.MaxRounds),
BestTimeMilliseconds: uint32(runtime.altar.MaxRounds * 100),
MaxRound: uint32(runtime.altar.MaxRounds),
CharacterStateBody: make([]byte, currentFinishLoadingCharacterStateBodySize),
},
rewardState: rewardState,
}
}
func prepareCurrentDungeonAltarRuntimeTest(
t *testing.T,
) (*Service, *gameSession, *runtimeDungeonState, worldmap.DungeonRoomScene, *bufferConn) {
t.Helper()
source := currentDungeonUltimateAltarPVFTestSource()
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "99",
AccountID: "account-1",
Level: 70,
Stats: map[string]int64{"fatigue": 100},
}); err != nil {
t.Fatal(err)
}
queue := newFakeCurrentDungeonDeathTimerQueue()
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 },
gameplayTimers: queue,
}
connection := &bufferConn{}
session := &gameSession{
conn: connection,
connID: "altar-runtime-test",
selectedCharacterID: 99,
characterGeneration: 1,
}
runtime, scene, err := service.prepareDungeonRuntime(
context.Background(),
session,
dungeoncmd.SelectDungeonRequest{DungeonID: 11007, Difficulty: 1},
)
if err != nil {
t.Fatal(err)
}
runtime.lifecycleToken = 1
session.dungeon.runtime = runtime
return service, session, runtime, scene, connection
}
func prepareCurrentDungeonEndlessAltarRoomMoveTest(
t *testing.T,
) (*Service, *gameSession, *runtimeDungeonState) {
t.Helper()
source := currentDungeonEndlessAltarRoomMovePVFTestSource()
table, resolver, monsters := loadBridgeDungeonStaticData(t, source)
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "99",
AccountID: "account-1",
Level: 70,
Stats: map[string]int64{"fatigue": 100, "gold": 100_000},
}); err != nil {
t.Fatal(err)
}
queue := newFakeCurrentDungeonDeathTimerQueue()
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 },
gameplayTimers: queue,
}
session := &gameSession{
conn: &bufferConn{},
connID: "endless-altar-room-move-test",
selectedCharacterID: 99,
characterGeneration: 1,
}
runtime, _, err := service.prepareDungeonRuntime(
context.Background(),
session,
dungeoncmd.SelectDungeonRequest{DungeonID: 11006, Difficulty: 1},
)
if err != nil {
t.Fatal(err)
}
runtime.lifecycleToken = 1
session.dungeon.runtime = runtime
return service, session, runtime
}
func currentDungeonEndlessAltarRoomMovePVFTestSource() bridgePVFSource {
phaseRows := ""
for round := 0; round < 5; round++ {
phaseRows += fmt.Sprintf("%d 0 0 1 0 0\n", round)
}
mapBody := func(name, mapType string) string {
return "[map name]\n`" + name + "`\n" +
"[dungeon]\n11006\n" +
"[type]\n`[" + mapType + "]`\n" +
"[blood monster]\n61010 0 440 222 0 0 0 1 0 1\n[/blood monster]\n" +
"[blood phase time]\n" + phaseRows + "[/blood phase time]\n"
}
return bridgePVFSource{
worldmap.DefaultMapList: "17020 `dungeon/altar/endless_start.map`\n17021 `dungeon/altar/endless_middle.map`\n17022 `dungeon/altar/endless_boss.map`\n",
"map/dungeon/altar/endless_start.map": mapBody("endless start", "start"),
"map/dungeon/altar/endless_middle.map": mapBody("endless middle", "normal"),
"map/dungeon/altar/endless_boss.map": mapBody("endless boss", "boss"),
worldmap.DefaultDungeonList: "11006 `altar/endless.dgn`\n",
"dungeon/altar/endless.dgn": "[name]\n`Endless Altar`\n" +
"[minimum required level]\n1\n[basis level]\n70\n[no fatigue]\n" +
"[blood dungeon]\n1\n[blood max round]\n15\n" +
"[maze info]\n[size]\n3 1\n[greed]\n`BFE`\n" +
"[map specification]\n`map` 0 0 17020 `map` 1 0 17021 `boss` 2 0 17022\n" +
"[start map]\n0 0\n[boss map]\n2 0\n",
worldmap.DefaultWorldMapList: "1 `altar.wdm`\n",
"worldmap/altar.wdm": "[name]\n`Altar`\n[dungeon]\n11006 -1\n[/dungeon]\n",
currentDungeonAltarAdmissionDefinitionPath: "[price average]\n1 50 8000 51 55 10000 56 99 12000\n" +
"[lotto cost]\n10000\n[admission increase]\n1 0 2 60 3 200 4 450 5 800 6 1350 7 1950 8 2650 9 3500 10 4550 11 4550 12 4550\n" +
"[lotto point]\n1 1 2 3 3 3 4 3 5 4 6 4 7 5 8 5 9 7 10 7 11 8 12 8 13 10\n",
dnfmonster.DefaultList: "1 `dummy.gob`\n",
"monster/dummy.gob": "[name]\n`dummy`\n[level]\n1\n[hp]\n1\n[exp]\n0\n",
}
}
func currentDungeonUltimateAltarPVFTestSource() bridgePVFSource {
phaseRows := ""
for round := 0; round < 7; round++ {
phaseRows += string(rune('0'+round)) + " 0 0 1 0 0 `A`\n"
phaseRows += string(rune('0'+round)) + " 0 0 1 0 0 `B`\n"
}
return bridgePVFSource{
worldmap.DefaultMapList: "17030 `dungeon/altar/ultimate.map`\n",
"map/dungeon/altar/ultimate.map": "[map name]\n`ultimate`\n" +
"[dungeon]\n11007\n" +
"[type]\n`[start]`\n" +
"[ultimate monster]\n56017 0 440 222 0 0 0 1 0 1\n[/ultimate monster]\n" +
"[ultimate phase time]\n" + phaseRows + "[/ultimate phase time]\n",
worldmap.DefaultDungeonList: "11007 `altar/ultimate.dgn`\n",
"dungeon/altar/ultimate.dgn": "[name]\n`Ultimate Altar`\n" +
"[minimum required level]\n1\n[basis level]\n70\n[no fatigue]\n[limit party count]\n1\n" +
"[blood dungeon]\n2\n[blood max round]\n7\n" +
"[maze info]\n[size]\n1 1\n[greed]\n`A`\n" +
"[map specification]\n`map` 0 0 17030\n[start map]\n0 0\n[boss map]\n0 0\n",
worldmap.DefaultWorldMapList: "1 `altar.wdm`\n",
"worldmap/altar.wdm": "[name]\n`Altar`\n[dungeon]\n11007 -1\n[/dungeon]\n",
dnfmonster.DefaultList: "1 `dummy.gob`\n",
"monster/dummy.gob": "[name]\n`dummy`\n[level]\n1\n[hp]\n1\n[exp]\n0\n",
}
}