服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
523 lines
23 KiB
Go
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)
|
|
}
|
|
}
|