Files
90CNServer/server/internal/services/dnfbridge/dungeon_boss_die_check_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

1290 lines
54 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"os"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
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"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestHandleDungeonBossDieCheckCompletesGenericPVFScriptedFinal(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
job: "12",
dungeonReference: tutorialScopeKnightFDungeonReference,
mapDirectory: tutorialScopeKnightFMapDirectory,
})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
room := runtime.Room.Snapshot()
targetKey := room.Monsters[0].ObjectKey
remainingKey := room.Monsters[1].ObjectKey
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "generic-pvf-scripted-final-op117-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
if err := service.handleDungeonMonsterDeath(session, tutorialScopeVariableZeroCombatDeathBody(targetKey)); err != nil {
t.Fatalf("commit PVF scripted target death: %v", err)
}
conn.write.Reset()
if err := service.handleDungeonBossDieCheck(session, bossDieCheckRequestBody(99, uint16(targetKey))); err != nil {
t.Fatalf("handle op117: %v", err)
}
assertBossResponseAndSettlementEntry(t, conn.write.Bytes(), uint16(targetKey))
snapshot := runtime.Session.Snapshot()
if snapshot.Run.Status != worldmap.DungeonRunCompleted || !snapshot.Scene.Cleared {
t.Fatalf("final lifecycle=%+v", snapshot)
}
if len(snapshot.Scene.DefeatedObjects) != 1 || snapshot.Scene.DefeatedObjects[0] != targetKey {
t.Fatalf("final completion fabricated defeated actors=%v", snapshot.Scene.DefeatedObjects)
}
after := runtime.Room.Snapshot()
if after.Monsters[0].State != runtimeDungeonMonsterDefeated ||
after.Monsters[1].ObjectKey != remainingKey || after.Monsters[1].State != runtimeDungeonMonsterAnnounced {
t.Fatalf("final completion changed unrelated actor state=%+v", after.Monsters)
}
if !runtime.bossDieCheckAccepted || !runtime.tutorialCompletionPersisted ||
!runtime.bossDieCheckResponseSent || !runtime.settlementEntrySent ||
session.dungeon.runtime != runtime || runtime.townReturnPending || runtime.townReturnOp24Sent {
t.Fatalf("completion ownership accepted=%t persisted=%t response=%t runtime=%p",
runtime.bossDieCheckAccepted, runtime.tutorialCompletionPersisted,
runtime.bossDieCheckResponseSent, session.dungeon.runtime)
}
assertTutorialCompletionPersistedAndSelectAcked(t, service, 99)
beforeReplay := conn.write.Len()
if err := service.handleDungeonBossDieCheck(session, bossDieCheckRequestBody(99, uint16(targetKey))); err != nil {
t.Fatalf("replayed op117: %v", err)
}
if conn.write.Len() != beforeReplay {
t.Fatalf("replayed op117 emitted another completion packet: before=%d after=%d", beforeReplay, conn.write.Len())
}
}
func TestHandleDungeonBossDieCheckCompletesClearedTutorialFinal(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
job: "12",
dungeonReference: tutorialScopeKnightFDungeonReference,
mapDirectory: tutorialScopeKnightFMapDirectory,
singleMonster: true,
})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "generic-cleared-final-op117-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
deathBody := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(deathBody[0:4], targetKey)
binary.LittleEndian.PutUint16(deathBody[4:6], 99)
if err := service.handleDungeonMonsterDeath(session, deathBody); err != nil {
t.Fatalf("clear final room: %v", err)
}
if scene := runtime.Session.Snapshot().Scene; !scene.Cleared {
t.Fatalf("single-monster final was not cleared: %+v", scene)
}
conn.write.Reset()
if err := service.handleDungeonBossDieCheck(session, bossDieCheckRequestBody(99, uint16(targetKey))); err != nil {
t.Fatal(err)
}
assertBossResponseAndSettlementEntry(t, conn.write.Bytes(), uint16(targetKey))
if status := runtime.Session.Snapshot().Run.Status; status != worldmap.DungeonRunCompleted {
t.Fatalf("cleared final status=%s", status)
}
if session.dungeon.runtime != runtime || !runtime.settlementEntrySent || runtime.townReturnPending {
t.Fatalf("cleared final lost pending runtime after op117=%+v", session.dungeon.runtime)
}
}
func TestHandleDungeonBossDieCheckCompletesClearedOrdinaryRuntimeBossWithoutTutorialPersistence(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
disableTutorial: true,
singleMonster: true,
})
if isPVFTutorialDungeon(runtime) {
t.Fatal("ordinary dungeon fixture retained tutorial ownership")
}
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
if _, cleared, err := runtime.Room.CommitActorDeathReport(targetKey, runtime.Session); err != nil || !cleared {
t.Fatalf("commit ordinary boss death cleared=%t err=%v", cleared, err)
}
repositories, ok := service.repositoryGroup()
if !ok || repositories.Character == nil {
t.Fatal("ordinary fixture character repository unavailable")
}
before, found, err := repositories.Character.Load(context.Background(), "99")
if err != nil || !found {
t.Fatalf("load ordinary character before op117 found=%t err=%v", found, err)
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "ordinary-cleared-runtime-boss-op117-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatalf("ordinary op117: %v", err)
}
assertBossResponseAndSettlementEntry(t, conn.write.Bytes(), uint16(targetKey))
if !runtime.bossDieCheckAccepted || !runtime.bossDieCheckResponseSent ||
!runtime.settlementEntrySent || runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunCompleted {
t.Fatalf("ordinary completion state=%+v", runtime)
}
after, found, err := repositories.Character.Load(context.Background(), "99")
if err != nil || !found {
t.Fatalf("load ordinary character after op117 found=%t err=%v", found, err)
}
if before.Stats[currentDungeonTutorialCompletedKey] != 0 ||
after.Stats[currentDungeonTutorialCompletedKey] != 0 ||
runtime.Character.Stats[currentDungeonTutorialCompletedKey] != 0 {
t.Fatalf("ordinary completion mutated tutorial state before=%v after=%v runtime=%v",
before.Stats, after.Stats, runtime.Character.Stats)
}
beforeReplay := conn.write.Len()
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatalf("ordinary op117 replay: %v", err)
}
if conn.write.Len() != beforeReplay {
t.Fatalf("ordinary replay emitted duplicate packets before=%d after=%d", beforeReplay, conn.write.Len())
}
}
func TestCurrentDungeonOrdinaryFinalRoomReadyRejectsBossCoordinateMismatch(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
disableTutorial: true,
singleMonster: true,
})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "ordinary-op39-without-op117-coordinate-mismatch-rejected",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
scene := runtime.Session.Snapshot().Scene
runtime.BossCoordinate = worldmap.RoomCoordinate{X: scene.Coordinate.X + 7, Y: scene.Coordinate.Y + 7}
deathBody := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(deathBody[0:4], targetKey)
binary.LittleEndian.PutUint16(deathBody[4:6], 99)
if err := service.handleDungeonMonsterDeath(session, deathBody); err != nil {
t.Fatal(err)
}
if runtime.ordinaryFinalRoomClearAccepted || runtime.bossDieCheckAccepted ||
runtime.settlementEntrySent || runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunActive {
t.Fatalf("ordinary coordinate mismatch entered settlement: runtime=%+v snapshot=%+v bytes=%x",
runtime, runtime.Session.Snapshot(), conn.write.Bytes())
}
}
func TestCurrentDungeonOrdinaryFinalRoomReadyRejectsClearedNonBossTerminalRoom(t *testing.T) {
_, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
disableTutorial: true,
singleMonster: true,
})
scene := runtime.Session.Snapshot().Scene
scene.Cleared = true
scene.Boss = false
if ready, source := currentDungeonOrdinaryFinalRoomReady(runtime, scene); ready || source != "" {
t.Fatalf("non-boss terminal room accepted as final ready=%t source=%q", ready, source)
}
}
func TestHandleDungeonMonsterDeathBossRankForcesOrdinaryFinalRoomClear(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
disableTutorial: true,
})
if isPVFTutorialDungeon(runtime) {
t.Fatal("ordinary boss forced-clear fixture retained tutorial ownership")
}
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
room := runtime.Room.Snapshot()
if len(room.Monsters) != 2 {
t.Fatalf("fixture monster count=%d want=2", len(room.Monsters))
}
targetKey := room.Monsters[0].ObjectKey
remainingKey := room.Monsters[1].ObjectKey
if normalizeDungeonPVFSymbol(room.Monsters[0].Spawn.Rank) != "boss" ||
normalizeDungeonPVFSymbol(room.Monsters[1].Spawn.Rank) == "boss" {
t.Fatalf("fixture ranks=%q/%q", room.Monsters[0].Spawn.Rank, room.Monsters[1].Spawn.Rank)
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "ordinary-boss-op39-forces-final-clear",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
deathBody := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(deathBody[0:4], targetKey)
binary.LittleEndian.PutUint16(deathBody[4:6], 99)
if err := service.handleDungeonMonsterDeath(session, deathBody); err != nil {
t.Fatal(err)
}
snapshot := runtime.Session.Snapshot()
if snapshot.Run.Status != worldmap.DungeonRunCompleted || !snapshot.Scene.Cleared ||
!runtime.ordinaryFinalRoomClearAccepted || !runtime.settlementEntrySent {
t.Fatalf("boss op39 did not enter ordinary final settlement: runtime=%+v snapshot=%+v",
runtime, snapshot)
}
if !dungeonSceneObjectDefeated(snapshot.Scene.DefeatedObjects, targetKey) ||
!dungeonSceneObjectDefeated(snapshot.Scene.DefeatedObjects, remainingKey) {
t.Fatalf("boss forced clear did not mark both actors defeated: defeated=%v boss=%d remaining=%d",
snapshot.Scene.DefeatedObjects, targetKey, remainingKey)
}
after := runtime.Room.Snapshot()
if after.Monsters[0].State != runtimeDungeonMonsterDefeated ||
after.Monsters[1].State != runtimeDungeonMonsterDefeated {
t.Fatalf("boss forced clear left runtime actor states=%+v", after.Monsters)
}
bossDeath, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if bossDeath.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
binary.LittleEndian.Uint16(bossDeath.Body[:2]) != uint16(targetKey) {
t.Fatalf("boss death packet header=%+v body=%x", bossDeath.Header, bossDeath.Body)
}
forcedDeath, _ := splitGameServerUpperPacket(t, rest)
if forcedDeath.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
binary.LittleEndian.Uint16(forcedDeath.Body[:2]) != uint16(remainingKey) {
t.Fatalf("forced visual death packet header=%+v body=%x want_key=%d",
forcedDeath.Header, forcedDeath.Body, remainingKey)
}
}
func TestHandleDungeonMonsterDeathSettlementContinuesWhenQuestSyncUnavailable(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
disableTutorial: true,
})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
room := runtime.Room.Snapshot()
if len(room.Monsters) != 2 {
t.Fatalf("fixture monster count=%d want=2", len(room.Monsters))
}
targetKey := room.Monsters[0].ObjectKey
service.repositoryProvider = nil
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "ordinary-boss-quest-sync-unavailable-still-settlement",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
deathBody := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(deathBody[0:4], targetKey)
binary.LittleEndian.PutUint16(deathBody[4:6], 99)
if err := service.handleDungeonMonsterDeath(session, deathBody); err != nil {
t.Fatal(err)
}
snapshot := runtime.Session.Snapshot()
if snapshot.Run.Status != worldmap.DungeonRunCompleted || !snapshot.Scene.Cleared ||
!runtime.ordinaryFinalRoomClearAccepted || !runtime.bossDieCheckAccepted ||
!runtime.bossDieCheckResponseSent || !runtime.settlementEntrySent {
t.Fatalf("quest-sync failure blocked 86JP settlement flow: runtime=%+v snapshot=%+v",
runtime, snapshot)
}
packets := splitAllFinishBridgePackets(t, conn.write.Bytes())
if len(packets) < 4 {
t.Fatalf("packet count=%d want boss death, forced death, op115, op31", len(packets))
}
if packets[len(packets)-2].Header.MsgID != uint16(dnfenum.CmdPacketNotifyBossDieCheck) ||
packets[len(packets)-1].Header.MsgID != currentDungeonSettlementEntryMsgID {
t.Fatalf("final packets msg=%d/%d want op115/op31",
packets[len(packets)-2].Header.MsgID,
packets[len(packets)-1].Header.MsgID)
}
}
func TestHandleDungeonMonsterDeathBossSuffixForcesOrdinaryFinalRoomClear(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
disableTutorial: true,
bossRank: "[dummy]",
bossSuffixMarker: "[boss]",
})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
room := runtime.Room.Snapshot()
if len(room.Monsters) != 2 {
t.Fatalf("fixture monster count=%d want=2", len(room.Monsters))
}
targetKey := room.Monsters[0].ObjectKey
remainingKey := room.Monsters[1].ObjectKey
if normalizeDungeonPVFSymbol(room.Monsters[0].Spawn.Rank) == "boss" ||
normalizeDungeonPVFSymbol(room.Monsters[0].Spawn.SuffixMarker) != "boss" {
t.Fatalf("fixture boss suffix not represented as rank+suffix: %+v", room.Monsters[0].Spawn)
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "ordinary-boss-suffix-op39-forces-final-clear",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
deathBody := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(deathBody[0:4], targetKey)
binary.LittleEndian.PutUint16(deathBody[4:6], 99)
if err := service.handleDungeonMonsterDeath(session, deathBody); err != nil {
t.Fatal(err)
}
snapshot := runtime.Session.Snapshot()
if snapshot.Run.Status != worldmap.DungeonRunCompleted || !snapshot.Scene.Cleared ||
!runtime.ordinaryFinalRoomClearAccepted || !runtime.settlementEntrySent {
t.Fatalf("boss suffix op39 did not enter settlement: runtime=%+v snapshot=%+v",
runtime, snapshot)
}
if !dungeonSceneObjectDefeated(snapshot.Scene.DefeatedObjects, targetKey) ||
!dungeonSceneObjectDefeated(snapshot.Scene.DefeatedObjects, remainingKey) {
t.Fatalf("boss suffix forced clear did not mark both actors defeated: defeated=%v boss=%d remaining=%d",
snapshot.Scene.DefeatedObjects, targetKey, remainingKey)
}
bossDeath, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if bossDeath.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
binary.LittleEndian.Uint16(bossDeath.Body[:2]) != uint16(targetKey) {
t.Fatalf("boss suffix death packet header=%+v body=%x", bossDeath.Header, bossDeath.Body)
}
forcedDeath, _ := splitGameServerUpperPacket(t, rest)
if forcedDeath.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
binary.LittleEndian.Uint16(forcedDeath.Body[:2]) != uint16(remainingKey) {
t.Fatalf("boss suffix forced visual death packet header=%+v body=%x want_key=%d",
forcedDeath.Header, forcedDeath.Body, remainingKey)
}
}
func TestHandleDungeonMonsterDeathClearConditionTargetForcesOrdinaryFinalRoomClear(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
disableTutorial: true,
bossRank: "[normal]",
clearCondition: "[clear condition]\n[hunt monster]\n3001 1\n[/clear condition]\n",
})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
room := runtime.Room.Snapshot()
if len(room.Monsters) != 2 {
t.Fatalf("fixture monster count=%d want=2", len(room.Monsters))
}
targetKey := room.Monsters[0].ObjectKey
remainingKey := room.Monsters[1].ObjectKey
if normalizeDungeonPVFSymbol(room.Monsters[0].Spawn.Rank) == "boss" ||
currentDungeonClearConditionSource(runtime, room.Monsters[0]) == "" {
t.Fatalf("fixture should rely on clear condition, not boss rank: monster=%+v conditions=%+v",
room.Monsters[0], runtime.Dungeon.Mazes[runtime.MazeIndex].ClearConditions)
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "ordinary-clear-condition-op39-forces-final-clear",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
deathBody := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(deathBody[0:4], targetKey)
binary.LittleEndian.PutUint16(deathBody[4:6], 99)
if err := service.handleDungeonMonsterDeath(session, deathBody); err != nil {
t.Fatal(err)
}
snapshot := runtime.Session.Snapshot()
if snapshot.Run.Status != worldmap.DungeonRunCompleted || !snapshot.Scene.Cleared ||
!runtime.ordinaryFinalRoomClearAccepted || !runtime.settlementEntrySent {
t.Fatalf("clear-condition op39 did not enter settlement: runtime=%+v snapshot=%+v",
runtime, snapshot)
}
if !dungeonSceneObjectDefeated(snapshot.Scene.DefeatedObjects, targetKey) ||
!dungeonSceneObjectDefeated(snapshot.Scene.DefeatedObjects, remainingKey) {
t.Fatalf("clear-condition forced clear did not mark both actors defeated: defeated=%v target=%d remaining=%d",
snapshot.Scene.DefeatedObjects, targetKey, remainingKey)
}
firstDeath, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if firstDeath.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
binary.LittleEndian.Uint16(firstDeath.Body[:2]) != uint16(targetKey) {
t.Fatalf("clear-condition death packet header=%+v body=%x", firstDeath.Header, firstDeath.Body)
}
forcedDeath, _ := splitGameServerUpperPacket(t, rest)
if forcedDeath.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
binary.LittleEndian.Uint16(forcedDeath.Body[:2]) != uint16(remainingKey) {
t.Fatalf("clear-condition forced visual death packet header=%+v body=%x want_key=%d",
forcedDeath.Header, forcedDeath.Body, remainingKey)
}
}
func TestHandleDungeonBossDieCheckOrdinaryRuntimeRequiresOp39DeathAndClearedRoom(t *testing.T) {
t.Run("op117 before op39 is not retained", func(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
disableTutorial: true,
singleMonster: true,
})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "ordinary-op117-before-op39-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
if err := service.handleDungeonBossDieCheck(session, bossDieCheckRequestBody(99, uint16(targetKey))); err != nil {
t.Fatal(err)
}
if conn.write.Len() != 0 || runtime.bossDieCheckPending || runtime.bossDieCheckAccepted ||
runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunActive {
t.Fatalf("early ordinary op117 changed state=%+v wrote=%x", runtime, conn.write.Bytes())
}
})
t.Run("defeated boss target force-clears another blocker before completion", func(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
room := runtime.Room.Snapshot()
targetKey := room.Monsters[0].ObjectKey
remainingKey := room.Monsters[1].ObjectKey
if _, cleared, err := runtime.Room.CommitActorDeathReport(targetKey, runtime.Session); err != nil || cleared {
t.Fatalf("commit partial ordinary boss death cleared=%t err=%v", cleared, err)
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "ordinary-uncleared-boss-op117-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
if err := service.handleDungeonBossDieCheck(session, bossDieCheckRequestBody(99, uint16(targetKey))); err != nil {
t.Fatal(err)
}
snapshot := runtime.Session.Snapshot()
if !runtime.bossDieCheckAccepted || !runtime.settlementEntrySent ||
snapshot.Run.Status != worldmap.DungeonRunCompleted ||
!dungeonSceneObjectDefeated(snapshot.Scene.DefeatedObjects, remainingKey) {
t.Fatalf("ordinary op117 did not force-clear and settle: state=%+v snapshot=%+v wrote=%x",
runtime, snapshot, conn.write.Bytes())
}
forcedDeath, _ := splitGameServerUpperPacket(t, conn.write.Bytes())
if forcedDeath.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) ||
binary.LittleEndian.Uint16(forcedDeath.Body[:2]) != uint16(remainingKey) {
t.Fatalf("op117 force-clear first packet header=%+v body=%x want_key=%d",
forcedDeath.Header, forcedDeath.Body, remainingKey)
}
})
}
func TestRealScriptPVFDungeon3BossRoomOp117EntersSettlementWithoutTutorialMutation(t *testing.T) {
pvfPath := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to run real dungeon 3 boss settlement smoke")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
table, err := worldmap.LoadSource(context.Background(), archive, worldmap.Options{})
if err != nil {
t.Fatal(err)
}
resolver, err := worldmap.NewResolver(table)
if err != nil {
t.Fatal(err)
}
monsters, err := newPVFDungeonMonsterCatalog(archive)
if err != nil {
t.Fatal(err)
}
aiCharacters, err := newPVFDungeonAICharacterCatalog(archive)
if err != nil {
t.Fatal(err)
}
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "99",
AccountID: "account-1",
Name: "Dungeon3BossSmoke",
Job: "2",
Level: 20,
Stats: map[string]int64{"fatigue": 156},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
CharacterID: "99",
States: map[int64]dnfrepo.QuestState{
3145: {Status: "active", ProgressValue: 1},
},
}); err != nil {
t.Fatal(err)
}
service := &Service{
options: options{accountID: "account-1", gameUpperBodyCodec: gameUpperBodyCodecPlain},
initialEquipmentArchive: archive,
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonAICharacterTable: aiCharacters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 1, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
session := &gameSession{
conn: &bufferConn{},
connID: "real-dungeon3-boss-op117-test",
selectedCharacterID: 99,
}
runtime, startScene, err := service.prepareDungeonRuntime(
context.Background(),
session,
dungeoncmd.SelectDungeonRequest{DungeonID: 3, Difficulty: 0},
)
if err != nil {
t.Fatalf("prepare real dungeon 3 runtime: %v", err)
}
session.dungeon.runtime = runtime
if runtime.MazeIndex != 1 || runtime.BossCoordinate != (worldmap.RoomCoordinate{X: 4, Y: 0}) ||
isPVFTutorialDungeon(runtime) {
t.Fatalf("real dungeon 3 owner maze=%d boss=%s tutorial=%t",
runtime.MazeIndex, runtime.BossCoordinate, isPVFTutorialDungeon(runtime))
}
topology, err := worldmap.BuildDungeonLayout(
resolver,
runtime.Dungeon.ID,
runtime.MazeIndex,
func(choice worldmap.DungeonMapChoice) (int64, error) { return choice.Candidates[0].ID, nil },
)
if err != nil {
t.Fatal(err)
}
queue := []worldmap.RoomCoordinate{startScene.Coordinate}
visited := map[worldmap.RoomCoordinate]bool{startScene.Coordinate: true}
previous := make(map[worldmap.RoomCoordinate]worldmap.RoomCoordinate)
for len(queue) != 0 && !visited[runtime.BossCoordinate] {
current := queue[0]
queue = queue[1:]
room, ok := topology.Room(current)
if !ok {
t.Fatalf("real dungeon 3 path room missing: %s", current)
}
for _, neighbor := range room.Neighbors {
if visited[neighbor.Coordinate] {
continue
}
visited[neighbor.Coordinate] = true
previous[neighbor.Coordinate] = current
queue = append(queue, neighbor.Coordinate)
}
}
if !visited[runtime.BossCoordinate] {
t.Fatalf("real dungeon 3 boss room %s unreachable from %s", runtime.BossCoordinate, startScene.Coordinate)
}
path := []worldmap.RoomCoordinate{runtime.BossCoordinate}
for path[len(path)-1] != startScene.Coordinate {
path = append(path, previous[path[len(path)-1]])
}
for left, right := 0, len(path)-1; left < right; left, right = left+1, right-1 {
path[left], path[right] = path[right], path[left]
}
clearCurrentRoom := func() {
t.Helper()
room := runtime.Room.Snapshot()
actorsPlanned := false
for _, monster := range room.Monsters {
actorsPlanned = actorsPlanned || monster.State == runtimeDungeonMonsterPlanned
}
for _, actor := range room.ExtendedActors {
actorsPlanned = actorsPlanned || actor.State == runtimeDungeonMonsterPlanned
}
if actorsPlanned {
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatalf("announce real dungeon 3 room: %v", err)
}
}
scene := runtime.Session.Snapshot().Scene
for _, reference := range scene.BlockingHostiles {
var objectKey uint32
for candidate, bound := range scene.RuntimeObjects {
if bound == reference {
objectKey = candidate
break
}
}
if objectKey == 0 {
t.Fatalf("real dungeon 3 blocker has no runtime object: %+v", reference)
}
if _, _, defeated := currentDungeonDefeatedActor(runtime, scene, objectKey); defeated {
continue
}
if _, _, err := runtime.Room.CommitActorDeathReport(objectKey, runtime.Session); err != nil {
t.Fatalf("defeat real dungeon 3 blocker key=%d reference=%+v: %v", objectKey, reference, err)
}
scene = runtime.Session.Snapshot().Scene
}
if !runtime.Session.Snapshot().Scene.Cleared {
t.Fatalf("real dungeon 3 room did not clear: %+v", runtime.Session.Snapshot().Scene)
}
}
for _, target := range path[1:] {
clearCurrentRoom()
body := make([]byte, dungeoncmd.MoveMapRequestSize)
body[0] = byte(target.X)
body[1] = byte(target.Y)
session.conn.(*bufferConn).write.Reset()
if err := service.handleDungeonMoveMap(session, body); err != nil {
t.Fatalf("move real dungeon 3 to %s: %v", target, err)
}
if scene := runtime.Session.Snapshot().Scene; scene.Coordinate != target {
t.Fatalf("real dungeon 3 move committed %s want %s", scene.Coordinate, target)
}
}
if scene := runtime.Session.Snapshot().Scene; !scene.Boss || scene.Map.Map.ID != 76126 ||
scene.Coordinate != runtime.BossCoordinate {
t.Fatalf("real dungeon 3 boss scene=%+v", scene)
}
roomBeforeDeath := runtime.Room.Snapshot()
var targetKey uint32
for _, monster := range roomBeforeDeath.Monsters {
if normalizeDungeonPVFSymbol(monster.Spawn.Rank) == "boss" {
targetKey = monster.ObjectKey
break
}
}
if targetKey == 0 {
t.Fatalf("real dungeon 3 boss room has no PVF boss-ranked monster: %+v", roomBeforeDeath.Monsters)
}
clearCurrentRoom()
conn := session.conn.(*bufferConn)
conn.write.Reset()
if err := service.handleDungeonBossDieCheck(session, bossDieCheckRequestBody(99, uint16(targetKey))); err != nil {
t.Fatalf("real dungeon 3 op117: %v", err)
}
assertClearMapNotificationThenBossResponseAndSettlementEntry(t, conn.write.Bytes(), uint16(targetKey), 3145)
if runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunCompleted ||
!runtime.bossDieCheckAccepted || !runtime.clearMapCompletionPhaseAPersisted || !runtime.settlementEntrySent {
t.Fatalf("real dungeon 3 completion state=%+v", runtime)
}
questRecord, found, err := repositories.Quest.Load(context.Background(), "99")
if err != nil || !found {
t.Fatalf("load real dungeon 3 quest after op117 found=%t err=%v", found, err)
}
questState := questRecord.States[3145]
if questState.Status != "active" || questState.ProgressValue != 0 ||
questState.Extra["completion_key"] != runtime.clearMapCompletionKey ||
questState.Extra["completion_dungeon_id"] != "3" ||
questState.Extra["completion_map_id"] != "76126" ||
questState.Extra["reward_state"] != "pending" {
t.Fatalf("real dungeon 3 clear-map state=%+v runtime_key=%q", questState, runtime.clearMapCompletionKey)
}
character, found, err := repositories.Character.Load(context.Background(), "99")
if err != nil || !found || character.Stats[currentDungeonTutorialCompletedKey] != 0 {
t.Fatalf("real dungeon 3 tutorial flag found=%t value=%d err=%v",
found, character.Stats[currentDungeonTutorialCompletedKey], err)
}
}
func TestHandleGameUpperRoutesPlainBossDieCheckAndRejectsWrongClass(t *testing.T) {
for _, test := range []struct {
name string
classification byte
wantResponse bool
}{
{name: "class one request", classification: dnfproto.DefaultChannelClassification, wantResponse: true},
{name: "wrong class", classification: 2},
} {
t.Run(test.name, func(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{singleMonster: true})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
if _, _, err := runtime.Room.CommitActorDeathReport(targetKey, runtime.Session); err != nil {
t.Fatal(err)
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "op117-upper-dispatch-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
request, err := dnfproto.BuildChannelPacket(
uint16(dnfenum.CmdPacketBossDieCheck),
bossDieCheckRequestBody(99, uint16(targetKey)),
0,
test.classification,
)
if err != nil {
t.Fatal(err)
}
if err := service.handleGameUpper(session, request); err != nil {
t.Fatalf("handle upper op117: %v", err)
}
if test.wantResponse {
assertBossResponseAndSettlementEntry(t, conn.write.Bytes(), uint16(targetKey))
if !runtime.bossDieCheckResponseSent || !runtime.tutorialCompletionPersisted ||
!runtime.settlementEntrySent || session.dungeon.runtime != runtime || runtime.townReturnPending {
t.Fatalf("upper op117 completion response=%t persisted=%t runtime=%p",
runtime.bossDieCheckResponseSent, runtime.tutorialCompletionPersisted, session.dungeon.runtime)
}
return
}
if conn.write.Len() != 0 || runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunActive {
t.Fatalf("wrong class wrote=%x snapshot=%+v", conn.write.Bytes(), runtime.Session.Snapshot())
}
})
}
}
func TestHandleDungeonBossDieCheckRejectsUnownedOrUnprovenFinals(t *testing.T) {
tests := []struct {
name string
fixture tutorialScopeFixtureOptions
mutate func(*runtimeDungeonState, *gameSession, uint32, []byte)
body func(uint32) []byte
killTarget bool
}{
{
name: "protected forty byte boundary",
body: func(uint32) []byte { return make([]byte, 40) },
},
{
name: "writer reserved invariant",
body: func(key uint32) []byte {
body := bossDieCheckRequestBody(99, uint16(key))
body[4] = 1
return body
},
},
{
name: "target not defeated",
body: func(key uint32) []byte { return bossDieCheckRequestBody(99, uint16(key)) },
},
{
name: "hostile related actor",
killTarget: true,
body: func(key uint32) []byte { return bossDieCheckRequestBody(uint16(key), uint16(key)) },
},
{
name: "active runtime room map owner mismatch",
killTarget: true,
mutate: func(runtime *runtimeDungeonState, _ *gameSession, _ uint32, _ []byte) {
runtime.Room.mu.Lock()
runtime.Room.mapID++
runtime.Room.mu.Unlock()
},
body: func(key uint32) []byte { return bossDieCheckRequestBody(99, uint16(key)) },
},
{
name: "not selected boss coordinate",
killTarget: true,
mutate: func(runtime *runtimeDungeonState, _ *gameSession, _ uint32, _ []byte) {
runtime.BossCoordinate.X++
},
body: func(key uint32) []byte { return bossDieCheckRequestBody(99, uint16(key)) },
},
{
name: "single cinematic does not cover every remaining blocker",
fixture: tutorialScopeFixtureOptions{
incompleteCinematicCoverage: true,
},
killTarget: true,
body: func(key uint32) []byte { return bossDieCheckRequestBody(99, uint16(key)) },
},
{
name: "current room is not boss",
fixture: tutorialScopeFixtureOptions{
currentRoomNonBoss: true,
singleMonster: true,
},
killTarget: true,
body: func(key uint32) []byte { return bossDieCheckRequestBody(99, uint16(key)) },
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, test.fixture)
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
room := runtime.Room.Snapshot()
if len(room.Monsters) == 0 {
t.Fatal("fixture has no target monster")
}
targetKey := room.Monsters[0].ObjectKey
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "rejected-op117-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
if test.killTarget {
if _, _, err := runtime.Room.CommitActorDeathReport(targetKey, runtime.Session); err != nil {
t.Fatalf("commit target death: %v", err)
}
}
body := test.body(targetKey)
if test.mutate != nil {
test.mutate(runtime, session, targetKey, body)
}
before := runtime.Session.Snapshot()
if err := service.handleDungeonBossDieCheck(session, body); err != nil {
t.Fatalf("handle rejected op117: %v", err)
}
if conn.write.Len() != 0 || runtime.bossDieCheckAccepted {
t.Fatalf("rejected op117 wrote=%x accepted=%t", conn.write.Bytes(), runtime.bossDieCheckAccepted)
}
after := runtime.Session.Snapshot()
if after.Run.Status != before.Run.Status || after.Scene.Cleared != before.Scene.Cleared ||
len(after.Scene.DefeatedObjects) != len(before.Scene.DefeatedObjects) {
t.Fatalf("rejected op117 changed state before=%+v after=%+v", before, after)
}
})
}
}
func TestHandleDungeonBossDieCheckRejectsStaleRuntimeCharacterOwner(t *testing.T) {
t.Run("initial request", func(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{singleMonster: true})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
if _, _, err := runtime.Room.CommitActorDeathReport(targetKey, runtime.Session); err != nil {
t.Fatal(err)
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "stale-owner-initial-op117-test",
selectedCharacterID: 100,
dungeon: dungeonSessionState{runtime: runtime},
}
if err := service.handleDungeonBossDieCheck(session, bossDieCheckRequestBody(100, uint16(targetKey))); err != nil {
t.Fatal(err)
}
if conn.write.Len() != 0 || runtime.bossDieCheckAccepted ||
runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunActive {
t.Fatalf("stale initial op117 wrote=%x accepted=%t status=%s",
conn.write.Bytes(), runtime.bossDieCheckAccepted,
runtime.Session.Snapshot().Run.Status)
}
})
t.Run("accepted replay after op115 failure", func(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{singleMonster: true})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
if _, _, err := runtime.Room.CommitActorDeathReport(targetKey, runtime.Session); err != nil {
t.Fatal(err)
}
wantErr := errors.New("first op115 write failed")
failingConn := &failNthDungeonWriteConn{failAt: 1, err: wantErr}
session := &gameSession{
conn: failingConn,
connID: "stale-owner-replay-op117-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); !errors.Is(err, wantErr) {
t.Fatalf("first op115 failure=%v want=%v", err, wantErr)
}
if !runtime.bossDieCheckAccepted || !runtime.tutorialCompletionPersisted || runtime.bossDieCheckResponseSent ||
runtime.Session.Snapshot().Run.Status != worldmap.DungeonRunCompleted {
t.Fatalf("failed op115 state accepted=%t persisted=%t response=%t status=%s",
runtime.bossDieCheckAccepted, runtime.tutorialCompletionPersisted, runtime.bossDieCheckResponseSent,
runtime.Session.Snapshot().Run.Status)
}
conn := &bufferConn{}
session.conn = conn
session.selectedCharacterID = 100
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatal(err)
}
if conn.write.Len() != 0 || runtime.bossDieCheckResponseSent || session.dungeon.runtime != runtime {
t.Fatalf("stale replay wrote=%x response=%t runtime=%p",
conn.write.Bytes(),
runtime.bossDieCheckResponseSent, session.dungeon.runtime)
}
})
}
func TestHandleDungeonBossDieCheckOp115WriteFailureKeepsPersistedRuntimeForRetry(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
if _, _, err := runtime.Room.CommitActorDeathReport(targetKey, runtime.Session); err != nil {
t.Fatal(err)
}
wantErr := errors.New("boss-die-check write failed")
conn := &failNthDungeonWriteConn{failAt: 1, err: wantErr}
session := &gameSession{
conn: conn,
connID: "op117-write-failure-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
err := service.handleDungeonBossDieCheck(session, bossDieCheckRequestBody(99, uint16(targetKey)))
if !errors.Is(err, wantErr) {
t.Fatalf("write failure error=%v want=%v", err, wantErr)
}
if !runtime.bossDieCheckAccepted || !runtime.tutorialCompletionPersisted ||
runtime.bossDieCheckResponseSent || session.dungeon.runtime != runtime {
t.Fatalf("write failure state accepted=%t persisted=%t response=%t runtime=%p",
runtime.bossDieCheckAccepted, runtime.tutorialCompletionPersisted,
runtime.bossDieCheckResponseSent, session.dungeon.runtime)
}
snapshot := runtime.Session.Snapshot()
if snapshot.Run.Status != worldmap.DungeonRunCompleted || !snapshot.Scene.Cleared {
t.Fatalf("op115 write failure did not retain committed completion=%+v", snapshot)
}
}
func TestHandleDungeonBossDieCheckWaitsForSameTargetDeathAcrossJobs(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{
job: "2",
dungeonReference: tutorialScopeGunnerDungeonReference,
mapDirectory: tutorialScopeGunnerMapDirectory,
singleMonster: true,
})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "job2-op117-before-op39-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatal(err)
}
if conn.write.Len() != 0 || !runtime.bossDieCheckPending || runtime.bossDieCheckAccepted {
t.Fatalf("early op117 wrote=%x pending=%t accepted=%t", conn.write.Bytes(), runtime.bossDieCheckPending, runtime.bossDieCheckAccepted)
}
deathBody := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(deathBody[0:4], targetKey)
binary.LittleEndian.PutUint16(deathBody[4:6], 99)
if err := service.handleDungeonMonsterDeath(session, deathBody); err != nil {
t.Fatalf("same target op39: %v", err)
}
deathAck, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if deathAck.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) {
t.Fatalf("first packet=%+v body=%x", deathAck.Header, deathAck.Body)
}
assertBossResponseAndSettlementEntry(t, rest, uint16(targetKey))
if runtime.bossDieCheckPending || !runtime.bossDieCheckAccepted ||
!runtime.tutorialCompletionPersisted || !runtime.bossDieCheckResponseSent ||
session.dungeon.runtime != runtime || !runtime.settlementEntrySent || runtime.townReturnPending {
t.Fatalf("post-death pending=%t accepted=%t persisted=%t response=%t runtime=%p",
runtime.bossDieCheckPending, runtime.bossDieCheckAccepted,
runtime.tutorialCompletionPersisted, runtime.bossDieCheckResponseSent,
session.dungeon.runtime)
}
if status := runtime.Session.Snapshot().Run.Status; status != worldmap.DungeonRunCompleted {
t.Fatalf("post-death status=%s", status)
}
}
func TestPendingBossDieCheckRetriesAfterOp115WriteFailure(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{singleMonster: true})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
wantErr := errors.New("pending op115 write failed")
conn := &failNthDungeonWriteConn{failAt: 2, err: wantErr}
session := &gameSession{
conn: conn,
connID: "pending-op115-retry-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatal(err)
}
deathBody := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(deathBody[0:4], targetKey)
binary.LittleEndian.PutUint16(deathBody[4:6], 99)
if err := service.handleDungeonMonsterDeath(session, deathBody); !errors.Is(err, wantErr) {
t.Fatalf("automatic completion error=%v want=%v", err, wantErr)
}
if runtime.bossDieCheckPending || !runtime.bossDieCheckAccepted ||
!runtime.tutorialCompletionPersisted || runtime.bossDieCheckResponseSent ||
session.dungeon.runtime != runtime {
t.Fatalf("failed op115 pending=%t accepted=%t persisted=%t response=%t runtime=%p",
runtime.bossDieCheckPending, runtime.bossDieCheckAccepted,
runtime.tutorialCompletionPersisted, runtime.bossDieCheckResponseSent,
session.dungeon.runtime)
}
if status := runtime.Session.Snapshot().Run.Status; status != worldmap.DungeonRunCompleted {
t.Fatalf("failed op115 run status=%s", status)
}
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatalf("retry exact op117: %v", err)
}
deathAck, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if deathAck.Header.MsgID != uint16(dnfenum.CmdPacketNotifyDieMonster) {
t.Fatalf("first packet=%+v", deathAck.Header)
}
assertBossResponseAndSettlementEntry(t, rest, uint16(targetKey))
}
func TestBossDieCheckOp115WriteFailureRetriesWithoutDuplicatePersistence(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{singleMonster: true})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
if _, _, err := runtime.Room.CommitActorDeathReport(targetKey, runtime.Session); err != nil {
t.Fatal(err)
}
wantErr := errors.New("op115 write failed")
conn := &failNthDungeonWriteConn{failAt: 1, err: wantErr}
session := &gameSession{
conn: conn,
connID: "op115-retry-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); !errors.Is(err, wantErr) {
t.Fatalf("op115 failure=%v want=%v", err, wantErr)
}
if !runtime.bossDieCheckAccepted || !runtime.tutorialCompletionPersisted ||
runtime.bossDieCheckResponseSent || session.dungeon.runtime != runtime {
t.Fatalf("failed op115 accepted=%t persisted=%t response=%t runtime=%p",
runtime.bossDieCheckAccepted, runtime.tutorialCompletionPersisted,
runtime.bossDieCheckResponseSent, session.dungeon.runtime)
}
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatalf("retry op115: %v", err)
}
assertBossResponseAndSettlementEntry(t, conn.write.Bytes(), uint16(targetKey))
}
func TestHandleDungeonBossDieCheckOp31WriteFailureRetriesOnlySettlementEntry(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
if _, _, err := runtime.Room.CommitActorDeathReport(targetKey, runtime.Session); err != nil {
t.Fatal(err)
}
wantErr := errors.New("settlement op31 write failed")
conn := &failNthDungeonWriteConn{failAt: 2, err: wantErr}
session := &gameSession{
conn: conn,
connID: "op117-op31-write-failure-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); !errors.Is(err, wantErr) {
t.Fatalf("op31 write failure=%v want=%v", err, wantErr)
}
first, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
if first.Header.MsgID != uint16(dnfenum.CmdPacketNotifyBossDieCheck) ||
!bytes.Equal(first.Body, buildCurrentDungeonBossDieCheckResponse(uint16(targetKey))) || len(rest) != 0 {
t.Fatalf("failed op31 stream first=%+v body=%x rest=%x", first.Header, first.Body, rest)
}
if !runtime.bossDieCheckAccepted || !runtime.tutorialCompletionPersisted ||
!runtime.bossDieCheckResponseSent || runtime.tutorialFinalFlagAckSent ||
runtime.settlementEntrySent || runtime.townReturnPending || runtime.townReturnOp24Sent ||
session.dungeon.runtime != runtime {
t.Fatalf("failed op31 lost retry owner accepted=%t persisted=%t ack=%t response=%t runtime=%p",
runtime.bossDieCheckAccepted, runtime.tutorialCompletionPersisted,
runtime.tutorialFinalFlagAckSent, runtime.bossDieCheckResponseSent,
session.dungeon.runtime)
}
if status := runtime.Session.Snapshot().Run.Status; status != worldmap.DungeonRunCompleted {
t.Fatalf("failed op31 run status=%s", status)
}
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatalf("retry op31: %v", err)
}
first, rest = splitGameServerUpperPacket(t, conn.write.Bytes())
second, rest := splitGameServerUpperPacket(t, rest)
if first.Header.MsgID != uint16(dnfenum.CmdPacketNotifyBossDieCheck) ||
second.Header.MsgID != currentDungeonSettlementEntryMsgID ||
second.Header.Classification != 0 || !bytes.Equal(second.Body, []byte{0}) ||
len(rest) != 0 {
t.Fatalf("retried stream first=%+v second=%+v second_body=%x rest=%x",
first.Header, second.Header, second.Body, rest)
}
if session.dungeon.runtime != runtime || !runtime.settlementEntrySent ||
runtime.townReturnPending || runtime.townReturnOp24Sent {
t.Fatalf("successful retry lost pending runtime=%+v state=%+v", session.dungeon.runtime, runtime)
}
}
func TestHandleDungeonBossDieCheckPersistenceFailureBlocksOp115AndTownUntilReplay(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{singleMonster: true})
if _, err := runtime.Room.AnnounceAllActors(runtime.Session); err != nil {
t.Fatal(err)
}
targetKey := runtime.Room.Snapshot().Monsters[0].ObjectKey
if _, _, err := runtime.Room.CommitActorDeathReport(targetKey, runtime.Session); err != nil {
t.Fatal(err)
}
baseRepositories, ok := service.repositoryGroup()
if !ok || baseRepositories.Character == nil {
t.Fatal("tutorial fixture character repository unavailable")
}
wantErr := errors.New("tutorial completion save failed")
failingRepositories := baseRepositories
failingRepositories.Character = &tutorialFailingCharacterStore{
CharacterRepository: baseRepositories.Character,
err: wantErr,
}
service.repositoryProvider = func() (dnfrepo.Group, bool) {
return failingRepositories, true
}
conn := &bufferConn{}
session := &gameSession{
conn: conn,
connID: "op117-persistence-retry-test",
selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatalf("persistence failure must keep connection alive: %v", err)
}
if !runtime.bossDieCheckAccepted || runtime.tutorialCompletionPersisted ||
runtime.bossDieCheckResponseSent || conn.write.Len() != 0 || session.dungeon.runtime != runtime {
t.Fatalf("failed persistence accepted=%t persisted=%t response=%t wrote=%x runtime=%p",
runtime.bossDieCheckAccepted, runtime.tutorialCompletionPersisted,
runtime.bossDieCheckResponseSent, conn.write.Bytes(), session.dungeon.runtime)
}
character, found, err := baseRepositories.Character.Load(context.Background(), "99")
if err != nil || !found {
t.Fatalf("load character after failed persistence found=%t err=%v", found, err)
}
if got := character.Stats[currentDungeonTutorialCompletedKey]; got != 0 {
t.Fatalf("failed persistence changed tutorial flag=%d", got)
}
service.repositoryProvider = func() (dnfrepo.Group, bool) {
return baseRepositories, true
}
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatalf("retry final op117: %v", err)
}
assertBossResponseAndSettlementEntry(t, conn.write.Bytes(), uint16(targetKey))
assertTutorialCompletionPersistedAndSelectAcked(t, service, 99)
if session.dungeon.runtime != runtime || !runtime.settlementEntrySent || runtime.townReturnPending {
t.Fatalf("successful persistence replay lost pending runtime=%+v", session.dungeon.runtime)
}
}
func TestBuildCurrentDungeonBossDieCheckResponseUsesValidatedObjectKey(t *testing.T) {
if got := buildCurrentDungeonBossDieCheckResponse(0x3412); !bytes.Equal(got, []byte{1, 1, 0x12, 0x34}) {
t.Fatalf("response=%x", got)
}
}
func assertBossResponseAndSettlementEntry(t *testing.T, stream []byte, targetObjectKey uint16) {
t.Helper()
op115, rest := splitGameServerUpperPacket(t, stream)
want := buildCurrentDungeonBossDieCheckResponse(targetObjectKey)
if op115.Header.MsgID != uint16(dnfenum.CmdPacketNotifyBossDieCheck) ||
op115.Header.Classification != 0 || !bytes.Equal(op115.Body, want) {
t.Fatalf("op115 response header=%+v body=%x want=%x rest=%x", op115.Header, op115.Body, want, rest)
}
entry, rest := splitGameServerUpperPacket(t, rest)
if entry.Header.MsgID != currentDungeonSettlementEntryMsgID || entry.Header.Classification != 0 ||
!bytes.Equal(entry.Body, []byte{0}) {
t.Fatalf("op31 settlement entry header=%+v body=%x rest=%x", entry.Header, entry.Body, rest)
}
if len(rest) == 0 {
return
}
autoAck, rest := splitGameServerUpperPacket(t, rest)
if autoAck.Header.MsgID != uint16(dnfenum.CmdPacketSetQuestTrigger) ||
autoAck.Header.Classification != dnfproto.DefaultChannelClassification ||
len(autoAck.Body) != 7 || autoAck.Body[0] != 1 ||
binary.LittleEndian.Uint16(autoAck.Body[1:3]) == 0 ||
binary.LittleEndian.Uint32(autoAck.Body[3:7]) != 0 || len(rest) != 0 {
t.Fatalf("settlement entry auto op33 header=%+v body=%x rest=%x", autoAck.Header, autoAck.Body, rest)
}
}
func assertTutorialFlagAckAndTownReturn(t *testing.T, stream []byte) {
t.Helper()
ack, rest := splitGameServerUpperPacket(t, stream)
if ack.Header.MsgID != uint16(dnfenum.CmdPacketChangeTutorialFlag) ||
ack.Header.Classification != dnfproto.DefaultChannelClassification ||
!bytes.Equal(ack.Body, []byte{1, 0}) {
t.Fatalf("op143 ack header=%+v body=%x rest=%x", ack.Header, ack.Body, rest)
}
transition, rest := splitGameServerUpperPacket(t, rest)
if transition.Header.MsgID != currentSceneTransitionMsgID || transition.Header.Classification != 0 ||
!bytes.Equal(transition.Body, wantTutorialCompletionTownTransitionBody()) {
t.Fatalf("op24 response header=%+v body=%x rest=%x", transition.Header, transition.Body, rest)
}
if len(rest) != 0 {
t.Fatalf("tutorial return injected an unconfirmed actor-state packet=%x", rest)
}
}
func tutorialFinalFlagRequestBody() []byte {
body := make([]byte, dungeoncmd.ChangeTutorialFlagRequestSize)
body[0] = currentDungeonTutorialFinalPrefix
binary.LittleEndian.PutUint32(body[1:5], currentDungeonTutorialFinalProgress)
body[5] = currentDungeonTutorialFinalCommit
return body
}
func bossDieCheckRequestBody(relatedActorObjectKey, targetObjectKey uint16) []byte {
body := make([]byte, dungeoncmd.BossDieCheckRequestSize)
binary.LittleEndian.PutUint16(body[0:2], relatedActorObjectKey)
binary.LittleEndian.PutUint16(body[2:4], targetObjectKey)
return body
}