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