DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,256 @@
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)
}
}