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

1294 lines
57 KiB
Go

package dnfbridge
// stress_raid_projection_test.go is a MEASUREMENT HARNESS for the raid
// scene-boundary mate-actor projection. It measures; it does not fix anything.
//
// The production code under measurement is, unmodified:
//
// Service.syncRuntimeRaidMateActorsAllAtSceneBoundary (online_player_manager.go:592)
// -> Service.sendRuntimeRaidPeerActor (online_player_manager.go:462)
// -> Service.sendTownRemoteActorProjection (online_player_manager.go:1002)
// -> Service.characterForEnter (game_select_resolve.go:171)
// -> Service.attachEquipmentSummary (initial_equipment_roster_projection.go:119)
//
// It is a serial double loop over state.FrozenMembers, so it performs exactly
// len(FrozenMembers) * (len(FrozenMembers)-1) synchronous peer-actor installs,
// each of which re-reads the peer's character aggregate from the repository with
// nothing shared between two sends of the same peer.
//
// The dungeon lifecycle boundaries no longer use that function: they call
// syncRuntimeRaidMateActorsMovedAtSceneBoundary, which installs only the moved
// member's 2*(N-1) pairs. The tests below that drive runSceneBoundary measure the
// every-pair function directly (phase activation's caller); the one test that
// drives the real lifecycle trigger measures the moved-scope function.
//
// What this file deliberately does NOT fake:
// - the two loops and the whole call chain above are the real production
// functions; nothing is stubbed, shadowed or reimplemented;
// - the raid state is a real runtimeRaidState registered in Service.raids and
// the sessions are real *gameSession values bound through
// Service.bindGameSessionCharacter, so Service.onlineGameSession and
// Service.runtimeRaidForMember resolve them exactly as in production;
// - the packets are built and written by the production wire path
// (sendGameUpperRawClass -> ... -> session.conn.Write).
//
// What IS a test double, stated plainly:
// - the socket: bufferConn plus a per-session write counter;
// - the repository: the ordinary in-memory group, optionally wrapped in
// counting decorators so every repository read is counted. Only *_test.go
// files are involved; no production file was modified for instrumentation.
// - the reported numbers are NOT built with -race: this box's cc1 cannot
// compile the 64-bit race runtime. The measured projection is fully serial
// on one goroutine, so dropping -race costs no fidelity for these numbers.
//
// This is the RAID path, not the ordinary party path: the rosters below go up to
// runtimeRaidMaxGroups*runtimeRaidGroupSize = 40 members, which the ordinary
// party table cannot express at all (runtimePartyMembers clamps to four). Every
// fixture asserts the roster size and that no ordinary party carries the
// sessions before it measures anything.
import (
"context"
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
platformdb "longheng.io/server/internal/platform/db"
)
// ---------------------------------------------------------------------------
// Repository read counting
// ---------------------------------------------------------------------------
// stressRaidRepoReads counts every repository call the measured chain performs,
// split by repository and method, keeping reads and writes apart. The mutex is
// only so the harness stays correct if it is ever driven concurrently; the
// measured projection itself is serial.
type stressRaidRepoReads struct {
mu sync.Mutex
reads int64
writes int64
byMethod map[string]int64
}
func newStressRaidRepoReads() *stressRaidRepoReads {
return &stressRaidRepoReads{byMethod: make(map[string]int64)}
}
func (counts *stressRaidRepoReads) noteRead(repo string, method string) {
counts.mu.Lock()
counts.reads++
counts.byMethod[repo+"."+method]++
counts.mu.Unlock()
}
func (counts *stressRaidRepoReads) noteWrite(repo string, method string) {
counts.mu.Lock()
counts.writes++
counts.byMethod[repo+"."+method]++
counts.mu.Unlock()
}
type stressRaidRepoReadSnapshot struct {
reads int64
writes int64
byMethod map[string]int64
}
func (counts *stressRaidRepoReads) snapshot() stressRaidRepoReadSnapshot {
counts.mu.Lock()
defer counts.mu.Unlock()
out := stressRaidRepoReadSnapshot{
reads: counts.reads,
writes: counts.writes,
byMethod: make(map[string]int64, len(counts.byMethod)),
}
for method, count := range counts.byMethod {
out.byMethod[method] = count
}
return out
}
// deltaFrom returns the calls performed between the receiver's snapshot and an
// earlier one. The receiver is the LATER snapshot.
func (after stressRaidRepoReadSnapshot) deltaFrom(before stressRaidRepoReadSnapshot) stressRaidRepoReadSnapshot {
out := stressRaidRepoReadSnapshot{
reads: after.reads - before.reads,
writes: after.writes - before.writes,
byMethod: make(map[string]int64),
}
for method, count := range after.byMethod {
if change := count - before.byMethod[method]; change != 0 {
out.byMethod[method] = change
}
}
return out
}
// stressRaidCountingStore wraps the repositories whose interface is just
// db.Store[T]. Embedding the interface keeps every method reachable, so only the
// counting changes; Load is overridden, Save is promoted.
type stressRaidCountingStore[T any] struct {
platformdb.Store[T]
counts *stressRaidRepoReads
repo string
}
func (store stressRaidCountingStore[T]) Load(ctx context.Context, key string) (T, bool, error) {
store.counts.noteRead(store.repo, "Load")
return store.Store.Load(ctx, key)
}
// stressRaidCountingCharacterRepository counts the whole CharacterRepository
// surface the measured chain could reach: Load is the important one, but
// ListByAccount / FindIDByName / NextNumericID are reads too, and leaving them
// uncounted would let the total understate itself.
type stressRaidCountingCharacterRepository struct {
dnfrepo.CharacterRepository
counts *stressRaidRepoReads
}
func (repository stressRaidCountingCharacterRepository) Load(ctx context.Context, key string) (dnfrepo.CharacterRecord, bool, error) {
repository.counts.noteRead("Character", "Load")
return repository.CharacterRepository.Load(ctx, key)
}
func (repository stressRaidCountingCharacterRepository) ListByAccount(ctx context.Context, accountID string, limit int) ([]dnfrepo.CharacterRecord, error) {
repository.counts.noteRead("Character", "ListByAccount")
return repository.CharacterRepository.ListByAccount(ctx, accountID, limit)
}
func (repository stressRaidCountingCharacterRepository) FindIDByName(ctx context.Context, name string) (string, bool, error) {
repository.counts.noteRead("Character", "FindIDByName")
return repository.CharacterRepository.FindIDByName(ctx, name)
}
func (repository stressRaidCountingCharacterRepository) NextNumericID(ctx context.Context) (int, error) {
repository.counts.noteRead("Character", "NextNumericID")
return repository.CharacterRepository.NextNumericID(ctx)
}
type stressRaidCountingDungeonPermissionRepository struct {
dnfrepo.DungeonPermissionRepository
counts *stressRaidRepoReads
}
func (repository stressRaidCountingDungeonPermissionRepository) Load(ctx context.Context, key string) (dnfrepo.DungeonPermissionRecord, bool, error) {
repository.counts.noteRead("DungeonPermission", "Load")
return repository.DungeonPermissionRepository.Load(ctx, key)
}
func (repository stressRaidCountingDungeonPermissionRepository) UpsertMax(ctx context.Context, key string, dungeonID uint32, count byte) (dnfrepo.DungeonPermissionEntry, bool, error) {
repository.counts.noteWrite("DungeonPermission", "UpsertMax")
return repository.DungeonPermissionRepository.UpsertMax(ctx, key, dungeonID, count)
}
type stressRaidCountingHellPartyPermissionRepository struct {
dnfrepo.HellPartyPermissionRepository
counts *stressRaidRepoReads
}
func (repository stressRaidCountingHellPartyPermissionRepository) Load(ctx context.Context, key string) (dnfrepo.HellPartyPermissionRecord, bool, error) {
repository.counts.noteRead("HellPartyPermission", "Load")
return repository.HellPartyPermissionRepository.Load(ctx, key)
}
func (repository stressRaidCountingHellPartyPermissionRepository) Upsert(ctx context.Context, key string, index int64, value int64) (dnfrepo.HellPartyPermissionEntry, bool, error) {
repository.counts.noteWrite("HellPartyPermission", "Upsert")
return repository.HellPartyPermissionRepository.Upsert(ctx, key, index, value)
}
type stressRaidCountingUnitedFriendRepository struct {
dnfrepo.UnitedFriendRepository
counts *stressRaidRepoReads
}
func (repository stressRaidCountingUnitedFriendRepository) Load(ctx context.Context, key string) (dnfrepo.UnitedFriendRecord, bool, error) {
repository.counts.noteRead("UnitedFriend", "Load")
return repository.UnitedFriendRepository.Load(ctx, key)
}
func (repository stressRaidCountingUnitedFriendRepository) ListObservers(ctx context.Context, name string) ([]string, error) {
repository.counts.noteRead("UnitedFriend", "ListObservers")
return repository.UnitedFriendRepository.ListObservers(ctx, name)
}
// stressRaidCountingRepositories wraps every read-capable field the in-memory
// group populates. LegacyUserInfo and LegacyInventory are deliberately left
// alone: the in-memory group leaves them nil, and wrapping a nil interface would
// turn a production `!= nil` availability check into a live nil dereference.
func stressRaidCountingRepositories(inner dnfrepo.Group, counts *stressRaidRepoReads) dnfrepo.Group {
out := inner
out.Account = stressRaidCountingStore[dnfrepo.AccountRecord]{Store: inner.Account, counts: counts, repo: "Account"}
out.AccountInventory = stressRaidCountingStore[dnfrepo.AccountInventoryRecord]{Store: inner.AccountInventory, counts: counts, repo: "AccountInventory"}
out.Character = stressRaidCountingCharacterRepository{CharacterRepository: inner.Character, counts: counts}
out.Inventory = stressRaidCountingStore[dnfrepo.InventoryRecord]{Store: inner.Inventory, counts: counts, repo: "Inventory"}
out.Equipment = stressRaidCountingStore[dnfrepo.EquipmentRecord]{Store: inner.Equipment, counts: counts, repo: "Equipment"}
out.Pet = stressRaidCountingStore[dnfrepo.PetRecord]{Store: inner.Pet, counts: counts, repo: "Pet"}
out.Quest = stressRaidCountingStore[dnfrepo.QuestRecord]{Store: inner.Quest, counts: counts, repo: "Quest"}
out.Skill = stressRaidCountingStore[dnfrepo.SkillRecord]{Store: inner.Skill, counts: counts, repo: "Skill"}
out.DungeonPermission = stressRaidCountingDungeonPermissionRepository{DungeonPermissionRepository: inner.DungeonPermission, counts: counts}
out.HellPartyPermission = stressRaidCountingHellPartyPermissionRepository{HellPartyPermissionRepository: inner.HellPartyPermission, counts: counts}
out.PacketTemplate = stressRaidCountingStore[dnfrepo.PacketTemplateRecord]{Store: inner.PacketTemplate, counts: counts, repo: "PacketTemplate"}
out.Settings = stressRaidCountingStore[dnfrepo.SettingsRecord]{Store: inner.Settings, counts: counts, repo: "Settings"}
out.Mailbox = stressRaidCountingStore[dnfrepo.MailboxRecord]{Store: inner.Mailbox, counts: counts, repo: "Mailbox"}
out.UnitedFriend = stressRaidCountingUnitedFriendRepository{UnitedFriendRepository: inner.UnitedFriend, counts: counts}
out.EpicBook = stressRaidCountingStore[dnfrepo.EpicBookRecord]{Store: inner.EpicBook, counts: counts, repo: "EpicBook"}
return out
}
// ---------------------------------------------------------------------------
// Wire counting
// ---------------------------------------------------------------------------
// stressRaidConn is the existing bufferConn plus a per-session write counter.
// One conn.Write call is one packet: writeGameConn (game_dproto.go:175) hands
// the whole built frame to conn.Write in a single call.
type stressRaidConn struct {
*bufferConn
name string
writes int64
bytes int64
}
func (connection *stressRaidConn) Write(data []byte) (int, error) {
connection.writes++
connection.bytes += int64(len(data))
return connection.bufferConn.Write(data)
}
func (connection *stressRaidConn) reset() {
connection.writes = 0
connection.bytes = 0
connection.bufferConn.write.Reset()
}
func (connection *stressRaidConn) stream() []byte {
return append([]byte(nil), connection.bufferConn.write.Bytes()...)
}
// ---------------------------------------------------------------------------
// Fixture
// ---------------------------------------------------------------------------
const (
stressRaidChannelID = 200
stressRaidBaseID = uint16(1001)
)
type stressRaidProjectionMember struct {
index int
charID uint16
id string
name string
session *gameSession
conn *stressRaidConn
}
type stressRaidProjectionFixture struct {
t *testing.T
service *Service
repos dnfrepo.Group
counts *stressRaidRepoReads
raidKey uint32
channel channelcatalog.Channel
members []stressRaidProjectionMember
}
func newStressRaidProjectionFixture(t *testing.T, memberCount int) *stressRaidProjectionFixture {
t.Helper()
return stressRaidBuildFixture(t, memberCount, true)
}
// newStressRaidProjectionFixtureRaw uses the raw in-memory group with no
// decorators at all. It exists only for the transparency check that proves the
// counting wrappers change nothing about the packets.
func newStressRaidProjectionFixtureRaw(t *testing.T, memberCount int) *stressRaidProjectionFixture {
t.Helper()
return stressRaidBuildFixture(t, memberCount, false)
}
// stressRaidBuildFixture builds memberCount raid members, each with a real bound
// game session and a real registered raid roster.
//
// Every member is seeded with a complete structured snapshot (inventory,
// equipment, skill and the three initialization settings scopes) so the measured
// path never enters the character-initialization backfill and every character
// costs the same number of repository reads. That is what makes the per-pair
// cost stable enough to compare against N*(N-1).
func stressRaidBuildFixture(t *testing.T, memberCount int, counted bool) *stressRaidProjectionFixture {
t.Helper()
if memberCount < 2 {
t.Fatalf("raid roster of %d cannot exercise the pair loop", memberCount)
}
counts := newStressRaidRepoReads()
inner := dnfrepomemory.NewMemoryGroup()
repos := inner
if counted {
repos = stressRaidCountingRepositories(inner, counts)
}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
onlinePlayers: newOnlinePlayerManager(),
raids: make(map[uint32]*runtimeRaidState),
repositoryProvider: func() (dnfrepo.Group, bool) { return repos, true },
}
channel := channelcatalog.Channel{ID: stressRaidChannelID, Type: 23, Name: "anton", Port: 10238}
fixture := &stressRaidProjectionFixture{
t: t,
service: service,
repos: repos,
counts: counts,
raidKey: uint32(0x00c8a100) + uint32(memberCount),
channel: channel,
members: make([]stressRaidProjectionMember, 0, memberCount),
}
roster := make([]runtimeRaidMemberState, 0, memberCount)
for index := 0; index < memberCount; index++ {
charID := stressRaidBaseID + uint16(index)
id := strconv.Itoa(int(charID))
stressRaidSeedCharacter(t, repos, charID, index)
conn := &stressRaidConn{bufferConn: &bufferConn{}, name: "raid-" + id}
session := &gameSession{
conn: conn,
connID: conn.name,
accountID: "stress-raid-account-" + id,
channel: channel,
// A raid member standing in the raid waiting area owns a channel
// scene. That is the ordinary production state at a gate boundary,
// and it is what makes each install the full mode0-record +
// mode1-binding form rather than the record-only one.
townActorOwnerChannel: byte(channel.ID),
connectionTownActorOwnerChannel: byte(channel.ID),
selectedCharacterID: charID,
residentChannel: channel,
}
service.bindGameSessionCharacter(session, charID)
// runtimeRaidMaxGroups squads x runtimeRaidGroupSize members: the same
// group/slot shape a real 40-member raid roster carries.
roster = append(roster, runtimeRaidMemberState{
CharID: charID,
Name: "raid" + id,
Level: 90,
GroupIndex: byte(index/runtimeRaidGroupSize) + 1,
SlotOrder: byte(index % runtimeRaidGroupSize),
UserState: 1,
HPPercent: 100,
MPPercent: 100,
})
fixture.members = append(fixture.members, stressRaidProjectionMember{
index: index,
charID: charID,
id: id,
name: conn.name,
session: session,
conn: conn,
})
}
service.raids[fixture.raidKey] = &runtimeRaidState{
RaidKey: fixture.raidKey,
LeaderID: roster[0].CharID,
NameBytes: []byte("anton"),
TailFlag: 5,
Members: append([]runtimeRaidMemberState(nil), roster...),
FrozenMembers: append([]runtimeRaidMemberState(nil), roster...),
Started: true,
EntryCostPaid: true,
StartAttempt: 1,
ChargedMembers: runtimeRaidMemberIDs(runtimeRaidState{Members: roster}),
Lifecycle: newRuntimeRaidLifecycleState(time.Now().UTC()),
}
fixture.assertRoster(t, memberCount)
return fixture
}
// stressRaidSeedCharacter writes the durable character state a real, already
// initialized character has: base record plus inventory/equipment/pet/skill and
// the three settings scopes characterInitializationPresenceForRecord checks.
// character_initialization.go:31 then returns immediately (presence.complete()),
// so the measured read count is the steady-state production one and no PVF
// archive or backfill is involved.
//
// The account and creature records are seeded too. Several readers on this path
// call Account.Load / Pet.Load unconditionally and only branch on `found`, so
// seeding them removes any doubt that a missing record could steer the chain
// down a different branch than a live server would take.
func stressRaidSeedCharacter(t *testing.T, repos dnfrepo.Group, characterID uint16, index int) {
t.Helper()
ctx := context.Background()
id := strconv.Itoa(int(characterID))
accountID := "stress-raid-account-" + id
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: accountID}); err != nil {
t.Fatalf("seed account %s: %v", accountID, err)
}
if err := repos.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: id,
AccountID: accountID,
Slot: index,
Name: "raid" + id,
Job: "0",
Level: 90,
Stats: map[string]int64{
"town_id": 38, "area_id": 1, "pos_x": 450, "pos_y": 234, "direction": 5, "area_state": 3,
},
}); err != nil {
t.Fatalf("seed character %s: %v", id, err)
}
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: id}); err != nil {
t.Fatalf("seed inventory %s: %v", id, err)
}
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: id}); err != nil {
t.Fatalf("seed equipment %s: %v", id, err)
}
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{CharacterID: id}); err != nil {
t.Fatalf("seed pet %s: %v", id, err)
}
if err := repos.Skill.Save(ctx, dnfrepo.SkillRecord{CharacterID: id}); err != nil {
t.Fatalf("seed skill %s: %v", id, err)
}
for _, scope := range []string{
newCharacterContainerStateSettingsScope(id),
newCharacterInitBodiesSettingsScope(id),
newCharacterHotkeySettingsScope(id),
} {
if err := repos.Settings.Save(ctx, dnfrepo.SettingsRecord{Scope: scope, Values: map[string]string{}}); err != nil {
t.Fatalf("seed settings %s/%s: %v", id, scope, err)
}
}
}
// assertRoster is the fidelity gate: prove the roster really carries N members
// on the RAID path before anything is measured, and that the ordinary party
// table -- which cannot hold more than four -- is not what is being measured.
func (fixture *stressRaidProjectionFixture) assertRoster(t *testing.T, want int) {
t.Helper()
for _, member := range fixture.members {
session, online := fixture.service.onlineGameSession(member.charID)
if !online || session != member.session {
t.Fatalf("char %d is not reachable through onlineGameSession (online=%t)", member.charID, online)
}
if session.selectedCharacterID != member.charID || session.conn == nil {
t.Fatalf("char %d session is not bound to a usable conn", member.charID)
}
state, ok := fixture.service.runtimeRaidForMember(member.charID)
if !ok {
t.Fatalf("char %d does not resolve a raid through runtimeRaidForMember", member.charID)
}
if len(state.FrozenMembers) != want {
t.Fatalf("char %d resolves a raid with %d frozen members, want %d", member.charID, len(state.FrozenMembers), want)
}
party := runtimePartyStateSnapshot(session)
if party.PartyID != 0 || len(runtimePartyMembers(party)) != 0 {
t.Fatalf("char %d is carried by an ordinary party (party_id=%d members=%d): this is not the raid path",
member.charID, party.PartyID, len(runtimePartyMembers(party)))
}
}
t.Logf("roster=%d members asserted present on the RAID path (runtimePartyStateSnapshot.PartyID=0 for every member; the ordinary party table clamps at 4)", want)
}
// ---------------------------------------------------------------------------
// Measurement
// ---------------------------------------------------------------------------
type stressRaidSendCost struct {
reads int64
writes int64
}
// calibrateSend runs the REAL production sendRuntimeRaidPeerActor exactly once
// and reports what that one ordered pair costs. Every packet the whole
// projection produces comes from this same function, so the calibration is the
// unit the totals are checked against.
func (fixture *stressRaidProjectionFixture) calibrateSend(target *gameSession, peerCharID uint16) stressRaidSendCost {
fixture.t.Helper()
state, ok := fixture.service.runtimeRaidForMember(target.selectedCharacterID)
if !ok {
fixture.t.Fatalf("calibration target %d has no raid", target.selectedCharacterID)
}
writesBefore := fixture.totalWrites()
before := fixture.counts.snapshot()
fixture.service.sendRuntimeRaidPeerActor(state, target, peerCharID, "stress_raid_calibration")
after := fixture.counts.snapshot()
return stressRaidSendCost{
reads: after.deltaFrom(before).reads,
writes: fixture.totalWrites() - writesBefore,
}
}
func (fixture *stressRaidProjectionFixture) totalWrites() int64 {
total := int64(0)
for index := range fixture.members {
total += fixture.members[index].conn.writes
}
return total
}
func (fixture *stressRaidProjectionFixture) totalBytes() int64 {
total := int64(0)
for index := range fixture.members {
total += fixture.members[index].conn.bytes
}
return total
}
// resetWire clears the packet counters and buffers of every member conn.
func (fixture *stressRaidProjectionFixture) resetWire() {
for index := range fixture.members {
fixture.members[index].conn.reset()
}
}
type stressRaidProjectionRun struct {
wall time.Duration
reads int64
writes int64
bytes int64
readsByMethod map[string]int64
}
// runSceneBoundary invokes the real production
// syncRuntimeRaidMateActorsAllAtSceneBoundary with a state snapshot obtained the
// same way the production caller obtains it (Service.runtimeRaidForMember) and
// measures one call.
func (fixture *stressRaidProjectionFixture) runSceneBoundary(source string, deferWhileBetweenScenes bool) stressRaidProjectionRun {
fixture.t.Helper()
state, ok := fixture.service.runtimeRaidForMember(fixture.members[0].charID)
if !ok {
fixture.t.Fatalf("leader does not resolve a raid")
}
fixture.resetWire()
before := fixture.counts.snapshot()
started := time.Now()
fixture.service.syncRuntimeRaidMateActorsAllAtSceneBoundary(state, source, deferWhileBetweenScenes)
wall := time.Since(started)
after := fixture.counts.snapshot()
delta := after.deltaFrom(before)
return stressRaidProjectionRun{
wall: wall,
reads: delta.reads,
writes: fixture.totalWrites(),
bytes: fixture.totalBytes(),
readsByMethod: delta.byMethod,
}
}
// timeSceneBoundaryBatches runs the projection `batch` times back to back inside
// ONE timed window and returns the mean per-call wall time plus the batch's
// repository-read and packet-write totals.
//
// The batching exists because this host's clock cannot resolve a sub-millisecond
// projection: a single N=4 call was observed to measure as exactly 0s, which
// would make the reported curve an artefact of the timer rather than of the
// code. The state snapshot is resolved once for the whole batch, so what is timed
// is the projection itself and not the trigger's roster clone.
func (fixture *stressRaidProjectionFixture) timeSceneBoundaryBatches(source string, batch int) (time.Duration, int64, int64) {
fixture.t.Helper()
if batch < 1 {
batch = 1
}
state, ok := fixture.service.runtimeRaidForMember(fixture.members[0].charID)
if !ok {
fixture.t.Fatalf("leader does not resolve a raid")
}
fixture.resetWire()
before := fixture.counts.snapshot()
started := time.Now()
for repeat := 0; repeat < batch; repeat++ {
fixture.service.syncRuntimeRaidMateActorsAllAtSceneBoundary(state, source, false)
}
total := time.Since(started)
delta := fixture.counts.snapshot().deltaFrom(before)
return total / time.Duration(batch), delta.reads, fixture.totalWrites()
}
// stressRaidClockGranularity reports the smallest non-zero interval this host's
// monotonic clock can express, by sampling time.Now() in a tight loop. It is
// logged with the results so the timing numbers can be read against the timer
// they were taken with.
func stressRaidClockGranularity() time.Duration {
best := time.Duration(1) << 62
previous := time.Now()
for sample := 0; sample < 500000; sample++ {
now := time.Now()
if delta := now.Sub(previous); delta > 0 && delta < best {
best = delta
}
previous = now
}
if best == time.Duration(1)<<62 {
return 0
}
return best
}
func stressRaidMedian(values []time.Duration) time.Duration {
if len(values) == 0 {
return 0
}
sorted := append([]time.Duration(nil), values...)
sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] })
return sorted[len(sorted)/2]
}
func stressRaidMin(values []time.Duration) time.Duration {
if len(values) == 0 {
return 0
}
best := values[0]
for _, value := range values[1:] {
if value < best {
best = value
}
}
return best
}
func stressRaidMax(values []time.Duration) time.Duration {
worst := time.Duration(0)
for _, value := range values {
if value > worst {
worst = value
}
}
return worst
}
// ---------------------------------------------------------------------------
// Main scaling measurement: N = 4 / 8 / 16 / 40
// ---------------------------------------------------------------------------
// stressRaidProjectionRepeats is how many timed windows each size is measured
// in after a discarded warm-up call, so the reported time is not dominated by
// first touch of the packet builder's lazy state.
const stressRaidProjectionRepeats = 5
// stressRaidTimingWindow is the minimum wall time one timed window should cover,
// and stressRaidTimingMaxBatch caps how many calls that costs. Both exist only
// to keep the host's clock granularity (see stressRaidClockGranularity) out of
// the reported per-call number.
const (
stressRaidTimingWindow = 20 * time.Millisecond
stressRaidTimingMaxBatch = 200
)
type stressRaidScalingRow struct {
members int
pairs int64 // N*(N-1): the sends the double loop performs
reads int64
characterLoads int64
writes int64
bytes int64
wallMedian time.Duration
wallMin time.Duration
wallMax time.Duration
timingBatch int
perPairReads float64
perPairWrites float64
perPairMicros float64
perMemberReads float64
}
func TestStressRaidSceneBoundaryProjectionScaling(t *testing.T) {
// Report the timer the timing numbers were taken with: a coarse clock is a
// first-class caveat for a sub-millisecond measurement, not a footnote.
t.Logf("host monotonic clock granularity = %s", stressRaidClockGranularity())
sizes := []int{4, 8, 16, 40}
rows := make([]stressRaidScalingRow, 0, len(sizes))
for _, size := range sizes {
size := size
t.Run(fmt.Sprintf("N=%d", size), func(t *testing.T) {
rows = append(rows, stressRaidMeasureSize(t, size))
})
}
if t.Failed() || len(rows) != len(sizes) {
t.Fatalf("scaling measurement incomplete: rows=%d want=%d", len(rows), len(sizes))
}
stressRaidReportScaling(t, rows)
}
func stressRaidMeasureSize(t *testing.T, memberCount int) stressRaidScalingRow {
t.Helper()
fixture := newStressRaidProjectionFixture(t, memberCount)
pairs := int64(memberCount) * int64(memberCount-1)
// Warm-up: one full projection, discarded. It also proves the chain runs.
warmup := fixture.runSceneBoundary("stress_raid_projection_warmup", false)
if warmup.reads == 0 || warmup.writes == 0 {
t.Fatalf("N=%d warm-up projection produced reads=%d writes=%d: the real chain did not run",
memberCount, warmup.reads, warmup.writes)
}
// Calibrate the per-pair cost with the real production single-pair send.
// This happens AFTER the warm-up so nothing lazy is charged to the unit.
single := fixture.calibrateSend(fixture.members[0].session, fixture.members[1].charID)
if single.reads == 0 || single.writes == 0 {
t.Fatalf("N=%d calibration send produced reads=%d writes=%d", memberCount, single.reads, single.writes)
}
// The PACKET count is the direct evidence that the whole chain really ran for
// every ordered pair: N*(N-1) sends, each producing exactly the packets one
// calibration send produced -- no silent early return, no skipped install. It
// is asserted exactly, and the batch-scoped read cache does not move it.
wantWrites := pairs * single.writes
if warmup.writes != wantWrites {
t.Fatalf("N=%d one projection wrote %d packets; %d ordered pairs x %d writes/send = %d",
memberCount, warmup.writes, pairs, single.writes, wantWrites)
}
// Repository reads used to be asserted as the exact equality
// pairs*single.reads with the comment "no ... memoization". That equality only
// held while nothing was cached, and it pinned the quadratic N*(N-1) read
// count in place. The projection now memoizes the per-actor loads for the
// duration of one fan-out (projection_read_cache.go), so the reads are
// asserted as the shape that fix promises instead:
//
// * a LINEAR upper bound: resolving every actor may cost at most one
// single-pair send's worth of reads per member, whatever the roster size;
// * strictly below the unmemoized total, which is what proves the cache is
// actually in effect on this route rather than silently disabled.
//
// A return to per-pair recomputation fails both checks at every N > 2.
maxReads := int64(memberCount) * single.reads
unmemoizedReads := pairs * single.reads
if warmup.reads > maxReads {
t.Fatalf("N=%d one projection read the repository %d times; one batch-scoped resolution per member allows at most %d (N x %d reads/pair)",
memberCount, warmup.reads, maxReads, single.reads)
}
if warmup.reads >= unmemoizedReads {
t.Fatalf("N=%d one projection read the repository %d times, not below the %d an unmemoized pair loop costs: the batch read cache is not in effect",
memberCount, warmup.reads, unmemoizedReads)
}
t.Logf("N=%d repo_reads=%d (linear bound %d, unmemoized %d, reduction %.1fx)",
memberCount, warmup.reads, maxReads, unmemoizedReads,
float64(unmemoizedReads)/float64(warmup.reads))
// Wall time. It is taken on a SECOND fixture whose repository group carries no
// counting decorators at all, so the reported number is the projection's own
// cost and not the instrumentation's: the decorators cost one mutex + one map
// write per call, which measured ~10% at N=40.
//
// Each window is batched so it is worth at least stressRaidTimingWindow
// regardless of how fast the projection is; a single sub-millisecond call
// would otherwise be quantised to zero by this host's ~508us clock, which is
// exactly what the first run of this harness produced.
raw := newStressRaidProjectionFixtureRaw(t, memberCount)
rawWarmup := raw.runSceneBoundary("stress_raid_projection_timing_warmup", false)
if rawWarmup.reads != 0 {
t.Fatalf("N=%d the raw timing fixture is unexpectedly instrumented (reads=%d)", memberCount, rawWarmup.reads)
}
batch := stressRaidBatchFor(rawWarmup.wall)
times := make([]time.Duration, 0, stressRaidProjectionRepeats)
var last stressRaidProjectionRun
for repeat := 0; repeat < stressRaidProjectionRepeats; repeat++ {
perCall, _, batchWrites := raw.timeSceneBoundaryBatches("stress_raid_projection_timing", batch)
if batchWrites != wantWrites*int64(batch) {
t.Fatalf("N=%d timing batch of %d wrote %d packets, want %d",
memberCount, batch, batchWrites, wantWrites*int64(batch))
}
times = append(times, perCall)
// The counted fixture keeps being re-verified on every repeat, so the
// read/write totals in the table are the numbers actually asserted.
last = fixture.runSceneBoundary("stress_raid_projection_scaling", false)
if last.reads > maxReads || last.writes != wantWrites {
t.Fatalf("N=%d single call drifted: reads=%d max=%d writes=%d want=%d",
memberCount, last.reads, maxReads, last.writes, wantWrites)
}
}
row := stressRaidScalingRow{
members: memberCount,
pairs: pairs,
reads: last.reads,
characterLoads: last.readsByMethod["Character.Load"],
writes: last.writes,
bytes: last.bytes,
wallMedian: stressRaidMedian(times),
wallMin: stressRaidMin(times),
wallMax: stressRaidMax(times),
timingBatch: batch,
}
row.perPairReads = float64(row.reads) / float64(pairs)
row.perPairWrites = float64(row.writes) / float64(pairs)
row.perPairMicros = float64(row.wallMedian.Microseconds()) / float64(pairs)
row.perMemberReads = float64(row.reads) / float64(memberCount)
t.Logf("N=%d ordered_pairs=%d reads=%d (Character.Load=%d) writes=%d bytes=%d",
memberCount, pairs, row.reads, row.characterLoads, row.writes, row.bytes)
t.Logf("N=%d wall per call (median of %d windows of %d calls each)=%s min=%s max=%s per_pair_time=%s",
memberCount, stressRaidProjectionRepeats, batch, row.wallMedian, row.wallMin, row.wallMax,
row.wallMedian/time.Duration(pairs))
t.Logf("N=%d per_pair_reads=%.2f per_pair_writes=%.2f per_member_reads=%.1f",
memberCount, row.perPairReads, row.perPairWrites, row.perMemberReads)
t.Logf("N=%d single_pair_unit(reads=%d writes=%d) reads_by_repository=%s",
memberCount, single.reads, single.writes, stressRaidFormatCounts(last.readsByMethod))
return row
}
func stressRaidFormatCounts(byMethod map[string]int64) string {
keys := make([]string, 0, len(byMethod))
for key := range byMethod {
keys = append(keys, key)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, fmt.Sprintf("%s=%d", key, byMethod[key]))
}
return strings.Join(parts, ",")
}
// stressRaidReportScaling prints the curve and fits it against the two candidate
// shapes: quadratic N*(N-1) and linear N.
func stressRaidReportScaling(t *testing.T, rows []stressRaidScalingRow) {
t.Helper()
t.Logf("SCALING TABLE -- one syncRuntimeRaidMateActorsAllAtSceneBoundary call per row:")
t.Logf("%6s %8s %11s %16s %10s %13s %13s %13s %8s",
"N", "pairs", "repo_reads", "character_loads", "packets", "wall_median", "wall/pair", "reads/member", "batch")
for _, row := range rows {
t.Logf("%6d %8d %11d %16d %10d %13s %13s %13.1f %8d",
row.members, row.pairs, row.reads, row.characterLoads, row.writes,
row.wallMedian.Round(time.Microsecond),
(row.wallMedian / time.Duration(row.pairs)).Round(time.Nanosecond),
row.perMemberReads, row.timingBatch)
}
// Fit each candidate shape by least squares through the origin and report
// the worst relative residual. The shape with the small residual is the one
// the cost actually tracks.
quadCoefficient := 0.0
linearCoefficient := 0.0
var quadDenominator, linearDenominator float64
for _, row := range rows {
pairs := float64(row.pairs)
quadCoefficient += pairs * float64(row.reads)
quadDenominator += pairs * pairs
linearCoefficient += float64(row.members) * float64(row.reads)
linearDenominator += float64(row.members) * float64(row.members)
}
quadCoefficient /= quadDenominator
linearCoefficient /= linearDenominator
worstQuadratic, worstLinear := 0.0, 0.0
t.Logf("MODEL FIT -- repository reads:")
t.Logf("%6s %8s %11s %15s %13s %11s %13s", "N", "pairs", "observed", "fit=N*(N-1)", "fit=N", "err_quad", "err_linear")
for _, row := range rows {
quadratic := quadCoefficient * float64(row.pairs)
linear := linearCoefficient * float64(row.members)
observed := float64(row.reads)
quadraticError := stressRaidRelativeError(observed, quadratic)
linearError := stressRaidRelativeError(observed, linear)
if quadraticError > worstQuadratic {
worstQuadratic = quadraticError
}
if linearError > worstLinear {
worstLinear = linearError
}
t.Logf("%6d %8d %11.0f %15.1f %13.1f %10.3f%% %12.3f%%",
row.members, row.pairs, observed, quadratic, linear, quadraticError*100, linearError*100)
}
t.Logf("fit: reads = %.3f * N*(N-1) (worst residual %.3f%%) vs reads = %.3f * N (worst residual %.3f%%)",
quadCoefficient, worstQuadratic*100, linearCoefficient, worstLinear*100)
first, last := rows[0], rows[len(rows)-1]
growth := float64(last.reads) / float64(first.reads)
pairGrowth := float64(last.pairs) / float64(first.pairs)
memberGrowth := float64(last.members) / float64(first.members)
t.Logf("growth N=%d -> N=%d: observed_reads=%.2fx N*(N-1) predicts=%.2fx N predicts=%.2fx",
first.members, last.members, growth, pairGrowth, memberGrowth)
t.Logf("per-member reads stay bounded at %.1f..%.1f for every N (the linear signature of the batch read cache); "+
"per-pair reads shrink %.1f -> %.1f as the roster grows",
rows[0].perMemberReads, rows[len(rows)-1].perMemberReads, rows[0].perPairReads, rows[len(rows)-1].perPairReads)
t.Logf("per-pair wall time: %s at N=%d, %s at N=%d (the N*(N-1) sends themselves are still serial)",
(rows[0].wallMedian / time.Duration(rows[0].pairs)).Round(time.Nanosecond), rows[0].members,
(last.wallMedian / time.Duration(last.pairs)).Round(time.Nanosecond), last.members)
// Hard assertions on the deterministic quantities.
//
// These were flipped when the per-actor projection became cached for the
// duration of one fan-out. The pre-cache contract asserted the reads TRACK
// N*(N-1); the contract now is that they track N, and that the quadratic
// shape is gone. The linear tolerance is loose because the count is a small
// constant per member plus one global settings read, which a least-squares
// line through the origin cannot hit exactly at N=4.
if worstLinear > 0.05 {
t.Errorf("repository reads no longer track N: worst residual %.3f%%", worstLinear*100)
}
if worstQuadratic <= 0.05 {
t.Errorf("repository reads still track N*(N-1) (worst residual %.3f%%): the batch-scoped read cache is not in effect",
worstQuadratic*100)
}
if growth > 1.25*memberGrowth {
t.Errorf("reads grew %.2fx for N=%d..%d; N predicts %.2fx -- the per-member read cost is no longer bounded",
growth, first.members, last.members, memberGrowth)
}
if growth >= 0.9*pairGrowth {
t.Errorf("reads grew %.2fx, indistinguishable from the quadratic prediction %.2fx -- the per-pair recomputation is back",
growth, pairGrowth)
}
// Timing is reported, not asserted tightly; only the coarse shape is checked,
// and only between the two extremes where the signal is far above the noise.
firstPerMember := float64(first.wallMedian.Nanoseconds()) / float64(first.members)
lastPerMember := float64(last.wallMedian.Nanoseconds()) / float64(last.members)
ratio := 0.0
if firstPerMember > 0 {
ratio = lastPerMember / firstPerMember
}
t.Logf("per-member wall time: N=%d %.1fus N=%d %.1fus ratio=%.2fx (a linear cost would hold it at ~1.0x)",
first.members, firstPerMember/1000, last.members, lastPerMember/1000, ratio)
// 时间只报数,不判红。
//
// 这条曾经断言 ratio >= 2(配对循环是串行的,每人成本应随人数增长)。但它量的是墙钟比值:
// 本机同时跑着游戏服与 MySQL,全包并行时 N=4 的中位数容易被抬高、N=40 被摊薄,比值掉到
// 2 以下就红 —— 2026-09-27 两次全包就是这么红的(隔离跑 3 次全绿;通过时 ratio=4.19x)。
// 真正防回归的是上面那些结构性断言:仓储读次数必须跟 N 走、不能跟 N(N-1) 走,包写次数按
// 公式精确核对,字节流在计数装饰器下必须一致 —— 批量读缓存一旦失效,那几条立刻红,
// 且与机器负载无关。所以这里保留数值、去掉判红。
if ratio < 2 {
t.Logf("NOTE: per-member wall time grew only %.2fx from N=%d to N=%d (an idle machine shows >= 2x): "+
"timing is informational here, the repository-read assertions above are the regression guard",
ratio, first.members, last.members)
}
}
func stressRaidRelativeError(observed float64, predicted float64) float64 {
if predicted == 0 {
return 1
}
error := (observed - predicted) / predicted
if error < 0 {
return -error
}
return error
}
// ---------------------------------------------------------------------------
// Corroboration: the real production trigger
// ---------------------------------------------------------------------------
// TestStressRaidSceneBoundaryProjectionThroughRealLifecycleTrigger drives the
// projection through its real production caller instead of calling it directly:
//
// Service.freezeRuntimeRaidDungeonSelection (raid_dungeon_lifecycle.go:84)
// -> notifyRuntimeRaidDungeonLifecycleLocked (:277)
// -> applyRuntimeRaidDungeonLifecycleEvent
// -> publishRuntimeRaidDungeonParticipation (:487)
// -> syncRuntimeRaidMateActorsMovedAtSceneBoundary (:418-423)
//
// The `selected` boundary is the exact transition raid_dungeon_lifecycle.go:418
// names, and publishRuntimeRaidDungeonParticipation returns early for it
// (raid_dungeon_lifecycle.go:521-529), so this route isolates the projection: it
// performs no other repository work and writes no other packet. The `loaded`
// boundary is then driven off the same binding, which is the
// deferWhileBetweenScenes=false shape the abandoned/completed boundaries use.
func TestStressRaidSceneBoundaryProjectionThroughRealLifecycleTrigger(t *testing.T) {
for _, memberCount := range []int{8, 40} {
memberCount := memberCount
t.Run(fmt.Sprintf("N=%d", memberCount), func(t *testing.T) {
stressRaidMeasureRealTrigger(t, memberCount)
})
}
}
func stressRaidMeasureRealTrigger(t *testing.T, memberCount int) {
t.Helper()
fixture := newStressRaidProjectionFixture(t, memberCount)
pairs := int64(memberCount) * int64(memberCount-1)
// Warm the chain first, so nothing lazy is charged to the trigger.
warmup := fixture.runSceneBoundary("stress_raid_trigger_warmup", false)
if warmup.reads == 0 {
t.Fatalf("N=%d warm-up produced no repository reads", memberCount)
}
mover := fixture.members[0]
other := fixture.members[1]
runtime := &runtimeDungeonState{
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 210},
Character: dnfrepo.CharacterRecord{CharacterID: mover.id},
lifecycleToken: 41,
}
mover.session.dungeon = dungeonSessionState{runtime: runtime}
// The real `selected` trigger. With deferWhileBetweenScenes=true this
// boundary skips a receiver whose channel scene is gone; every member here
// owns a channel scene, so no receiver is skipped and both directions of the
// moved member's pairs are installed.
fixture.resetWire()
before := fixture.counts.snapshot()
selectedStarted := time.Now()
if err := fixture.service.freezeRuntimeRaidDungeonSelection(mover.session, runtime); err != nil {
t.Fatalf("N=%d freeze raid dungeon selection (the real selected trigger): %v", memberCount, err)
}
selectedWall := time.Since(selectedStarted)
selectedReads := fixture.counts.snapshot().deltaFrom(before)
selectedWrites := fixture.totalWrites()
// The trigger leaves the mover inside an open gate run, which changes the
// install mode for the two directions this boundary installs: an install INTO
// the mover is the record-only form (mode1 is withheld from a client rendering
// an instance) while the mover's record INTO each member that stayed is the
// bound form. Both units are measured through the real
// sendRuntimeRaidPeerActor and their PACKET totals are checked against the
// trigger separately, because the two forms write a different number of
// packets -- that exact packet count is the evidence that the moved member's
// own scene change reached both directions of every one of its pairs.
//
// The boundary no longer sweeps every ordered pair, and it no longer installs
// the moved member alone either. A pair that excludes the mover has neither
// side changing scene, so the old N*(N-1) sweep re-sent it verbatim: measured
// on 203.0.113.11 (2026-09-27, 12-member Anton raid), 88.2% of this boundary's
// installs and 57.5% of the `selected` boundary's were an identical (target,
// actor, mode1 length) triple already sent to the same target for the same
// actor within 10 s. Cutting it to the moved member's own pairs then produced
// the operator's "看不到是哪队进图 就显示个单 队长头像也是黑的", because the
// 战况表 resolves a row's cell through the row's FIRST member, who is typically
// NOT the member that entered. The scope is therefore the moved member's board
// row -- its squad, runtimeRaidGroupSize members -- so a mate that stayed gets
// the whole row minus itself, and the mover still gets every mate:
//
// wantBoundSends = (rowSize-1)*(rowSize-1) + (N-rowSize)*rowSize
// wantRecordSends = N-1
//
// which at N=8 is 3x3 + 4x4 = 25 and at N=40 is 3x3 + 36x4 = 153, against 7
// and 39 for the moved-only scope and 56 / 1560 for the every-pair sweep.
// The read total is no longer expected to be N*(N-1) x (reads per send): the
// projection memoizes its per-actor loads for the duration of one fan-out
// (projection_read_cache.go), so the reads are bounded linearly instead. The
// read count does not depend on the install mode either way, which is why one
// per-pair figure bounds both forms. Narrowing the pair scope also narrows the
// gap the two read assertions below separate: a cached run resolves one body
// per member (N*R) while an unmemoized one pays per installed direction, so the
// guard still holds for every N this test drives, with a 2x margin at N=40
// instead of the N-fold margin the pair sweep used to give.
recordCost := fixture.calibrateSend(mover.session, other.charID)
boundCost := fixture.calibrateSend(other.session, mover.charID)
rowSize := int64(runtimeRaidGroupSize)
if rowSize > int64(memberCount) {
rowSize = int64(memberCount)
}
wantRecordSends := int64(memberCount - 1) // every mate's record INTO the mover
wantBoundSends := (rowSize-1)*(rowSize-1) + (int64(memberCount)-rowSize)*rowSize
perPairReads := recordCost.reads
if boundCost.reads > perPairReads {
perPairReads = boundCost.reads
}
maxReads := int64(memberCount) * perPairReads
unmemoizedReads := wantRecordSends*recordCost.reads + wantBoundSends*boundCost.reads
wantWrites := wantRecordSends*recordCost.writes + wantBoundSends*boundCost.writes
if selectedReads.reads > maxReads {
t.Fatalf("N=%d real selected trigger read the repository %d times; one batch-scoped resolution per member allows at most %d (N x %d reads/pair)",
memberCount, selectedReads.reads, maxReads, perPairReads)
}
if selectedReads.reads >= unmemoizedReads {
t.Fatalf("N=%d real selected trigger read the repository %d times, not below the %d an unmemoized pair loop costs: the batch read cache is not in effect",
memberCount, selectedReads.reads, unmemoizedReads)
}
if selectedWrites != wantWrites {
t.Fatalf("N=%d real selected trigger wrote %d packets; %d x %d + %d x %d = %d",
memberCount, selectedWrites, wantRecordSends, recordCost.writes, wantBoundSends, boundCost.writes, wantWrites)
}
t.Logf("N=%d REAL selected trigger: ordered_pairs=%d record_sends=%d bound_sends=%d repo_reads=%d (max=%d, unmemoized=%d) packets=%d wall=%s",
memberCount, pairs, wantRecordSends, wantBoundSends, selectedReads.reads, maxReads, unmemoizedReads, selectedWrites, selectedWall)
t.Logf("N=%d single-pair unit via the real sendRuntimeRaidPeerActor: record_only=%d reads/%d writes bound=%d reads/%d writes reads_per_pair=%d",
memberCount, recordCost.reads, recordCost.writes, boundCost.reads, boundCost.writes, recordCost.reads)
// The real `loaded` boundary repeats the same install off the same binding,
// and additionally publishes the squad's op584 participation rows.
fixture.resetWire()
before = fixture.counts.snapshot()
loadedStarted := time.Now()
if err := fixture.service.notifyRuntimeRaidDungeonLoaded(mover.session); err != nil {
t.Fatalf("N=%d notify raid dungeon loaded (the real loaded trigger): %v", memberCount, err)
}
loadedWall := time.Since(loadedStarted)
loadedReads := fixture.counts.snapshot().deltaFrom(before)
loadedWrites := fixture.totalWrites()
// This route's read floor used to be asserted as ">= the unmemoized pair
// loop cost". It is now the opposite: the cached route must stay strictly
// below it (participation reads included), which is the cache-is-live guard
// for the `loaded` boundary.
if loadedReads.reads >= unmemoizedReads {
t.Fatalf("N=%d loaded trigger read the repository %d times, not below the %d an unmemoized pair loop costs: the batch read cache is not in effect",
memberCount, loadedReads.reads, unmemoizedReads)
}
t.Logf("N=%d REAL loaded trigger: repo_reads=%d (unmemoized pair loop=%d, saved=%d) packets=%d (extra=%d) wall=%s",
memberCount, loadedReads.reads, unmemoizedReads, unmemoizedReads-loadedReads.reads,
loadedWrites, loadedWrites-wantWrites, loadedWall)
}
// ---------------------------------------------------------------------------
// Fidelity check: the counting decorators must not change behaviour
// ---------------------------------------------------------------------------
// TestStressRaidProjectionCountingDecoratorIsTransparent is the guard on the
// instrumentation itself. The counting wrappers embed interfaces, so an optional
// interface the concrete repository also implements is no longer reachable
// through a type assertion on the wrapped value, and such an assertion could in
// principle take a different branch in production code. This test runs the same
// projection over the raw in-memory group and over the counting group and
// requires byte-identical per-connection packet streams.
func TestStressRaidProjectionCountingDecoratorIsTransparent(t *testing.T) {
const memberCount = 8
counted := newStressRaidProjectionFixture(t, memberCount)
countedRun := counted.runSceneBoundary("stress_raid_decorator_fidelity", false)
raw := newStressRaidProjectionFixtureRaw(t, memberCount)
rawRun := raw.runSceneBoundary("stress_raid_decorator_fidelity", false)
if countedRun.writes != rawRun.writes {
t.Errorf("counting decorators changed the packet count: counted=%d raw=%d", countedRun.writes, rawRun.writes)
}
if countedRun.bytes != rawRun.bytes {
t.Errorf("counting decorators changed the byte count: counted=%d raw=%d", countedRun.bytes, rawRun.bytes)
}
for index := range counted.members {
countedStream := counted.members[index].conn.stream()
rawStream := raw.members[index].conn.stream()
if string(countedStream) != string(rawStream) {
t.Fatalf("member %s received a different packet stream under the counting decorators (%d vs %d bytes)",
counted.members[index].name, len(countedStream), len(rawStream))
}
}
t.Logf("counting decorators transparent: N=%d packets=%d bytes=%d per-conn streams byte-identical to the raw group",
memberCount, countedRun.writes, countedRun.bytes)
t.Logf("counted repo_reads=%d (the raw-group fixture is not instrumented and reports %d)", countedRun.reads, rawRun.reads)
// The decorators still cost something at runtime: one mutex lock plus one map
// write per counted call. Measure it, so the reported wall times can be read
// with or without that overhead made explicit.
stressRaidReportDecoratorOverhead(t, 8)
stressRaidReportDecoratorOverhead(t, 40)
}
// stressRaidReportDecoratorOverhead times the same projection on the counted and
// the raw fixture and logs the difference. Both sides use the same batch sizing
// as the scaling measurement, so a sub-millisecond N=8 projection is not
// quantised by the ~508us clock into a meaningless ratio.
func stressRaidReportDecoratorOverhead(t *testing.T, memberCount int) {
t.Helper()
counted := newStressRaidProjectionFixture(t, memberCount)
countedProbe := counted.runSceneBoundary("stress_raid_decorator_overhead_warmup", false)
countedTimes := make([]time.Duration, 0, stressRaidProjectionRepeats)
for repeat := 0; repeat < stressRaidProjectionRepeats; repeat++ {
wall, _, _ := counted.timeSceneBoundaryBatches("stress_raid_decorator_overhead", stressRaidBatchFor(countedProbe.wall))
countedTimes = append(countedTimes, wall)
}
raw := newStressRaidProjectionFixtureRaw(t, memberCount)
rawProbe := raw.runSceneBoundary("stress_raid_decorator_overhead_warmup", false)
rawTimes := make([]time.Duration, 0, stressRaidProjectionRepeats)
for repeat := 0; repeat < stressRaidProjectionRepeats; repeat++ {
wall, _, _ := raw.timeSceneBoundaryBatches("stress_raid_decorator_overhead", stressRaidBatchFor(rawProbe.wall))
rawTimes = append(rawTimes, wall)
}
countedMedian := stressRaidMedian(countedTimes)
rawMedian := stressRaidMedian(rawTimes)
overhead := 0.0
if rawMedian > 0 {
overhead = float64(countedMedian-rawMedian) / float64(rawMedian)
}
batch := stressRaidBatchFor(rawProbe.wall)
t.Logf("N=%d counting-decorator timing overhead (windows of %d calls): counted=%s raw=%s delta=%s (%.1f%% of the raw projection)",
memberCount, batch, countedMedian, rawMedian, countedMedian-rawMedian, overhead*100)
}
// stressRaidBatchFor sizes a timed window from a single observed call so the
// window is worth at least stressRaidTimingWindow.
func stressRaidBatchFor(observed time.Duration) int {
batch := 1
if observed > 0 {
if window := int64(stressRaidTimingWindow / observed); window > 0 {
batch = int(window) + 1
}
}
if batch > stressRaidTimingMaxBatch {
batch = stressRaidTimingMaxBatch
}
return batch
}
// ---------------------------------------------------------------------------
// Independent install count from the production packet log
// ---------------------------------------------------------------------------
// TestStressRaidSceneBoundaryProjectionInstallCount reads the number of
// successful installs straight out of the production packet log: every
// successful sendTownRemoteActorProjection emits exactly one
// game-town-copresence-actor-state-send EVENT before it writes, so the event
// count is an independent check on the arithmetic the tests above assert.
//
// The logger has a bounded queue (packetLogQueueSize) and drops lines when the
// writer falls behind. Any drop is reported, and the count is then a lower bound
// rather than an equality.
func TestStressRaidSceneBoundaryProjectionInstallCount(t *testing.T) {
packetLogTestKeyEnv(t)
for _, memberCount := range []int{8, 40} {
memberCount := memberCount
t.Run(fmt.Sprintf("N=%d", memberCount), func(t *testing.T) {
fixture := newStressRaidProjectionFixture(t, memberCount)
// Warm up BEFORE the logger exists, so the file contains exactly the
// lines of the one measured projection below and no flush-timing
// guesswork is needed to separate the two runs.
fixture.runSceneBoundary("stress_raid_log_warmup", false)
logPath := filepath.Join(t.TempDir(), "raid-projection-packets.log")
logger, err := openPacketLogger(logPath)
if err != nil {
t.Fatal(err)
}
fixture.service.packetLog.Store(logger)
run := fixture.runSceneBoundary("stress_raid_install_count", false)
// Close drains the queue and waits for the writer, so the file is
// complete when it is read back.
if err := logger.Close(); err != nil {
t.Fatal(err)
}
dropped := logger.Dropped()
data := stressRaidReadLog(t, logPath)
installs := strings.Count(data, "kind=game-town-copresence-actor-state-send")
failures := strings.Count(data, "kind=game-raid-peer-actor-failed") +
strings.Count(data, "kind=game-raid-peer-actor-unavailable")
deferred := strings.Count(data, "kind=game-raid-peer-actor-deferred")
pairs := memberCount * (memberCount - 1)
t.Logf("N=%d production packet log for one projection: installs=%d ordered_pairs=%d failed_or_unavailable=%d deferred=%d dropped_log_lines=%d packets=%d",
memberCount, installs, pairs, failures, deferred, dropped, run.writes)
if dropped != 0 {
t.Logf("N=%d the packet log dropped %d lines: the install count above is a LOWER BOUND, not an equality", memberCount, dropped)
}
if failures != 0 {
t.Errorf("N=%d the production chain reported %d failed/unavailable peer-actor installs", memberCount, failures)
}
if deferred != 0 {
t.Errorf("N=%d %d receivers were deferred although every member owns a channel scene", memberCount, deferred)
}
if dropped == 0 && int64(installs) != int64(pairs) {
t.Errorf("N=%d packet log recorded %d installs, want one per ordered pair = %d", memberCount, installs, pairs)
}
})
}
}
func stressRaidReadLog(t *testing.T, path string) string {
t.Helper()
// 包日志自 2026-09-30 起落盘即密文;这里走与运维工具相同的解密路径。
return packetLogPlaintext(t, path)
}