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

350 lines
11 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
// raid_log.go —— 团本(攻坚战)专用日志。
//
// 为什么单独开一类
//
// 攻坚相关事件以前全部混在 packet_log.txt 里(那台机器一天 6~10 GB),排查时要在
// 几十万行里翻几十条,且真正有用的字段(谁在等谁、名单几个人、就绪几个、
// 驱动者是谁、某个成员为什么没就绪)当时根本没记。本文件把团本事件单独落一个
// 文件(records/raid-YYYYMMDD-NNN.jsonl),字段尽量全,并且不阻塞玩法。
//
// 与 packet_log 的关系
//
// 同一事件仍然照常进 packet_log(保留原样,便于和客户端 trace 对齐),
// 但高频、低信息量的那些由 suppressPacketLogEvent 过滤掉,降低 packet_log 噪声。
//
// 三条纪律(沿用 activity_record.go 的既有约定)
//
// 1. 旁路:记录器未启用(records == nil)时,所有函数立刻返回,代价是一次指针判空。
// 2. 不阻塞:写入是 channel 入队 + 单协程落盘;这里只做 map 组装。
// 3. 锁安全:**本文件里的函数绝不主动获取 session.dungeon.mu**。
// 攻坚记录大量发生在已经持有 dungeon.mu 的路径上(例如 release 与
// 怪物死亡投影),在那里再取一次锁就是死锁风险。需要 runtime 字段时,
// 由调用方(已经持有 runtime)通过 raidStateFields 显式传进来。
//
// 字段命名约定
//
// raid_key / raid_phase / lifecycle_status / lifecycle_revision / roster_size /
// frozen_size / online_members / squad / slot / is_leader / leader_id /
// party_id / conn_id / character_id / character_name / channel_id /
// event / event_name / reason / result / wall_ms / error
import (
"fmt"
"time"
)
// recordRaidKind 是团本记录的文件类别(records/raid-*.jsonl)。
const recordRaidKind = recordKindRaid
// raidEventName 把事件名翻成中文,便于直接看文件排查。
func raidEventName(event string) string {
switch event {
// 生命周期
case "raid_created":
return "创建攻坚队"
case "raid_disbanded":
return "攻坚队解散"
case "raid_started":
return "开始攻坚"
case "raid_start_rejected":
return "开始攻坚被拒"
case "raid_phase_advanced":
return "阶段推进"
case "raid_phase_timeout":
return "阶段攻击超时"
case "raid_phase_force_return":
return "阶段收尾强制回城"
case "raid_cleared":
return "攻坚通关"
// 副本门(gate)生命周期
case "gate_selected":
return "选中副本门"
case "gate_loaded":
return "副本门加载完成"
case "gate_completed":
return "副本门通关"
case "gate_abandoned":
return "副本门放弃/被踢出"
case "gate_boundary_fanout":
return "门边界演员扇出"
// 读条 / 进图
case "loading_ready":
return "成员读条完成登记"
case "loading_released":
return "读条放行完成"
case "loading_release_driver":
return "读条放行驱动者"
case "loading_release_skipped":
return "读条放行跳过"
case "loading_barrier_timeout":
return "读条屏障超时"
case "entry_blocked":
return "进图被拒"
case "entry_prepared":
return "进图已准备"
case "forced_exit":
return "被强制移出副本"
case "forced_return":
return "被强制回城"
// 队伍 / 端点 / 中继
case "party_formed":
return "组队完成"
case "party_publish_deferred":
return "队伍发布被推迟"
case "party_peer_endpoint":
return "队友端点登记"
case "party_peer_endpoint_missing":
return "队友端点缺失"
case "party_member_removed":
return "成员被移出队伍"
case "party_kick_result":
return "踢人结果"
case "relay_leg_missing":
return "中继腿缺失"
case "relay_leg_unresolved":
return "中继腿重试仍失败"
case "relay_throttled":
return "中继被限流"
case "town_return_copresence":
return "回城共现重建"
case "scene_tail_deferred":
return "场景尾巴包被推迟"
// 奖励
case "session_cost_summary":
return "本场服务端成本汇总"
case "movie_confirm_fallback":
return "过场确认超时兜底开窗"
case "reward_cards_opened":
return "翻牌窗口打开"
case "reward_cards_open_skipped":
return "翻牌窗口被跳过"
case "reward_auto_flipped":
return "自动翻牌"
case "reward_manual_flip":
return "手动翻牌"
case "reward_delivered":
return "奖励已发放"
case "reward_incomplete":
return "奖励不完整"
default:
return event
}
}
// raidLifecycleStatusName 攻坚生命周期状态中文名。
func raidLifecycleStatusName(status runtimeRaidLifecycleStatus) string {
switch status {
case runtimeRaidLifecycleCreated:
return "已创建"
case runtimeRaidLifecyclePreparing:
return "准备中"
case runtimeRaidLifecyclePhaseActive:
return "阶段进行中"
case runtimeRaidLifecycleAwaitingMovie:
return "等待过场"
case runtimeRaidLifecycleAwaitingRewards:
return "等待翻牌"
case runtimeRaidLifecycleStandby:
return "待命"
case runtimeRaidLifecycleFinished:
return "已完成"
case runtimeRaidLifecycleAbandoned:
return "已放弃"
case runtimeRaidLifecycleFailed:
return "失败(攻击超时)"
default:
return fmt.Sprintf("状态%d", int(status))
}
}
// raidStateFields 从一份**调用方已经持有**的攻坚状态里取出排查所需字段。
//
// 调用方必须自己保证读 state 的并发安全(本函数不加锁,也不复制整份状态)。
// 缺字段就留空——记录是旁路,不为凑字段去多查一次库或抢一次锁。
func raidStateFields(state *runtimeRaidState, characterID uint16) map[string]any {
fields := map[string]any{}
if state == nil {
return fields
}
fields["raid_key"] = state.RaidKey
fields["raid_phase"] = int(state.Lifecycle.Phase)
fields["lifecycle_status"] = int(state.Lifecycle.Status)
fields["lifecycle_status_name"] = raidLifecycleStatusName(state.Lifecycle.Status)
fields["lifecycle_revision"] = state.Lifecycle.Revision
fields["raid_started"] = state.Started
fields["entry_cost_paid"] = state.EntryCostPaid
fields["start_attempt"] = state.StartAttempt
fields["leader_id"] = state.LeaderID
fields["roster_size"] = len(state.Members)
fields["frozen_size"] = len(state.FrozenMembers)
if len(state.NameBytes) != 0 {
fields["raid_name"] = string(state.NameBytes)
}
if characterID != 0 {
fields["is_leader"] = characterID == state.LeaderID
squad, slot, found := 0, 0, false
for _, member := range state.Members {
if member.CharID != characterID {
continue
}
squad = int(member.GroupIndex)
slot = int(member.SlotOrder)
found = true
break
}
if found {
fields["squad"] = squad
fields["slot"] = slot
if state.SquadPartyLeaders != nil {
if partyLeader := state.SquadPartyLeaders[byte(squad)]; partyLeader != 0 {
fields["squad_party_leader"] = partyLeader
}
}
}
}
return fields
}
// raidOnlineMembers 数一遍当前在线的攻坚成员。
//
// 会取 s.mu(onlineGameSession 内部),因此**只在本来就安全的调用点使用**:
// 创建/解散/开始/阶段推进/奖励这类低频事件。高频路径(门边界扇出、读条放行)
// 不要调用它,避免在持有 dungeon.mu 的调用栈里引入新的锁序。
func (s *Service) raidOnlineMembers(state *runtimeRaidState) (online int, total int) {
if s == nil || state == nil {
return 0, 0
}
for _, member := range state.FrozenMembers {
total++
if session, ok := s.onlineGameSession(member.CharID); ok && session != nil && session.conn != nil {
online++
}
}
if total == 0 {
for _, member := range state.Members {
total++
if session, ok := s.onlineGameSession(member.CharID); ok && session != nil && session.conn != nil {
online++
}
}
}
return online, total
}
// recordRaidEvent 记一条团本事件(轻量上下文:只读会话上已有的普通字段)。
func (s *Service) recordRaidEvent(session *gameSession, event string, fields map[string]any) {
if s == nil || s.records.Load() == nil {
return
}
merged := s.activitySessionFields(session)
if session != nil {
merged["conn_id"] = session.connID
merged["character_generation"] = session.characterGeneration
merged["session_online"] = session.conn != nil
}
merged["event"] = event
merged["event_name"] = raidEventName(event)
for key, value := range fields {
merged[key] = value
}
s.recordActivity(recordRaidKind, merged)
}
// recordRaidStateEvent 记一条团本事件,并带上调用方持有的攻坚状态与在线人数。
// 只用于低频事件(见 raidOnlineMembers 的说明)。
func (s *Service) recordRaidStateEvent(session *gameSession, event string, state *runtimeRaidState, fields map[string]any) {
if s == nil || s.records.Load() == nil {
return
}
merged := fields
if merged == nil {
merged = map[string]any{}
}
charID := uint16(0)
if session != nil {
charID = session.selectedCharacterID
}
for key, value := range raidStateFields(state, charID) {
merged[key] = value
}
if online, total := s.raidOnlineMembers(state); total > 0 {
merged["online_members"] = online
merged["roster_members"] = total
}
s.recordRaidEvent(session, event, merged)
}
// recordRaidStateEventLight 与 recordRaidStateEvent 相同,但**不**去数在线人数。
//
// 热路径/可能持有 session.dungeon.mu 的调用栈必须用这个版本:数在线人数要取 s.mu,
// 在已持有 dungeon.mu 的栈里再取 s.mu 会引入新的锁序。
func (s *Service) recordRaidStateEventLight(session *gameSession, event string, state *runtimeRaidState, fields map[string]any) {
if s == nil || s.records.Load() == nil {
return
}
merged := fields
if merged == nil {
merged = map[string]any{}
}
charID := uint16(0)
if session != nil {
charID = session.selectedCharacterID
}
for key, value := range raidStateFields(state, charID) {
merged[key] = value
}
s.recordRaidEvent(session, event, merged)
}
// raidMillis 把时长写成毫秒(float,保留一位小数),便于直接排序/筛选。
func raidMillis(d time.Duration) float64 {
return float64(d.Microseconds()) / 1000
}
// raidWallField 组装"这次操作花了多久"。
func raidWallField(d time.Duration) map[string]any {
return map[string]any{"wall_ms": raidMillis(d)}
}
// errorText 把 error 写成可读字符串(nil 记空串,便于 JSON 里直接看)。
func errorText(err error) string {
if err == nil {
return ""
}
return err.Error()
}
// raidMemberIDs 把成员列表压成 [1 12 22] 这样的数组,便于看名单。
func raidMemberIDsFromState(members []runtimeRaidMemberState) []uint16 {
out := make([]uint16, 0, len(members))
for _, member := range members {
out = append(out, member.CharID)
}
return out
}
// raidBitsCount 数一个 8 位槽位位图里有几个人(队伍最多 4 人,8 位足够)。
func raidBitsCount(mask uint8) int {
count := 0
for bit := uint8(1); bit != 0; bit <<= 1 {
if mask&bit != 0 {
count++
}
}
return count
}
// raidMissingSlots 返回"期望到齐但还没登记"的槽位,用于直接点名谁卡住了。
func raidMissingSlots(ready, expected uint8) []int {
missing := make([]int, 0, 8)
for slot := 0; slot < 8; slot++ {
bit := uint8(1) << slot
if expected&bit != 0 && ready&bit == 0 {
missing = append(missing, slot)
}
}
return missing
}