DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,758 @@
|
||||
package dnfbridge
|
||||
|
||||
// activity_hooks.go 把三类审计记录接到玩法主路径上,并暴露 GM 查询接口。
|
||||
//
|
||||
// 记录器本身(落盘/轮转/检索)在 activity_record.go;这里只负责
|
||||
// "在正确的时机、用尽量全的字段" 调用它。
|
||||
//
|
||||
// 三条硬约束(记录是旁路,不能反过来影响玩法):
|
||||
// 1. recordActivity 可从任意协程调用,永不 panic、永不阻塞在主路径上;
|
||||
// 2. 记录字段取不到就留空,绝不为凑字段去改仓储/阻塞协议流程;
|
||||
// 3. 角色名做了会话级缓存——聊天很频繁,不能每条消息都查一次角色表。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
dnfauction "longheng.io/server/internal/modules/dnf/auction"
|
||||
)
|
||||
|
||||
// recordActivity 追加一条审计记录。记录器未启用(目录不可写/未启动)时直接空转。
|
||||
func (s *Service) recordActivity(kind string, fields map[string]any) {
|
||||
if s == nil || fields == nil {
|
||||
return
|
||||
}
|
||||
recorder := s.records.Load()
|
||||
if recorder == nil {
|
||||
return
|
||||
}
|
||||
recorder.record(kind, fields, s.logger)
|
||||
}
|
||||
|
||||
// activitySessionFields 组装"这个会话是谁"的公共字段。
|
||||
// 三个类别都要这几项,集中一处避免各埋点写法不一致。
|
||||
//
|
||||
// 注意这里**不能**用 s.accountIDForSession:那个函数在开启启动器鉴权且会话未绑定
|
||||
// 账号时会写日志并主动断开连接。审计埋点是旁路,绝不能因为记录一条聊天就把玩家
|
||||
// 踢下线。这里只读会话上已经绑定好的账号,读不到就留空。
|
||||
func (s *Service) activitySessionFields(session *gameSession) map[string]any {
|
||||
fields := map[string]any{}
|
||||
if session == nil {
|
||||
return fields
|
||||
}
|
||||
if session.selectedCharacterID != 0 {
|
||||
fields["character_id"] = strconv.Itoa(int(session.selectedCharacterID))
|
||||
}
|
||||
if name := s.activityCharacterName(session); name != "" {
|
||||
fields["character_name"] = name
|
||||
}
|
||||
if accountID := strings.TrimSpace(session.accountID); accountID != "" {
|
||||
fields["account_id"] = accountID
|
||||
}
|
||||
if session.channel.ID != 0 {
|
||||
fields["channel_id"] = session.channel.ID
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// activityCharacterName 取当前选中角色的名字,带会话级缓存。
|
||||
//
|
||||
// 为什么必须缓存:对话记录挂在每一次发言上,而角色名在 gameSession 里没有常驻字段,
|
||||
// 只能查一次角色表。每条消息查一次库会给聊天加上一次 DB 往返,代价与收益不成比例;
|
||||
// 角色名在同一次登录期间不会变,缓存到 selectedCharacterID 变化为止即可。
|
||||
func (s *Service) activityCharacterName(session *gameSession) string {
|
||||
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
||||
return ""
|
||||
}
|
||||
session.activityNameMu.Lock()
|
||||
if session.activityNameCharacterID == session.selectedCharacterID && session.activityName != "" {
|
||||
name := session.activityName
|
||||
session.activityNameMu.Unlock()
|
||||
return name
|
||||
}
|
||||
session.activityNameMu.Unlock()
|
||||
|
||||
repositories, ok := s.repositoryGroup()
|
||||
if !ok || repositories.Character == nil {
|
||||
return ""
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
defer cancel()
|
||||
record, found, err := repositories.Character.Load(ctx, strconv.Itoa(int(session.selectedCharacterID)))
|
||||
if err != nil || !found {
|
||||
return ""
|
||||
}
|
||||
session.activityNameMu.Lock()
|
||||
session.activityNameCharacterID = session.selectedCharacterID
|
||||
session.activityName = record.Name
|
||||
session.activityNameMu.Unlock()
|
||||
return record.Name
|
||||
}
|
||||
|
||||
// ================= 对话记录 =================
|
||||
|
||||
// chatRecordText 把聊天原文解码成可读文本。
|
||||
//
|
||||
// 客户端聊天正文是 GBK 字节(与角色名、NPC 文本同一套编码),直接落盘会得到乱码,
|
||||
// 查记录的人就看不懂了。解不出来时原样保留字节转成的字符串:宁可留一段乱码,
|
||||
// 也不要丢掉玩家到底发了什么这个事实。
|
||||
func chatRecordText(message []byte) string {
|
||||
if len(message) == 0 {
|
||||
return ""
|
||||
}
|
||||
ascii := true
|
||||
for _, value := range message {
|
||||
if value >= 0x80 {
|
||||
ascii = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if ascii {
|
||||
return string(message)
|
||||
}
|
||||
decoded, err := simplifiedchinese.GB18030.NewDecoder().Bytes(message)
|
||||
if err != nil {
|
||||
return string(message)
|
||||
}
|
||||
return string(decoded)
|
||||
}
|
||||
|
||||
// recordChat 记一条对话。result 传 "ok" / "rejected",rejected 时 reason 必填。
|
||||
//
|
||||
// message 收原始字节(客户端发来的 GBK),解码在这里做,调用点不必关心编码。
|
||||
// 字段刻意保留完整原文——运营/管理端查"谁在喊什么"是这类记录的主要用途,
|
||||
// 截断会让它失去意义。文件轮转与保留期由记录器控制,不在这里做二次裁剪。
|
||||
func (s *Service) recordChat(
|
||||
session *gameSession,
|
||||
mode byte,
|
||||
modeName string,
|
||||
message []byte,
|
||||
recipients int,
|
||||
itemLinks int,
|
||||
result string,
|
||||
reason string,
|
||||
target map[string]any,
|
||||
) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
text := chatRecordText(message)
|
||||
fields := s.activitySessionFields(session)
|
||||
fields["mode"] = mode
|
||||
fields["mode_name"] = modeName
|
||||
fields["message"] = text
|
||||
fields["message_len"] = len([]rune(text))
|
||||
fields["message_bytes"] = len(message)
|
||||
if result != "ok" && len(message) > 0 {
|
||||
// 被拒绝的消息正文也留一份十六进制,便于核对解码是否正确。
|
||||
fields["message_hex"] = hex.EncodeToString(message)
|
||||
}
|
||||
fields["recipients"] = recipients
|
||||
fields["item_links"] = itemLinks
|
||||
fields["result"] = result
|
||||
if reason != "" {
|
||||
fields["reason"] = reason
|
||||
}
|
||||
for key, value := range target {
|
||||
fields[key] = value
|
||||
}
|
||||
s.recordActivity(recordKindChat, fields)
|
||||
}
|
||||
|
||||
// currentTownChatModeName 聊天模式的中文名(记录里给人看,比数字好读)。
|
||||
func currentTownChatModeName(mode byte) string {
|
||||
switch mode {
|
||||
case currentTownChatWhisperMode:
|
||||
return "私聊"
|
||||
case currentTownChatOneToOneMode:
|
||||
return "1:1会话"
|
||||
case currentTownChatPeerMenuMode:
|
||||
return "好友菜单消息"
|
||||
case currentTownChatMegaphoneMode:
|
||||
return "服务器喇叭"
|
||||
case currentTownChatChannelMegaphoneMode:
|
||||
return "频道喇叭"
|
||||
case currentTownChatAreaMode:
|
||||
return "区域"
|
||||
case currentTownChatArenaMode:
|
||||
return "决斗场"
|
||||
case currentTownChatPartyMode:
|
||||
return "队伍"
|
||||
case currentTownChatGuildMode, currentTownChatGuildLegacyMode:
|
||||
return "公会"
|
||||
case currentTownChatRaidMode:
|
||||
return "攻坚队"
|
||||
default:
|
||||
return fmt.Sprintf("未知模式(%d)", mode)
|
||||
}
|
||||
}
|
||||
|
||||
// ================= 点券消费记录 =================
|
||||
|
||||
// recordCeraSpend 记一次点券消费。
|
||||
//
|
||||
// 余额前后值能拿到就填:运营最常问的是"这条消费之后他还剩多少",
|
||||
// 只写扣了多少没法回答这个问题。
|
||||
func (s *Service) recordCeraSpend(session *gameSession, fields map[string]any) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
merged := s.activitySessionFields(session)
|
||||
for key, value := range fields {
|
||||
merged[key] = value
|
||||
}
|
||||
s.recordActivity(recordKindCera, merged)
|
||||
}
|
||||
|
||||
// recordAlignedCeraSpend 记一条由 aligned 命令模块上报的点券消费。
|
||||
//
|
||||
// 目前只有金库创建/升级走这条路(模块自己不经过商城结账)。金额为 0 表示
|
||||
// 这条命令没有点券消费,直接跳过——不能让"每次 op 都写一条 0 元消费"。
|
||||
func (s *Service) recordAlignedCeraSpend(session *gameSession, result alignedcmd.Result) {
|
||||
if s == nil || result.CeraSpent <= 0 {
|
||||
return
|
||||
}
|
||||
fields := map[string]any{
|
||||
"result": "ok",
|
||||
"source": "aligned_command",
|
||||
"operation": result.Operation,
|
||||
"payment_mode": "点券",
|
||||
"payment_name": "点券",
|
||||
"cera_spent": result.CeraSpent,
|
||||
"cera_after": result.CeraAfter,
|
||||
"cera_before": result.CeraAfter + result.CeraSpent,
|
||||
"cera_spent_note": result.CeraSpentNote,
|
||||
}
|
||||
s.recordCeraSpend(session, fields)
|
||||
}
|
||||
|
||||
// recordGoldConsignCeraSpend 记一条"金币寄售买入"的点券消费。
|
||||
//
|
||||
// 为什么单独一个函数而不是复用 recordAlignedCeraSpend:金币寄售走的是拍卖行
|
||||
// 玩法模块(access_auction 的 op185/op334),**不经过 alignedcmd**,
|
||||
// 所以 alignedcmd.Result.CeraSpent 永远是 0,那条路覆盖不到这里。
|
||||
//
|
||||
// 字段口径与商城购买对齐(result / source / operation / payment_* / cera_*),
|
||||
// 运营在「记录查询 → 点券消费记录」里能直接把两类消费放在一起看:
|
||||
//
|
||||
// · source="gold_consign" 用来区分来源(商城成功那条没有 source;
|
||||
// aligned 那条是 "aligned_command");
|
||||
// · payment_mode 复用商城的"点券"档(0),让 payment_mode 在所有点券记录里同值可比。
|
||||
//
|
||||
// 调用方必须保证 charged=true(真的扣了钱)才调用本函数 —— 重放同一个 op185 时
|
||||
// 钱包不会再被扣一次,重复记账会让运营看到两条一模一样的消费。
|
||||
func (s *Service) recordGoldConsignCeraSpend(
|
||||
session *gameSession,
|
||||
accountID, buyerID string,
|
||||
listing dnfauction.GoldConsignListing,
|
||||
ceraBefore, ceraAfter int64,
|
||||
) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
fields := map[string]any{
|
||||
"result": "ok",
|
||||
"source": "gold_consign",
|
||||
"operation": "buy_gold_consign",
|
||||
"payment_mode": currentCeraShopPaymentModeCera,
|
||||
"payment_name": "点券",
|
||||
// 实扣点券 = 寄售单标价。金币寄售没有"券抵扣",标价就是实扣。
|
||||
"cera_spent": listing.PriceCera,
|
||||
"cera_before": ceraBefore,
|
||||
"cera_after": ceraAfter,
|
||||
// 概要列直接显示这句,运营一眼能看出买的是哪一档金币。
|
||||
"cera_spent_note": "金币寄售:" + goldConsignAmountText(listing.GoldAmount) + "金币",
|
||||
// 显式补会话字段:activitySessionFields 只读 session.accountID /
|
||||
// selectedCharacterID,而这里用的是 accountIDForSession 解析出来的值,
|
||||
// 两者不一定同源(账号没绑定时前者可能就是空的)。记录里必须有人。
|
||||
"account_id": strings.TrimSpace(accountID),
|
||||
"character_id": strings.TrimSpace(buyerID),
|
||||
// 寄售单与卖家侧信息(卖家侧的收入是代币券,不进本类别)。
|
||||
"listing_id": listing.ID,
|
||||
"gold_item_id": listing.GoldItemID,
|
||||
"gold_amount": listing.GoldAmount,
|
||||
"seller_id": listing.SellerID,
|
||||
"seller_name": listing.SellerName,
|
||||
"fee_percent": listing.FeePercent,
|
||||
"proceeds_cera": listing.ProceedsCera,
|
||||
}
|
||||
s.recordCeraSpend(session, fields)
|
||||
}
|
||||
|
||||
// goldConsignAmountText 把金币数量渲染成人读的形式(1_000_000 → "100万")。
|
||||
//
|
||||
// 金币档位最大到 2 亿,直接写原始数字在概要列里很难一眼分辨量级。
|
||||
func goldConsignAmountText(amount int64) string {
|
||||
switch {
|
||||
case amount <= 0:
|
||||
return "0"
|
||||
case amount%100_000_000 == 0:
|
||||
return strconv.FormatInt(amount/100_000_000, 10) + "亿"
|
||||
case amount%10_000 == 0:
|
||||
return strconv.FormatInt(amount/10_000, 10) + "万"
|
||||
default:
|
||||
return strconv.FormatInt(amount, 10)
|
||||
}
|
||||
}
|
||||
|
||||
// ================= 金币寄售记录 =================
|
||||
|
||||
// recordGoldConsign 记一条金币寄售事件(上架 / 下架 / 买家买入 / 成交收款 / 流拍退回)。
|
||||
//
|
||||
// 形状与 recordDungeon 一致:先铺会话身份字段,再写 event / event_name,
|
||||
// 最后由调用方补齐本事件自己的字段。result 缺省为 "ok",只有"明确被拒绝"的
|
||||
// 那几条才显式传 rejected + reason —— 记录里不出现"没有 result"的行:
|
||||
// 少一个字段的拒绝行与成功行长得一模一样,事后没人分得清。
|
||||
//
|
||||
// 与「点券消费记录」的分工:那边记的是**钱**(买家实扣点券,字段口径与商城
|
||||
// 购买对齐,方便两类消费同表对比),这里记的是**一单寄售的完整生命周期**
|
||||
// (谁挂的、卖给了谁、手续费抽了多少、卖家实得多少、有没有真的投递出去)。
|
||||
// 同一笔买入因此在两个类别里各有一行,这不是重复记账,而是两个检索口径。
|
||||
func (s *Service) recordGoldConsign(session *gameSession, event string, fields map[string]any) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
merged := s.activitySessionFields(session)
|
||||
merged["event"] = event
|
||||
merged["event_name"] = goldConsignEventName(event)
|
||||
merged["result"] = "ok"
|
||||
for key, value := range fields {
|
||||
merged[key] = value
|
||||
}
|
||||
s.recordActivity(recordKindGoldConsign, merged)
|
||||
}
|
||||
|
||||
// goldConsignEventName 金币寄售事件的中文名。
|
||||
func goldConsignEventName(event string) string {
|
||||
switch event {
|
||||
case "list":
|
||||
return "上架"
|
||||
case "cancel":
|
||||
return "下架"
|
||||
case "buy":
|
||||
return "买家买入"
|
||||
case "sale_delivered":
|
||||
return "成交收款"
|
||||
case "expired":
|
||||
return "流拍退回"
|
||||
default:
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
// ================= 拍卖行记录 =================
|
||||
|
||||
// recordAuction 记一条拍卖行事件
|
||||
// (上架 / 下架 / 竞拍出价 / 一口价成交 / 竞拍成交 / 卖家领取收益)。
|
||||
//
|
||||
// 与 recordGoldConsign 同形,理由同上:拍卖行与金币寄售共用 op183/184/185,
|
||||
// 但一个记的是物品竞拍(押金/竞价/一口价/手续费金币),另一个记的是金币档位
|
||||
// (点券标价/代币券结算),字段集合不重叠,所以分成两个文件、两个类别。
|
||||
func (s *Service) recordAuction(session *gameSession, event string, fields map[string]any) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
merged := s.activitySessionFields(session)
|
||||
merged["event"] = event
|
||||
merged["event_name"] = auctionEventName(event)
|
||||
merged["result"] = "ok"
|
||||
for key, value := range fields {
|
||||
merged[key] = value
|
||||
}
|
||||
s.recordActivity(recordKindAuction, merged)
|
||||
}
|
||||
|
||||
// auctionEventName 拍卖行事件的中文名。
|
||||
func auctionEventName(event string) string {
|
||||
switch event {
|
||||
case "list":
|
||||
return "上架"
|
||||
case "cancel":
|
||||
return "下架"
|
||||
case "bid":
|
||||
return "竞拍出价"
|
||||
case "buyout":
|
||||
return "一口价成交"
|
||||
case "won":
|
||||
return "竞拍成交"
|
||||
case "payout":
|
||||
return "卖家领取收益"
|
||||
default:
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
// ================= 场景加载诊断记录 =================
|
||||
|
||||
// recordScene 记一条场景加载/引导包(scene bootstrap tail)诊断事件。
|
||||
//
|
||||
// 与 recordGoldConsign / recordAuction 同形:先铺会话身份字段,再写
|
||||
// event / event_name,最后由调用方补齐本事件自己的字段。
|
||||
//
|
||||
// 为什么要有这个包装、而不是继续用 recordRaidEvent:这批诊断事件
|
||||
// (scene_tail_deferred 一族)与团本毫无关系,却被写进了 raid 类别,于是
|
||||
// 1. 只要有人在选人选场景/进图,每秒就冒出好几条,真正的团本事件被淹得翻不到;
|
||||
// 2. 记录器每类只保留最近 recordRetentionPerKind 个文件,这批高频写入会飞快
|
||||
// 把 raid-*.jsonl 顶到上限,把真正的团本记录连旧文件一起挤出保留窗口。
|
||||
//
|
||||
// 所以它们必须有自己的类别(recordKindScene),而不能复用团本的写入路径。
|
||||
//
|
||||
// 关于会话字段:这里额外补 conn_id / character_generation / session_online 三项,
|
||||
// 与 recordRaidEvent 保持一致——搬家前这些字段就在 scene_tail_deferred 记录里,
|
||||
// 少了它们等于排障时丢信息(尤其 session_online 是判断"玩家还在不在线"的第一手依据)。
|
||||
func (s *Service) recordScene(session *gameSession, event string, fields map[string]any) {
|
||||
if s == 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"] = sceneEventName(event)
|
||||
merged["result"] = "ok"
|
||||
for key, value := range fields {
|
||||
merged[key] = value
|
||||
}
|
||||
s.recordActivity(recordKindScene, merged)
|
||||
}
|
||||
|
||||
// sceneEventName 场景加载诊断事件的中文名。
|
||||
//
|
||||
// 名字与 raidEventName 里的同名分支刻意保持一致:同一个事件只是换了文件,
|
||||
// 概要列显示的文字不该跟着变,否则运营会以为出现了新事件。
|
||||
func sceneEventName(event string) string {
|
||||
switch event {
|
||||
case "scene_tail_deferred":
|
||||
// "被推迟"与"最终发出"共用同一个事件名,靠 result(deferred / sent)区分,
|
||||
// 这个口径搬家前就是这样,保持不变。
|
||||
return "场景尾巴包被推迟"
|
||||
default:
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
// activityRejectionFields 拒绝记录的公共字段:result=rejected + reason。
|
||||
//
|
||||
// 统一一处是为了让"被拒绝"在全部记录类别里永远是同一对键值:运营按 result
|
||||
// 过滤就能捞出所有失败尝试,reason 再回答是哪一种拒绝(余额不足 / 价格不符 /
|
||||
// 已被买走……)。调用方可以继续往返回的 map 里补字段。
|
||||
func activityRejectionFields(reason string) map[string]any {
|
||||
return map[string]any{"result": "rejected", "reason": reason}
|
||||
}
|
||||
|
||||
// activityIdentityOverride 显式覆盖记录里的账号/角色标识,且一律写成字符串。
|
||||
//
|
||||
// 两条都是硬要求:
|
||||
// 1. **显式**:activitySessionFields 读的是 session.accountID / selectedCharacterID,
|
||||
// 而拍卖行这几条路径用的是 accountIDForSession / selectedCharacterIDString
|
||||
// 解析出来的值,两者不一定同源(账号没绑定时前者可能就是空的);
|
||||
// 2. **字符串**:运维工具只按字符串型读 account_id / character_id
|
||||
// (handlers_records.go 的 recordAccountIDs),数字型会被静默忽略,
|
||||
// 账号名翻译随即失效 —— 记录里就只剩一个没有主人的数字。
|
||||
func activityIdentityOverride(fields map[string]any, accountID, characterID string) {
|
||||
if accountID = strings.TrimSpace(accountID); accountID != "" {
|
||||
fields["account_id"] = accountID
|
||||
}
|
||||
if characterID = strings.TrimSpace(characterID); characterID != "" {
|
||||
fields["character_id"] = characterID
|
||||
}
|
||||
}
|
||||
|
||||
// ================= 副本记录 =================
|
||||
|
||||
// recordDungeon 记一条副本事件(enter / clear / exit)。
|
||||
func (s *Service) recordDungeon(session *gameSession, event string, fields map[string]any) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
merged := s.activitySessionFields(session)
|
||||
merged["event"] = event
|
||||
merged["event_name"] = dungeonEventName(event)
|
||||
for key, value := range fields {
|
||||
merged[key] = value
|
||||
}
|
||||
s.recordActivity(recordKindDungeon, merged)
|
||||
}
|
||||
|
||||
// dungeonEventName 副本事件的中文名。
|
||||
func dungeonEventName(event string) string {
|
||||
switch event {
|
||||
case "enter":
|
||||
return "进入副本"
|
||||
case "clear":
|
||||
return "通关"
|
||||
case "exit":
|
||||
return "离开副本"
|
||||
default:
|
||||
return event
|
||||
}
|
||||
}
|
||||
|
||||
// dungeonRuntimeFields 组装一条副本记录里"这次刷的是哪个本、什么状态"的公共字段。
|
||||
//
|
||||
// 调用时机分三种(进入/通关/离开),三者能拿到的字段不完全一样,所以缺的就留空,
|
||||
// 不为了凑字段去补一次仓储查询——副本埋点位于玩法主路径上,多查一次库就是给
|
||||
// 玩家多一次卡顿。
|
||||
func dungeonRuntimeFields(runtime *runtimeDungeonState) map[string]any {
|
||||
fields := map[string]any{}
|
||||
if runtime == nil {
|
||||
return fields
|
||||
}
|
||||
if runtime.Dungeon.ID != 0 {
|
||||
fields["dungeon_id"] = runtime.Dungeon.ID
|
||||
}
|
||||
if name := strings.TrimSpace(runtime.Dungeon.Metadata.Name); name != "" {
|
||||
fields["dungeon_name"] = name
|
||||
}
|
||||
if path := strings.TrimSpace(runtime.Dungeon.Path); path != "" {
|
||||
fields["dungeon_path"] = path
|
||||
}
|
||||
if runtime.clientDungeonID != 0 && runtime.clientDungeonID != runtime.Dungeon.ID {
|
||||
fields["client_dungeon_id"] = runtime.clientDungeonID
|
||||
}
|
||||
fields["maze_index"] = runtime.MazeIndex
|
||||
fields["difficulty"] = int(runtime.Request.Difficulty)
|
||||
fields["request_dungeon_id"] = runtime.Request.DungeonID
|
||||
fields["selection_mode"] = runtime.Request.SelectionMode
|
||||
fields["entry_option"] = runtime.Request.EntryOption
|
||||
fields["party_state"] = runtime.Request.PartyState
|
||||
if runtime.lifecycleToken != 0 {
|
||||
fields["lifecycle_token"] = runtime.lifecycleToken
|
||||
}
|
||||
if runtime.Character.CharacterID != "" {
|
||||
if level := runtime.Character.Level; level != 0 {
|
||||
fields["character_level"] = level
|
||||
}
|
||||
}
|
||||
fields["practice"] = currentDungeonRuntimeIsPractice(runtime)
|
||||
fields["tutorial"] = isPVFTutorialDungeon(runtime)
|
||||
if !runtime.startedAt.IsZero() {
|
||||
fields["started_at"] = runtime.startedAt.UTC().Format(time.RFC3339)
|
||||
fields["elapsed_seconds"] = int64(time.Since(runtime.startedAt).Seconds())
|
||||
}
|
||||
if runtime.clearKillNormal != 0 || runtime.clearKillChampion != 0 || runtime.clearKillBoss != 0 {
|
||||
fields["killed_normal"] = runtime.clearKillNormal
|
||||
fields["killed_champion"] = runtime.clearKillChampion
|
||||
fields["killed_boss"] = runtime.clearKillBoss
|
||||
}
|
||||
// 技能消耗的无色小晶块(判断外挂的关键指标之一)。
|
||||
// 只在真发生过时输出,避免每条进入记录都挂一个 0;晶体契约的自动扣减不计入。
|
||||
if runtime.colorlessSpent != 0 {
|
||||
fields["colorless_spent"] = runtime.colorlessSpent
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
// recordDungeonEnter 记一条"进入副本"。
|
||||
func (s *Service) recordDungeonEnter(session *gameSession, runtime *runtimeDungeonState) {
|
||||
if s == nil || runtime == nil {
|
||||
return
|
||||
}
|
||||
s.recordDungeon(session, "enter", dungeonRuntimeFields(runtime))
|
||||
}
|
||||
|
||||
// recordDungeonClear 记一条"通关"。
|
||||
//
|
||||
// 只记一次由调用方保证:函数内部不持任何去重状态,落到运行时字段上的
|
||||
// "结算前置屏障"(tutorialCompletionPersisted / clear-map 阶段 A)才是唯一判据。
|
||||
func (s *Service) recordDungeonClear(session *gameSession, runtime *runtimeDungeonState, source string) {
|
||||
if s == nil || runtime == nil {
|
||||
return
|
||||
}
|
||||
fields := dungeonRuntimeFields(runtime)
|
||||
fields["clear_source"] = source
|
||||
if runtime.Session != nil {
|
||||
snapshot := runtime.Session.Snapshot()
|
||||
fields["run_status"] = string(snapshot.Run.Status)
|
||||
fields["room"] = snapshot.Run.Current.String()
|
||||
if snapshot.Scene.Map.Map.ID != 0 {
|
||||
fields["map_id"] = snapshot.Scene.Map.Map.ID
|
||||
}
|
||||
fields["defeated_actor_count"] = len(snapshot.Scene.DefeatedObjects)
|
||||
}
|
||||
if runtime.settlementMonsterExperienceTotal != 0 {
|
||||
fields["monster_experience_total"] = runtime.settlementMonsterExperienceTotal
|
||||
}
|
||||
s.recordDungeon(session, "clear", fields)
|
||||
}
|
||||
|
||||
// recordCompletedReturnOutcome writes the outcome of a completed-dungeon 返回城镇
|
||||
// into the GM dungeon record file (runtime/logs/records/dungeon-*.jsonl).
|
||||
//
|
||||
// It deliberately avoids the two obvious channels:
|
||||
//
|
||||
// - logGameEvent goes to packet_log.txt, which DROPS LINES under load. Measured
|
||||
// on the live file 2026-09-25T07:00Z for conn game-003214: 99 of 855 adjacent
|
||||
// pkt_seq pairs were discontinuous, i.e. ~25% of that connection's lines never
|
||||
// landed. Five live 返回城镇 hangs (2026-09-24T17:07:49Z, 2026-09-25T05:25:22Z,
|
||||
// 05:25:23Z, 06:55:10Z, 07:10:01Z) were unattributable for exactly that reason:
|
||||
// every guard on the completed-return path is either silent or packet-log-only,
|
||||
// so "the marker is absent" proved nothing.
|
||||
// - logWarn goes to the slog logger, but on this deployment the server is started
|
||||
// by the one-click launcher and its stdout is not captured at all
|
||||
// (runtime/logs contains no server-*.stdout.log), so a warning there is
|
||||
// invisible.
|
||||
//
|
||||
// The activity recorder's queue blocks rather than drops in normal operation
|
||||
// (activity_record.go: the second select waits for room and only gives up while the
|
||||
// recorder is shutting down), so this is the one channel that survives both.
|
||||
func (s *Service) recordCompletedReturnOutcome(session *gameSession, outcome string, fields ...any) {
|
||||
if s == nil || session == nil {
|
||||
return
|
||||
}
|
||||
merged := map[string]any{"outcome": outcome}
|
||||
for index := 0; index+1 < len(fields); index += 2 {
|
||||
key, ok := fields[index].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
merged[key] = fields[index+1]
|
||||
}
|
||||
s.recordDungeon(session, "return-outcome", merged)
|
||||
}
|
||||
|
||||
// recordDungeonExit 记一条"离开副本"。
|
||||
//
|
||||
// reason 是运行时自己的生命周期结束原因(返回城镇、死亡回城、被新一次选本替换……),
|
||||
// 排障时它比"玩家走了"有用得多。
|
||||
func (s *Service) recordDungeonExit(session *gameSession, runtime *runtimeDungeonState, reason string) {
|
||||
if s == nil || runtime == nil {
|
||||
return
|
||||
}
|
||||
fields := dungeonRuntimeFields(runtime)
|
||||
fields["exit_reason"] = strings.TrimSpace(reason)
|
||||
fields["cleared"] = runtime.ordinaryFinalRoomClearAccepted || runtime.tutorialFinalRoomClearAccepted ||
|
||||
runtime.bossDieCheckAccepted || runtime.settlementEntrySent
|
||||
fields["settlement_entry_sent"] = runtime.settlementEntrySent
|
||||
fields["settlement_result_sent"] = runtime.settlementResultNoticeSent
|
||||
fields["town_return_sent"] = runtime.townReturnOp24Sent
|
||||
s.recordDungeon(session, "exit", fields)
|
||||
}
|
||||
|
||||
// ================= GM 查询接口 =================
|
||||
|
||||
// GMRecordQuery 记录查询条件(GM 接口入参)。
|
||||
//
|
||||
// Offset 与 Limit 一起构成分页:Limit 是单次返回上限(上限 2000,一次请求不可能把
|
||||
// 全部记录吐出来),Offset 是跳过最新的多少条命中。要"看全部"就翻页,直到
|
||||
// 返回的 Limited=false。
|
||||
type GMRecordQuery struct {
|
||||
Kind string
|
||||
Limit int
|
||||
Offset int
|
||||
Keyword string
|
||||
CharacterID string
|
||||
CharacterName string
|
||||
AccountID string
|
||||
Since time.Time
|
||||
Until time.Time
|
||||
}
|
||||
|
||||
// GMRecordPage 记录查询结果。
|
||||
type GMRecordPage struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
Dir string `json:"dir"`
|
||||
Records []map[string]any `json:"records"`
|
||||
Count int `json:"count"`
|
||||
Scanned int `json:"scanned"`
|
||||
// Offset 是本次返回的起始位置(跳过的最新命中条数),调用方翻页时回填。
|
||||
Offset int `json:"offset"`
|
||||
// Limited=true 表示"还有更多记录"(本次是装满一页返回的),可以继续用
|
||||
// Offset+Count 作为下一页起点再查。
|
||||
Limited bool `json:"limited"`
|
||||
Retention int `json:"retention_files"`
|
||||
}
|
||||
|
||||
// GMRecordKindInfo 一个类别的元信息。
|
||||
type GMRecordKindInfo struct {
|
||||
Kind string `json:"kind"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
// GMRecordMeta 记录系统元信息(目录、类别、文件列表)。
|
||||
type GMRecordMeta struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Dir string `json:"dir"`
|
||||
Kinds []GMRecordKindInfo `json:"kinds"`
|
||||
Files []recordFileInfo `json:"files"`
|
||||
}
|
||||
|
||||
// GMRecordMeta 返回记录目录与文件清单,供管理端展示"记录落在哪、有多大"。
|
||||
func (s *Service) GMRecordMeta() GMRecordMeta {
|
||||
meta := GMRecordMeta{Enabled: false, Kinds: make([]GMRecordKindInfo, 0, len(recordKinds))}
|
||||
for _, kind := range recordKinds {
|
||||
meta.Kinds = append(meta.Kinds, GMRecordKindInfo{Kind: kind, Title: recordKindTitles[kind]})
|
||||
}
|
||||
recorder := s.records.Load()
|
||||
if recorder == nil {
|
||||
meta.Dir = s.options.recordDir
|
||||
return meta
|
||||
}
|
||||
meta.Enabled = true
|
||||
meta.Dir = recorder.Dir()
|
||||
meta.Files = recorder.listRecordFiles()
|
||||
return meta
|
||||
}
|
||||
|
||||
// GMQueryRecords 检索记录。kind 必须是三类之一,否则返回 ErrGMInvalidRequest。
|
||||
func (s *Service) GMQueryRecords(query GMRecordQuery) (GMRecordPage, error) {
|
||||
query.Kind = strings.ToLower(strings.TrimSpace(query.Kind))
|
||||
if !isRecordKind(query.Kind) {
|
||||
return GMRecordPage{}, fmt.Errorf("%w: 未知的记录类别 %q(可选:%s)",
|
||||
ErrGMInvalidRequest, query.Kind, strings.Join(recordKinds, " / "))
|
||||
}
|
||||
page := GMRecordPage{
|
||||
Kind: query.Kind,
|
||||
Title: recordKindTitles[query.Kind],
|
||||
Retention: recordRetentionPerKind,
|
||||
}
|
||||
recorder := s.records.Load()
|
||||
if recorder == nil {
|
||||
// 记录器没起来(目录不可写/服务未启动)不是错误:返回空页,让调用方看到 dir 提示。
|
||||
page.Dir = s.options.recordDir
|
||||
page.Records = []map[string]any{}
|
||||
return page, nil
|
||||
}
|
||||
page.Dir = recorder.Dir()
|
||||
limit := query.Limit
|
||||
if limit <= 0 {
|
||||
limit = 200
|
||||
}
|
||||
offset := query.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
records, scanned, err := recorder.queryRecords(recordQuery{
|
||||
Kind: query.Kind,
|
||||
Limit: limit,
|
||||
Offset: offset,
|
||||
Since: query.Since,
|
||||
Until: query.Until,
|
||||
CharacterID: strings.TrimSpace(query.CharacterID),
|
||||
CharacterName: strings.TrimSpace(query.CharacterName),
|
||||
AccountID: strings.TrimSpace(query.AccountID),
|
||||
Keyword: strings.TrimSpace(query.Keyword),
|
||||
})
|
||||
if err != nil {
|
||||
return GMRecordPage{}, err
|
||||
}
|
||||
page.Records = records
|
||||
page.Count = len(records)
|
||||
page.Scanned = scanned
|
||||
page.Offset = offset
|
||||
// limited = "因为装满一页而停",用来告诉调用方还有更多(可以带着新的 offset 继续翻页,
|
||||
// 或者缩小时间范围再查)。
|
||||
page.Limited = len(records) >= limit
|
||||
return page, nil
|
||||
}
|
||||
Reference in new issue
Block a user