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

204 lines
6.6 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 (
"context"
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"runtime"
"strings"
"time"
)
// 本文件是「进图加载卡顿」调查用的第二支探针,与 slow_path_probe.go 并列。
//
// 背景:slow_path_probe.go 的分步探针只覆盖了 completeRuntimePartyDungeonLoading
// 里 5 个**猜出来的**候选调用点。两台服务器 38 次实测下来,那 5 步**一次都没有
// 超过阈值**,而 complete_loading_total 稳定在 7.6~10.2 秒 —— 即「时间花在探针
// 没有覆盖的地方」。再按猜测补探针,就是重复同一个错误。
//
// 所以这里换成两支**结构上不可能空手而归**的探针:
//
// 1. 卡顿栈采样(startStallStackSampler)
// 定时抓「全部 goroutine 的栈」,只在内容变化时落盘。无论那几秒是在算、
// 在等锁、在等数据库、还是在等 socket,栈上都会写明是谁、在哪一行。
//
// 2. 无缝分段(slowProbeSegments)
// 把整段代码的墙钟按语句顺序切成**连续、无缝隙**的段落,
// Σ段 + 未归因 ≡ 总时长(构造上恒等),因此不存在盲区。
//
// 两者都只**读**运行状态、只**写**独立日志文件,不改任何既有行为。
// ---------------------------------------------------------------------------
// 1. 卡顿栈采样
// ---------------------------------------------------------------------------
const (
// stallSamplerDefaultInterval 是采样间隔。300ms 对 4~10 秒的卡顿足够密,
// 又不至于在正常运行(栈内容不变)时产生任何落盘。
stallSamplerDefaultInterval = 300 * time.Millisecond
// stallSamplerMaxBytes 是单个文件的封顶字节数,防止长时间开启把磁盘写满。
stallSamplerMaxBytes = 64 << 20
// stallSamplerStackBuffer 是一次 runtime.Stack 的缓冲区上限。
// 本服务实测约 137~150 个 goroutine,8MB 有充足余量。
stallSamplerStackBuffer = 8 << 20
)
// startStallStackSampler 启动卡顿栈采样。
//
// 开关是环境变量 DNF90_STALL_SAMPLER。**默认关闭**:只有显式给
// 1 / on / true / yes 才启动。这样线上不带这个变量跑就完全不受影响,
// 与 goroutine_watchdog.go 的 DNF90_GOROUTINE_WATCHDOG 是同一套约定。
//
// 落盘策略是**按内容去重**:只有栈快照与上一份不同才追加。正常运行期间
// 栈内容几乎不变,所以文件会很小;一旦发生卡顿,栈会变化,时间线自然浮现。
func (s *Service) startStallStackSampler(ctx context.Context) {
if s == nil || !stallSamplerEnabled() {
return
}
path := filepath.Join("logs", "stall_stacks.txt")
interval := stallSamplerDefaultInterval
go func() {
ticker := time.NewTicker(interval)
defer ticker.Stop()
var lastKey string
var written int64
var samples, emitted int64
truncated := false
for {
select {
case <-ctx.Done():
return
case at := <-ticker.C:
buf := make([]byte, stallSamplerStackBuffer)
size := runtime.Stack(buf, true)
body := buf[:size]
samples++
sum := sha256.Sum256(body)
key := hex.EncodeToString(sum[:])
if key == lastKey {
// 与上一份逐字节相同:正常运行,不落盘。
continue
}
lastKey = key
if written >= stallSamplerMaxBytes {
if !truncated {
truncated = true
appendStallSample(path, []byte(fmt.Sprintf(
"\n===== %s sampler stopped at %d bytes (cap) =====\n",
at.Format(time.RFC3339Nano), stallSamplerMaxBytes)))
}
continue
}
header := fmt.Sprintf(
"\n===== %s goroutines=%d sample=%d =====\n",
at.Format(time.RFC3339Nano), runtime.NumGoroutine(), samples)
chunk := make([]byte, 0, len(header)+len(body)+1)
chunk = append(chunk, header...)
chunk = append(chunk, body...)
chunk = append(chunk, '\n')
if appendStallSample(path, chunk) != nil {
return
}
written += int64(len(chunk))
emitted++
_ = emitted
}
}
}()
s.logPacketEvent("game-stall-stack-sampler-started",
"path", path,
"interval_ms", interval.Milliseconds(),
"max_bytes", stallSamplerMaxBytes)
}
func appendStallSample(path string, chunk []byte) error {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
if err != nil {
return err
}
if _, err := f.Write(chunk); err != nil {
_ = f.Close()
return err
}
return f.Close()
}
// stallSamplerEnabled 只在显式开启时返回 true(默认关闭)。
func stallSamplerEnabled() bool {
switch strings.ToLower(strings.TrimSpace(os.Getenv("DNF90_STALL_SAMPLER"))) {
case "1", "on", "true", "yes":
return true
default:
return false
}
}
// ---------------------------------------------------------------------------
// 2. 无缝分段
// ---------------------------------------------------------------------------
// slowProbeSegments 把一段代码的墙钟切成连续、无缝隙的段落。
//
// 用法:
//
// seg := newSlowProbeSegments()
// defer seg.report(s, session, "party-dungeon-loading-release")
// ... 做 A ...
// seg.mark("A")
// ... 做 B ...
// seg.mark("B")
//
// report 打出的 total 与各段之和的差即「两个 mark 之外的时间」,所以
// **Σ段 + 未归因 ≡ 总时长** —— 无论慢在哪,都会被某一段或未归因余量吃掉,
// 不可能出现「哪一段都不慢但总共很慢」的空白结论。
type slowProbeSegments struct {
started time.Time
last time.Time
parts []string
}
func newSlowProbeSegments() *slowProbeSegments {
now := time.Now()
return &slowProbeSegments{started: now, last: now}
}
// mark 把「上一次 mark 到现在」这段耗时记到 name 名下。
func (p *slowProbeSegments) mark(name string) {
if p == nil {
return
}
now := time.Now()
p.parts = append(p.parts, fmt.Sprintf("%s=%d", name, now.Sub(p.last).Milliseconds()))
p.last = now
}
// report 无条件打出一行:总时长 + 未归因余量 + 各段明细。
//
// 注意是**无条件**:不设阈值。这样即使某一轮卡得轻,也能拿到完整分布,
// 不会出现「跑了一整轮却什么都没记录」。
func (p *slowProbeSegments) report(
s *Service, session *gameSession, source string, extra ...any,
) {
if p == nil || s == nil {
return
}
total := time.Since(p.started)
// 各段之和 = 最后一次 mark 距起点的时长。
covered := p.last.Sub(p.started)
residual := total - covered
args := []any{
"source", source,
"total_ms", total.Milliseconds(),
"unattributed_ms", residual.Milliseconds(),
"segments", strings.Join(p.parts, ","),
}
args = append(args, extra...)
s.logGameEvent(session, "game-slow-segments", args...)
}