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