DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,656 @@
|
||||
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)
|
||||
Reference in new issue
Block a user