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

523 lines
23 KiB
Go

package dnfbridge
import (
"context"
"encoding/binary"
"path/filepath"
"strings"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/channelcatalog"
"longheng.io/server/internal/modules/dnf/dnfenum"
"longheng.io/server/internal/modules/dnf/dungeoncmd"
"longheng.io/server/internal/modules/dnf/raid"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// A raid squad must exist as a real ordinary party, not only as a raid roster
// row: the client draws its own party HUD from the ordinary party table, so a
// squad that only exists in the roster is invisible to its own members. These
// tests pin the projection at the boundaries where a squad's membership can
// change.
type runtimeRaidSquadProjectionFixture struct {
service *Service
raidKey uint32
sessions map[uint16]*gameSession
}
func newRuntimeRaidSquadProjectionFixture(
t *testing.T,
members []runtimeRaidMemberState,
started bool,
) runtimeRaidSquadProjectionFixture {
t.Helper()
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
onlinePlayers: newOnlinePlayerManager(),
}
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "anton", Port: 10238}
sessions := make(map[uint16]*gameSession, len(members))
for _, member := range members {
session := &gameSession{conn: &bufferConn{}, channel: channel}
service.bindGameSessionCharacter(session, member.CharID)
// A squad party is published only once every remote member has a native
// probe endpoint; without one the two-phase publish defers silently.
registerPartyPeerEndpointForTest(session, byte(member.CharID), uint16(50000+member.CharID))
sessions[member.CharID] = session
}
const raidKey = uint32(0x00c80900)
state := &runtimeRaidState{
RaidKey: raidKey,
LeaderID: members[0].CharID,
NameBytes: []byte("anton"),
Members: append([]runtimeRaidMemberState(nil), members...),
FrozenMembers: append([]runtimeRaidMemberState(nil), members...),
Started: started,
EntryCostPaid: started,
StartAttempt: 1,
ChargedMembers: runtimeRaidMemberIDs(runtimeRaidState{Members: members}),
}
if started {
state.Lifecycle = newRuntimeRaidLifecycleState(time.Now().UTC())
}
service.raids[raidKey] = state
service.gameSessions = sessions
return runtimeRaidSquadProjectionFixture{service: service, raidKey: raidKey, sessions: sessions}
}
func (f runtimeRaidSquadProjectionFixture) state(t *testing.T) runtimeRaidState {
t.Helper()
f.service.raidMu.Lock()
defer f.service.raidMu.Unlock()
state := f.service.raids[f.raidKey]
if state == nil {
return runtimeRaidState{}
}
return cloneRuntimeRaidState(*state)
}
func runtimeRaidSquadProjectionMember(charID uint16, group byte, slot byte) runtimeRaidMemberState {
return runtimeRaidMemberState{
CharID: charID,
Name: "member",
GroupIndex: group,
SlotOrder: slot,
UserState: 1,
HPPercent: 100,
MPPercent: 100,
}
}
// One non-zero squad must end up as exactly one real ordinary party, and a second
// pass over an unchanged roster must refresh that same party instead of forming a
// new one. op11 is the formation handshake: it belongs to the first pass only, and
// replaying it during a plain refresh tears down a healthy peer link.
func TestRuntimeRaidSquadPartiesAreMaterializedAndRefreshedInPlace(t *testing.T) {
fixture := newRuntimeRaidSquadProjectionFixture(t, []runtimeRaidMemberState{
runtimeRaidSquadProjectionMember(1001, 1, 0),
runtimeRaidSquadProjectionMember(1002, 1, 1),
}, true)
state := fixture.state(t)
if err := fixture.service.applyRuntimeRaidParties(state); err != nil {
t.Fatalf("first projection: %v", err)
}
projected := fixture.state(t)
if len(projected.SquadParties) != 1 || projected.SquadParties[1] == 0 {
t.Fatalf("squad 1 owns no ordinary party: %+v", projected.SquadParties)
}
manager := fixture.service.runtimePartyManagerForService()
snapshot, found := manager.SnapshotByID(projected.SquadParties[1])
if !found || snapshot.Leader != 1001 || len(snapshot.Members) != 2 {
t.Fatalf("projected party=%+v found=%t", snapshot, found)
}
for _, session := range []*gameSession{fixture.sessions[1001], fixture.sessions[1002]} {
got := runtimePartyStateSnapshot(session)
if got.PartyID != int(projected.SquadParties[1]) || len(got.Members) != 2 {
t.Fatalf("char %d cached party=%+v", session.selectedCharacterID, got)
}
if got.Members[0].UserID != 1001 || got.Members[0].Slot != 0 ||
got.Members[1].UserID != 1002 || got.Members[1].Slot != 1 {
t.Fatalf("char %d cached squad table=%+v", session.selectedCharacterID, got.Members)
}
}
leaderPackets := runtimeRaidPeerTake(t, fixture.sessions[1001])
if _, ok := runtimeRaidPeerFind(leaderPackets, 0x000b); !ok {
t.Fatal("a newly formed squad party did not install its op11 peer table")
}
// Drain the member's first-pass stream too, so the refresh assertions below
// only see what the refresh itself sent.
runtimeRaidPeerTake(t, fixture.sessions[1002])
// Second pass over an unchanged roster: refresh the same party, never reform it.
state = fixture.state(t)
if err := fixture.service.applyRuntimeRaidParties(state); err != nil {
t.Fatalf("refresh: %v", err)
}
refreshed := fixture.state(t)
if refreshed.SquadParties[1] != projected.SquadParties[1] {
t.Fatalf("a refresh reformed the squad party: %d -> %d",
projected.SquadParties[1], refreshed.SquadParties[1])
}
for _, charID := range []uint16{1001, 1002} {
packets := runtimeRaidPeerTake(t, fixture.sessions[charID])
if _, ok := runtimeRaidPeerFind(packets, 0x000b); ok {
t.Fatalf("char %d had op11 replayed during a squad refresh", charID)
}
if _, ok := runtimeRaidPeerFind(packets, 0x0099); !ok {
t.Fatalf("char %d received no op153 during a squad refresh", charID)
}
}
}
// A leader's regroup has to reach the ordinary parties too: moving a member into
// another squad retires the old party and forms the new one, so neither client
// keeps showing the squads they no longer fight in.
func TestRuntimeRaidSquadRegroupRebuildsOrdinaryParty(t *testing.T) {
fixture := newRuntimeRaidSquadProjectionFixture(t, []runtimeRaidMemberState{
runtimeRaidSquadProjectionMember(1001, 1, 0),
runtimeRaidSquadProjectionMember(1002, 2, 0),
}, true)
if err := fixture.service.applyRuntimeRaidParties(fixture.state(t)); err != nil {
t.Fatalf("initial projection: %v", err)
}
before := fixture.state(t)
if before.SquadParties[1] == 0 || before.SquadParties[2] == 0 {
t.Fatalf("initial squads are not both projected: %+v", before.SquadParties)
}
// The regroup is only offered during the phase-one standby break.
fixture.service.raidMu.Lock()
fixture.service.raids[fixture.raidKey].Lifecycle.Status = runtimeRaidLifecycleStandby
fixture.service.raidMu.Unlock()
for _, session := range fixture.sessions {
session.conn.(*bufferConn).write.Reset()
}
if err := fixture.service.handleOnlineRaidManagerWork(
fixture.sessions[1001],
uint16(dnfenum.CmdPacketRaidManagerWork),
runtimeRaidManagerWorkBody(runtimeRaidManagerWorkAssignGroup, 1002, 1),
); err != nil {
t.Fatalf("regroup: %v", err)
}
after := fixture.state(t)
if len(after.SquadParties) != 1 || after.SquadParties[1] == 0 {
t.Fatalf("regroup left %d projected squads: %+v", len(after.SquadParties), after.SquadParties)
}
manager := fixture.service.runtimePartyManagerForService()
snapshot, found := manager.SnapshotByID(after.SquadParties[1])
if !found || snapshot.Leader != 1001 || len(snapshot.Members) != 2 {
t.Fatalf("regrouped party=%+v found=%t", snapshot, found)
}
// The old squad-2 party must be gone with its only member.
if _, stillThere := manager.SnapshotByID(before.SquadParties[2]); stillThere {
t.Fatalf("the retired squad-2 party %d survived the regroup", before.SquadParties[2])
}
for _, charID := range []uint16{1001, 1002} {
got := runtimePartyStateSnapshot(fixture.sessions[charID])
if got.PartyID != int(after.SquadParties[1]) || len(got.Members) != 2 ||
got.Members[0].UserID != 1001 || got.Members[1].UserID != 1002 {
t.Fatalf("char %d party after regroup=%+v", charID, got)
}
}
}
// A member pulled back to standby (group 0) fights in no squad, so the party this
// projection gave their old squad must be taken away -- and only the survivors'
// rebuilt squad may remain.
func TestRuntimeRaidStandbyRegroupDetachesSquadProjection(t *testing.T) {
fixture := newRuntimeRaidSquadProjectionFixture(t, []runtimeRaidMemberState{
runtimeRaidSquadProjectionMember(1001, 1, 0),
runtimeRaidSquadProjectionMember(1002, 1, 1),
}, true)
if err := fixture.service.applyRuntimeRaidParties(fixture.state(t)); err != nil {
t.Fatalf("initial projection: %v", err)
}
projected := fixture.state(t)
fixture.service.raidMu.Lock()
fixture.service.raids[fixture.raidKey].Lifecycle.Status = runtimeRaidLifecycleStandby
fixture.service.raidMu.Unlock()
if err := fixture.service.handleOnlineRaidManagerWork(
fixture.sessions[1001],
uint16(dnfenum.CmdPacketRaidManagerWork),
runtimeRaidManagerWorkBody(runtimeRaidManagerWorkAssignGroup, 1002, 0),
); err != nil {
t.Fatalf("standby regroup: %v", err)
}
manager := fixture.service.runtimePartyManagerForService()
if snapshot, found := manager.SnapshotByUser(1002, 0); found {
t.Fatalf("standby member 1002 kept the squad party: %+v", snapshot)
}
if got := runtimePartyStateSnapshot(fixture.sessions[1002]); got.PartyID != 0 || len(got.Members) != 0 {
t.Fatalf("standby member 1002 kept a client party table: %+v", got)
}
after := fixture.state(t)
if len(after.SquadParties) != 1 || after.SquadParties[1] == projected.SquadParties[1] {
t.Fatalf("squad 1 party was not rebuilt for its survivor: %+v", after.SquadParties)
}
remaining, found := manager.SnapshotByID(after.SquadParties[1])
if !found || len(remaining.Members) != 1 || remaining.Members[0].UserID != 1001 {
t.Fatalf("survivor party=%+v found=%t", remaining, found)
}
}
// Leaving the raid detaches the leaver from the party this projection created for
// their squad and rebuilds what is left for the survivors.
func TestRuntimeRaidLeaveDetachesSquadProjection(t *testing.T) {
fixture := newRuntimeRaidSquadProjectionFixture(t, []runtimeRaidMemberState{
runtimeRaidSquadProjectionMember(1001, 1, 0),
runtimeRaidSquadProjectionMember(1002, 1, 1),
}, true)
if err := fixture.service.applyRuntimeRaidParties(fixture.state(t)); err != nil {
t.Fatalf("initial projection: %v", err)
}
leaveBody := make([]byte, 2)
binary.LittleEndian.PutUint16(leaveBody, 1002)
if err := fixture.service.handleOnlineLeaveRaid(
fixture.sessions[1002],
uint16(dnfenum.CmdPacketLeaveRaid),
leaveBody,
); err != nil {
t.Fatalf("leave raid: %v", err)
}
manager := fixture.service.runtimePartyManagerForService()
if snapshot, found := manager.SnapshotByUser(1002, 0); found {
t.Fatalf("leaver kept the squad party: %+v", snapshot)
}
after := fixture.state(t)
if len(after.SquadParties) != 1 || after.SquadParties[1] == 0 {
t.Fatalf("squad 1 is no longer projected for its survivor: %+v", after.SquadParties)
}
remaining, found := manager.SnapshotByID(after.SquadParties[1])
if !found || len(remaining.Members) != 1 || remaining.Members[0].UserID != 1001 {
t.Fatalf("survivor party=%+v found=%t", remaining, found)
}
}
// With one squad the reveal board is one body for everybody, and it has to stay
// byte-identical to the body the settlement committed: the per-squad split only
// exists to separate squads that are actually different.
func TestRuntimeRaidRewardListKeepsOneBodyForOneSquad(t *testing.T) {
fixture := newRuntimeRaidRewardTestFixture(t, true)
fixture.service.raidMu.Lock()
for index := range fixture.service.raids[fixture.raidKey].FrozenMembers {
fixture.service.raids[fixture.raidKey].FrozenMembers[index].GroupIndex = 1
}
for index := range fixture.service.raids[fixture.raidKey].Members {
fixture.service.raids[fixture.raidKey].Members[index].GroupIndex = 1
}
fixture.service.raidMu.Unlock()
delivery, err := fixture.service.commitRuntimeRaidSettlementRewards(
fixture.raidKey,
runtimeRaidDungeonPhaseOne,
fixture.now,
)
if err != nil {
t.Fatalf("commit rewards: %v", err)
}
bodies, err := runtimeRaidRewardSquadBodies(delivery, delivery.RewardBody)
if err != nil {
t.Fatalf("split reward bodies: %v", err)
}
if bodies != nil {
t.Fatalf("one squad produced %d squad bodies: %+v", len(bodies), bodies)
}
publication := &runtimeRaidRewardPublication{RewardListMembers: make(map[uint16]bool)}
if err := fixture.service.publishRuntimeRaidRewardListLocked(delivery, delivery.RewardBody, publication); err != nil {
t.Fatalf("publish rewards: %v", err)
}
for _, session := range []*gameSession{fixture.leader, fixture.member} {
packets := runtimeRaidPeerTake(t, session)
reward, found := runtimeRaidPeerFind(packets, raid.RaidRewardListMsgID)
if !found {
t.Fatalf("char %d received no reward list: %d packets", session.selectedCharacterID, len(packets))
}
if string(reward.Body) != string(delivery.RewardBody) {
t.Fatalf("char %d body=%x want the committed body %x",
session.selectedCharacterID, reward.Body, delivery.RewardBody)
}
}
}
// Entering a raid gate changes the scene for that member, and both the member that
// moved and the ones that stayed need the other's actor record to draw the 战况表
// cell and the standby room. The projection must reach every pair, in both
// directions, without the same-area shortcut.
//
// The direction into the client that is inside the gate is deliberately excluded:
// the body carries the channel's scene context, and a client rendering a dungeon
// instance binds it to the locally controlled actor instead of creating a peer
// (see sendRuntimeRaidPeerActor). The install into the member that stayed is the
// one that carries the roster cell, and it is asserted here.
func TestRuntimeRaidDungeonLoadedInstallsMateActorsIntoTargetsOutsideTheGate(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, characterID := range []string{"1001", "1002"} {
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: characterID,
AccountID: "dnf:1",
Name: "char" + characterID,
Level: 90,
}); err != nil {
t.Fatalf("seed character %s: %v", characterID, err)
}
}
logPath := filepath.Join(t.TempDir(), "packet.log")
logger, err := openPacketLogger(logPath)
if err != nil {
t.Fatal(err)
}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
onlinePlayers: newOnlinePlayerManager(),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
service.packetLog.Store(logger)
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "anton", Port: 10238}
leader := &gameSession{conn: &bufferConn{}, channel: channel}
member := &gameSession{conn: &bufferConn{}, channel: channel}
// The member that stayed is sitting in the raid waiting area, so its channel
// scene actor is committed (game_bootstrap.go:54-55 and the town route set
// townActorOwnerChannel; only a scene the channel does not own resets it).
// Without this the fixture models a client that is inside a selector or an
// instance, which gets the record-only install below.
member.townActorOwnerChannel = byte(channel.ID)
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
const raidKey = uint32(0x00c80901)
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
Members: []runtimeRaidMemberState{runtimeRaidSquadProjectionMember(1001, 1, 0), runtimeRaidSquadProjectionMember(1002, 1, 1)},
FrozenMembers: []runtimeRaidMemberState{runtimeRaidSquadProjectionMember(1001, 1, 0), runtimeRaidSquadProjectionMember(1002, 1, 1)},
Started: true,
EntryCostPaid: true,
ChargedMembers: []uint16{1001, 1002},
Lifecycle: newRuntimeRaidLifecycleState(time.Now().UTC()),
}
service.gameSessions = map[uint16]*gameSession{1001: leader, 1002: member}
runtime := &runtimeDungeonState{
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 210},
Character: dnfrepo.CharacterRecord{CharacterID: "1001"},
lifecycleToken: 41,
}
leader.dungeon = dungeonSessionState{runtime: runtime}
if err := service.freezeRuntimeRaidDungeonSelection(leader, runtime); err != nil {
t.Fatalf("freeze selection: %v", err)
}
if err := service.notifyRuntimeRaidDungeonLoaded(leader); err != nil {
t.Fatalf("loaded: %v", err)
}
if err := logger.Close(); err != nil {
t.Fatal(err)
}
// 包日志自 2026-09-30 起落盘即密文;断言前先解密。
text := packetLogPlaintext(t, logPath)
// The member that stayed receives the record of the one that entered.
if !strings.Contains(text, "kind=game-town-copresence-actor-state-send conn_id= channel_id=200 source=raid_dungeon_loaded_raid_mate_actors target_char_id=1002") ||
!strings.Contains(text, "actor_char_id=1001") {
t.Fatalf("raid dungeon loaded did not install the entering actor into the member that stayed:\n%s", text)
}
// The member inside the gate still gets the record -- it is the client whose
// roster portraits were the default ones without it -- but never the mode=1
// binding, whose skill pages the client applies to its own controlled actor.
if !strings.Contains(text, "kind=game-raid-peer-actor-sent conn_id= channel_id=200 peer_char_id=1002 source=raid_dungeon_loaded_raid_mate_actors mode=mode0_record_only") {
t.Fatalf("raid dungeon loaded did not give the client inside the gate the record-only install:\n%s", text)
}
// The mate that stayed in town is a channel-owned scene. It used to be the one
// shape that kept the full (mode0+mode1) install, on the theory that the binding
// is what paints the peer's equipment there. 2026-09-29: that theory cost a
// full-second freeze for every client in the raid -- live raid 1507329 (8
// members, own build) measured the SAME fan-out at 981ms..1.025s per loop while
// the installs carried mode1 (waiting area) and 351..377ms on those same loops
// five minutes later once they were record-only -- so the waiting area is now
// record-only too, exactly like the town co-presence path has been since
// 2026-09-26 ("mode1 现在没有任何消费者").
if !strings.Contains(text, "kind=game-raid-peer-actor-sent conn_id= channel_id=200 peer_char_id=1001 source=raid_dungeon_loaded_raid_mate_actors mode=mode0_record_only") {
t.Fatalf("the mate that stayed in town did not get the record-only install:\n%s", text)
}
if !strings.Contains(text, "kind=game-raid-peer-actor-sent") {
t.Fatalf("the loaded boundary did not run its mate-actor install: %s", text)
}
}
// The `selected` boundary runs while the entering client may still be between
// scenes: its channel-owned scene is gone (owner byte 0) and the next one does
// not exist yet. A peer record installed then lands in a scene the client is
// about to destroy, and the client then walks that half-built user-table entry
// from its own raid UI (live 2026-09-17: 11:05:52.017 selected(224)
// target_local_owner=0 -> client died 11:05:52.377 inside the raid UI with
// eip=0). The receiver in that state must be skipped, the receiver that still
// has a live scene must not be, and the loaded boundary must still install.
func TestRuntimeRaidDungeonSelectedDefersMateActorsBetweenScenes(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewMemoryGroup()
for _, characterID := range []string{"1001", "1002"} {
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
CharacterID: characterID,
AccountID: "dnf:1",
Name: "char" + characterID,
Level: 90,
}); err != nil {
t.Fatalf("seed character %s: %v", characterID, err)
}
}
logPath := filepath.Join(t.TempDir(), "packet.log")
logger, err := openPacketLogger(logPath)
if err != nil {
t.Fatal(err)
}
service := &Service{
options: options{gameUpperBodyCodec: gameUpperBodyCodecPlain},
raids: make(map[uint32]*runtimeRaidState),
onlinePlayers: newOnlinePlayerManager(),
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
}
service.packetLog.Store(logger)
channel := channelcatalog.Channel{ID: 200, Type: 23, Name: "anton", Port: 10238}
leader := &gameSession{conn: &bufferConn{}, channel: channel}
member := &gameSession{conn: &bufferConn{}, channel: channel}
// The member that stayed owns a channel scene; the entering leader does not
// (that is exactly the between-scenes state the selected boundary sees).
member.townActorOwnerChannel = byte(channel.ID)
leader.townActorOwnerChannel = 0
service.bindGameSessionCharacter(leader, 1001)
service.bindGameSessionCharacter(member, 1002)
const raidKey = uint32(0x00c80902)
lifecycle := newRuntimeRaidLifecycleState(time.Now().UTC())
// The gate lives in phase two, which is the phase whose entries produced the
// live crash.
lifecycle.Status = runtimeRaidLifecyclePhaseActive
lifecycle.Phase = runtimeRaidDungeonPhaseTwo
service.raids[raidKey] = &runtimeRaidState{
RaidKey: raidKey,
LeaderID: 1001,
Members: []runtimeRaidMemberState{runtimeRaidSquadProjectionMember(1001, 1, 0), runtimeRaidSquadProjectionMember(1002, 1, 1)},
FrozenMembers: []runtimeRaidMemberState{runtimeRaidSquadProjectionMember(1001, 1, 0), runtimeRaidSquadProjectionMember(1002, 1, 1)},
Started: true,
EntryCostPaid: true,
ChargedMembers: []uint16{1001, 1002},
Lifecycle: lifecycle,
}
service.gameSessions = map[uint16]*gameSession{1001: leader, 1002: member}
runtime := &runtimeDungeonState{
Request: dungeoncmd.SelectDungeonRequest{DungeonID: 218},
Character: dnfrepo.CharacterRecord{CharacterID: "1001"},
lifecycleToken: 42,
}
leader.dungeon = dungeonSessionState{runtime: runtime}
if err := service.freezeRuntimeRaidDungeonSelection(leader, runtime); err != nil {
t.Fatalf("freeze selection: %v", err)
}
if err := logger.Close(); err != nil {
t.Fatal(err)
}
// 包日志自 2026-09-30 起落盘即密文;断言前先解密。
text := packetLogPlaintext(t, logPath)
if !strings.Contains(text, "kind=game-raid-peer-actor-deferred") ||
!strings.Contains(text, "reason=scene_between_scenes") ||
!strings.Contains(text, "char_id=1001") {
t.Fatalf("the between-scenes receiver was not deferred:\n%s", text)
}
if strings.Contains(text, "source=raid_dungeon_selected_raid_mate_actors target_char_id=1001") {
t.Fatalf("a peer record was installed into the between-scenes receiver:\n%s", text)
}
// The mate that owns a scene still gets its record from the same boundary.
if !strings.Contains(text, "source=raid_dungeon_selected_raid_mate_actors target_char_id=1002") {
t.Fatalf("the selected boundary stopped installing into the receiver that owns a scene:\n%s", text)
}
if strings.Contains(text, "source=raid_dungeon_selected_raid_mate_actors") &&
!strings.Contains(text, "kind=game-raid-peer-actor-sent") {
t.Fatalf("the selected boundary logged no install at all:\n%s", text)
}
}