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

257 lines
12 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}