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

657 lines
29 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"strings"
"sync/atomic"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfquest "longheng.io/server/internal/modules/dnf/quest"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
func TestCurrentDungeonAllRoomsClearedRequiresEveryTopologyCoordinate(t *testing.T) {
rooms := []worldmap.DungeonRoom{
{Coordinate: worldmap.RoomCoordinate{X: 0, Y: 0}},
{Coordinate: worldmap.RoomCoordinate{X: 1, Y: 0}},
}
if currentDungeonAllRoomsCleared(nil, nil) {
t.Fatal("empty topology must not count as all rooms cleared")
}
if currentDungeonAllRoomsCleared(rooms, []worldmap.RoomCoordinate{{X: 0, Y: 0}}) {
t.Fatal("partial cleared set must not count as all rooms cleared")
}
if !currentDungeonAllRoomsCleared(rooms, []worldmap.RoomCoordinate{{X: 1, Y: 0}, {X: 0, Y: 0}}) {
t.Fatal("complete cleared set must count as all rooms cleared")
}
}
func TestBossDieCheckPersistsRealClearMapPhaseABeforeSettlementAcrossDungeonKinds(t *testing.T) {
for _, test := range []struct {
name string
tutorial bool
}{
{name: "ordinary", tutorial: false},
{name: "tutorial", tutorial: true},
} {
t.Run(test.name, func(t *testing.T) {
fixture := tutorialScopeFixtureOptions{singleMonster: true}
if !test.tutorial {
fixture.disableTutorial = true
}
service, runtime := prepareTutorialScopeRuntime(t, fixture)
repositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("repository group unavailable")
}
mapID := runtime.Session.Snapshot().Scene.Map.Map.ID
service.questCatalog = buildDungeonClearMapCompletionCatalog(t, mapID)
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
CharacterID: "99",
States: map[int64]dnfrepo.QuestState{
3145: {Status: "active", ProgressValue: 1, Extra: map[string]string{"kept": "yes"}},
3146: {Status: "active", ProgressValue: 7},
},
}); err != nil {
t.Fatal(err)
}
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 boss death cleared=%t err=%v", cleared, err)
}
conn := &bufferConn{}
session := &gameSession{
conn: conn, connID: "clear-map-phase-a-" + test.name,
selectedCharacterID: 99, dungeon: dungeonSessionState{runtime: runtime},
}
if err := service.handleDungeonBossDieCheck(session, bossDieCheckRequestBody(99, uint16(targetKey))); err != nil {
t.Fatal(err)
}
if test.tutorial {
assertBossResponseAndSettlementEntry(t, conn.write.Bytes(), uint16(targetKey))
} else {
assertClearMapNotificationThenBossResponseAndSettlementEntry(
t,
conn.write.Bytes(),
uint16(targetKey),
3145,
)
}
if !runtime.clearMapCompletionPhaseAPersisted || runtime.clearMapCompletionKey == "" ||
runtime.clearMapCompletionAt.IsZero() || !runtime.bossDieCheckResponseSent || !runtime.settlementEntrySent {
t.Fatalf("phase A/settlement state=%+v", runtime)
}
persisted, found, err := repositories.Quest.Load(context.Background(), "99")
if err != nil || !found {
t.Fatalf("load persisted quest found=%t err=%v", found, err)
}
completed := persisted.States[3145]
if completed.Status != "active" || completed.ProgressValue != 0 ||
completed.Extra["completion_key"] != runtime.clearMapCompletionKey ||
completed.Extra["reward_state"] != "pending" ||
completed.Extra["completion_dungeon_id"] != fmt.Sprint(runtime.Dungeon.ID) ||
completed.Extra["completion_map_id"] != fmt.Sprint(mapID) ||
completed.Extra["kept"] != "yes" {
t.Fatalf("completed clear-map state=%+v runtime_key=%q", completed, runtime.clearMapCompletionKey)
}
if other := persisted.States[3146]; other.ProgressValue != 7 || other.Extra["completion_key"] != "" {
t.Fatalf("unrelated quest mutated=%+v", other)
}
if !strings.Contains(runtime.clearMapCompletionKey, fmt.Sprintf(":dungeon:%d:maze:%d:map:%d:", runtime.Dungeon.ID, runtime.MazeIndex, mapID)) {
t.Fatalf("completion key lacks owned run facts: %q", runtime.clearMapCompletionKey)
}
if test.tutorial {
assertTutorialCompletionPersistedAndSelectAcked(t, service, 99)
} else {
character, found, err := repositories.Character.Load(context.Background(), "99")
if err != nil || !found || character.Stats[currentDungeonTutorialCompletedKey] != 0 {
t.Fatalf("ordinary clear-map mutated tutorial state found=%t err=%v character=%+v", found, err, character)
}
}
})
}
}
func TestBossDieCheckAutoAcksClearMapTriggerAfterSettlementEntry(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true, singleMonster: true})
repositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("repository group unavailable")
}
mapID := runtime.Session.Snapshot().Scene.Map.Map.ID
service.questCatalog = buildDungeonClearMapCompletionCatalog(t, mapID)
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)
}
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 boss death cleared=%t err=%v", cleared, err)
}
connection := &bufferConn{}
session := &gameSession{
conn: connection, connID: "clear-map-auto-op33", selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
if err := service.handleDungeonBossDieCheck(session, bossDieCheckRequestBody(99, uint16(targetKey))); err != nil {
t.Fatal(err)
}
op574, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if op574.Header.MsgID != currentActiveQuestSnapshotMsgID || op574.Header.Classification != 0 {
t.Fatalf("first packet header=%+v", op574.Header)
}
op115, rest := splitGameServerUpperPacket(t, rest)
if op115.Header.MsgID != uint16(dnfenum.CmdPacketNotifyBossDieCheck) {
t.Fatalf("second packet header=%+v", op115.Header)
}
op31, rest := splitGameServerUpperPacket(t, rest)
if op31.Header.MsgID != currentDungeonSettlementEntryMsgID || op31.Header.Classification != 0 ||
!bytes.Equal(op31.Body, []byte{0}) {
t.Fatalf("third packet header=%+v body=%x", op31.Header, op31.Body)
}
op33, rest := splitGameServerUpperPacket(t, rest)
if len(rest) != 0 || op33.Header.MsgID != uint16(dnfenum.CmdPacketSetQuestTrigger) ||
op33.Header.Classification != dnfproto.DefaultChannelClassification || len(op33.Body) != 7 ||
!bytes.Equal(op33.Body[:3], []byte{1, 0x49, 0x0c}) || binary.LittleEndian.Uint32(op33.Body[3:7]) != 0 {
t.Fatalf("auto op33 header=%+v body=%x rest=%x", op33.Header, op33.Body, rest)
}
key := newCurrentSetQuestTriggerReplayKey(99, dnfquest.SetTriggerRequest{QuestID: 3145})
if !session.currentSetQuestTriggerReplaySuppressed(key) {
t.Fatal("auto op33 did not suppress the matching client replay")
}
persisted, found, err := repositories.Quest.Load(context.Background(), "99")
if err != nil || !found {
t.Fatalf("load persisted quest found=%t err=%v", found, err)
}
state := persisted.States[3145]
if state.Status != "active" || state.ProgressValue != 0 || state.Extra["reward_state"] != "pending" {
t.Fatalf("auto op33 changed clear-map state=%+v", state)
}
connection.write.Reset()
clientOp33 := []byte{0x21, 0x00, 0x49, 0x0c, 0x00, 0x00}
if err := service.handleCurrentSetQuestTrigger(session, clientOp33); err != nil {
t.Fatal(err)
}
if connection.write.Len() != 0 {
t.Fatalf("duplicate client op33 emitted an ACK=%x", connection.write.Bytes())
}
}
func TestBossDieCheckRetriesOnlyUnsentClearMapOp33AfterSocketFailure(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true, singleMonster: true})
repositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("repository group unavailable")
}
mapID := runtime.Session.Snapshot().Scene.Map.Map.ID
service.questCatalog = buildDungeonClearMapCompletionCatalog(t, mapID)
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)
}
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 boss death cleared=%t err=%v", cleared, err)
}
wantErr := errors.New("clear-map automatic op33 write failed")
failing := &failNthDungeonWriteConn{failAt: 4, err: wantErr}
session := &gameSession{
conn: failing, connID: "clear-map-auto-op33-retry", selectedCharacterID: 99,
dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); !errors.Is(err, wantErr) {
t.Fatalf("automatic op33 failure=%v want=%v", err, wantErr)
}
if !runtime.settlementEntrySent {
t.Fatalf("op31 was not retained after op33 failure: %+v", runtime)
}
if session.currentSetQuestTriggerReplaySuppressed(newCurrentSetQuestTriggerReplayKey(99, dnfquest.SetTriggerRequest{QuestID: 3145})) {
t.Fatal("failed op33 was marked as suppressed")
}
first, rest := splitGameServerUpperPacket(t, failing.write.Bytes())
if first.Header.MsgID != currentActiveQuestSnapshotMsgID {
t.Fatalf("first packet after failure=%+v", first.Header)
}
second, rest := splitGameServerUpperPacket(t, rest)
third, rest := splitGameServerUpperPacket(t, rest)
if second.Header.MsgID != uint16(dnfenum.CmdPacketNotifyBossDieCheck) || third.Header.MsgID != currentDungeonSettlementEntryMsgID || len(rest) != 0 {
t.Fatalf("failed op33 prefix=%d->%d trailing=%x", second.Header.MsgID, third.Header.MsgID, rest)
}
connection := &bufferConn{}
session.conn = connection
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatal(err)
}
op33, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(rest) != 0 || op33.Header.MsgID != uint16(dnfenum.CmdPacketSetQuestTrigger) || len(op33.Body) != 7 ||
binary.LittleEndian.Uint16(op33.Body[1:3]) != 3145 || binary.LittleEndian.Uint32(op33.Body[3:7]) != 0 {
t.Fatalf("retry op33 header=%+v body=%x rest=%x", op33.Header, op33.Body, rest)
}
}
func TestBossDieCheckClearMapPhaseAFailureDoesNotBlockSettlementAndReplaysDeterministically(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true, singleMonster: true})
baseRepositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("repository group unavailable")
}
mapID := runtime.Session.Snapshot().Scene.Map.Map.ID
service.questCatalog = buildDungeonClearMapCompletionCatalog(t, mapID)
if err := baseRepositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
CharacterID: "99",
States: map[int64]dnfrepo.QuestState{3145: {Status: "active", ProgressValue: 1}},
}); err != nil {
t.Fatal(err)
}
wantErr := errors.New("clear-map phase A save failed")
failingStore := &clearMapCountingQuestStore{QuestRepository: baseRepositories.Quest, err: wantErr}
failingRepositories := baseRepositories
failingRepositories.Quest = failingStore
service.repositoryProvider = func() (dnfrepo.Group, bool) { return failingRepositories, true }
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 boss death cleared=%t err=%v", cleared, err)
}
conn := &bufferConn{}
session := &gameSession{
conn: conn, connID: "clear-map-phase-a-failure",
selectedCharacterID: 99, dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatalf("phase-A persistence failure must keep connection alive: %v", err)
}
assertBossResponseAndSettlementEntry(t, conn.write.Bytes(), uint16(targetKey))
if runtime.clearMapCompletionPhaseAPersisted || !runtime.bossDieCheckResponseSent || !runtime.settlementEntrySent {
t.Fatalf("failed Phase A state=%+v", runtime)
}
if failingStore.saves.Load() != 1 || runtime.clearMapCompletionKey == "" || runtime.clearMapCompletionAt.IsZero() {
t.Fatalf("failed Phase A saves=%d key=%q at=%v", failingStore.saves.Load(), runtime.clearMapCompletionKey, runtime.clearMapCompletionAt)
}
stableKey, stableAt := runtime.clearMapCompletionKey, runtime.clearMapCompletionAt
stored, found, err := baseRepositories.Quest.Load(context.Background(), "99")
if err != nil || !found || stored.States[3145].ProgressValue != 1 {
t.Fatalf("failed Phase A mutated quest found=%t err=%v state=%+v", found, err, stored.States[3145])
}
conn.write.Reset()
countingStore := &clearMapCountingQuestStore{QuestRepository: baseRepositories.Quest}
retryRepositories := baseRepositories
retryRepositories.Quest = countingStore
service.repositoryProvider = func() (dnfrepo.Group, bool) { return retryRepositories, true }
conn.write.Reset()
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatal(err)
}
notification, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
assertClearMapCompletionNotification(t, notification, 3145)
autoAck, rest := splitGameServerUpperPacket(t, rest)
if autoAck.Header.MsgID != uint16(dnfenum.CmdPacketSetQuestTrigger) ||
autoAck.Header.Classification != dnfproto.DefaultChannelClassification || len(autoAck.Body) != 7 ||
binary.LittleEndian.Uint16(autoAck.Body[1:3]) != 3145 || binary.LittleEndian.Uint32(autoAck.Body[3:7]) != 0 || len(rest) != 0 {
t.Fatalf("retry must repair clear-map notification then auto-ack op33, header=%+v body=%x rest=%x", autoAck.Header, autoAck.Body, rest)
}
if countingStore.saves.Load() != 1 || !runtime.clearMapCompletionPhaseAPersisted ||
runtime.clearMapCompletionKey != stableKey || !runtime.clearMapCompletionAt.Equal(stableAt) {
t.Fatalf("retry Phase A saves=%d state=%+v", countingStore.saves.Load(), runtime)
}
}
func TestBossDieCheckClearMapNotificationWriteFailureDoesNotBlockSettlementAndReplaysWithoutPhaseAWrite(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true, singleMonster: true})
baseRepositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("repository group unavailable")
}
mapID := runtime.Session.Snapshot().Scene.Map.Map.ID
service.questCatalog = buildDungeonClearMapCompletionCatalog(t, mapID)
if err := baseRepositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
CharacterID: "99",
States: map[int64]dnfrepo.QuestState{3145: {Status: "active", ProgressValue: 1}},
}); err != nil {
t.Fatal(err)
}
countingStore := &clearMapCountingQuestStore{QuestRepository: baseRepositories.Quest}
repositories := baseRepositories
repositories.Quest = countingStore
service.repositoryProvider = func() (dnfrepo.Group, bool) { return repositories, true }
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 boss death cleared=%t err=%v", cleared, err)
}
wantWriteErr := errors.New("clear-map op574 write failed")
session := &gameSession{
conn: &failNthDungeonWriteConn{failAt: 1, err: wantWriteErr}, connID: "clear-map-op574-retry",
selectedCharacterID: 99, dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatalf("op574 write failure must not block settlement: %v", err)
}
assertBossResponseAndSettlementEntry(t, session.conn.(*failNthDungeonWriteConn).write.Bytes(), uint16(targetKey))
if countingStore.saves.Load() != 1 || !runtime.clearMapCompletionPhaseAPersisted ||
runtime.clearMapCompletionNotificationClosed || !runtime.bossDieCheckResponseSent ||
!runtime.settlementEntrySent {
t.Fatalf("failed op574 saves=%d state=%+v wrote=%x",
countingStore.saves.Load(), runtime, session.conn.(*failNthDungeonWriteConn).write.Bytes())
}
conn := &bufferConn{}
session.conn = conn
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatal(err)
}
notification, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
assertClearMapCompletionNotification(t, notification, 3145)
if len(rest) != 0 {
t.Fatalf("op574 replay should only repair notification, trailing=%x", rest)
}
if countingStore.saves.Load() != 1 || !runtime.clearMapCompletionNotificationClosed ||
!runtime.bossDieCheckResponseSent || !runtime.settlementEntrySent {
t.Fatalf("op574 replay repeated Phase-A or missed settlement: saves=%d state=%+v",
countingStore.saves.Load(), runtime)
}
}
func TestBossDieCheckClearMapFinalLinkedChildUsesNativeParentCompletionAndDoesNotBlockTownExit(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true, singleMonster: true})
repositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("repository group unavailable")
}
mapID := runtime.Session.Snapshot().Scene.Map.Map.ID
service.questCatalog = buildDungeonQuestClearSubQuestCompletionCatalog(t, mapID)
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
CharacterID: "99",
States: map[int64]dnfrepo.QuestState{
3146: {Status: "active", ProgressValue: 1},
3157: {Status: "completed", ProgressValue: 0},
3054: {Status: "active", ProgressValue: 1},
},
}); err != nil {
t.Fatal(err)
}
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 boss death cleared=%t err=%v", cleared, err)
}
conn := &bufferConn{}
session := &gameSession{
conn: conn, connID: "clear-map-no-reward-subquest-parent-refresh",
selectedCharacterID: 99, dungeon: dungeonSessionState{runtime: runtime},
}
if err := service.handleDungeonBossDieCheck(session, bossDieCheckRequestBody(99, uint16(targetKey))); err != nil {
t.Fatal(err)
}
active, rest := splitGameServerUpperPacket(t, conn.write.Bytes())
assertClearMapQuestTriggerNotification(t, active, 3146, 1)
assertClearMapQuestTriggerNotification(t, active, 3054, 0)
assertBossResponseAndSettlementEntry(t, rest, uint16(targetKey))
if got := runtime.clearMapCompletionQuestIDs; len(got) != 1 || got[0] != 3054 {
t.Fatalf("clear-map completed quest ids=%v, want [3054]", got)
}
if got := runtime.clearMapCompletionCompletedQuestIDs; len(got) != 1 || got[0] != 3054 {
t.Fatalf("clear-map objective-complete ids=%v, want [3054]", got)
}
if got := runtime.clearMapCompletionPendingQuestIDs; len(got) != 1 || got[0] != 3054 {
t.Fatalf("final linked child pending ids=%v, want [3054]", got)
}
persisted, found, err := repositories.Quest.Load(context.Background(), "99")
if err != nil || !found {
t.Fatalf("load persisted quest found=%t err=%v", found, err)
}
if parent := persisted.States[3146]; parent.Status != "active" || parent.ProgressValue != 1 {
t.Fatalf("parent quest 3146 state=%+v, want active trigger one until native finish", parent)
}
child := persisted.States[3054]
if child.Status != "active" || child.ProgressValue != 0 ||
child.Extra["reward_state"] != "pending" ||
child.Extra["completion_key"] != runtime.clearMapCompletionKey {
t.Fatalf("child quest 3054 state=%+v key=%q", child, runtime.clearMapCompletionKey)
}
}
func TestBossDieCheckNoRewardCompletionOp21RetryDoesNotRepeatOp574(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true, singleMonster: true})
repositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("repository group unavailable")
}
mapID := runtime.Session.Snapshot().Scene.Map.Map.ID
service.questCatalog = buildDungeonQuestClearSubQuestCompletionCatalog(t, mapID)
if err := repositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
CharacterID: "99",
States: map[int64]dnfrepo.QuestState{
3146: {Status: "active", ProgressValue: 2},
3157: {Status: "active", ProgressValue: 1},
3054: {Status: "active", ProgressValue: 1},
},
}); err != nil {
t.Fatal(err)
}
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 boss death cleared=%t err=%v", cleared, err)
}
wantWriteErr := errors.New("clear-map no-reward op21 write failed")
failingConn := &failNthDungeonWriteConn{failAt: 2, err: wantWriteErr}
session := &gameSession{
conn: failingConn, connID: "clear-map-no-reward-op21-retry",
selectedCharacterID: 99, dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatalf("op21 write failure must not block settlement: %v", err)
}
active, rest := splitGameServerUpperPacket(t, failingConn.write.Bytes())
assertClearMapQuestTriggerNotification(t, active, 3146, 1)
assertBossResponseAndSettlementEntry(t, rest, uint16(targetKey))
if !runtime.clearMapCompletionActiveSnapshotSent || runtime.clearMapCompletionNotificationClosed ||
!runtime.bossDieCheckResponseSent || !runtime.settlementEntrySent {
t.Fatalf("failed no-reward op21 state=%+v", runtime)
}
retryConn := &bufferConn{}
session.conn = retryConn
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatal(err)
}
acceptable, trailing := splitGameServerUpperPacket(t, retryConn.write.Bytes())
if acceptable.Header.MsgID != currentAcceptableQuestListMsgID || acceptable.Header.Classification != 0 || len(trailing) != 0 {
t.Fatalf("op21 retry header=%+v trailing=%x", acceptable.Header, trailing)
}
if !runtime.clearMapCompletionNotificationClosed {
t.Fatalf("op21 retry did not close notification state=%+v", runtime)
}
}
func TestBossDieCheckOp115RetryDoesNotRepeatClearMapPhaseAWrite(t *testing.T) {
service, runtime := prepareTutorialScopeRuntime(t, tutorialScopeFixtureOptions{disableTutorial: true, singleMonster: true})
baseRepositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("repository group unavailable")
}
mapID := runtime.Session.Snapshot().Scene.Map.Map.ID
service.questCatalog = buildDungeonClearMapCompletionCatalog(t, mapID)
if err := baseRepositories.Quest.Save(context.Background(), dnfrepo.QuestRecord{
CharacterID: "99",
States: map[int64]dnfrepo.QuestState{3145: {Status: "active", ProgressValue: 1}},
}); err != nil {
t.Fatal(err)
}
countingStore := &clearMapCountingQuestStore{QuestRepository: baseRepositories.Quest}
repositories := baseRepositories
repositories.Quest = countingStore
service.repositoryProvider = func() (dnfrepo.Group, bool) { return repositories, true }
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 boss death cleared=%t err=%v", cleared, err)
}
wantWriteErr := errors.New("first op115 write failed after Phase A")
session := &gameSession{
conn: &failNthDungeonWriteConn{failAt: 2, err: wantWriteErr}, connID: "clear-map-op115-retry",
selectedCharacterID: 99, dungeon: dungeonSessionState{runtime: runtime},
}
request := bossDieCheckRequestBody(99, uint16(targetKey))
if err := service.handleDungeonBossDieCheck(session, request); !errors.Is(err, wantWriteErr) {
t.Fatalf("first op115 error=%v want=%v", err, wantWriteErr)
}
firstNotification, firstRest := splitGameServerUpperPacket(t, session.conn.(*failNthDungeonWriteConn).write.Bytes())
assertClearMapCompletionNotification(t, firstNotification, 3145)
if len(firstRest) != 0 {
t.Fatalf("failed op115 emitted bytes after op574=%x", firstRest)
}
if countingStore.saves.Load() != 1 || !runtime.clearMapCompletionPhaseAPersisted ||
!runtime.clearMapCompletionNotificationClosed || runtime.bossDieCheckResponseSent {
t.Fatalf("first op115 state saves=%d runtime=%+v", countingStore.saves.Load(), runtime)
}
conn := &bufferConn{}
session.conn = conn
if err := service.handleDungeonBossDieCheck(session, request); err != nil {
t.Fatal(err)
}
assertBossResponseAndSettlementEntry(t, conn.write.Bytes(), uint16(targetKey))
if countingStore.saves.Load() != 1 {
t.Fatalf("op115 replay repeated Phase-A write: saves=%d", countingStore.saves.Load())
}
}
func assertClearMapNotificationThenBossResponseAndSettlementEntry(
t *testing.T,
stream []byte,
targetObjectKey uint16,
questID uint16,
) {
t.Helper()
notification, rest := splitGameServerUpperPacket(t, stream)
assertClearMapCompletionNotification(t, notification, questID)
assertBossResponseAndSettlementEntry(t, rest, targetObjectKey)
}
func assertClearMapCompletionNotification(t *testing.T, packet dnfproto.ChannelPacket, questID uint16) {
t.Helper()
assertClearMapQuestTriggerNotification(t, packet, questID, 0)
}
func assertClearMapQuestTriggerNotification(t *testing.T, packet dnfproto.ChannelPacket, questID uint16, trigger uint32) {
t.Helper()
if packet.Header.MsgID != currentActiveQuestSnapshotMsgID || packet.Header.Classification != 0 || len(packet.Body) < 4 {
t.Fatalf("clear-map op574 header=%+v body=%x", packet.Header, packet.Body)
}
count := int(binary.LittleEndian.Uint32(packet.Body[:4]))
if len(packet.Body) != 4+count*6 {
t.Fatalf("clear-map op574 count=%d body_len=%d body=%x", count, len(packet.Body), packet.Body)
}
for offset := 4; offset < len(packet.Body); offset += 6 {
if binary.LittleEndian.Uint16(packet.Body[offset:offset+2]) == questID &&
binary.LittleEndian.Uint32(packet.Body[offset+2:offset+6]) == trigger {
return
}
}
t.Fatalf("clear-map op574 lacks quest=%d trigger=%d body=%x", questID, trigger, packet.Body)
}
type clearMapCountingQuestStore struct {
dnfrepo.QuestRepository
saves atomic.Int64
err error
}
func (s *clearMapCountingQuestStore) Save(ctx context.Context, record dnfrepo.QuestRecord) error {
s.saves.Add(1)
if s.err != nil {
return s.err
}
return s.QuestRepository.Save(ctx, record)
}
func buildDungeonClearMapCompletionCatalog(t *testing.T, mapID int64) *dnfquest.Catalog {
t.Helper()
source := questListTestSource{
dnfquest.DefaultList: "3145 `clear_map.qst`\n3146 `other.qst`\n",
"n_quest/clear_map.qst": "[grade]\n`[epic]`\n[level]\n1 99\n[job]\n`[gunner]`\n" +
"[type]\n`[clear map]`\n[int data]\n" + fmt.Sprint(mapID) + "\n",
"n_quest/other.qst": "[grade]\n`[epic]`\n[level]\n1 99\n[job]\n`[gunner]`\n" +
"[type]\n`[clear map]`\n[int data]\n99999\n",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfquest.Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
return catalog
}
func buildDungeonQuestClearSubQuestCompletionCatalog(t *testing.T, mapID int64) *dnfquest.Catalog {
t.Helper()
source := questListTestSource{
dnfquest.DefaultList: "3146 `parent.qst`\n3157 `hunt.qst`\n3054 `clear_sub.qst`\n",
"n_quest/parent.qst": "[grade]\n`[epic]`\n[level]\n1 99\n[job]\n`[gunner]`\n" +
"[type]\n`[quest clear]`\n[int data]\n3157 3054\n",
"n_quest/hunt.qst": "[grade]\n`[sub]`\n[level]\n1 99\n[job]\n`[gunner]`\n" +
"[type]\n`[hunt enemy]`\n[main quest]\n3146\n[int data]\n3 -1 13099 3 1\n",
"n_quest/clear_sub.qst": "[grade]\n`[sub]`\n[level]\n1 99\n[job]\n`[gunner]`\n" +
"[type]\n`[clear map]`\n[main quest]\n3146\n[int data]\n" + fmt.Sprint(mapID) + "\n",
}
index, err := dnfpvf.Build(context.Background(), source, dnfpvf.BuildOptions{Lists: []string{dnfquest.DefaultList}})
if err != nil {
t.Fatal(err)
}
catalog, err := dnfquest.Load(context.Background(), index)
if err != nil {
t.Fatal(err)
}
return catalog
}
var _ dnfrepo.QuestRepository = (*clearMapCountingQuestStore)(nil)