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

484 lines
21 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 (
"fmt"
"hash/fnv"
"math"
"sort"
"longheng.io/server/internal/modules/dnf/raid"
)
// 卢克攻坚战一阶段的塔罗牌显示包:opcode 1181 (0x49D),
// LUKE_RAID_CARD_DISPLAY。客户端侧没有对应的上行请求包(名字表里含 LUKE 的
// 包全是 ENUM_NOTIPACKET_*),1181 只能由服务端推;逐字段逆向见
// tools\_rev_卢克塔罗牌_1181规格_20260929.md。
//
// 下发时机(官方系统说明,2017-06-22 国服开放卢克团队模式的更新公告):
//
// 「塔罗卡牌在团队频道入场时可以看见1次,之后可以在攻坚战进行中的状况表上确认。」
// 「塔罗卡牌以卢克团队模式进行的当日6点为基准进行变更,所有频道的塔罗卡牌统一相同。」
//
// ⇒ 推送点 = 成员走进卢克攻坚队待机室的那一次,每个成员每次进入恰好一包,
// openWindowFlag = 1(自动弹窗);牌面随后留在客户端本地,战况表自己重开窗口,
// 所以服务端**不重连补发、不周期重发**——这一点是有意的:那会变成刷屏源,
// 而且客户端没有任何字段能区分「补发」与「今天的牌变了」。
//
// 牌面本身官方未公开选牌规则(客户端不校验 P、不去重、没有「未翻开」哨兵,
// 见规格 §8.3),下面这套选法是**本实现的约定**,不是逆向结论。
// lukeCardSlotsPerPattern 是一个图案的卡位数 = `[CARD POS]` 的 (x,y) 对数,也就是
// 包体里每条记录的 K。出货 PVF 的 8 个图案全部是 7(规格 §5.2.1;客户端侧的上界
// 就是 `sar edx,3` 数出来的 `[CARD POS]` 对数,规格 §7.4)。它不是发出去的常量:
// K 实际从解析到的 `[CARD POS]` 数来,这里只做一致性守卫——形状与已逆向的包体
// 不一致时宁可不发(fail closed)。客户端按该对数遍历、并与包内 P 列表同步推进,
// 所以两者长度必须一致。
const lukeCardSlotsPerPattern = 7
// runtimeRaidLukeTarotSanctums 是一阶段三个圣所的发包顺序。顺序本身客户端不校验,
// 用 luke.etc [DEFAULT OBJECT COUNT] 的书写顺序(诞生/湮灭/扩散)。
var runtimeRaidLukeTarotSanctums = []uint32{
lukeBirthSanctumMapID,
lukeExtinctionSanctumMapID,
lukeSpreadSanctumMapID,
}
// runtimeRaidLukeCardSetRecordCount 是 luke.etc `[CARD SET MAP INFO]` 的记录条数。
// 该段是扁平列表(出货 15 个数:0 0 0 3 3 3 6 6 6 9 9 9 12 12 12),客户端按每 3 个
// 数字 push 进 3 字节元素的向量,1181 的字段 1 正是拿这个条数做上界(规格 §1.1/§8.1.2)。
// 定义点与迷宫侧共用(runtimeRaidLukeTarotCardSetRecords)。
func runtimeRaidLukeCardSetRecordCount(cardSetMapInfo []int) int {
return runtimeRaidLukeTarotCardSetRecords(cardSetMapInfo)
}
// runtimeRaidLukeTarotCardSetIndex 是「当天那一套牌」的下标(0..记录条数-1)。
//
// 它只依赖 tarot day:官方文案写明全频道统一,且客户端只把它当合法性字节用
// (越界则整包丢弃,之后再无引用,规格 §8.1.1),所以它既不带圣所也不带攻坚队。
// **它与 raid_luke_sanctum_maze.go 用的是同一个抽取器**:1181 里这个字节代表的那条
// [CARD SET MAP INFO] 记录,正是当天三个圣所走的迷宫组所在的那条 —— 一个概念只留
// 一个数字,免得日后有人以为"显示用的套"和"实际走的组"是两回事。
func runtimeRaidLukeTarotCardSetIndex(raidDay int, cardSetMapInfo []int) (int, int) {
records := runtimeRaidLukeCardSetRecordCount(cardSetMapInfo)
if records <= 0 {
return 0, 0
}
return runtimeRaidLukeTarotCardSetRecord(raidDay, records), records
}
// runtimeRaidLukeTarotPatternIndex 选一个圣所今天启动哪一套防御体系(包体字段 B,
// 即 `[PATTERN] <地牢ID> <图案序号>` 的序号)。
//
// 官方文案:「卢克手下的怪物们以塔罗牌的形式展现,且分配在源生宫殿(1阶段)的各个
// 地下城中。随着塔罗牌分配的形态不同,各个地下城的防御体系启动。」⇒ B 就是
// 「该圣所今天启动哪套防御体系」,其 `[TOOLTIP TEXT]` 即那套系统的名字。
// 出货 PVF 里:5054 → 属性值弱化系统 / 防御体系;5055 → 元素防御系统(冰/光/火/暗);
// 5056 → 超合金装甲系统 / 反对魔法系统。
//
// 本实现的约定:沿用既有的每日抽取器 runtimeRaidLukeTarotGroup(day, 地牢ID, n),
// 即同一天同一圣所全服取同一套(与「所有频道统一」一致),换日即变。
// **本次不动它的选法**:「每天一套牌」怎么映射到各圣所各自的图案序号没有证据
// (各圣所 [PATTERN] 段数是 2/4/2,不是同一张表的三个分量),所以 B 保持按
// (day, 地牢ID) 抽;改的只是"走哪组迷宫"。
func runtimeRaidLukeTarotPatternIndex(raidDay int, dungeonID uint32, patterns []int) (int, bool) {
if len(patterns) == 0 {
return 0, false
}
return patterns[runtimeRaidLukeTarotGroup(raidDay, int(dungeonID), len(patterns))], true
}
// runtimeRaidLukeTarotCards 从一个圣所那份 22 张大阿卡纳里选 7 张互不重复的牌。
//
// **本实现的约定**(官方选牌规则未知,客户端也不校验、不去重):
// 按 (tarot day, 圣所地牢 ID, 图案序号) 对每张大阿卡纳做一次确定性哈希,按键升序取前
// want 张。因此同一场攻坚队、同一天、同一圣所每次算出同一批牌(可复现、可对账),
// 换一天就换一批;不同圣所、不同图案也各自不同。哈希键里带卡号本身,所以天然不会重复。
func runtimeRaidLukeTarotCards(raidDay int, dungeonID uint32, pattern int, cardIDs []uint32, want int) []uint32 {
if want <= 0 || len(cardIDs) < want {
return nil
}
type rankedCard struct {
score uint32
cardID uint32
}
ranked := make([]rankedCard, 0, len(cardIDs))
for _, cardID := range cardIDs {
hash := fnv.New32a()
_, _ = fmt.Fprintf(hash, "luke-tarot-card:%d:%d:%d:%d", raidDay, dungeonID, pattern, cardID)
ranked = append(ranked, rankedCard{score: hash.Sum32(), cardID: cardID})
}
sort.Slice(ranked, func(left, right int) bool {
if ranked[left].score != ranked[right].score {
return ranked[left].score < ranked[right].score
}
// 同分时按卡号定序,保证结果与卡表顺序无关(可复现)。
return ranked[left].cardID < ranked[right].cardID
})
cards := make([]uint32, 0, want)
for _, entry := range ranked[:want] {
cards = append(cards, entry.cardID)
}
return cards
}
// lukeCardEmptySlot is what a slot carries when the day's mazes do not fill it.
//
// The client draws one arcanum per slot by looking the value up in its own
// `[CARD]` table and **silently draws nothing for a value that misses** (see
// raid.LukeCardSetConfig.HasCard). All 22 shipped keys are five-digit monster
// ids, so the inactive-id sentinel can never collide with one, and a short day
// therefore leaves a quietly empty slot instead of a wrong card. Zero would be
// the other natural marker, but BuildLukeCardDisplayBody rejects it as a shape
// error, so the empty slot has to be a real u32 that simply is not a card.
const lukeCardEmptySlot uint32 = math.MaxUint32
// runtimeRaidLukeTarotCardsFromMazeMonsters derives a sanctum's card slots from
// the monsters that sanctum's three mazes really spawn on the day's group.
//
// The tarot faces are not decorative: every `[CARD] <monsterID>` block in
// LukeCardSet.etc is one major arcanum whose tooltip names the Luke monster it
// belongs to (00 愚者_红色小丑 59171, 02 女祭司_量产型贝奇 59873, 10 命运之轮
// 56673, 16 塔_全金属机甲斗神 56063 ...), and the value the notification carries
// is that monster id. The client owns the mapping, so naming the monsters the
// run will actually fight is exactly what makes the board show the day's real
// faces.
//
// Order: the sanctum's mazes in maze order (groupStart, +1, +2), and inside one
// maze the monster order the document lists ([NAMED] then [BOSS], see
// LukeRaidMonsterListMaze). Monsters that are not [CARD] keys are skipped, and a
// monster already used keeps its first slot, so the seven slots stay distinct.
//
// ⚠ 未证实约定:「第 n 只怪填第 n 格」没有任何客户端证据 —— 客户端只是把 7 个
// 值逐个查表画出来,槽位与怪的对应关系官方没有公开。这里按最自然的顺序填充
// (迷宫顺序 → 迷宫内出场顺序),它的可验证后果只有一条:**牌面集合**必须是当天
// 真会刷的那几只。出货 PVF 里每个组只能推出 5 只带 [CARD] 的怪
// (3 个迷宫 = 1 组中 boss + 1 只 named + 1 个圣所 boss,第三迷宫与第一迷宫共用
// 中 boss),所以 7 格里固定有 2 格是 lukeCardEmptySlot。
//
// nil when the day yields no arcanum at all: 调用方必须**不发包**(失败关闭),
// 不能发一包 7 个空槽的牌面。A non-nil result always has exactly want entries.
func runtimeRaidLukeTarotCardsFromMazeMonsters(
dungeonID uint32,
groupStart int,
monsters *raid.LukeRaidMonsterListConfig,
cardSet *raid.LukeCardSetConfig,
want int,
) []uint32 {
if want <= 0 || monsters == nil || cardSet == nil || groupStart < 0 {
return nil
}
if _, ok := monsters.Dungeon(dungeonID); !ok {
return nil
}
cards := make([]uint32, 0, want)
used := make(map[uint32]bool, want)
for maze := groupStart; maze < groupStart+lukeSanctumClearsPerGroup && len(cards) < want; maze++ {
monsterIDs, ok := monsters.MazeMonsters(dungeonID, maze)
if !ok {
// This sanctum does not declare that maze; the group is only ever
// accepted when all three fit, so this is a table that changed
// shape. Stop rather than read a neighbour's monsters.
break
}
for _, monsterID := range monsterIDs {
if len(cards) >= want {
break
}
if used[monsterID] || !cardSet.HasCard(monsterID) {
continue
}
used[monsterID] = true
cards = append(cards, monsterID)
}
}
if len(cards) == 0 {
return nil
}
for len(cards) < want {
cards = append(cards, lukeCardEmptySlot)
}
return cards
}
// runtimeRaidLukeTarotGroupStart is the group of mazes a sanctum walks on one
// tarot day: the day's shared [CARD SET MAP INFO] record's own component, with
// the old candidate list as the fallback for a table whose shape does not
// address a whole group (same relationship runtimeRaidLukeSanctumMazeDrawnGroup
// has at dungeon entry). The fallback index is the tarot-day hash rather than a
// dice roll, so the card display stays deterministic within a day.
func runtimeRaidLukeTarotGroupStart(
cardSetMapInfo []int,
record int,
dungeonID int,
mazeCount int,
raidDay int,
) (int, bool) {
if mazeCount >= lukeSanctumClearsPerGroup {
if start, ok := runtimeRaidLukeSanctumMazeRecordGroup(cardSetMapInfo, record, dungeonID, mazeCount); ok {
return start, true
}
}
groups := runtimeRaidLukeSanctumMazeGroups(cardSetMapInfo, mazeCount)
if len(groups) == 0 {
return 0, false
}
return groups[runtimeRaidLukeTarotCardSetRecord(raidDay, len(groups))], true
}
// runtimeRaidLukeTarotCardsForSanctum is one sanctum's seven slots for one tarot
// day: resolve the day's group for this sanctum, then read the monsters that
// group really spawns. nil means "this sanctum's face cannot be described
// today", which the caller turns into "do not send the packet at all".
func runtimeRaidLukeTarotCardsForSanctum(
dungeonID uint32,
cardSetMapInfo []int,
record int,
raidDay int,
monsters *raid.LukeRaidMonsterListConfig,
cardSet *raid.LukeCardSetConfig,
want int,
) []uint32 {
if want <= 0 || cardSet == nil || monsters == nil {
return nil
}
mazeCount, ok := monsters.MazeCount(dungeonID)
if !ok {
return nil
}
groupStart, ok := runtimeRaidLukeTarotGroupStart(cardSetMapInfo, record, int(dungeonID), mazeCount, raidDay)
if !ok {
return nil
}
return runtimeRaidLukeTarotCardsFromMazeMonsters(dungeonID, groupStart, monsters, cardSet, want)
}
// runtimeRaidLukeCardDisplayBody 组出今天这一包 1181 的包体。
//
// ok=false 表示「今天这套牌描述不出来」,调用方必须**不发包**:配置缺失、
// 图案缺失、卡位对数不是 7、大阿卡纳不足 7 张,任何一种都不发半截包。
//
// cardSetMapInfo 是 luke.etc 的 `[CARD SET MAP INFO]`(它同时决定字段 1 的上界和
// 三个圣所今天走的迷宫组所在的那条记录,见 raid_luke_sanctum_maze.go)。
// monsters 是 luke.etc.buff 的 `[RAID MONSTER LIST]`,牌面由**当天这三个圣所
// 真会刷的怪**推出(runtimeRaidLukeTarotCardsFromMazeMonsters)。
// openWindow 直接写进包体末字节:只有"本 session 第一次落到团队频道"那一包为 true。
func runtimeRaidLukeCardDisplayBody(
cardSetMapInfo []int,
cardSet *raid.LukeCardSetConfig,
monsters *raid.LukeRaidMonsterListConfig,
raidDay int,
openWindow bool,
) ([]byte, bool, error) {
if cardSet == nil {
return nil, false, nil
}
cardSetIndex, records := runtimeRaidLukeTarotCardSetIndex(raidDay, cardSetMapInfo)
if records <= 0 {
return nil, false, nil
}
display := raid.LukeCardDisplay{
CardSetIndex: byte(cardSetIndex),
CardSetCount: records,
Sanctums: make([]raid.LukeCardDisplaySanctum, 0, len(runtimeRaidLukeTarotSanctums)),
OpenWindow: openWindow,
}
for _, dungeonID := range runtimeRaidLukeTarotSanctums {
patterns := cardSet.PatternIndices(dungeonID)
patternIndex, ok := runtimeRaidLukeTarotPatternIndex(raidDay, dungeonID, patterns)
if !ok {
return nil, false, nil
}
pattern, found := cardSet.Pattern(dungeonID, patternIndex)
if !found || len(pattern.CardPositions) != lukeCardSlotsPerPattern {
return nil, false, nil
}
cards := runtimeRaidLukeTarotCardsForSanctum(
dungeonID,
cardSetMapInfo,
cardSetIndex,
raidDay,
monsters,
cardSet,
len(pattern.CardPositions),
)
if cards == nil {
// 牌面表在、却一张都推不出来 = 今天这组怪没有一张是 [CARD],这属于
// 「描述不出来」:失败关闭,不发半截包。
return nil, false, nil
}
display.Sanctums = append(display.Sanctums, raid.LukeCardDisplaySanctum{
DungeonID: dungeonID,
Pattern: patternIndex,
Cards: cards,
})
}
body, err := raid.BuildLukeCardDisplayBody(display)
if err != nil {
return nil, false, err
}
return body, true, nil
}
// runtimeRaidLukeCardDisplayPopupOwed reports whether this login session still
// owes the one pop-up the official behaviour grants
// ("每次登陆后,第一次进入卢克 RAID 频道时…自动展示一次塔罗牌"). The ledger is
// keyed by tarot day, because the packet itself does not depend on a raid.
// Read-only: the ledger is only spent by markRuntimeRaidLukeCardDisplayPopupSpent,
// and only after the packet actually went out with the flag set.
func runtimeRaidLukeCardDisplayPopupOwed(session *gameSession, tarotDay int) bool {
if session == nil {
return false
}
session.lukeCardDisplayMu.Lock()
defer session.lukeCardDisplayMu.Unlock()
return !session.lukeCardDisplayPoppedDays[tarotDay]
}
// markRuntimeRaidLukeCardDisplayPopupSpent spends this session's one pop-up for
// this tarot day. The lock is a leaf and is released before returning.
func markRuntimeRaidLukeCardDisplayPopupSpent(session *gameSession, tarotDay int) {
if session == nil {
return
}
session.lukeCardDisplayMu.Lock()
if session.lukeCardDisplayPoppedDays == nil {
session.lukeCardDisplayPoppedDays = make(map[int]bool, 2)
}
session.lukeCardDisplayPoppedDays[tarotDay] = true
session.lukeCardDisplayMu.Unlock()
}
// publishRuntimeRaidLukeCardDisplay 给一名玩家发一包 1181(今天的塔罗牌面)。
// 它自己吞掉全部错误并只记日志:频道入场不能被一张牌面拖垮。
//
// 官方行为(服主 2026-09-29 口径):
//
// 「玩家在每次登陆后,第一次进入卢克 RAID 频道时,离开赛利亚旅馆会自动展示
// 一次塔罗牌。」
//
// 所以这个包**不依赖攻坚队**。它的数据全部来自 PVF 的**全局配置**,与任何
// runtimeRaidState 都无关:
//
// 当天那套牌 = runtimeRaidLukeTarotCardSetRecord(tarotDay, 记录条数)
// (全服统一,只按日期)
// 牌面 = 当天那条 [CARD SET MAP INFO] 记录 + LukeCardSet.etc 的 22 张大
// 阿卡纳 + luke.etc.buff [RAID MONSTER LIST] 里当天那三个迷宫会刷的怪
//
// 前者来自 luke.etc(服务级缓存 runtimeRaidLukeConfig),后两者来自各自的
// PVF(runtimeRaidLukeCardSetConfig / runtimeRaidLukeRaidMonsterListConfig)。
// 这里**不再**读 state.LukeConfig —— 那份配置只有在团里才有,而官方规则说的是
// 进频道,不是进团(线上 2026-09-29T12:50:46Z 那条
// `..._skipped reason=luke_team_channel_entry_without_a_raid` 就是"没团"被跳过)。
//
// 账本是**每个登录会话、每个 tarot day 一次**(不是每个团一次):
// - 本次登录第一次进入卢克频道城镇 → open_window=1(弹窗);
// - 同一次登录里之后再进(换城镇回来、重连走同一条路、op36 进待机室、通关返回
// 待机室)→ 仍然发,但 open_window=0:数据保持新鲜,不再打扰;
// - 重新登录 = 新 session = 空账本 → 再弹一次。这正是"每次登陆后…一次"。
//
// raidKey 的两种取值(三条真实入口共用同一份账本):
// - 非 0:调用点已知这个团 —— op36 走进攻坚队待机室(handleTownSetUserArea)、
// 通关后落回待机室(restoreCompletedRaidWaitingPresence)。必须确实是本人的
// 卢克团,安徒恩一个字都不改(单人卢克也算:见下)。
// - 0:与团无关的入口 —— currentRaidTownTransition 落进卢克频道城镇。
// **没团也发**:官方规则只要求"人在卢克频道"。
//
// 单人模式(TailFlag == 1)不再特殊对待:官方规则的门槛是"进卢克 RAID 频道",
// 与团的编制无关;这条守卫生来是因为牌面过去取自团配置,现在不取了。
func (s *Service) publishRuntimeRaidLukeCardDisplay(session *gameSession, raidKey uint32, source string) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return
}
characterID := session.selectedCharacterID
if raidKey != 0 {
state, member := s.runtimeRaidForMember(characterID)
if !member || state.RaidKey == 0 || state.RaidKey != raidKey {
return
}
// 安徒恩不进这里:塔罗牌面只属于卢克。
if state.RaidMode != runtimeRaidOwnerLuke {
return
}
} else if !isCurrentLukeRaidChannel(session) {
// 与团无关的入口必须**确实是卢克频道**才发,安徒恩频道一个字都不改。
return
}
// luke.etc 的 [CARD SET MAP INFO]:全局配置,不是某个团的配置。
lukeConfig, err := s.runtimeRaidLukeConfig()
if err != nil {
s.logPacketEvent("game-raid-luke-card-display-skipped",
"char_id", characterID,
"raid_key", raidKey,
"msg_id", int(raid.LukeCardDisplayMsgID),
"source", source,
"reason", "luke_config_pvf_unavailable",
"error", err)
return
}
cardSet, err := s.runtimeRaidLukeCardSetConfig()
if err != nil {
s.logPacketEvent("game-raid-luke-card-display-skipped",
"char_id", characterID,
"raid_key", raidKey,
"msg_id", int(raid.LukeCardDisplayMsgID),
"source", source,
"reason", "card_set_pvf_unavailable",
"error", err)
return
}
monsters, err := s.runtimeRaidLukeRaidMonsterListConfig()
if err != nil {
s.logPacketEvent("game-raid-luke-card-display-skipped",
"char_id", characterID,
"raid_key", raidKey,
"msg_id", int(raid.LukeCardDisplayMsgID),
"source", source,
"reason", "raid_monster_list_pvf_unavailable",
"error", err)
return
}
raidDay := runtimeRaidLukeTarotDay(s.gameplayNow())
openWindow := runtimeRaidLukeCardDisplayPopupOwed(session, raidDay)
body, ok, err := runtimeRaidLukeCardDisplayBody(lukeConfig.CardSetMapInfo, cardSet, monsters, raidDay, openWindow)
if err != nil || !ok || len(body) == 0 {
s.logPacketEvent("game-raid-luke-card-display-skipped",
"char_id", characterID,
"raid_key", raidKey,
"msg_id", int(raid.LukeCardDisplayMsgID),
"tarot_day", raidDay,
"source", source,
"reason", "card_display_not_describable",
"error", err)
return
}
if err := s.sendGameUpperRawClassCodec(session, raid.LukeCardDisplayMsgID, body, 0, true); err != nil {
s.logPacketEvent("game-raid-luke-card-display-send-failed",
"char_id", characterID,
"raid_key", raidKey,
"msg_id", int(raid.LukeCardDisplayMsgID),
"tarot_day", raidDay,
"body_len", len(body),
"source", source,
"error", err)
return
}
// 只有真正出去了才花掉这一天这一次的弹窗额度:发失败的那一包不算"看见过"。
if openWindow {
markRuntimeRaidLukeCardDisplayPopupSpent(session, raidDay)
}
s.logPacketEvent("game-raid-luke-card-display-send",
"char_id", characterID,
"raid_key", raidKey,
"msg_id", int(raid.LukeCardDisplayMsgID),
"tarot_day", raidDay,
// card_set_index 是包体字段 1;card_set_records 是它被校验的上界,也就是
// luke.etc [CARD SET MAP INFO] 的记录条数(出货数据 = 15/3 = 5);packet_records
// 是包体字段 N(本包几条圣所记录,出货数据 = 3)。三个名字以前混用过,别再合并。
"card_set_index", int(body[0]),
"card_set_records", runtimeRaidLukeCardSetRecordCount(lukeConfig.CardSetMapInfo),
"packet_records", int(body[1]),
"open_window", int(body[len(body)-1]),
"body_len", len(body),
"source", source)
}