服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
350 lines
11 KiB
Go
350 lines
11 KiB
Go
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
|
||
}
|