Files
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

1060 lines
39 KiB
Go

package dnfbridge
// stress_dungeon_entry_test.go stress-tests simultaneous party dungeon entry.
//
// TestPartyLeaderDungeonSelectionEntersEveryOnlineMemberWithStablePartyIndex is
// the behavioural model for one 2-member party. The tests here run the SAME
// production flow (op16 -> handleDungeonSelectUpper -> shared
// runtimePartyDungeonOwner -> op37 loading barrier -> follower-first/leader-last
// release fan-out in game_select_scene_request_handlers.go) for larger member
// counts, and measure what that fan-out actually costs.
//
// What is deliberately NOT faked: every packet asserted on is produced by the
// production handlers, every release goes through completeRuntimePartyDungeonLoading
// and fanOutToGameSessions, and no production symbol is stubbed. The only test
// doubles are the connection (bufferConn + the shared ordered write log + an
// optional per-write latency that models socket cost) and the in-memory
// repository group, exactly like the 2-member model test.
//
// TestStressPartyDungeonEntryPartySizeParameterization measures the production
// ceiling on ONE party roster. TestStressPartyDungeonEntryConcurrentSquads runs
// the aggregate shapes the game actually uses for 40 and 1000 players
// (runtimeRaidMaxGroups squads of runtimeRaidGroupSize members).
import (
"bytes"
"context"
"encoding/binary"
"fmt"
"runtime"
"strconv"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
"longheng.io/server/internal/modules/dnf/worldmap"
)
const (
stressDungeonID = 700
stressTownID byte = 38
stressAreaID byte = 1
// stressBaseChannelID keeps every stress squad inside a normal channel
// catalogue range. Squads that must share a town lobby reuse one channel.
stressBaseChannelID = 42
// stressPartyLimit is the synthetic dungeon's [limit party count]. One static
// table backs every N below, so it is raised once to the game's legal maximum
// (runtimeRaidMaxGroups * runtimeRaidGroupSize = 40) and built once.
stressPartyLimit = 40
// stressReleaseWriteLatency stands in for the per-write socket cost that made
// the live party entry slow (5.95s for a single member, 2026-09-25 00:27).
// It is applied ONLY around the loading-release phase, so entry stays cheap,
// and it turns "these writes overlapped" into a measurable claim: a serial
// implementation has to pay latency x writes, a concurrent one pays about one
// wave of it.
stressReleaseWriteLatency = 250 * time.Microsecond
// stressReleaseWaveCeiling bounds a party release by whole member-equivalents.
// The production release is three sequential waves (the trigger releases
// itself, then every follower at once, then the leader alone), so a party's
// wall time is a small constant multiple of ONE member's release -- never the
// sum over its members. 4 leaves room for the fan-out's own overhead.
stressReleaseWaveCeiling = 5
)
// stressDungeonPVFSource is bridgeDungeonPVF with a 40-member party limit and a
// dungeon admission item every member must own and spend.
func stressDungeonPVFSource() bridgePVFSource {
source := bridgeDungeonPVF(false)
source["dungeon/test.dgn"] = strings.Replace(
source["dungeon/test.dgn"],
"[limit party count]\n1\n",
fmt.Sprintf("[limit party count]\n%d\n", stressPartyLimit),
1,
)
source["dungeon/test.dgn"] += "[required item]\n4726 1 0\n[/required item]\n"
return source
}
// stressWriteLog is the existing orderedPartyWriteLog plus a timestamp per write.
//
// The 2-member model test reuses orderedPartyConn/orderedPartyWriteLog for
// cross-member ORDER. Measuring the fan-out needs the same thing plus time, and
// the timestamps must stay index-aligned with log.writes, so they are appended
// inside the log's own critical section.
type stressWriteLog struct {
*orderedPartyWriteLog
times []time.Time
}
// stressWireConn is orderedPartyConn plus (a) a mutex around the byte buffer and
// (b) a test-controlled per-write latency.
//
// The buffer mutex matters here and not in the 2-member model test: a town-area
// departure broadcast writes into PEER connections from the departing member's
// goroutine, so one bufferConn can be written by two goroutines at once. The log
// mutex is released before the (possibly slow) buffer write so the fan-out is
// not accidentally serialized by the instrumentation itself.
type stressWireConn struct {
*bufferConn
name string
log *stressWriteLog
mu sync.Mutex
delay atomic.Int64 // nanoseconds
}
func (connection *stressWireConn) Write(data []byte) (int, error) {
connection.log.mu.Lock()
connection.log.writes = append(connection.log.writes, orderedPartyWrite{
connection: connection.name,
data: append([]byte(nil), data...),
})
connection.log.times = append(connection.log.times, time.Now())
connection.log.mu.Unlock()
if delay := time.Duration(connection.delay.Load()); delay > 0 {
time.Sleep(delay)
}
connection.mu.Lock()
defer connection.mu.Unlock()
return connection.bufferConn.Write(data)
}
// stream returns a stable copy of everything written so far.
func (connection *stressWireConn) stream() []byte {
connection.mu.Lock()
defer connection.mu.Unlock()
return append([]byte(nil), connection.bufferConn.write.Bytes()...)
}
type stressPartyMember struct {
slot int
characterID uint16
accountID string
id string
name string
session *gameSession
conn *stressWireConn
// entered is set once the production flow proved the member holds a shared
// party dungeon runtime.
entered bool
// finishLoadingCall is how long this member's own op37 call took. The trigger
// is the one whose call carries the whole fan-out.
finishLoadingCall time.Duration
}
// stressPartyFixture is one party (one runtimePartyDungeonOwner roster) built the
// same way the 2-member model test builds its two members.
type stressPartyFixture struct {
t *testing.T
service *Service
repos dnfrepo.Group
channel channelcatalog.Channel
log *stressWriteLog
partyID int
members []stressPartyMember
}
func stressNewService(
t *testing.T,
repositories dnfrepo.Group,
table *worldmap.Table,
resolver *worldmap.Resolver,
monsters *pvfDungeonMonsterCatalog,
) *Service {
t.Helper()
return &Service{
options: options{accountID: "fallback-account", gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
worldMapTable: table,
worldMapResolver: resolver,
dungeonMonsterTable: monsters,
dungeonChoice: func(int) (int, error) { return 0, nil },
dungeonSeed: func() (uint32, error) { return 0x12345678, nil },
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
gameplayTimers: newFakeCurrentDungeonDeathTimerQueue(),
}
}
// buildStressPartyFixture performs the full per-member setup of the model test
// for `memberCount` sessions of one party. Member 0 is the leader and the party
// owner's leader slot; every member stands in the same town area of `channel`.
func buildStressPartyFixture(
t *testing.T,
service *Service,
repositories dnfrepo.Group,
partyID int,
baseCharacterID uint16,
memberCount int,
channel channelcatalog.Channel,
) *stressPartyFixture {
t.Helper()
fixture := &stressPartyFixture{
t: t,
service: service,
repos: repositories,
channel: channel,
log: &stressWriteLog{orderedPartyWriteLog: &orderedPartyWriteLog{}},
partyID: partyID,
members: make([]stressPartyMember, 0, memberCount),
}
states := make([]alignedcmd.PartyMemberState, 0, memberCount)
for index := 0; index < memberCount; index++ {
characterID := baseCharacterID + uint16(index)
id := strconv.Itoa(int(characterID))
accountID := "stress-account-" + id
if err := repositories.Character.Save(context.Background(), dnfrepo.CharacterRecord{
CharacterID: id,
AccountID: accountID,
Level: 20,
Stats: map[string]int64{
"fatigue": 100, "town_id": int64(stressTownID), "area_id": int64(stressAreaID),
"pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
},
}); err != nil {
t.Fatal(err)
}
// One worn equipment row (the actor projection reads it) plus the dungeon
// admission item the synthetic dungeon requires.
itemID := int64(116010135 + index)
serial := 20 + index
raw := make([]byte, currentItemListEntryWireSize)
binary.LittleEndian.PutUint16(raw[0:2], 17)
binary.LittleEndian.PutUint32(raw[2:6], uint32(itemID))
raw[0x27] = 1
binary.LittleEndian.PutUint32(raw[0x28:0x2c], currentChronicleCanonicalAuraBase+2)
raw[0x30] = 15
raw[0x32] = 2
raw[0x34] = 12
raw[0x36] = 11
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: id,
Slots: map[string]dnfrepo.ItemStack{
"0:17": {
ItemID: itemID,
Count: 1,
RawEntry: raw,
Extra: map[string]string{"item_serial": strconv.Itoa(serial)},
},
"0:180": {ItemID: 4726, Count: 1},
},
}); err != nil {
t.Fatal(err)
}
if err := saveSkillSwitchInventoryState(context.Background(), repositories.Settings, id, skillSwitchInventoryState{
SkillID: 46,
Items: []skillSwitchBinding{{
EquipmentPart: 12,
ItemSerial: uint32(serial),
ItemID: uint32(itemID),
}},
}); err != nil {
t.Fatal(err)
}
name := fmt.Sprintf("p%d-m%d", partyID, index)
conn := &bufferConn{}
wire := &stressWireConn{bufferConn: conn, name: name, log: fixture.log}
session := &gameSession{
conn: wire,
connID: name,
accountID: accountID,
channel: channel,
residentChannel: channel,
selectedCharacterID: characterID,
}
service.bindGameSessionCharacter(session, characterID)
seedPartyOwnerActorProjectionForTest(t, service, session, characterID, byte(channel.ID))
registerPartyPeerEndpointForTest(session, byte(index+1), uint16(50000+index))
enableTownMoveSkillProjection(t, service, repositories, id)
fixture.members = append(fixture.members, stressPartyMember{
slot: index,
characterID: characterID,
accountID: accountID,
id: id,
name: name,
session: session,
conn: wire,
})
states = append(states, alignedcmd.PartyMemberState{
UserID: characterID,
Slot: byte(index),
UserState: 1,
HPPercent: 100,
MPPercent: 100,
})
}
state := alignedcmd.PartyState{
PartyID: partyID,
UserID: baseCharacterID, // the leader owns the party
Members: states,
}
for index := range fixture.members {
member := &fixture.members[index]
storeRuntimePartyState(member.session, state)
service.onlinePlayers.EnterArea(&onlinePlayerInfo{
CharacterID: member.characterID,
AccountID: member.accountID,
Level: 20,
ChannelID: uint16(channel.ID),
TownID: stressTownID,
AreaID: stressAreaID,
PositionX: 450,
PositionY: 234,
Direction: 5,
AreaState: 3,
Session: member.session,
})
}
for index := range fixture.members {
member := &fixture.members[index]
bindDungeonSelectorOriginForTestAt(t, service, member.session, stressTownID, stressAreaID, 450, 234)
}
return fixture
}
func (fixture *stressPartyFixture) leader() *stressPartyMember {
return &fixture.members[0]
}
// enter runs the one op16 request that carries the whole party, exactly like the
// model test: only the leader asks, every other member is driven by the server.
func (fixture *stressPartyFixture) enter() error {
body := make([]byte, dungeoncmd.SelectDungeonRequestSize)
binary.LittleEndian.PutUint32(body[:4], stressDungeonID)
return fixture.service.handleDungeonSelectUpper(fixture.leader().session, body)
}
// markEntered resolves which members actually hold a shared party dungeon
// runtime, which is the production evidence that they were admitted.
func (fixture *stressPartyFixture) markEntered() []int {
entered := make([]int, 0, len(fixture.members))
for index := range fixture.members {
session := fixture.members[index].session
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
admitted := runtime != nil && runtime.partyDungeonOwner != nil
session.dungeon.mu.Unlock()
fixture.members[index].entered = admitted
if admitted {
entered = append(entered, index)
}
}
return entered
}
func (fixture *stressPartyFixture) setWriteLatency(latency time.Duration) {
for index := range fixture.members {
fixture.members[index].conn.delay.Store(int64(latency))
}
}
type stressLogSnapshot struct {
writes []orderedPartyWrite
times []time.Time
}
// snapshotLog is goroutine-safe and must be used instead of releaseChains when
// the caller is not the test goroutine: releaseChains parses packets through the
// shared test helpers, which fail the test, and only the test goroutine may do
// that.
func (fixture *stressPartyFixture) snapshotLog() stressLogSnapshot {
fixture.log.mu.Lock()
defer fixture.log.mu.Unlock()
return stressLogSnapshot{
writes: append([]orderedPartyWrite(nil), fixture.log.writes...),
times: append([]time.Time(nil), fixture.log.times...),
}
}
func (fixture *stressPartyFixture) resetWriteLog() {
fixture.log.mu.Lock()
fixture.log.writes = nil
fixture.log.times = nil
fixture.log.mu.Unlock()
}
// release drives the shared op37 loading barrier. Every admitted member asks to
// finish loading, the last registration releases the party, and the production
// handler releases followers first and the leader last. The leader asks first so
// that a follower is the trigger, which is the shape the model test asserts.
func (fixture *stressPartyFixture) release() (time.Duration, error) {
request := make([]byte, currentFinishLoadingLegacyRequestBodySize)
started := time.Now()
for index := range fixture.members {
member := &fixture.members[index]
if !member.entered {
continue
}
callStarted := time.Now()
err := fixture.service.sendFinishLoadingStatus(member.session, request)
member.finishLoadingCall = time.Since(callStarted)
if err != nil {
return time.Since(started), fmt.Errorf("party %d member %s finish loading: %w",
fixture.partyID, member.name, err)
}
}
return time.Since(started), nil
}
// fanoutWallTime is the measured wall time of the release fan-out itself: the
// trigger member's own op37 call is the one that carries fanOutToGameSessions for
// every follower and then for the leader, so its duration is the fan-out's cost.
// It is also the value partyFanoutBudget actually bounds -- a breach inside the
// fan-out surfaces as a context deadline error out of Release().
func (fixture *stressPartyFixture) fanoutWallTime() time.Duration {
worst := time.Duration(0)
for index := range fixture.members {
member := &fixture.members[index]
if member.entered && member.finishLoadingCall > worst {
worst = member.finishLoadingCall
}
}
return worst
}
// stressReleaseChain locates one member's loading-release write chain inside the
// shared ordered log. The chain starts at the first op120 placement row for
// roster slot 0 -- completeRuntimePartyDungeonLoading writes one placement row
// per roster slot before anything else -- and ends at the bodyless class0/op42
// consumable-use gate reset, which is the last write of a release.
type stressReleaseChain struct {
member *stressPartyMember
startIndex int
endIndex int
startedAt time.Time
finishedAt time.Time
writes int // this member's own writes inside the chain
ok bool
}
func (chain *stressReleaseChain) window() time.Duration {
if chain.startedAt.IsZero() || chain.finishedAt.IsZero() || chain.finishedAt.Before(chain.startedAt) {
return 0
}
return chain.finishedAt.Sub(chain.startedAt)
}
func (fixture *stressPartyFixture) releaseChains() ([]stressReleaseChain, int) {
fixture.t.Helper()
return fixture.releaseChainsFrom(fixture.snapshotLog())
}
func (fixture *stressPartyFixture) releaseChainsFrom(snapshot stressLogSnapshot) ([]stressReleaseChain, int) {
chains := make([]stressReleaseChain, len(fixture.members))
byName := make(map[string]int, len(fixture.members))
for index := range fixture.members {
chains[index] = stressReleaseChain{member: &fixture.members[index], startIndex: -1, endIndex: -1}
byName[fixture.members[index].name] = index
}
newestEnd := -1
for writeIndex, write := range snapshot.writes {
memberIndex, known := byName[write.connection]
if !known {
continue
}
packet, trailing := splitGameServerUpperPacket(fixture.t, write.data)
if len(trailing) != 0 || packet.Header.Classification != 0 {
continue
}
chain := &chains[memberIndex]
switch {
case packet.Header.MsgID == uint16(dnfenum.CmdPacketRequestBlacklist) &&
bytes.Equal(packet.Body, buildRuntimePartyDungeonPlacementBody(0)):
if chain.startIndex < 0 {
chain.startIndex = writeIndex
if writeIndex < len(snapshot.times) {
chain.startedAt = snapshot.times[writeIndex]
}
}
case packet.Header.MsgID == currentDungeonConsumableUseGateResetWireOpcode:
if chain.startIndex >= 0 {
chain.endIndex = writeIndex
if writeIndex < len(snapshot.times) {
chain.finishedAt = snapshot.times[writeIndex]
}
}
}
}
for index := range chains {
chain := &chains[index]
chain.ok = chain.startIndex >= 0 && chain.endIndex >= chain.startIndex
if !chain.ok {
continue
}
for writeIndex := chain.startIndex; writeIndex <= chain.endIndex; writeIndex++ {
if snapshot.writes[writeIndex].connection == chain.member.name {
chain.writes++
}
}
if chain.endIndex > newestEnd {
newestEnd = chain.endIndex
}
}
return chains, newestEnd
}
// stressAssertReleaseWaveShaped is the concurrency evidence for one party: the
// measured release wall time must be a small constant number of member-equivalents
// rather than the serial sum, and the followers released together must actually
// overlap in time. Returns the serial sum and the slowest member window so the
// caller can report them.
func stressAssertReleaseWaveShaped(
t *testing.T,
label string,
elapsed time.Duration,
chains []stressReleaseChain,
leaderIndex int,
) (time.Duration, time.Duration) {
t.Helper()
serialSum := time.Duration(0)
worst := time.Duration(0)
measured := 0
trigger := -1
for index := range chains {
chain := &chains[index]
if !chain.ok {
continue
}
window := chain.window()
serialSum += window
if window > worst {
worst = window
}
measured++
if trigger < 0 || chain.startedAt.Before(chains[trigger].startedAt) {
trigger = index
}
}
if measured == 0 || worst == 0 {
return serialSum, worst
}
if elapsed > time.Duration(stressReleaseWaveCeiling)*worst {
t.Errorf("%s: release took %s, more than %d member-equivalents of the slowest member window %s "+
"(serial sum %s): the fan-out is not releasing concurrently",
label, elapsed, stressReleaseWaveCeiling, worst, serialSum)
}
// Followers released by the same fan-out must overlap: they share an instant.
overlapStart, overlapFinish := time.Time{}, time.Time{}
followerCount := 0
for index := range chains {
chain := &chains[index]
if !chain.ok || index == leaderIndex || index == trigger {
continue
}
followerCount++
if overlapStart.IsZero() || chain.startedAt.After(overlapStart) {
overlapStart = chain.startedAt
}
if overlapFinish.IsZero() || chain.finishedAt.Before(overlapFinish) {
overlapFinish = chain.finishedAt
}
}
if followerCount >= 2 && (overlapFinish.IsZero() || overlapFinish.Before(overlapStart)) {
t.Errorf("%s: the %d followers released by the same fan-out never overlapped in time", label, followerCount)
}
return serialSum, worst
}
// stressGoroutineBaseline waits for the process to settle and returns the
// current goroutine count.
func stressGoroutineBaseline() int {
previous := runtime.NumGoroutine()
for attempt := 0; attempt < 40; attempt++ {
time.Sleep(25 * time.Millisecond)
current := runtime.NumGoroutine()
if current == previous {
return current
}
previous = current
}
return previous
}
// stressAssertGoroutinesSettled waits for the goroutine count to come back to the
// baseline and fails when it does not.
func stressAssertGoroutinesSettled(t *testing.T, label string, baseline int) int {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
current := runtime.NumGoroutine()
for {
current = runtime.NumGoroutine()
if current <= baseline+2 || time.Now().After(deadline) {
break
}
time.Sleep(50 * time.Millisecond)
}
if current > baseline+8 {
t.Errorf("%s: goroutines did not return to the baseline: before=%d after=%d", label, baseline, current)
}
t.Logf("%s: goroutines before=%d after=%d", label, baseline, current)
return current
}
// stressAssertAdmissionItemSpent checks the per-member side of the shared entry:
// each admitted member consumed its OWN dungeon admission item and got its own
// op14 deletion row.
func stressAssertAdmissionItemSpent(t *testing.T, label string, fixture *stressPartyFixture) {
t.Helper()
for index := range fixture.members {
member := &fixture.members[index]
if !member.entered {
continue
}
inventory, found, err := fixture.repos.Inventory.Load(context.Background(), member.id)
if err != nil || !found {
t.Fatalf("%s: load member %s admission inventory found=%t err=%v", label, member.name, found, err)
}
if _, remains := inventory.Slots["0:180"]; remains {
t.Errorf("%s: member %s retained its consumed dungeon admission item", label, member.name)
}
if !containsAdmissionDeleteUpdateForSlot(t, member.conn.stream(), 180) {
t.Errorf("%s: member %s did not receive its own final-stack op14 deletion", label, member.name)
}
}
}
// stressAssertDungeonInfoForEveryMember proves every admitted member received the
// shared dungeon prelude (op28 currentDungeonInfoNotification), and that a member
// the production roster never carried received none of it.
func stressAssertDungeonInfoForEveryMember(t *testing.T, label string, fixture *stressPartyFixture) {
t.Helper()
for index := range fixture.members {
member := &fixture.members[index]
seen := false
leftTown := 0
for _, packet := range splitAllCurrentGameServerUpperPackets(t, member.conn.stream()) {
if packet.Header.Classification != 0 {
continue
}
switch packet.Header.MsgID {
case currentDungeonInfoNotification:
seen = true
case currentTownUserLeaveMsgID:
leftTown++
}
}
if member.entered && !seen {
t.Errorf("%s: admitted member %s (slot %d) never received op28", label, member.name, member.slot)
}
if member.entered {
continue
}
if seen {
t.Errorf("%s: non-admitted member %s received op28", label, member.name)
}
// A member the runtime party table dropped is still standing in the town
// area: it must have seen its party leave instead of entering.
if leftTown == 0 {
t.Errorf("%s: non-admitted member %s saw no town-leave row for its departing party", label, member.name)
}
}
}
// TestStressPartyDungeonEntryPartySizeParameterization is the table-driven
// version of the 2-member model test. For each N it builds N sessions, puts them
// in ONE party roster (member 0 is the leader), puts them in the same channel and
// town area, and asks the leader to enter one dungeon for all of them.
//
// Measured production ceiling (asserted below and reported by t.Logf): one party
// roster cannot exceed 4 members anywhere on this path.
//
// - runtimePartyMembers (party_online.go) caps a party at MaxMembers, and
// MaxMembers is itself clamped to 4 (currentRuntimePartyMaxMembers).
// - validateDungeonEntry counts that clamped roster.
// - newRuntimePartyDungeonOwner requires "an active 2..4 member party".
// - buildRuntimePartyDungeonActorProjection rejects rosters outside 2..4.
// - the ready/release barrier in runtimePartyDungeonOwner is a uint8 slot mask,
// so it cannot even address slot 8.
//
// Above 4 the extra members are dropped from the runtime party table: they stay
// in town, receive no op28 and no release. A live process cannot build such a
// state -- the central party manager caps every party at 4 -- so this is an
// unreachable configuration rather than a live defect, which is why the
// 40-member fan-out measurement lives in
// TestStressPartyDungeonEntryConcurrentSquads, in the shape the game actually
// uses for 40 players (runtimeRaidMaxGroups squads x runtimeRaidGroupSize).
func TestStressPartyDungeonEntryPartySizeParameterization(t *testing.T) {
table, resolver, monsters := loadBridgeDungeonStaticData(t, stressDungeonPVFSource())
goroutinesBefore := stressGoroutineBaseline()
for _, test := range []struct {
name string
memberCount int
wantAdmitted int
wantPartyCeiling int
}{
{name: "two_members", memberCount: 2, wantAdmitted: 2, wantPartyCeiling: 2},
{name: "eight_members", memberCount: 8, wantAdmitted: 4, wantPartyCeiling: 4},
{name: "forty_members", memberCount: 40, wantAdmitted: 4, wantPartyCeiling: 4},
} {
test := test
t.Run(fmt.Sprintf("N=%d", test.memberCount), func(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
service := stressNewService(t, repositories, table, resolver, monsters)
channel := channelcatalog.Channel{
ServerID: 1, ID: stressBaseChannelID, Type: 11,
Name: fmt.Sprintf("ch.%d", stressBaseChannelID), Port: 10000 + stressBaseChannelID,
}
fixture := buildStressPartyFixture(t, service, repositories, 99, 99, test.memberCount, channel)
// The runtime party table is what the entry actually carries.
partyTable := runtimePartyMembers(runtimePartyStateSnapshot(fixture.leader().session))
if len(partyTable) != test.wantPartyCeiling {
t.Fatalf("N=%d runtime party table=%d members, want the production ceiling %d",
test.memberCount, len(partyTable), test.wantPartyCeiling)
}
enterStarted := time.Now()
if err := fixture.enter(); err != nil {
t.Fatalf("N=%d leader op16 party entry: %v", test.memberCount, err)
}
enterElapsed := time.Since(enterStarted)
entered := fixture.markEntered()
if len(entered) != test.wantAdmitted {
t.Fatalf("N=%d admitted members=%d want=%d (roster=%d)",
test.memberCount, len(entered), test.wantAdmitted, test.memberCount)
}
t.Logf("N=%d requested_roster=%d runtime_party_table=%d admitted=%d entry_elapsed=%s",
test.memberCount, test.memberCount, len(partyTable), len(entered), enterElapsed)
if test.memberCount > test.wantAdmitted {
t.Logf("N=%d production party ceiling is %d; the other %d members were never in the party table, "+
"stayed in town and received no dungeon state",
test.memberCount, test.wantPartyCeiling, test.memberCount-test.wantAdmitted)
}
stressAssertDungeonInfoForEveryMember(t, fmt.Sprintf("N=%d entry", test.memberCount), fixture)
stressAssertAdmissionItemSpent(t, fmt.Sprintf("N=%d entry", test.memberCount), fixture)
owner := fixture.leader().session.dungeon.runtime.partyDungeonOwner
if owner == nil || len(owner.roster()) != test.wantAdmitted {
t.Fatalf("N=%d shared owner roster=%v want=%d members",
test.memberCount, owner.roster(), test.wantAdmitted)
}
fixture.resetWriteLog()
fixture.setWriteLatency(stressReleaseWriteLatency)
releaseElapsed, releaseErr := fixture.release()
fixture.setWriteLatency(0)
if releaseErr != nil {
t.Fatalf("N=%d loading release: %v", test.memberCount, releaseErr)
}
chains, newestEnd := fixture.releaseChains()
released := 0
for index := range chains {
if chains[index].ok {
released++
}
}
if released != test.wantAdmitted {
t.Fatalf("N=%d released members=%d want=%d (newest release write=%d)",
test.memberCount, released, test.wantAdmitted, newestEnd)
}
leaderChain := chains[0]
if !leaderChain.ok {
t.Fatalf("N=%d the leader never received its release", test.memberCount)
}
// Leader-last contract: every follower's release is completely on the
// wire before the leader's release starts.
for index := range fixture.members {
chain := chains[index]
if index == 0 || !chain.ok {
continue
}
if chain.endIndex >= leaderChain.startIndex {
t.Fatalf("N=%d leader release started at write %d before member %s finished at write %d",
test.memberCount, leaderChain.startIndex, fixture.members[index].name, chain.endIndex)
}
}
if leaderChain.endIndex != newestEnd {
t.Fatalf("N=%d the last release write (%d) is not the leader's (%d)",
test.memberCount, newestEnd, leaderChain.endIndex)
}
if releaseElapsed >= partyFanoutBudget {
t.Fatalf("N=%d loading release took %s, over partyFanoutBudget=%s",
test.memberCount, releaseElapsed, partyFanoutBudget)
}
serialSum, worstMember := stressAssertReleaseWaveShaped(
t, fmt.Sprintf("N=%d", test.memberCount), releaseElapsed, chains, 0)
fanoutWall := fixture.fanoutWallTime()
if fanoutWall >= partyFanoutBudget {
t.Fatalf("N=%d the release fan-out itself took %s, over partyFanoutBudget=%s",
test.memberCount, fanoutWall, partyFanoutBudget)
}
t.Logf("N=%d admitted=%d release_elapsed=%s fan_out_wall_time=%s budget=%s serial_member_sum=%s "+
"worst_member=%s release_elapsed/worst_member=%.2fx release_elapsed/serial_sum=%.2fx",
test.memberCount, len(entered), releaseElapsed, fanoutWall, partyFanoutBudget, serialSum, worstMember,
float64(releaseElapsed)/float64(worstMember),
float64(releaseElapsed)/float64(serialSum))
for index := range fixture.members {
member := &fixture.members[index]
if !member.entered {
continue
}
chain := chains[index]
t.Logf("N=%d member=%s slot=%d admitted=true release_writes=%d release_window=%s "+
"own_finish_loading_call=%s",
test.memberCount, member.name, member.slot, chain.writes, chain.window(),
member.finishLoadingCall)
}
})
}
stressAssertGoroutinesSettled(t, "party-size parameterization", goroutinesBefore)
}
// stressSquadOutcome is one squad's measured contribution to the aggregate run.
type stressSquadOutcome struct {
index int
partyID int
admitted int
released int
enterErr error
releaseErr error
enterElapsed time.Duration
releaseElapsed time.Duration
fanoutWall time.Duration
log stressLogSnapshot
}
// TestStressPartyDungeonEntryConcurrentSquads is the aggregate scale test.
//
// A 40-player raid is runtimeRaidMaxGroups squads x runtimeRaidGroupSize members,
// and each squad owns its own party dungeon: 10 squads for the raid maximum, and
// 250 squads (1000 sessions) for a 1000-player booking. Every squad's leader
// triggers handleDungeonSelectUpper concurrently, then every squad runs the real
// op37 loading barrier, whose release fan-out is the code under test.
//
// Fidelity note: the 250-squad row places each squad in its OWN channel instead
// of putting 250 parties in one town area. The shared entry broadcasts a
// town-leave row to every non-party peer in the area, so 1000 sessions in one
// area measures the quadratic town broadcast rather than the dungeon fan-out.
// The 40-member row keeps the single shared channel/area the task asks for.
func TestStressPartyDungeonEntryConcurrentSquads(t *testing.T) {
table, resolver, monsters := loadBridgeDungeonStaticData(t, stressDungeonPVFSource())
goroutinesBefore := stressGoroutineBaseline()
for _, test := range []struct {
name string
squads int
shareOneLobby bool
}{
{name: "forty_members_ten_squads_same_lobby", squads: runtimeRaidMaxGroups, shareOneLobby: true},
{name: "thousand_members_two_hundred_fifty_squads", squads: 250, shareOneLobby: false},
} {
test := test
t.Run(test.name, func(t *testing.T) {
repositories := dnfrepomemory.NewMemoryGroup()
service := stressNewService(t, repositories, table, resolver, monsters)
// Warm the lazily created central party manager on this goroutine so the
// concurrent phase measures entry + fan-out, not first-use init.
_ = service.runtimePartyManagerForService()
setupStarted := time.Now()
squads := make([]*stressPartyFixture, 0, test.squads)
for squadIndex := 0; squadIndex < test.squads; squadIndex++ {
channelID := stressBaseChannelID
if !test.shareOneLobby {
channelID = stressBaseChannelID + squadIndex
}
channel := channelcatalog.Channel{
ServerID: 1, ID: channelID, Type: 11,
Name: fmt.Sprintf("ch.%d", channelID), Port: 10000 + channelID%5000,
}
squads = append(squads, buildStressPartyFixture(
t,
service,
repositories,
squadIndex+1,
uint16(squadIndex*runtimeRaidGroupSize+1),
runtimeRaidGroupSize,
channel,
))
}
memberCount := test.squads * runtimeRaidGroupSize
setupElapsed := time.Since(setupStarted)
for _, squad := range squads {
squad.resetWriteLog()
}
// Phase 1: every squad's leader triggers handleDungeonSelectUpper at the
// same instant. No injected latency here, so this measures the real
// entry work (including the shared town-area departure broadcast).
outcomes := make([]stressSquadOutcome, len(squads))
entryStart := make(chan struct{})
var entryWait sync.WaitGroup
concurrentEntryStarted := time.Now()
for squadIndex := range squads {
entryWait.Add(1)
go func(index int) {
defer entryWait.Done()
squad := squads[index]
outcome := stressSquadOutcome{index: index, partyID: squad.partyID}
defer func() { outcomes[index] = outcome }()
<-entryStart
enterStarted := time.Now()
if err := squad.enter(); err != nil {
outcome.enterErr = err
return
}
outcome.enterElapsed = time.Since(enterStarted)
outcome.admitted = len(squad.markEntered())
}(squadIndex)
}
close(entryStart)
entryWait.Wait()
concurrentEntryElapsed := time.Since(concurrentEntryStarted)
// Phase 2: every squad runs the real op37 loading barrier at the same
// instant. This is the fan-out under test, so the per-write latency is
// enabled only here and the logs are re-based to it.
for _, squad := range squads {
squad.resetWriteLog()
squad.setWriteLatency(stressReleaseWriteLatency)
}
releaseStart := make(chan struct{})
var releaseWait sync.WaitGroup
concurrentReleaseStarted := time.Now()
for squadIndex := range squads {
releaseWait.Add(1)
go func(index int) {
defer releaseWait.Done()
squad := squads[index]
<-releaseStart
elapsed, err := squad.release()
// Packet parsing stays on the test goroutine.
outcomes[index].releaseElapsed = elapsed
outcomes[index].releaseErr = err
outcomes[index].fanoutWall = squad.fanoutWallTime()
outcomes[index].log = squad.snapshotLog()
}(squadIndex)
}
close(releaseStart)
releaseWait.Wait()
concurrentReleaseElapsed := time.Since(concurrentReleaseStarted)
for _, squad := range squads {
squad.setWriteLatency(0)
}
totalReleased := 0
totalEntered := 0
memberSum := time.Duration(0)
worstMember := time.Duration(0)
slowestSquad := time.Duration(0)
serialSquadSum := time.Duration(0)
slowestFanoutWall := time.Duration(0)
for index := range outcomes {
outcome := &outcomes[index]
if outcome.enterErr != nil {
t.Fatalf("squad %d op16 entry: %v", outcome.partyID, outcome.enterErr)
}
if outcome.releaseErr != nil {
t.Fatalf("squad %d loading release: %v", outcome.partyID, outcome.releaseErr)
}
if outcome.admitted != runtimeRaidGroupSize {
t.Fatalf("squad %d admitted=%d want=%d", outcome.partyID, outcome.admitted, runtimeRaidGroupSize)
}
if outcome.releaseElapsed >= partyFanoutBudget {
t.Fatalf("squad %d loading release took %s, over partyFanoutBudget=%s",
outcome.partyID, outcome.releaseElapsed, partyFanoutBudget)
}
if outcome.fanoutWall >= partyFanoutBudget {
t.Fatalf("squad %d release fan-out itself took %s, over partyFanoutBudget=%s",
outcome.partyID, outcome.fanoutWall, partyFanoutBudget)
}
if outcome.fanoutWall > slowestFanoutWall {
slowestFanoutWall = outcome.fanoutWall
}
squad := squads[index]
chains, newestEnd := squad.releaseChainsFrom(outcome.log)
squadReleased := 0
for chainIndex := range chains {
if chains[chainIndex].ok {
squadReleased++
}
}
if squadReleased != runtimeRaidGroupSize {
t.Fatalf("squad %d released=%d want=%d", outcome.partyID, squadReleased, runtimeRaidGroupSize)
}
if !chains[0].ok || chains[0].endIndex != newestEnd {
t.Fatalf("squad %d did not release its leader last (leader_end=%d newest=%d)",
outcome.partyID, chains[0].endIndex, newestEnd)
}
for chainIndex := range chains {
chain := &chains[chainIndex]
if chainIndex == 0 || !chain.ok {
continue
}
if chain.endIndex >= chains[0].startIndex {
t.Fatalf("squad %d leader release started at write %d before member %s finished at %d",
outcome.partyID, chains[0].startIndex, chain.member.name, chain.endIndex)
}
}
squadSum, squadWorst := stressAssertReleaseWaveShaped(
t, fmt.Sprintf("squad %d", outcome.partyID), outcome.releaseElapsed, chains, 0)
outcome.released = squadReleased
totalReleased += squadReleased
totalEntered += outcome.admitted
memberSum += squadSum
serialSquadSum += outcome.releaseElapsed
if squadWorst > worstMember {
worstMember = squadWorst
}
if outcome.releaseElapsed > slowestSquad {
slowestSquad = outcome.releaseElapsed
}
for memberIndex := range squad.members {
if !squad.members[memberIndex].entered {
t.Fatalf("squad %d member %s was never admitted", outcome.partyID, squad.members[memberIndex].name)
}
}
stressAssertDungeonInfoForEveryMember(t, fmt.Sprintf("squad %d entry", outcome.partyID), squad)
stressAssertAdmissionItemSpent(t, fmt.Sprintf("squad %d entry", outcome.partyID), squad)
}
for _, squad := range squads {
squad.setWriteLatency(0)
}
if totalReleased != memberCount || totalEntered != memberCount {
t.Fatalf("entered=%d released=%d want=%d", totalEntered, totalReleased, memberCount)
}
t.Logf("squads=%d members=%d entered=%d released=%d setup_elapsed=%s",
test.squads, memberCount, totalEntered, totalReleased, setupElapsed)
t.Logf("squads=%d concurrent_entry_elapsed=%s concurrent_release_elapsed=%s slowest_squad_release=%s",
test.squads, concurrentEntryElapsed, concurrentReleaseElapsed, slowestSquad)
t.Logf("squads=%d serial_squad_release_sum=%s member_release_sum=%s worst_member_release=%s",
test.squads, serialSquadSum, memberSum, worstMember)
t.Logf("squads=%d budget=%s gain_vs_serial_member_sum=%.1fx release_elapsed/worst_member=%.1fx "+
"slowest_fan_out_wall_time=%s",
test.squads, partyFanoutBudget,
float64(memberSum)/float64(concurrentReleaseElapsed),
float64(concurrentReleaseElapsed)/float64(worstMember),
slowestFanoutWall)
// The whole point of the fan-out: N squads cost far less than the sum of
// their members' release times, because the squads -- and the members
// inside each squad -- release concurrently.
if test.squads > 1 && concurrentReleaseElapsed*2 >= memberSum {
t.Errorf("concurrent release of %d squads took %s, not better than half the serial member sum %s",
test.squads, concurrentReleaseElapsed, memberSum)
}
if concurrentReleaseElapsed >= partyFanoutBudget {
t.Fatalf("concurrent release took %s, over partyFanoutBudget=%s",
concurrentReleaseElapsed, partyFanoutBudget)
}
})
}
stressAssertGoroutinesSettled(t, "concurrent squads", goroutinesBefore)
}