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

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

692 lines
28 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"fmt"
"os"
"reflect"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/adventuregroup"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
"longheng.io/server/internal/modules/dnf/progression"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func enableTowerCountsForTest(t *testing.T, service *Service, sessions ...*gameSession) {
t.Helper()
service.adventureGroupTable = loadAdventureGroupTestTables(t)
repositories, ok := service.repositoryGroup()
if !ok || repositories.Account == nil {
t.Fatal("tower account repository missing")
}
for _, session := range sessions {
accountID := service.accountIDForSession(session)
if _, found, err := repositories.Account.Load(context.Background(), accountID); err != nil {
t.Fatal(err)
} else if !found {
if err := repositories.Account.Save(context.Background(), dnfrepo.AccountRecord{AccountID: accountID}); err != nil {
t.Fatal(err)
}
}
}
}
func assertTowerCountsPacket(t *testing.T, stream, want []byte) []byte {
t.Helper()
packet, rest := splitGameServerUpperPacket(t, stream)
if packet.Header.MsgID != 1294 || packet.Header.Classification != 0 || !bytes.Equal(packet.Body, want) {
t.Fatalf("native tower counters: header=%+v body=%v want=%v", packet.Header, packet.Body, want)
}
return rest
}
func assertTowerCharacterEntries(t *testing.T, stream []byte, despair, grief byte) []byte {
t.Helper()
for i, remaining := range []byte{despair, grief} {
packet, rest := splitGameServerUpperPacket(t, stream)
if packet.Header.MsgID != 546 || packet.Header.Classification != 0 || len(packet.Body) != 5 ||
binary.LittleEndian.Uint32(packet.Body) != [2]uint32{11008, 5115}[i] || packet.Body[4] != remaining {
t.Fatalf("native character entry: header=%+v body=%x want remaining=%d", packet.Header, packet.Body, remaining)
}
stream = rest
}
return stream
}
func assertTowerCountsBeforeSelector(t *testing.T, stream, want []byte) {
t.Helper()
foundCounts, foundSelector := false, false
for len(stream) != 0 {
packet, rest := splitGameServerUpperPacket(t, stream)
switch packet.Header.MsgID {
case 1294:
assertTowerCountsPacket(t, stream, want)
if foundCounts || foundSelector {
t.Fatal("selector counters duplicated or sent after the floor context")
}
foundCounts = true
case 27:
if !foundCounts {
t.Fatal("selector opened before counter refresh")
}
foundSelector = true
}
stream = rest
}
if !foundCounts || !foundSelector {
t.Fatal("selector counts or floor context missing")
}
}
func TestTowerSettlementDisplaysNativeRewardAndReturnsWithoutOrdinaryCards(t *testing.T) {
for _, tower := range []struct {
flag string
id uint16
}{{"tower of despair", 348}, {"tower of grief", 1263}} {
for _, exit := range []string{"op42", "op72"} {
t.Run(tower.flag+"/"+exit, func(t *testing.T) {
service, runtime, session, conn := prepareOrdinaryCompletedSettlementRuntimeForExitTest(t)
enableTowerCountsForTest(t, service, session)
repositories, _ := service.repositoryGroup()
enableTownMoveSkillProjection(t, service, repositories, "99")
runtime.Dungeon.Metadata.TutorialDungeon.Set = false
runtime.Dungeon.Metadata.Flags = map[string]bool{tower.flag: true}
plan := currentDungeonSettlementPacketPlanForTest(t, runtime, 0)
plan.TowerRewardMsgID = tower.id
plan.TowerRewardBody, _ = buildCurrentDungeonTowerRewardBody(12345, 1, nil)
runtime.settlementResultPlan = &plan
conn.write.Reset()
if err := service.handleDungeonSetPlayResult(session, make([]byte, currentDungeonPlayResultBaseSize)); err != nil {
t.Fatal(err)
}
remaining := conn.write.Bytes()
for _, id := range []uint16{34, 37, 35, 546, 546, 1294, tower.id} {
packet, tail := splitGameServerUpperPacket(t, remaining)
if packet.Header.MsgID != id || packet.Header.Classification != 0 {
t.Fatalf("packet=%+v want class0/%d", packet.Header, id)
}
if id == tower.id && !bytes.Equal(packet.Body, plan.TowerRewardBody) {
t.Fatalf("tower body=%x", packet.Body)
}
remaining = tail
}
if len(remaining) != 0 || runtime.settlementStatisticReceived ||
!completedDungeonSettlementExitReady(runtime, session.selectedCharacterID) ||
completedDungeonSettlementExitReady(runtime, session.selectedCharacterID+1) {
t.Fatal("tower completion/character barrier is incorrect")
}
conn.write.Reset()
if err := service.handleDungeonSetPlayResult(session, make([]byte, currentDungeonPlayResultBaseSize)); err != nil {
t.Fatal(err)
}
if err := service.handleDungeonScoreScrollState(session, nil); err != nil {
t.Fatal(err)
}
if err := service.handleDungeonCardSelectRightState(session, nil); err != nil {
t.Fatal(err)
}
if conn.write.Len() != 0 || runtime.settlementCardRewardState != nil ||
runtime.settlementCardLayoutSent || runtime.settlementCardRewardCommitted {
t.Fatal("tower replay or card request entered the ordinary reward chain")
}
var err error
if exit == "op42" {
err = service.handleDungeonGiveupGame(session, nil)
} else {
err = service.handleDungeonEplpCommand(session, []byte{1, 2})
}
if err != nil {
t.Fatal(err)
}
if !runtime.townReturnOp24Sent || session.dungeon.runtime != nil {
t.Fatal("tower exit failed to send town transition and detach completed runtime")
}
foundTown := false
for remaining = conn.write.Bytes(); len(remaining) != 0; {
packet, tail := splitGameServerUpperPacket(t, remaining)
foundTown = foundTown || packet.Header.MsgID == 24 && packet.Header.Classification == 0
remaining = tail
}
if !foundTown {
t.Fatal("tower exit has no wire town transition")
}
})
}
}
}
func TestTowerSettlementFailedNotificationKeepsExitClosedAndResumesOnce(t *testing.T) {
for _, failAt := range []int{4, 5, 6, 7} {
t.Run(fmt.Sprint(failAt), func(t *testing.T) {
service, runtime, session, _, _ := prepareCompletedSettlementRuntime(t)
enableTowerCountsForTest(t, service, session)
runtime.Dungeon.Metadata.TutorialDungeon.Set = false
runtime.Dungeon.Metadata.Flags = map[string]bool{"tower of despair": true}
plan := currentDungeonSettlementPacketPlanForTest(t, runtime, 0)
plan.TowerRewardMsgID = 348
plan.TowerRewardBody, _ = buildCurrentDungeonTowerRewardBody(1, 1, nil)
runtime.settlementResultPlan = &plan
wantErr := errors.New("tower notification write failed")
conn := &failNthDungeonWriteConn{failAt: failAt, err: wantErr}
session.conn = conn
request := make([]byte, currentDungeonPlayResultBaseSize)
if err := service.handleDungeonSetPlayResult(session, request); !errors.Is(err, wantErr) {
t.Fatalf("send error=%v", err)
}
if runtime.settlementTowerRewardSent || completedDungeonSettlementExitReady(runtime, session.selectedCharacterID) {
t.Fatal("failed tower notification opened exit")
}
if runtime.settlementTowerCountsSent != (failAt == 7) {
t.Fatal("tower counter delivery was marked before a successful write")
}
written := conn.write.Len()
if err := service.handleDungeonGiveupGame(session, nil); err != nil {
t.Fatal(err)
}
if conn.write.Len() != written || session.dungeon.runtime != runtime {
t.Fatal("op42 crossed failed tower notification")
}
if err := service.handleDungeonSetPlayResult(session, request); err != nil {
t.Fatal(err)
}
rest := conn.write.Bytes()[written:]
if failAt < 7 {
rest = assertTowerCharacterEntries(t, rest, 1, 1)
rest = assertTowerCountsPacket(t, rest, []byte{3, 3, 1, 1})
}
packet, tail := splitGameServerUpperPacket(t, rest)
if packet.Header.MsgID != 348 || len(tail) != 0 || !completedDungeonSettlementExitReady(runtime, session.selectedCharacterID) {
t.Fatal("retry did not resume exactly the missing tower packet")
}
})
}
}
func TestTowerFloorUsesPVFMappingAndNativePacketBoundaries(t *testing.T) {
source := bridgePVFSource{"etc/towerofgrief.etc": "[top layer]\n100\n[each layer matching dungeon]\n1 5115 -1 50 5164 -1 100 5214 -1\n[/each layer matching dungeon]"}
for _, row := range []struct {
id int64
floor uint16
}{{5115, 1}, {5164, 50}, {5214, 100}} {
floor, err := resolveCurrentDungeonTowerFloor(source, worldmap.Dungeon{ID: row.id}, 1263)
if err != nil || floor != row.floor {
t.Fatalf("dungeon=%d floor=%d err=%v", row.id, floor, err)
}
}
if _, err := resolveCurrentDungeonTowerFloor(source, worldmap.Dungeon{ID: 9999}, 1263); err == nil {
t.Fatal("unmapped dungeon was assigned a floor")
}
body, err := buildCurrentDungeonTowerRewardBody(12345, 50, []currentDungeonClearRewardPair{{Key: 10160931, Value: 2}})
if err != nil || len(body) != 87 || binary.LittleEndian.Uint32(body) != 12345 ||
binary.LittleEndian.Uint16(body[4:]) != 50 || body[6] != 10 ||
binary.LittleEndian.Uint32(body[7:]) != 10160931 || binary.LittleEndian.Uint32(body[11:]) != 2 {
t.Fatalf("native tower body=%x err=%v", body, err)
}
for slot := 1; slot < 10; slot++ {
if binary.LittleEndian.Uint32(body[7+slot*8:]) != currentTowerEmptyRewardItemID || binary.LittleEndian.Uint32(body[11+slot*8:]) != 0 {
t.Fatal("missing native empty card")
}
}
empty, err := buildCurrentDungeonTowerRewardBody(12345, 1, nil)
if err != nil || len(empty) != 87 || empty[6] != 10 {
t.Fatalf("empty clear cannot create participant panel: %x %v", empty, err)
}
for _, flag := range []string{"tower of despair", "tower of grief"} {
runtime := &runtimeDungeonState{Dungeon: worldmap.Dungeon{SourceSections: []worldmap.RawSection{{Name: flag}}}}
if currentDungeonTowerRewardMsgID(runtime) == 0 {
t.Fatalf("source section %s missed", flag)
}
runtime.ProtocolMode = currentDungeonProtocolModePractice
if currentDungeonTowerRewardMsgID(runtime) != 0 {
t.Fatal("practice mode acquired real tower settlement")
}
}
}
func TestTowerSettlementRejectsMissingOrWrongNativeNotification(t *testing.T) {
for _, body := range [][]byte{nil, {1, 0, 0, 0, 1, 0, 1}, {1, 0, 0, 0, 0, 0, 0}} {
service, runtime, session, conn, _ := prepareCompletedSettlementRuntime(t)
runtime.Dungeon.Metadata.Flags = map[string]bool{"tower of grief": true}
plan := currentDungeonSettlementPacketPlanForTest(t, runtime, 0)
plan.TowerRewardMsgID, plan.TowerRewardBody = 1263, body
runtime.settlementResultPlan = &plan
conn.write.Reset()
if err := service.handleDungeonSetPlayResult(session, make([]byte, currentDungeonPlayResultBaseSize)); err != nil {
t.Fatal(err)
}
if conn.write.Len() != 0 || runtime.settlementTowerRewardSent {
t.Fatalf("invalid tower body was sent: %x", body)
}
plan.TowerRewardBody, _ = buildCurrentDungeonTowerRewardBody(1, 1, nil)
plan.TowerRewardMsgID = 348
if currentDungeonTowerPlanValid(runtime, &plan) {
t.Fatal("grief accepted a despair notification")
}
}
}
func TestTowerPickupDisplayUsesRemainingRunDelta(t *testing.T) {
runtime := &runtimeDungeonState{DropOwner: newRuntimeDungeonDropOwner()}
runtime.DropOwner.byObjectKey[1] = &runtimeDungeonDrop{Status: runtimeDungeonDropConsumed, DestinationSlot: 65, Item: dungeonDropItemDefinition{ItemID: 8474}, Amount: 2}
runtime.DropOwner.byObjectKey[2] = &runtimeDungeonDrop{Status: runtimeDungeonDropConsumed, DestinationSlot: 65, Item: dungeonDropItemDefinition{ItemID: 8474}, Amount: 1}
runtime.DropOwner.byObjectKey[3] = &runtimeDungeonDrop{Status: runtimeDungeonDropConsumed, DestinationSlot: 66, Item: dungeonDropItemDefinition{ItemID: 9000}, Amount: 50}
rows := currentDungeonTowerPickupRewards(runtime, []currentDungeonClearRewardDrop{{ObjectKey: 65, TemplateID: 8474, StackCount: 20}})
if len(rows) != 1 || rows[0].Key != 8474 || rows[0].Value != 3 {
t.Fatalf("pickup delta=%+v", rows)
}
rows = currentDungeonTowerPickupRewards(runtime, []currentDungeonClearRewardDrop{{ObjectKey: 65, TemplateID: 8474, StackCount: 1}})
if len(rows) != 1 || rows[0].Value != 1 {
t.Fatalf("consumed stack projected old pickup count: %+v", rows)
}
if rows := currentDungeonTowerPickupRewards(runtime, nil); len(rows) != 0 {
t.Fatalf("stale pickup projected: %+v", rows)
}
}
func TestRealScriptPVFTowerSettlementProducer(t *testing.T) {
path := os.Getenv("DNF_WORLDMAP_REAL_PVF_SMOKE")
if path == "" {
t.Skip("set DNF_WORLDMAP_REAL_PVF_SMOKE to verify tower settlement against Script.pvf")
}
archive, err := platformpvf.Open(path)
if err != nil {
t.Fatal(err)
}
table, err := worldmap.LoadSource(context.Background(), archive, worldmap.Options{CompactRuntime: true})
if err != nil {
t.Fatal(err)
}
resolver, err := worldmap.NewResolver(table)
if err != nil {
t.Fatal(err)
}
monsters, err := newPVFDungeonMonsterCatalog(archive)
if err != nil {
t.Fatal(err)
}
aiCharacters, err := newPVFDungeonAICharacterCatalog(archive)
if err != nil {
t.Fatal(err)
}
progressionTables, err := progression.Load(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
experience, err := progression.LoadMonsterExperienceSources(archive)
if err != nil {
t.Fatal(err)
}
ranks, err := loadCurrentDungeonClearRankCatalog(archive)
if err != nil {
t.Fatal(err)
}
resources := currentDungeonSettlementRewardResources{
Progression: progressionTables, ExperienceSources: experience, ClearRankCatalog: ranks,
}
adventureTables, err := adventuregroup.LoadComplete(context.Background(), archive)
if err != nil {
t.Fatal(err)
}
for _, id := range []int64{11008, 11107, 5115, 5117, 5119, 5164, 5214} {
for _, exit := range []string{"op42", "op72"} {
t.Run(fmt.Sprintf("%d/%s", id, exit), func(t *testing.T) {
dungeon, found := table.FindDungeon(id)
if !found {
t.Fatalf("PVF dungeon %d missing", id)
}
service, runtime, session, conn := prepareOrdinaryCompletedSettlementRuntimeForExitTest(t)
service.initialEquipmentArchive = archive
service.adventureGroupTable = adventureTables
service.worldMapTable, service.worldMapResolver = table, resolver
service.dungeonMonsterTable, service.dungeonAICharacterTable = monsters, aiCharacters
runtime.Dungeon = dungeon
if len(dungeon.SourceSections) != 0 || currentDungeonTowerRewardMsgID(runtime) == 0 {
t.Fatalf("production compact dungeon lost tower identity: %+v", dungeon.Metadata.Flags)
}
runtime.Character.Level = 90
runtime.Character.Stats["exp"] = 0
repositories, _ := service.repositoryGroup()
enableTownMoveSkillProjection(t, service, repositories, "99")
ctx := context.Background()
account := dnfrepo.AccountRecord{AccountID: runtime.Character.AccountID,
Metadata: map[string]string{}}
if id == 5117 || id == 5119 {
for i := 0; i < 2; i++ {
if err := dnfdungeon.RecordTowerDailyClear(&account, dnfdungeon.TowerGrief, service.gameplayNow()); err != nil {
t.Fatal(err)
}
}
}
if err := repositories.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
if err := repositories.Character.Save(ctx, runtime.Character); err != nil {
t.Fatal(err)
}
if id == 5119 {
conn.write.Reset()
if err := service.sendCurrentAdventureTowerCounts(ctx, session, "before_last_clear"); err != nil {
t.Fatal(err)
}
if rest := assertTowerCountsPacket(t, assertTowerCharacterEntries(t, conn.write.Bytes(), 1, 1), []byte{6, 6, 1, 3}); len(rest) != 0 {
t.Fatal("unexpected initial counter packets")
}
conn.write.Reset()
}
if id == 5117 {
if err := service.validateCurrentTowerAdmission(ctx, session, runtime.Character, dungeon); err != nil {
t.Fatalf("Lv8 bonus must allow the third grief clear: %v", err)
}
if err := dnfdungeon.RecordTowerDailyClear(&account, dnfdungeon.TowerGrief, service.gameplayNow()); err != nil {
t.Fatal(err)
}
if err := repositories.Account.Save(ctx, account); err != nil {
t.Fatal(err)
}
if err := service.validateCurrentTowerAdmission(ctx, session, runtime.Character, dungeon); err != nil {
t.Fatalf("exhausted account must retain unused character entry: %v", err)
}
var projection adventuregroup.Projection
service.projectAdventureTowerCounts(&projection, account, true, 8)
if projection.TowerLimits != [2]uint32{6, 3} || projection.TowerRemaining != [2]uint32{6, 0} {
t.Fatalf("Lv8 tooltip disagrees with admission: %+v", projection)
}
}
if err := repositories.Skill.Save(ctx, dnfrepo.SkillRecord{CharacterID: "99", Points: dnfrepo.SkillPointState{SyncedLevel: 90}}); err != nil {
t.Fatal(err)
}
runtime.settlementPlayResultReceived = true
runtime.settlementPlayResultBody = make([]byte, currentDungeonPlayResultBaseSize)
runtime.startedAt = time.Unix(1700000000, 0)
runtime.clearMapCompletionAt = runtime.startedAt.Add(12345 * time.Millisecond)
if err := service.produceCurrentDungeonSettlementPlanWithResourcesLocked(ctx, session, runtime, resources); err != nil {
t.Fatalf("dungeon %d: %v", id, err)
}
plan := runtime.settlementResultPlan
if plan == nil || !currentDungeonTowerPlanValid(runtime, plan) || plan.clearRewardSnapshot.Tail.ShowResult || runtime.settlementCardRewardState != nil {
t.Fatalf("dungeon %d produced ordinary card/result UI", id)
}
before, _, _ := repositories.Character.Load(ctx, "99")
floor, err := service.currentDungeonTowerFloor(runtime)
if err != nil {
t.Fatal(err)
}
kind := currentDungeonTowerKind(runtime)
wantFloor := floor%100 + 1
if got, err := dnfdungeon.TowerFloor(before, kind); err != nil || got != wantFloor {
t.Fatalf("persisted next floor=%d want=%d err=%v", got, wantFloor, err)
}
selector, err := service.currentTowerSelectorContext(ctx, session)
if err != nil {
t.Fatal(err)
}
selectorBody, err := selector.Build()
if err != nil {
t.Fatal(err)
}
offset := 15
if kind == dnfdungeon.TowerGrief {
offset = 34
}
if binary.LittleEndian.Uint16(selectorBody[offset:]) != wantFloor {
t.Fatalf("selector reset floor: %x", selectorBody)
}
baseID, err := dnfdungeon.TowerDungeonID(archive, kind, 1)
if err != nil {
t.Fatal(err)
}
base, _ := table.FindDungeon(baseID)
nextID, err := service.resolveCurrentTowerDungeonSelection(before, base)
wantID, wantErr := dnfdungeon.TowerDungeonID(archive, kind, wantFloor)
if err != nil || wantErr != nil || nextID != wantID {
t.Fatalf("next entry=%d want=%d err=%v %v", nextID, wantID, err, wantErr)
}
if len(plan.TowerRewardBody) != 87 || plan.TowerRewardBody[6] != 10 {
t.Fatalf("missing reward panel: %x", plan.TowerRewardBody)
}
if kind == dnfdungeon.TowerGrief && (floor == 50 || floor == 100) && binary.LittleEndian.Uint32(plan.TowerRewardBody[79:]) != 10160930 {
t.Fatalf("missing PVF special reward: %x", plan.TowerRewardBody)
}
first := append([]byte(nil), plan.TowerRewardBody...)
runtime.settlementResultPlan = nil
if err := service.produceCurrentDungeonSettlementPlanWithResourcesLocked(ctx, session, runtime, resources); err != nil {
t.Fatal(err)
}
after, _, _ := repositories.Character.Load(ctx, "99")
if before.Stats["exp"] != after.Stats["exp"] || !bytes.Equal(first, runtime.settlementResultPlan.TowerRewardBody) {
t.Fatal("retry changed frozen tower reward or granted experience twice")
}
if got, _ := dnfdungeon.TowerFloor(after, kind); got != wantFloor {
t.Fatal("replay advanced tower twice")
}
if id == 5117 {
account, _, err := repositories.Account.Load(ctx, runtime.Character.AccountID)
if err != nil {
t.Fatal(err)
}
if used, err := dnfdungeon.TowerDailyUsed(account, kind, service.gameplayNow()); err != nil || used != 3 {
t.Fatalf("accepted clear/replay usage=%d err=%v", used, err)
}
}
conn.write.Reset()
if err := service.handleDungeonSetPlayResult(session, runtime.settlementPlayResultBody); err != nil {
t.Fatal(err)
}
wantCounts := []byte{6, 6, 3, 3}
if id == 5119 {
wantCounts = []byte{6, 6, 1, 3}
} else if id == 5117 {
wantCounts = []byte{6, 6, 0, 3}
}
foundReward, foundCounts := false, false
for remaining := conn.write.Bytes(); len(remaining) != 0; {
packet, tail := splitGameServerUpperPacket(t, remaining)
if packet.Header.MsgID == 1294 {
assertTowerCountsPacket(t, remaining, wantCounts)
if foundCounts || foundReward {
t.Fatal("tower counts duplicated or sent after the reward window")
}
foundCounts = true
}
if packet.Header.MsgID == plan.TowerRewardMsgID && packet.Header.Classification == 0 {
foundReward = bytes.Equal(packet.Body, first)
}
remaining = tail
}
if !foundCounts || !foundReward || !completedDungeonSettlementExitReady(runtime, session.selectedCharacterID) {
t.Fatal("real compact tower did not send its reward notification and release the exit")
}
conn.write.Reset()
if exit == "op42" {
err = service.handleDungeonGiveupGame(session, nil)
} else {
err = service.handleDungeonEplpCommand(session, []byte{1, 2})
}
if err != nil || !runtime.townReturnOp24Sent || session.dungeon.runtime != nil {
t.Fatalf("real compact tower exit failed: err=%v op24=%t", err, runtime.townReturnOp24Sent)
}
conn.write.Reset()
if err := service.sendCurrentTownEnterSelectContext(session, "after_tower_return"); err != nil {
t.Fatal(err)
}
assertTowerCountsBeforeSelector(t, conn.write.Bytes(), wantCounts)
if id == 5117 {
inventory, _, _ := repositories.Inventory.Load(ctx, "99")
if inventory.Slots == nil {
inventory.Slots = make(map[string]dnfrepo.ItemStack)
}
inventory.Slots["0:121"] = dnfrepo.ItemStack{ItemID: 10161065, Count: 5}
if err := repositories.Inventory.Save(ctx, inventory); err != nil {
t.Fatal(err)
}
conn.write.Reset()
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
binary.LittleEndian.PutUint32(body, uint32(baseID))
binary.LittleEndian.PutUint16(body[9:11], 0xffff)
if err := service.handleDungeonSelectUpper(session, body); err != nil {
t.Fatal(err)
}
blocked, refreshed := false, false
for stream := conn.write.Bytes(); len(stream) > 0; {
packet, tail := splitGameServerUpperPacket(t, stream)
if packet.Header.MsgID == 16 {
blocked = bytes.Equal(packet.Body, []byte{0, 133, 0})
}
if packet.Header.MsgID == currentTownChatNotificationMsgID {
t.Fatal("entry refusal impersonated the character in chat")
}
if packet.Header.MsgID == 1294 {
assertTowerCountsPacket(t, stream, []byte{6, 6, 0, 3})
refreshed = true
}
if packet.Header.MsgID == currentDungeonInfoNotification {
t.Fatal("exhausted account received dungeon entry")
}
stream = tail
}
unchanged, _, _ := repositories.Inventory.Load(ctx, "99")
if !blocked || !refreshed || session.dungeon.runtime != nil || !reflect.DeepEqual(inventory.Slots, unchanged.Slots) {
t.Fatalf("limit guard: blocked=%t refreshed=%t inventory=%+v", blocked, refreshed, unchanged.Slots)
}
nextDay := service.gameplayNow().Add(24 * time.Hour)
service.gameplayTimers = &processGameplayTimeQueue{now: func() time.Time { return nextDay }}
conn.write.Reset()
if err := service.sendCurrentTownEnterSelectContext(session, "next_service_day"); err != nil {
t.Fatal(err)
}
assertTowerCountsBeforeSelector(t, conn.write.Bytes(), []byte{6, 6, 3, 3})
}
if floor == 1 || floor == 3 {
for _, state := range []byte{0, 1} {
next, scene, err := service.prepareDungeonRuntime(ctx, session, dungeoncmd.SelectDungeonRequest{DungeonID: uint32(baseID), RuntimeState: state, RuntimeToken: 0xffff})
if err != nil {
t.Fatalf("next floor runtime state=%d: %v", state, err)
}
resolvedID := wantID
if kind == dnfdungeon.TowerDespair {
resolvedID, _, err = resolveCurrentTowerOfDespairSoloDungeon(table, resolver, wantID)
if err != nil {
t.Fatal(err)
}
}
if next.Dungeon.ID != resolvedID || scene.Map.Map.ID == 0 {
t.Fatalf("wrong next dungeon=%d expected=%d", next.Dungeon.ID, resolvedID)
}
}
}
if floor == 1 {
// The first clear above consumed the character entry. Exercise
// every remaining shared entry with the real PVF admission cap.
limit, err := dnfdungeon.TowerDailyEnterableLimit(archive, kind, 8)
if err != nil {
t.Fatal(err)
}
owner, err := dnfdungeon.NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
for used := uint32(0); used <= limit; used++ {
current, _, err := repositories.Character.Load(ctx, "99")
if err != nil {
t.Fatal(err)
}
admissionErr := service.validateCurrentTowerAdmission(ctx, session, current, base)
if (used == limit && !errors.Is(admissionErr, dnfdungeon.ErrTowerDailyLimitReached)) || (used < limit && admissionErr != nil) {
t.Fatalf("%s after %d clears: shared=%d/%d admission=%v", kind, used+1, limit-used, limit, admissionErr)
}
conn.write.Reset()
if err := service.sendCurrentAdventureTowerCounts(ctx, session, "additive_budget"); err != nil {
t.Fatal(err)
}
personal := [2]byte{1, 1}
counts := []byte{6, 6, 3, 3}
index := 0
if kind == dnfdungeon.TowerGrief {
index = 1
}
personal[index], counts[index*2] = 0, byte(limit-used)
rest := assertTowerCharacterEntries(t, conn.write.Bytes(), personal[0], personal[1])
if rest = assertTowerCountsPacket(t, rest, counts); len(rest) != 0 {
t.Fatal("unexpected entry packets")
}
if used == limit {
break
}
cmd := dnfdungeon.SettlementCommand{AccountID: current.AccountID, CharacterID: "99", Tables: progressionTables,
CompletionKey: fmt.Sprintf("additive-%s-%d", kind, used), UpdatedAt: service.gameplayNow(),
Tower: &dnfdungeon.TowerClear{Kind: kind, Floor: uint16(used + 2)}}
for replay := 0; replay < 2; replay++ {
result, err := owner.CommitSettlement(ctx, cmd)
if err != nil || result.Replayed != (replay == 1) {
t.Fatalf("settlement/replay: %v", err)
}
}
}
// Exhaustion must reject before entry and leave the inventory
// untouched, even if the client resends the real selector request.
inventory, _, _ := repositories.Inventory.Load(ctx, "99")
conn.write.Reset()
request := make([]byte, dungeoncmd.SelectDungeonRequestSize)
binary.LittleEndian.PutUint32(request, uint32(baseID))
binary.LittleEndian.PutUint16(request[9:11], 0xffff)
if err := service.handleDungeonSelectUpper(session, request); err != nil {
t.Fatal(err)
}
blocked := false
for stream := conn.write.Bytes(); len(stream) != 0; {
packet, tail := splitGameServerUpperPacket(t, stream)
if packet.Header.MsgID == 16 {
blocked = bytes.Equal(packet.Body, []byte{0, 133, 0})
}
if packet.Header.MsgID == currentDungeonInfoNotification {
t.Fatal("exhausted pools entered dungeon")
}
stream = tail
}
after, _, _ := repositories.Inventory.Load(ctx, "99")
if !blocked || !reflect.DeepEqual(inventory.Slots, after.Slots) {
t.Fatal("exhaustion did not preserve inventory")
}
t.Logf("%s: %d character + %d shared clears, next entry rejected; replay and native counters verified", kind, 1, limit)
}
})
}
}
}
// completedDungeonSettlementExitReady mirrors the aggregate exit barrier the
// production path composes inline (the cardless exit readiness in
// dungeon_card_flow.go). A tower releases the exit only once its own native
// clear window (op348/op1263) has actually been published.
func completedDungeonSettlementExitReady(runtime *runtimeDungeonState, characterID uint16) bool {
if completedCurrentDungeonAltarSettlementExitReady(runtime, characterID) {
return true
}
if completedRuntimeRaidDungeonExitReady(runtime, characterID) {
return true
}
if !completedDungeonSettlementResultReady(runtime, characterID) {
return false
}
if currentDungeonTowerRewardMsgID(runtime) != 0 {
return runtime.settlementTowerRewardSent
}
if runtime.tournament != nil {
return runtime.tournament.RewardSelectionComplete()
}
if currentDungeonRuntimeIsPractice(runtime) {
return true
}
return runtime.settlementCardLayoutSent && runtime.settlementCardSelectionSent &&
runtime.settlementCardRewardCommitted &&
runtime.settlementPhase >= currentDungeonSettlementPhaseRewardCommitted
}