服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
257 lines
12 KiB
Go
257 lines
12 KiB
Go
package dnfbridge
|
||
|
||
import (
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"longheng.io/server/internal/modules/dnf/dungeoncmd"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
// This file pins the trigger condition of the rebuilt-scene participation replay
|
||
// (raid_current_compat.go: sendRuntimeRaidDungeonParticipationOnFinishLoading ->
|
||
// replayRuntimeRaidStateForRebuiltScene -> replayRuntimeRaidParticipationRows).
|
||
//
|
||
// 服主实测 2026-09-29:在卢克/安徒恩副本里**每过一个房间**聊天框就弹一次
|
||
// 「其中1支战队已进入[诞生之圣所]地下城。」(客户端字符串 32048)。成因不是"多发了一个
|
||
// 包",而是这一串调用在**每一次装载完成**时都跑:门内换房间 = 客户端再装载一次 =
|
||
// 重放一次参战行 = op=1 再插一次行 = 客户端 sub_17C0F20 再弹一次 32048
|
||
// (op 表见 publishRuntimeRaidDungeonParticipation 与
|
||
// `安徒恩_攻坚队技能与小队技能与战况表_参考对照_20260923.md` §3.2)。
|
||
//
|
||
// 现在按"客户端的战况表手上是哪一份参战行集"做幂等:门内换房间不改变行集(不重放),
|
||
// 真正换门/换图会改变行集(重放)——而丢包正好发生在换门那一刻,所以自愈能力仍然在,
|
||
// 只是不再每房刷屏。两条用例一条钉"不再重放",一条钉"该重放时仍然重放到每一个成员"。
|
||
|
||
// raidParticipationReplayFixture is one raid whose members can be walked into gates
|
||
// through the real entry path (freezeRuntimeRaidDungeonSelection) and whose wire is
|
||
// captured per connection.
|
||
type raidParticipationReplayFixture struct {
|
||
t *testing.T
|
||
service *Service
|
||
logPath string
|
||
logger *packetLogger
|
||
}
|
||
|
||
func newRaidParticipationReplayFixture(t *testing.T, memberCount int) (*raidParticipationReplayFixture, *stressRaidProjectionFixture) {
|
||
t.Helper()
|
||
raid := newStressRaidProjectionFixture(t, memberCount)
|
||
logPath := filepath.Join(t.TempDir(), "raid-participation-replay.log")
|
||
logger, err := openPacketLogger(logPath)
|
||
if err != nil {
|
||
t.Fatalf("open packet logger: %v", err)
|
||
}
|
||
raid.service.packetLog.Store(logger)
|
||
t.Cleanup(func() { _ = logger.Close() })
|
||
return &raidParticipationReplayFixture{
|
||
t: t,
|
||
service: raid.service,
|
||
logPath: logPath,
|
||
logger: logger,
|
||
}, raid
|
||
}
|
||
|
||
// bindGate freezes one member's session onto a real raid run in one gate, exactly
|
||
// like the dungeon-entry commit does. It deliberately does NOT publish the loaded
|
||
// event: the caller drives that through the finish-loading hook under test.
|
||
func (fixture *raidParticipationReplayFixture) bindGate(session *gameSession, runes string, mapID int, lifecycleToken uint64) *runtimeDungeonState {
|
||
fixture.t.Helper()
|
||
runtime := &runtimeDungeonState{
|
||
Request: dungeoncmd.SelectDungeonRequest{DungeonID: uint32(mapID)},
|
||
Character: dnfrepo.CharacterRecord{CharacterID: runes},
|
||
lifecycleToken: lifecycleToken,
|
||
}
|
||
session.dungeon = dungeonSessionState{runtime: runtime}
|
||
if err := fixture.service.freezeRuntimeRaidDungeonSelection(session, runtime); err != nil {
|
||
fixture.t.Fatalf("freeze raid gate %d for char %s: %v", mapID, runes, err)
|
||
}
|
||
return runtime
|
||
}
|
||
|
||
// leaveGate walks a squad out of its gate the way the real path does: the abandoned
|
||
// lifecycle event removes the gate's row from every board and releases the seat.
|
||
func (fixture *raidParticipationReplayFixture) leaveGate(session *gameSession, runtime *runtimeDungeonState) {
|
||
fixture.t.Helper()
|
||
session.dungeon.mu.Lock()
|
||
err := fixture.service.notifyRuntimeRaidDungeonAbandonedLocked(session, runtime)
|
||
session.dungeon.mu.Unlock()
|
||
if err != nil {
|
||
fixture.t.Fatalf("abandon raid gate %d: %v", runtime.Request.DungeonID, err)
|
||
}
|
||
}
|
||
|
||
// finishLoading is the production hook the client's FINISH_LOADING (op37) reaches.
|
||
func (fixture *raidParticipationReplayFixture) finishLoading(session *gameSession) {
|
||
fixture.t.Helper()
|
||
if err := fixture.service.sendRuntimeRaidDungeonParticipationOnFinishLoading(session); err != nil {
|
||
fixture.t.Fatalf("finish loading: %v", err)
|
||
}
|
||
}
|
||
|
||
func (fixture *raidParticipationReplayFixture) close() string {
|
||
fixture.t.Helper()
|
||
if err := fixture.logger.Close(); err != nil {
|
||
fixture.t.Fatalf("close packet logger: %v", err)
|
||
}
|
||
return stressRaidReadLog(fixture.t, fixture.logPath)
|
||
}
|
||
|
||
func raidParticipationOpsContain(ops []int, want int) bool {
|
||
for _, op := range ops {
|
||
if op == want {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// raidParticipationOpCount counts one op among the rows of a single gate. The number
|
||
// of op=1 rows is exactly the number of times the client pops
|
||
// 32048 「其中1支战队已进入[%s]地下城。」, so "one entry = one line" is a count pin.
|
||
func raidParticipationOpCount(ops []int, want int) int {
|
||
count := 0
|
||
for _, op := range ops {
|
||
if op == want {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
// TestRuntimeRaidParticipationReplayIsIdempotentInsideOneGate is the regression pin
|
||
// for the 2026-09-29 report "每过一个房间就弹一次 32048", extended 2026-10-01 for the
|
||
// follow-up "有时候进图 这个提示会刷两条".
|
||
//
|
||
// One squad, one gate. The first finish-loading (the entry) MUST re-state the rows --
|
||
// that is the self-heal that puts back a 584 row the client dropped while its scene
|
||
// was being rebuilt. The second finish-loading inside the SAME gate (what every room
|
||
// change inside a gate produces) must send NO 584 at all. Changing gate must replay
|
||
// again.
|
||
//
|
||
// The re-state must use op=2, never op=1: the client's op=1 branch (0x01D43B7A) pops
|
||
// 32048 on top of inserting the row, while op=2 (0x01D43B44) runs the SAME
|
||
// sub_17C0F20 insert and skips the print tail. The entry publish already announced
|
||
// the entry with op=1, so a replay that announced again is exactly the second line
|
||
// the operator saw at 2026-09-30 23:54:31 (packet log: pkt 3995/3996 = 0,1 from the
|
||
// entry publish, pkt 4000/4001 = 0,1 from this replay).
|
||
func TestRuntimeRaidParticipationReplayIsIdempotentInsideOneGate(t *testing.T) {
|
||
fixture, raid := newRaidParticipationReplayFixture(t, runtimeRaidGroupSize)
|
||
mover := raid.members[0]
|
||
|
||
gateOne := fixture.bindGate(mover.session, mover.id, 210, 41)
|
||
|
||
// 1. 进副本:装载完成那一次必须重放(自愈能力不许丢)。
|
||
if err := fixture.service.sendRuntimeRaidDungeonParticipationOnFinishLoading(mover.session); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
entry := raidParticipationOpsForTest(t, mover.conn.stream(), 210)
|
||
if !raidParticipationOpsContain(entry, 2) {
|
||
t.Fatalf("进图那一次没有重放参战行 ops=%v,自愈能力被删掉了", entry)
|
||
}
|
||
// 进图 = 恰好一条 32048。这两条 op=1 曾经同时出现(现场包日志 23:54:31 与
|
||
// 00:02:18 都是 `0,1` 紧跟 `0,1`),聊天框就刷两条。
|
||
if got := raidParticipationOpCount(entry, 1); got != 1 {
|
||
t.Fatalf("进图那一次 op=1 出现 %d 次 ops=%v,want 1:多一次就多弹一条「已进入」", got, entry)
|
||
}
|
||
|
||
// 2. 同一个门里换房间:再说一次"装载完成"。
|
||
mover.conn.reset()
|
||
if err := fixture.service.sendRuntimeRaidDungeonParticipationOnFinishLoading(mover.session); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ops := raidParticipationOpsForTest(t, mover.conn.stream(), 210); len(ops) != 0 {
|
||
t.Fatalf("同一个门(210)内第二次装载完成又重放了参战行 ops=%v:客户端会再弹一次 32048", ops)
|
||
}
|
||
|
||
// 3. 换门 210 -> 213:行集变了,必须重放。
|
||
fixture.leaveGate(mover.session, gateOne)
|
||
fixture.bindGate(mover.session, mover.id, 213, 42)
|
||
mover.conn.reset()
|
||
if err := fixture.service.sendRuntimeRaidDungeonParticipationOnFinishLoading(mover.session); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
changed := raidParticipationOpsForTest(t, mover.conn.stream(), 213)
|
||
if !raidParticipationOpsContain(changed, 0) || !raidParticipationOpsContain(changed, 2) {
|
||
t.Fatalf("换门之后没有重新重放参战行 ops=%v(op=0/op=2 都要)", changed)
|
||
}
|
||
if got := raidParticipationOpCount(changed, 1); got != 1 {
|
||
t.Fatalf("换门那一次 op=1 出现 %d 次 ops=%v,want 1:多一次就多弹一条 32048", got, changed)
|
||
}
|
||
|
||
log := fixture.close()
|
||
if got := strings.Count(log, "kind=game-raid-participation-replay-skipped"); got != 1 {
|
||
t.Errorf("同一个门内的跳过日志出现 %d 次,want 1\n%s", got, log)
|
||
}
|
||
if got := strings.Count(log, "kind=game-raid-participation-replay-sent"); got != 2 {
|
||
t.Errorf("重放日志出现 %d 次,want 2(进图 + 换门)\n%s", got, log)
|
||
}
|
||
}
|
||
|
||
// TestRuntimeRaidParticipationReplayStillHealsEveryMemberWhenAGateChanges is the
|
||
// other half of the pin: the replay must stay a RAID-WIDE re-statement of the gate
|
||
// rows, because the client that lost a row is typically NOT the one that moved --
|
||
// 服主原文 "1队进图了 2队在外面看 看不到是哪队进图 就显示个单 队长头像也是黑的".
|
||
//
|
||
// Two squads, each standing in its own gate. A room change by either squad must
|
||
// silence the replay for BOTH clients (that is the fix); a real gate change by one
|
||
// squad must re-state the rows on BOTH clients (that is the self-heal).
|
||
func TestRuntimeRaidParticipationReplayStillHealsEveryMemberWhenAGateChanges(t *testing.T) {
|
||
fixture, raid := newRaidParticipationReplayFixture(t, 2*runtimeRaidGroupSize)
|
||
mover := raid.members[0]
|
||
stayer := raid.members[runtimeRaidGroupSize]
|
||
if stayer.charID == mover.charID {
|
||
t.Fatal("fixture: the two squads share a member")
|
||
}
|
||
|
||
// Both squads enter their own gate through the real entry path, and each member's
|
||
// own client finishes loading there. Each of these replays states BOTH rows.
|
||
fixture.bindGate(stayer.session, stayer.id, 216, 51)
|
||
if err := fixture.service.sendRuntimeRaidDungeonParticipationOnFinishLoading(stayer.session); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
fixture.bindGate(mover.session, mover.id, 210, 52)
|
||
if err := fixture.service.sendRuntimeRaidDungeonParticipationOnFinishLoading(mover.session); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
if ops := raidParticipationOpsForTest(t, stayer.conn.stream(), 210); !raidParticipationOpsContain(ops, 2) {
|
||
t.Fatalf("队友进 210 之后,留在 216 的那个成员没有拿到 210 的参战行 ops=%v", ops)
|
||
} else if got := raidParticipationOpCount(ops, 1); got != 1 {
|
||
t.Fatalf("队友进 210 那一次 op=1 出现 %d 次 ops=%v,want 1:多一次就多弹一条「已进入 210」", got, ops)
|
||
}
|
||
|
||
// A room change inside the move's own gate: nobody may be replayed.
|
||
mover.conn.reset()
|
||
stayer.conn.reset()
|
||
fixture.finishLoading(mover.session)
|
||
for _, peer := range []struct {
|
||
name string
|
||
conn *stressRaidConn
|
||
}{{"mover", mover.conn}, {"stayer", stayer.conn}} {
|
||
if ops := raidParticipationOpsForTest(t, peer.conn.stream(), 210); len(ops) != 0 {
|
||
t.Errorf("%s 在同一个门内的第二次装载完成又收到 210 的参战行 ops=%v", peer.name, ops)
|
||
}
|
||
if ops := raidParticipationOpsForTest(t, peer.conn.stream(), 216); len(ops) != 0 {
|
||
t.Errorf("%s 在同一个门内的第二次装载完成又收到 216 的参战行 ops=%v", peer.name, ops)
|
||
}
|
||
}
|
||
|
||
// The moved squad's own client finishing loading on the far side of a real gate
|
||
// change: every member's board has to hear the new row.
|
||
fixture.leaveGate(mover.session, mover.session.dungeon.runtime)
|
||
fixture.bindGate(mover.session, mover.id, 213, 53)
|
||
mover.conn.reset()
|
||
stayer.conn.reset()
|
||
fixture.finishLoading(mover.session)
|
||
if ops := raidParticipationOpsForTest(t, mover.conn.stream(), 213); !raidParticipationOpsContain(ops, 2) {
|
||
t.Errorf("换门之后移动者自己没有再收到 213 的参战行 ops=%v", ops)
|
||
} else if got := raidParticipationOpCount(ops, 1); got != 1 {
|
||
t.Errorf("换门对移动者 op=1 出现 %d 次 ops=%v,want 1", got, ops)
|
||
}
|
||
if ops := raidParticipationOpsForTest(t, stayer.conn.stream(), 213); !raidParticipationOpsContain(ops, 2) {
|
||
t.Errorf("换门之后留在原地的成员没有再收到 213 的参战行 ops=%v:战况表那一格又画不出来了", ops)
|
||
} else if got := raidParticipationOpCount(ops, 1); got != 1 {
|
||
t.Errorf("换门对留在原地的成员 op=1 出现 %d 次 ops=%v,want 1", got, ops)
|
||
}
|
||
}
|