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

894 lines
35 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"sync"
"sync/atomic"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func TestRuntimePartyDungeonRoomMoveRejoinsDivergedPrivateObjectKeyStreams(t *testing.T) {
service, leaderRuntime := prepareSyntheticMoveRuntime(t, false)
_, memberRuntime := prepareSyntheticMoveRuntime(t, false)
service.options.gameUpperBodyCodec = gameUpperBodyCodecPlain
service.gameSessions = make(map[uint16]*gameSession)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
leaderConn := &bufferConn{}
memberConn := &bufferConn{}
leader := &gameSession{
ctx: ctx, conn: leaderConn, connID: "party-room-move-leader",
selectedCharacterID: 99, dungeon: dungeonSessionState{runtime: leaderRuntime},
}
member := &gameSession{
ctx: ctx, conn: memberConn, connID: "party-room-move-member",
selectedCharacterID: 100, dungeon: dungeonSessionState{runtime: memberRuntime},
}
service.bindGameSessionCharacter(leader, 99)
service.bindGameSessionCharacter(member, 100)
owner := &runtimePartyDungeonOwner{
partyID: 7, leaderID: 99, leaderSlot: 0,
members: []runtimePartyDungeonMember{
{slot: 0, userID: 99, generation: leader.characterGeneration},
{slot: 1, userID: 100, generation: member.characterGeneration},
},
worldRooms: make(map[runtimeDungeonRoomKey]*runtimePartyDungeonSharedRoom),
worldDeaths: make(map[runtimePartyDungeonDeathKey]*runtimePartyDungeonDeathFact),
}
leaderRuntime.partyDungeonOwner, leaderRuntime.partyMemberIndexed, leaderRuntime.partyMemberIndex = owner, true, 0
memberRuntime.partyDungeonOwner, memberRuntime.partyMemberIndexed, memberRuntime.partyMemberIndex = owner, true, 1
// Reproduce the live failure: member-private rewards consumed one more
// object key than the leader before both clients entered room two.
leaderRuntime.NextObjectKey = 407
memberRuntime.NextObjectKey = 408
if err := service.startGameSessionEvents(ctx, member); err != nil {
t.Fatal(err)
}
defer stopTestGameSessionEvents(t, service, member)
body := make([]byte, dungeoncmd.MoveMapRequestSize)
body[0] = 1
if err := service.handleDungeonMoveMap(leader, body); err != nil {
t.Fatalf("move party into shared second room: %v", err)
}
leaderRoom := leaderRuntime.Room.Snapshot()
memberRoom := memberRuntime.Room.Snapshot()
if len(leaderRoom.Monsters) != 1 || len(memberRoom.Monsters) != 1 {
t.Fatalf("target monster counts leader=%d member=%d", len(leaderRoom.Monsters), len(memberRoom.Monsters))
}
if leaderRoom.Monsters[0].ObjectKey != 408 || memberRoom.Monsters[0].ObjectKey != 408 {
t.Fatalf("shared actor keys leader=%d member=%d want=408",
leaderRoom.Monsters[0].ObjectKey, memberRoom.Monsters[0].ObjectKey)
}
if leaderRuntime.NextObjectKey != 409 || memberRuntime.NextObjectKey != 409 {
t.Fatalf("next object keys leader=%d member=%d want=409",
leaderRuntime.NextObjectKey, memberRuntime.NextObjectKey)
}
leaderStart, leaderTrailing := splitGameServerUpperPacket(t, leaderConn.write.Bytes())
memberStart, memberTrailing := splitGameServerUpperPacket(t, memberConn.write.Bytes())
leaderTrailing = consumeOptionalCurrentDungeonFatigueRefreshForTest(t, leaderTrailing)
memberTrailing = consumeOptionalCurrentDungeonFatigueRefreshForTest(t, memberTrailing)
if len(leaderTrailing) != 0 || len(memberTrailing) != 0 ||
leaderStart.Header.MsgID != currentDungeonStartNotification || memberStart.Header.MsgID != currentDungeonStartNotification ||
!bytes.Equal(leaderStart.Body, memberStart.Body) {
t.Fatalf("second-room op29 projections diverged:\nleader=%x\nmember=%x",
leaderConn.write.Bytes(), memberConn.write.Bytes())
}
}
func TestRuntimePartyDungeonRoomReloadReplaysDeadPeerState(t *testing.T) {
service, leaderRuntime := prepareSyntheticMoveRuntime(t, false)
_, memberRuntime := prepareSyntheticMoveRuntime(t, false)
service.options.gameUpperBodyCodec = gameUpperBodyCodecPlain
service.gameSessions = make(map[uint16]*gameSession)
service.gameplayTimers = newFakeCurrentDungeonDeathTimerQueue()
repositories, ok := service.repositoryGroup()
if !ok {
t.Fatal("party death replay repositories unavailable")
}
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "100",
AccountID: "account-1",
Name: "follower",
Job: atSwordmanTutorialJob,
Level: 20,
Stats: map[string]int64{
"fatigue": 100, "town_id": 7, "area_id": 3,
"pos_x": 500, "pos_y": 234, "direction": 5, "area_state": 3,
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "100",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
memberRuntime.Character.CharacterID = "100"
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
leaderConn := &bufferConn{}
memberConn := &bufferConn{}
leader := &gameSession{
ctx: ctx, conn: leaderConn, connID: "party-death-replay-leader",
selectedCharacterID: 99, dungeon: dungeonSessionState{runtime: leaderRuntime},
}
member := &gameSession{
ctx: ctx, conn: memberConn, connID: "party-death-replay-member",
selectedCharacterID: 100, dungeon: dungeonSessionState{runtime: memberRuntime},
}
service.bindGameSessionCharacter(leader, 99)
service.bindGameSessionCharacter(member, 100)
owner := &runtimePartyDungeonOwner{
partyID: 7, leaderID: 99, leaderSlot: 0,
members: []runtimePartyDungeonMember{
{slot: 0, userID: 99, generation: leader.characterGeneration},
{slot: 1, userID: 100, generation: member.characterGeneration},
},
worldRooms: make(map[runtimeDungeonRoomKey]*runtimePartyDungeonSharedRoom),
worldDeaths: make(map[runtimePartyDungeonDeathKey]*runtimePartyDungeonDeathFact),
}
leaderRuntime.partyDungeonOwner, leaderRuntime.partyMemberIndexed, leaderRuntime.partyMemberIndex = owner, true, 0
memberRuntime.partyDungeonOwner, memberRuntime.partyMemberIndexed, memberRuntime.partyMemberIndex = owner, true, 1
for _, runtime := range []*runtimeDungeonState{leaderRuntime, memberRuntime} {
if runtime.lifecycleToken == 0 {
runtime.lifecycleToken = 1
}
}
for _, session := range []*gameSession{leader, member} {
if err := service.startGameSessionEvents(ctx, session); err != nil {
t.Fatal(err)
}
defer stopTestGameSessionEvents(t, service, session)
}
// The follower dies in the first room: the shared world records the dead
// slot and projects one op32 to the living leader.
if characterID, reason := currentDungeonDeathRuntimeOwner(memberRuntime, member); reason != "" {
t.Fatalf("member death owner id=%d reason=%q run=%+v", characterID, reason, memberRuntime.Session.Snapshot().Run)
}
if err := service.handleDungeonDieCharacter(
member,
make([]byte, currentDungeonDieCharacterBodySize),
); err != nil {
t.Fatal(err)
}
if owner.deadSlots != 0b10 || !memberRuntime.deathReturnWaiting {
t.Fatalf("member death dead_slots=%08b waiting=%t",
owner.deadSlots, memberRuntime.deathReturnWaiting)
}
// Both clients reload the next room. Every client respawns the party actors
// from its own room payload, so the leader must be told again that the
// follower is still a ghost. The dead member must not receive a replay of
// its own death, which would restart its revive window.
if err := service.callGameSession(
ctx, leader, "party-death-replay-projection-barrier", func() error { return nil },
); err != nil {
t.Fatal(err)
}
resetDungeonEntrySceneGates(leader)
resetDungeonEntrySceneGates(member)
// The party is already inside room one: model that completed initial load
// cycle without replaying the whole entry barrier.
if cycle := owner.beginRoomLoading(); cycle != 1 {
t.Fatalf("initial party loading cycle=%d want=1", cycle)
}
if cycle := owner.beginRoomLoading(); cycle != 2 {
t.Fatalf("second party loading cycle=%d want=2", cycle)
}
leaderConn.write.Reset()
memberConn.write.Reset()
if err := service.sendFinishLoadingStatus(
leader,
make([]byte, currentFinishLoadingLegacyRequestBodySize),
); err != nil {
t.Fatal(err)
}
if err := service.sendFinishLoadingStatus(
member,
make([]byte, currentFinishLoadingLegacyRequestBodySize),
); err != nil {
t.Fatal(err)
}
want := buildCurrentDungeonDeathStateBody(currentSceneActorObjectKey(100), 0, 0)
replayed := 0
for _, packet := range splitAllCurrentGameServerUpperPackets(t, leaderConn.write.Bytes()) {
if packet.Header.MsgID != currentDungeonDeathStateMsgID {
continue
}
if !bytes.Equal(packet.Body, want) {
t.Fatalf("leader death replay body=%x want=%x", packet.Body, want)
}
replayed++
}
if replayed != 1 {
t.Fatalf("leader death replay count=%d want=1 packets=%x",
replayed, leaderConn.write.Bytes())
}
for _, packet := range splitAllCurrentGameServerUpperPackets(t, memberConn.write.Bytes()) {
if packet.Header.MsgID == currentDungeonDeathStateMsgID {
t.Fatalf("dead member received its own death replay body=%x", packet.Body)
}
}
}
func TestRuntimePartyDungeonLoadingBarrierPublishesRaidAttemptLoaded(t *testing.T) {
service, runtime := prepareSyntheticMoveRuntime(t, false)
service.options.gameUpperBodyCodec = gameUpperBodyCodecPlain
service.gameSessions = make(map[uint16]*gameSession)
service.gameplayTimers = newFakeCurrentDungeonDeathTimerQueue()
service.onlinePlayers = newOnlinePlayerManager()
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
connection := &bufferConn{}
session := &gameSession{
ctx: ctx, conn: connection, connID: "party-raid-loaded",
selectedCharacterID: 99, dungeon: dungeonSessionState{runtime: runtime},
}
service.bindGameSessionCharacter(session, 99)
if runtime.lifecycleToken == 0 {
runtime.lifecycleToken = 1
}
owner := &runtimePartyDungeonOwner{
partyID: 7, leaderID: 99, leaderSlot: 0,
members: []runtimePartyDungeonMember{
{slot: 0, userID: 99, generation: session.characterGeneration},
},
worldRooms: make(map[runtimeDungeonRoomKey]*runtimePartyDungeonSharedRoom),
worldDeaths: make(map[runtimePartyDungeonDeathKey]*runtimePartyDungeonDeathFact),
}
runtime.partyDungeonOwner, runtime.partyMemberIndexed, runtime.partyMemberIndex = owner, true, 0
// This party run sits inside a raid gate opened by this very session, so the
// binding token matches and the attempt is the opener's own.
token := runtime.lifecycleToken
runtime.raidDungeon = runtimeRaidDungeonBinding{
RaidKey: 0x10063,
Phase: runtimeRaidDungeonPhaseOne,
MapID: 210,
LifecycleToken: token,
selectedNotified: true,
}
service.raids = map[uint32]*runtimeRaidState{
0x10063: {
RaidKey: 0x10063, LeaderID: 99, Started: true, EntryCostPaid: true,
Members: []runtimeRaidMemberState{{CharID: 99, GroupIndex: 1}},
FrozenMembers: []runtimeRaidMemberState{{CharID: 99, GroupIndex: 1}},
Lifecycle: runtimeRaidLifecycleState{
Status: runtimeRaidLifecyclePhaseActive,
Phase: runtimeRaidDungeonPhaseOne,
Dungeons: map[runtimeRaidDungeonRunKey]runtimeRaidDungeonProgress{
{MapID: 210, Seat: runtimeRaidDungeonSeat{Group: 1}}: {
Phase: runtimeRaidDungeonPhaseOne, MapID: 210,
Seat: runtimeRaidDungeonSeat{Group: 1},
CharacterID: 99,
LifecycleToken: token,
Status: runtimeRaidDungeonProgressSelected,
},
},
},
},
}
if err := service.startGameSessionEvents(ctx, session); err != nil {
t.Fatal(err)
}
defer stopTestGameSessionEvents(t, service, session)
resetDungeonEntrySceneGates(session)
// The initial cycle installs op19 and needs the real job/skill PVF; the
// shared release path is the same for every cycle, so drive the room-reload
// cycle that this fixture can serve.
if cycle := owner.beginRoomLoading(); cycle != 1 {
t.Fatalf("initial party loading cycle=%d want=1", cycle)
}
if cycle := owner.beginRoomLoading(); cycle != 2 {
t.Fatalf("second party loading cycle=%d want=2", cycle)
}
connection.write.Reset()
if err := service.sendFinishLoadingStatus(
session,
make([]byte, currentFinishLoadingLegacyRequestBodySize),
); err != nil {
t.Fatal(err)
}
// The shared party barrier must publish the same "loaded" event the solo op37
// path does; without it the attempt stays at selected and the opener's own
// completion is refused by the raid state machine as out of order.
if !runtime.raidDungeon.loadedNotified {
t.Fatalf("party loading barrier skipped the raid attempt loaded event: %+v",
runtime.raidDungeon)
}
service.raidMu.Lock()
progress := service.raids[0x10063].Lifecycle.Dungeons[runtimeRaidDungeonRunKey{MapID: 210, Seat: runtimeRaidDungeonSeat{Group: 1}}]
service.raidMu.Unlock()
if progress.Status != runtimeRaidDungeonProgressLoaded {
t.Fatalf("authoritative raid attempt status=%d want=%d",
progress.Status, runtimeRaidDungeonProgressLoaded)
}
}
// newPartyDungeonTownReturnFixture builds a three-member admitted party dungeon
// (leader 99 in slot 0, members 100 and 101 in slots 1 and 2) so a town return
// leaves TWO survivors behind: the party is only reduced, not dissolved, and the
// survivor projections (op9 kind2 + 0x0099 + op26 host transfer + scene op6) stay
// observable. The two-member dissolve case is covered by
// TestRuntimePartyDungeonTownReturnDisbandsATwoMemberParty.
func newPartyDungeonTownReturnFixture(t *testing.T) (*Service, map[uint16]*gameSession, *runtimePartyDungeonOwner, alignedcmd.PartyState) {
t.Helper()
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
gameSessions: make(map[uint16]*gameSession),
}
sessions := make(map[uint16]*gameSession, 3)
for _, characterID := range []uint16{99, 100, 101} {
session := &gameSession{
conn: &bufferConn{},
connID: fmt.Sprintf("party-return-%d", characterID),
selectedCharacterID: characterID,
}
service.bindGameSessionCharacter(session, characterID)
sessions[characterID] = session
}
if _, result, joined := service.createOrJoinManagedRuntimeParty(sessions[100], sessions[99]); !joined || !result.OK {
t.Fatalf("create party result=%+v", result)
}
state, result, joined := service.createOrJoinManagedRuntimeParty(sessions[101], sessions[99])
if !joined || !result.OK || len(runtimePartyMembers(state)) != 3 {
t.Fatalf("third join joined=%t result=%+v state=%+v", joined, result, state)
}
snapshot, found := service.runtimePartyManagerForService().SnapshotByID(uint16(state.PartyID))
if !found {
t.Fatal("managed party snapshot unavailable")
}
owner, err := newRuntimePartyDungeonOwner(snapshot)
if err != nil {
t.Fatal(err)
}
for index, characterID := range []uint16{99, 100, 101} {
sessions[characterID].dungeon.runtime = &runtimeDungeonState{
partyDungeonOwner: owner,
partyMemberIndexed: true,
partyMemberIndex: byte(index),
}
}
return service, sessions, owner, state
}
func TestRuntimePartyDungeonTownReturnRemovesOnlyReturningMember(t *testing.T) {
for _, test := range []struct {
name string
returningID uint16
newLeaderID uint16
newLeaderSlot byte
hostTransfer bool
}{
{
name: "leader returns and leadership transfers", returningID: 99,
newLeaderID: 100, newLeaderSlot: 1, hostTransfer: true,
},
{
name: "follower returns and leader remains", returningID: 100,
newLeaderID: 99, newLeaderSlot: 0, hostTransfer: false,
},
} {
t.Run(test.name, func(t *testing.T) {
service, sessions, owner, state := newPartyDungeonTownReturnFixture(t)
returning := sessions[test.returningID]
previous := runtimePartyStateSnapshot(returning)
if err := service.detachRuntimePartyDungeonMemberForTownReturn(
returning, owner, previous, "test_midrun_town_return",
); err != nil {
t.Fatal(err)
}
if got := runtimePartyStateSnapshot(returning); got.PartyID != 0 || len(runtimePartyMembers(got)) != 0 {
t.Fatalf("returning member retained party projection: %+v", got)
}
if _, found := service.runtimePartyManagerForService().SnapshotByUser(
test.returningID, returning.characterGeneration,
); found {
t.Fatal("returning member remained in managed party")
}
roster := owner.roster()
if len(roster) != 2 || owner.leaderID != test.newLeaderID ||
owner.leaderSlot != test.newLeaderSlot {
t.Fatalf("shared dungeon roster=%+v leader=%d/%d", roster, owner.leaderID, owner.leaderSlot)
}
for _, survivorID := range []uint16{99, 100, 101} {
if survivorID == test.returningID {
continue
}
survivor := sessions[survivorID]
survivorState := runtimePartyStateSnapshot(survivor)
if survivorState.PartyID != state.PartyID || survivorState.UserID != test.newLeaderID ||
len(runtimePartyMembers(survivorState)) != 2 {
t.Fatalf("survivor %d party state=%+v", survivorID, survivorState)
}
packets := splitAllCurrentGameServerUpperPackets(t, survivor.conn.(*bufferConn).write.Bytes())
wantPackets := 3
if test.hostTransfer {
wantPackets = 4
}
if len(packets) != wantPackets {
t.Fatalf("survivor %d projections=%+v", survivorID, packets)
}
rest := assertRuntimePartyMembershipReduction(
t, survivor.conn.(*bufferConn).write.Bytes(), 2, test.newLeaderSlot, 2)
if test.hostTransfer {
host, after := splitGameServerUpperPacket(t, rest)
rest = after
if host.Header.MsgID != 26 || len(host.Body) != 1 || host.Body[0] != test.newLeaderSlot {
t.Fatalf("survivor %d replacement dungeon host projection=%+v", survivorID, host)
}
}
leave, rest := splitCurrentGameServerUpperPacketAuto(t, rest)
if leave.Header.MsgID != currentTownUserLeaveMsgID ||
!bytes.Equal(leave.Body, buildTownUserLeaveBody(test.returningID)) || len(rest) != 0 {
t.Fatalf("survivor %d leave projection=%+v trailing=%d", survivorID, leave, len(rest))
}
if survivor.dungeon.runtime == nil || survivor.dungeon.runtime.partyDungeonOwner != owner {
t.Fatalf("survivor %d dungeon runtime was detached", survivorID)
}
}
// The returning member's own frame is reduced to the party it left.
returnPackets := splitAllCurrentGameServerUpperPackets(t, returning.conn.(*bufferConn).write.Bytes())
if len(returnPackets) != 2 ||
returnPackets[0].Header.MsgID != uint16(dnfenum.CmdPacketRecoverStamina) ||
returnPackets[1].Header.MsgID != 0x0099 {
t.Fatalf("returning projections=%+v", returnPackets)
}
})
}
}
// TestRuntimePartyDungeonTownReturnDisbandsATwoMemberParty pins the
// single-member disband rule (operator, 2026-09-22: 「一个人不成队」) at the
// dungeon town-return boundary: the member left inside is detached as well, so
// neither client keeps a party window, while the shared dungeon roster still
// hands the dungeon host to the member that stayed and retires the returning
// member's actor from that member's scene.
func TestRuntimePartyDungeonTownReturnDisbandsATwoMemberParty(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
gameSessions: make(map[uint16]*gameSession),
}
leader := &gameSession{conn: &bufferConn{}, connID: "party-return-leader", selectedCharacterID: 99}
follower := &gameSession{conn: &bufferConn{}, connID: "party-return-follower", selectedCharacterID: 100}
service.bindGameSessionCharacter(leader, 99)
service.bindGameSessionCharacter(follower, 100)
state, result, joined := service.createOrJoinManagedRuntimeParty(follower, leader)
if !joined || !result.OK || len(runtimePartyMembers(state)) != 2 {
t.Fatalf("create party joined=%t result=%+v state=%+v", joined, result, state)
}
snapshot, found := service.runtimePartyManagerForService().SnapshotByID(uint16(state.PartyID))
if !found {
t.Fatal("managed party snapshot unavailable")
}
owner, err := newRuntimePartyDungeonOwner(snapshot)
if err != nil {
t.Fatal(err)
}
leader.dungeon.runtime = &runtimeDungeonState{partyDungeonOwner: owner, partyMemberIndexed: true, partyMemberIndex: 0}
follower.dungeon.runtime = &runtimeDungeonState{partyDungeonOwner: owner, partyMemberIndexed: true, partyMemberIndex: 1}
if err := service.detachRuntimePartyDungeonMemberForTownReturn(
leader, owner, runtimePartyStateSnapshot(leader), "test_solo_completed_town_return",
); err != nil {
t.Fatal(err)
}
for _, session := range []*gameSession{leader, follower} {
if got := runtimePartyStateSnapshot(session); got.PartyID != 0 || len(runtimePartyMembers(got)) != 0 {
t.Fatalf("character %d kept a party after the dissolve: %+v", session.selectedCharacterID, got)
}
if _, found := service.runtimePartyManagerForService().SnapshotByUser(
session.selectedCharacterID, session.characterGeneration,
); found {
t.Fatalf("character %d remained in the managed party", session.selectedCharacterID)
}
}
roster := owner.roster()
if len(roster) != 1 || roster[0].userID != 100 || owner.leaderID != 100 || owner.leaderSlot != 1 {
t.Fatalf("shared dungeon roster=%+v leader=%d/%d", roster, owner.leaderID, owner.leaderSlot)
}
followerRest := assertRuntimePartySoloRowThenCleared(
t, follower.conn.(*bufferConn).write.Bytes(), 1, uint16(state.PartyID))
host, followerRest := splitGameServerUpperPacket(t, followerRest)
if host.Header.MsgID != 26 || len(host.Body) != 1 || host.Body[0] != 1 {
t.Fatalf("survivor replacement dungeon host projection=%+v", host)
}
leave, followerRest := splitCurrentGameServerUpperPacketAuto(t, followerRest)
if leave.Header.MsgID != currentTownUserLeaveMsgID ||
!bytes.Equal(leave.Body, buildTownUserLeaveBody(99)) || len(followerRest) != 0 {
t.Fatalf("survivor leave projection=%+v trailing=%d", leave, len(followerRest))
}
leaderRest := assertRuntimePartyMembershipCleared(
t, leader.conn.(*bufferConn).write.Bytes(), uint16(state.PartyID))
if len(leaderRest) != 0 {
t.Fatalf("returning member trailing bytes=%d", len(leaderRest))
}
}
func TestRuntimePartyDungeonConcurrentTownReturnsDoNotRepublishStaleRoster(t *testing.T) {
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
gameSessions: make(map[uint16]*gameSession),
}
leader := &gameSession{conn: &bufferConn{}, connID: "party-return-race-leader", selectedCharacterID: 99}
follower := &gameSession{conn: &bufferConn{}, connID: "party-return-race-follower", selectedCharacterID: 100}
service.bindGameSessionCharacter(leader, 99)
service.bindGameSessionCharacter(follower, 100)
state, result, joined := service.createOrJoinManagedRuntimeParty(follower, leader)
if !joined || !result.OK || len(runtimePartyMembers(state)) != 2 {
t.Fatalf("create party joined=%t result=%+v state=%+v", joined, result, state)
}
snapshot, found := service.runtimePartyManagerForService().SnapshotByID(uint16(state.PartyID))
if !found {
t.Fatal("managed party snapshot unavailable")
}
owner, err := newRuntimePartyDungeonOwner(snapshot)
if err != nil {
t.Fatal(err)
}
leader.dungeon.runtime = &runtimeDungeonState{partyDungeonOwner: owner, partyMemberIndexed: true, partyMemberIndex: 0}
follower.dungeon.runtime = &runtimeDungeonState{partyDungeonOwner: owner, partyMemberIndexed: true, partyMemberIndex: 1}
start := make(chan struct{})
errs := make(chan error, 2)
var workers sync.WaitGroup
for _, session := range []*gameSession{leader, follower} {
workers.Add(1)
go func(session *gameSession) {
defer workers.Done()
<-start
errs <- service.detachRuntimePartyDungeonMemberForTownReturn(
session,
owner,
runtimePartyStateSnapshot(session),
"test_concurrent_midrun_town_return",
)
}(session)
}
close(start)
workers.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatal(err)
}
}
if roster := owner.roster(); len(roster) != 0 {
t.Fatalf("concurrent returns retained shared roster=%+v", roster)
}
for _, session := range []*gameSession{leader, follower} {
if got := runtimePartyStateSnapshot(session); got.PartyID != 0 || len(runtimePartyMembers(got)) != 0 {
t.Fatalf("concurrent return retained local party state=%+v", got)
}
if _, found := service.runtimePartyManagerForService().SnapshotByUser(
session.selectedCharacterID,
session.characterGeneration,
); found {
t.Fatalf("character %d remained in managed party", session.selectedCharacterID)
}
}
}
func TestRuntimePartyDungeonConcurrentCommandsDoNotCrossWaitOnMemberMailboxes(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
service := &Service{gameSessions: make(map[uint16]*gameSession)}
leader := &gameSession{connID: "party-command-leader", ctx: ctx}
member := &gameSession{connID: "party-command-member", ctx: ctx}
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
owner := &runtimePartyDungeonOwner{
partyID: 7, leaderID: 1001, leaderSlot: 0,
members: []runtimePartyDungeonMember{
{slot: 0, userID: 1001, generation: leader.characterGeneration},
{slot: 1, userID: 1002, generation: member.characterGeneration},
},
}
leader.dungeon.runtime = &runtimeDungeonState{partyDungeonOwner: owner}
member.dungeon.runtime = &runtimeDungeonState{partyDungeonOwner: owner}
if err := service.startGameSessionEvents(ctx, leader); err != nil {
t.Fatal(err)
}
defer stopTestGameSessionEvents(t, service, leader)
if err := service.startGameSessionEvents(ctx, member); err != nil {
t.Fatal(err)
}
defer stopTestGameSessionEvents(t, service, member)
started := make(chan struct{}, 2)
release := make(chan struct{})
results := make(chan error, 2)
var leaderRuns atomic.Int32
var memberRuns atomic.Int32
run := func(source *gameSession) {
results <- service.callGameSession(ctx, source, "simultaneous-monster-death", func() error {
started <- struct{}{}
<-release
return service.executeRuntimePartyDungeonCommand(
source,
"simultaneous-monster-death",
func(target *gameSession) error {
if target == leader {
leaderRuns.Add(1)
} else if target == member {
memberRuns.Add(1)
}
return nil
},
)
})
}
go run(leader)
go run(member)
for range 2 {
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("both member mailboxes did not enter their inbound commands")
}
}
close(release)
for range 2 {
select {
case err := <-results:
if err != nil {
t.Fatal(err)
}
case <-time.After(time.Second):
t.Fatal("concurrent party commands cross-waited on member mailboxes")
}
}
barrierCtx, barrierCancel := context.WithTimeout(ctx, time.Second)
defer barrierCancel()
if err := service.callGameSession(barrierCtx, leader, "leader-projection-barrier", func() error { return nil }); err != nil {
t.Fatal(err)
}
if err := service.callGameSession(barrierCtx, member, "member-projection-barrier", func() error { return nil }); err != nil {
t.Fatal(err)
}
if got := leaderRuns.Load(); got != 2 {
t.Fatalf("leader command applications=%d want=2", got)
}
if got := memberRuns.Load(); got != 2 {
t.Fatalf("member command applications=%d want=2", got)
}
}
func TestRuntimePartyDungeonDropRNGIsStableAndPrivatePerMember(t *testing.T) {
owner := &runtimePartyDungeonOwner{
partyID: 7,
members: []runtimePartyDungeonMember{
{slot: 0, userID: 1001},
{slot: 1, userID: 1002},
},
}
leader := &runtimeDungeonState{
partyDungeonOwner: owner,
partyMemberIndexed: true,
partyMemberIndex: 0,
}
member := &runtimeDungeonState{
partyDungeonOwner: owner,
partyMemberIndexed: true,
partyMemberIndex: 1,
}
const sharedRoomSeed = uint32(0x12345678)
leaderSeed := currentPartyDungeonPrivateDropRNG(leader, sharedRoomSeed)
memberSeed := currentPartyDungeonPrivateDropRNG(member, sharedRoomSeed)
if leaderSeed == 0 || memberSeed == 0 || leaderSeed == memberSeed {
t.Fatalf("private drop seeds leader=%08x member=%08x", leaderSeed, memberSeed)
}
if replay := currentPartyDungeonPrivateDropRNG(leader, sharedRoomSeed); replay != leaderSeed {
t.Fatalf("leader private drop seed changed: %08x -> %08x", leaderSeed, replay)
}
if solo := currentPartyDungeonPrivateDropRNG(&runtimeDungeonState{}, sharedRoomSeed); solo != sharedRoomSeed {
t.Fatalf("solo drop seed=%08x want shared room seed=%08x", solo, sharedRoomSeed)
}
}
func TestRuntimePartyDungeonDropProjectionBuildsPrivateOp38RowsPerMember(t *testing.T) {
service, prepared, _ := prepareCurrentDungeonDropTest(t, 0, 3227)
source, ok := service.dungeonMonsterTable.source.(bridgePVFSource)
if !ok {
t.Fatalf("test source type=%T", service.dungeonMonsterTable.source)
}
// Make the gold branch deterministic (always selected) while retaining a
// wide RNG-backed amount variance, so this test observes the real planner
// result carried by op38 rather than merely comparing derived seed words.
source[dungeonCardDropRulePath] = currentDungeonGoldDropTestRuleText()
source[dungeonCardGoldReferencePath] = "[gold drop ref table]\n20 100 50\n"
currentDungeonMonsterDropRulesByCatalog.Delete(service.dungeonMonsterTable)
currentDungeonGoldReferencesByCatalog.Delete(service.dungeonMonsterTable)
owner := &runtimePartyDungeonOwner{
partyID: 7,
members: []runtimePartyDungeonMember{
{slot: 0, userID: 1001},
{slot: 1, userID: 1002},
},
}
cloneForMember := func(slot byte) *runtimeDungeonState {
clone := *prepared
clone.partyDungeonOwner = owner
clone.partyMemberIndexed = true
clone.partyMemberIndex = slot
clone.DropOwner = nil
clone.Rooms = make(map[runtimeDungeonRoomKey]*runtimeDungeonRoomVisit, len(prepared.Rooms))
for key, visit := range prepared.Rooms {
visitClone := *visit
visitClone.DropRNG = currentPartyDungeonPrivateDropRNG(&clone, visit.Seed)
clone.Rooms[key] = &visitClone
}
return &clone
}
leader := cloneForMember(0)
member := cloneForMember(1)
monster := prepared.Room.Snapshot().Monsters[0]
leaderDrops, err := service.planCurrentDungeonMonsterDrops(leader, monster, 1001)
if err != nil {
t.Fatal(err)
}
memberDrops, err := service.planCurrentDungeonMonsterDrops(member, monster, 1002)
if err != nil {
t.Fatal(err)
}
goldAmount := func(drops []currentDungeonDeathDropWire, ownerKey uint16) uint32 {
t.Helper()
for _, drop := range drops {
if drop.OwnerActorObjectKey != ownerKey {
t.Fatalf("private drop owner=%d want=%d", drop.OwnerActorObjectKey, ownerKey)
}
if binary.LittleEndian.Uint32(drop.Item.data[0x02:0x06]) == 0 {
return binary.LittleEndian.Uint32(drop.Item.data[0x06:0x0A])
}
}
t.Fatalf("member %d received no selected gold row: %+v", ownerKey, drops)
return 0
}
leaderGold := goldAmount(leaderDrops, 1001)
memberGold := goldAmount(memberDrops, 1002)
if leaderGold == memberGold {
t.Fatalf("private op38 gold rolls were shared: leader=%d member=%d", leaderGold, memberGold)
}
leaderBody, err := buildCurrentDungeonDeathNotificationBodyWithDrops(monster.ObjectKey, currentDungeonDeathResponseMonster, leaderDrops)
if err != nil {
t.Fatal(err)
}
memberBody, err := buildCurrentDungeonDeathNotificationBodyWithDrops(monster.ObjectKey, currentDungeonDeathResponseMonster, memberDrops)
if err != nil {
t.Fatal(err)
}
if string(leaderBody) == string(memberBody) {
t.Fatalf("private op38 bodies are identical: %x", leaderBody)
}
}
func TestRuntimePartyDungeonConcurrentMonsterDeathCommitsOneSharedWorldFact(t *testing.T) {
service, leaderRuntime, repositories := prepareCurrentDungeonDropTest(t, 0, 3227)
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: "100",
AccountID: "account-1",
Level: 20,
Stats: map[string]int64{"fatigue": 100},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "100",
Slots: map[string]dnfrepo.ItemStack{},
}); err != nil {
t.Fatal(err)
}
memberRuntime, _, err := service.prepareDungeonRuntime(
context.Background(),
&gameSession{selectedCharacterID: 100, accountID: "account-1"},
dungeoncmd.SelectDungeonRequest{DungeonID: 700, Difficulty: 1},
)
if err != nil {
t.Fatal(err)
}
if _, err := memberRuntime.Room.AnnounceAllActors(memberRuntime.Session); err != nil {
t.Fatal(err)
}
leaderConn := &bufferConn{}
memberConn := &bufferConn{}
leader := &gameSession{
conn: leaderConn, connID: "party-shared-world-leader", accountID: "account-1",
selectedCharacterID: 99, dungeon: dungeonSessionState{runtime: leaderRuntime},
}
member := &gameSession{
conn: memberConn, connID: "party-shared-world-member", accountID: "account-1",
selectedCharacterID: 100, dungeon: dungeonSessionState{runtime: memberRuntime},
}
service.bindGameSessionCharacter(leader, 99)
service.bindGameSessionCharacter(member, 100)
owner := &runtimePartyDungeonOwner{
partyID: 7, leaderID: 99, leaderSlot: 0,
members: []runtimePartyDungeonMember{
{slot: 0, userID: 99, generation: leader.characterGeneration},
{slot: 1, userID: 100, generation: member.characterGeneration},
},
worldRooms: make(map[runtimeDungeonRoomKey]*runtimePartyDungeonSharedRoom),
worldDeaths: make(map[runtimePartyDungeonDeathKey]*runtimePartyDungeonDeathFact),
}
leaderRuntime.partyDungeonOwner, leaderRuntime.partyMemberIndexed, leaderRuntime.partyMemberIndex = owner, true, 0
memberRuntime.partyDungeonOwner, memberRuntime.partyMemberIndexed, memberRuntime.partyMemberIndex = owner, true, 1
owner.worldMu.Lock()
leaderRoomKey, err := owner.observeRuntimePartyDungeonWorldLocked(leaderRuntime)
if err == nil {
_, err = owner.observeRuntimePartyDungeonWorldLocked(memberRuntime)
}
owner.worldMu.Unlock()
if err != nil {
t.Fatal(err)
}
objectKey := leaderRuntime.Room.Snapshot().Monsters[0].ObjectKey
deathBody := func(ownerObjectKey uint16) []byte {
body := make([]byte, dungeoncmd.DieMonsterRequestSize)
binary.LittleEndian.PutUint32(body[0:4], objectKey)
binary.LittleEndian.PutUint16(body[4:6], ownerObjectKey)
return body
}
results := make(chan error, 2)
go func() { results <- service.handleDungeonMonsterDeath(leader, deathBody(99)) }()
go func() { results <- service.handleDungeonMonsterDeath(member, deathBody(100)) }()
for range 2 {
if err := <-results; err != nil {
t.Fatal(err)
}
}
if !runtimePartyDungeonActorDefeated(leaderRuntime, objectKey) ||
!runtimePartyDungeonActorDefeated(memberRuntime, objectKey) {
t.Fatalf(
"shared death diverged: leader=%t member=%t",
runtimePartyDungeonActorDefeated(leaderRuntime, objectKey),
runtimePartyDungeonActorDefeated(memberRuntime, objectKey),
)
}
owner.worldMu.Lock()
fact := owner.worldDeaths[runtimePartyDungeonDeathKey{room: leaderRoomKey, objectKey: objectKey}]
sharedRoom := owner.worldRooms[leaderRoomKey]
owner.worldMu.Unlock()
if fact == nil || fact.consumers != 0b11 {
t.Fatalf("shared death fact=%+v want both member consumers", fact)
}
if sharedRoom == nil {
t.Fatal("shared room state is missing")
}
if actor := sharedRoom.actors[objectKey]; actor.state != runtimeDungeonMonsterDefeated || !sharedRoom.cleared {
t.Fatalf("shared room state=%+v actor=%+v", sharedRoom, actor)
}
if leaderConn.write.Len() == 0 || memberConn.write.Len() == 0 {
t.Fatalf("death projection writes leader=%d member=%d", leaderConn.write.Len(), memberConn.write.Len())
}
}