Files
90CNServer/server/internal/modules/dnf/raid/luke_card_display.go
T
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

148 lines
6.4 KiB
Go

package raid
import (
"errors"
"fmt"
"math"
)
// LukeCardDisplayMsgID is the tarot display notification the client's
// CardSetWindow is driven by: opcode 1181 (0x49D), LUKE_RAID_CARD_DISPLAY.
//
// It is a server push. The name table lists no C2S counterpart -- every
// LUKE_RAID_* packet is an ENUM_NOTIPACKET_* -- and the handler at 0x010CDB90
// is registered by 0x010CDF50, so nothing the client sends can produce one.
const LukeCardDisplayMsgID uint16 = 1181
// The body is a single record with no outer count:
//
// u8 cardSetIndex index of the day's [CARD SET MAP INFO] record; the
// client only checks it against the record count and
// drops the WHOLE packet when it is out of range
// u8 N number of card records (one per phase-one sanctum = 3)
// N x {
// u16 A sanctum dungeon id (5054 / 5055 / 5056)
// u8 B that sanctum's [PATTERN] index in LukeCardSet.etc
// u8 K card slots of that pattern = its [CARD POS] pair count
// K x {
// u32 P the tarot card of this slot: a [CARD] monster id
// u8 Q read but never consumed by this client build; must be
// sent, or every following field shifts
// }
// }
// u8 openWindowFlag non-zero pops the CardSetWindow on its own
//
// Field widths are pinned by the handler's read helpers: 1 byte 0x34E3970,
// 2 bytes 0x34E39E0, 4 bytes 0x34E3A50 (tooling: tools\_rev_卢克塔罗牌_1181规格_20260929.md
// §1.3). There are no coordinates in the body -- the client takes those from
// `[CARD POS]` in LukeCardSet.etc itself.
const (
// lukeCardDisplayMaxRecords bounds N: it is a u8 on the wire.
lukeCardDisplayMaxRecords = 255
// lukeCardDisplaySlotPadding is the Q byte of every slot. This client build
// reads it into [ebp-0x12] and never looks at it again; it is emitted as a
// fixed zero rather than guessed at.
lukeCardDisplaySlotPadding byte = 0
)
var ErrLukeCardDisplayShapeInvalid = errors.New("luke card display shape is invalid")
// LukeCardDisplaySanctum is one card record: which sanctum, which of its
// patterns, and the tarot card sitting in each of that pattern's slots.
type LukeCardDisplaySanctum struct {
// DungeonID is field A. It must be one of the sanctum ids the 1181 handler
// can resolve in LukeCardSet.etc, or the window draws nothing for it.
DungeonID uint32
// Pattern is field B: the index half of that sanctum's `[PATTERN]` key.
Pattern int
// Cards is the P field of every slot, in slot order. Its length is K, and it
// must equal the pattern's `[CARD POS]` pair count: the client walks the slot
// coordinates and the P list together, so a short list reads past the end.
Cards []uint32
}
// LukeCardDisplay is one 1181 body before encoding.
type LukeCardDisplay struct {
// CardSetIndex is the day's `[CARD SET MAP INFO]` record index.
CardSetIndex byte
// CardSetCount is how many such records the client holds, i.e. the exclusive
// upper bound the client validates CardSetIndex against. It comes from
// luke.etc's `[CARD SET MAP INFO]` (5 records = 15 values / 3) and the
// builder refuses to emit a body the client would drop whole.
CardSetCount int
// Sanctums is N records, one per phase-one sanctum, in wire order.
Sanctums []LukeCardDisplaySanctum
// OpenWindow asks the client to open the CardSetWindow itself.
OpenWindow bool
}
// LukeCardDisplayBodySize is the exact body length of that shape, so a caller
// can size a buffer without encoding it first.
func LukeCardDisplayBodySize(sanctums []LukeCardDisplaySanctum) int {
size := 2 + 1 // cardSetIndex, N, openWindowFlag
for _, sanctum := range sanctums {
size += 4 + 5*len(sanctum.Cards) // A, B, K, then K x (u32 P, u8 Q)
}
return size
}
// BuildLukeCardDisplayBody encodes one 1181 body.
//
// It refuses anything the client would silently mis-draw instead of sending a
// half-described packet: an out-of-range cardSetIndex (the client drops the
// whole packet), a non-positive N or K, a dungeon id that does not fit its u16
// field, a zero card id (the client reads a missing key as an empty slot, so a
// zero is always a caller bug), or a card count above the u8 field width.
func BuildLukeCardDisplayBody(display LukeCardDisplay) ([]byte, error) {
if display.CardSetCount <= 0 {
return nil, fmt.Errorf("%w: card_set_count=%d want_positive", ErrLukeCardDisplayShapeInvalid, display.CardSetCount)
}
if int(display.CardSetIndex) >= display.CardSetCount {
return nil, fmt.Errorf("%w: card_set_index=%d card_set_count=%d",
ErrLukeCardDisplayShapeInvalid, display.CardSetIndex, display.CardSetCount)
}
if len(display.Sanctums) == 0 || len(display.Sanctums) > lukeCardDisplayMaxRecords {
return nil, fmt.Errorf("%w: records=%d want=1..%d",
ErrLukeCardDisplayShapeInvalid, len(display.Sanctums), lukeCardDisplayMaxRecords)
}
for index, sanctum := range display.Sanctums {
if sanctum.DungeonID == 0 || sanctum.DungeonID > math.MaxUint16 {
return nil, fmt.Errorf("%w: record=%d dungeon=%d want=1..%d",
ErrLukeCardDisplayShapeInvalid, index, sanctum.DungeonID, math.MaxUint16)
}
if sanctum.Pattern < 0 || sanctum.Pattern > math.MaxUint8 {
return nil, fmt.Errorf("%w: record=%d dungeon=%d pattern=%d want=0..%d",
ErrLukeCardDisplayShapeInvalid, index, sanctum.DungeonID, sanctum.Pattern, math.MaxUint8)
}
if len(sanctum.Cards) == 0 || len(sanctum.Cards) > lukeCardDisplayMaxRecords {
return nil, fmt.Errorf("%w: record=%d dungeon=%d slots=%d want=1..%d",
ErrLukeCardDisplayShapeInvalid, index, sanctum.DungeonID, len(sanctum.Cards), lukeCardDisplayMaxRecords)
}
for slot, cardID := range sanctum.Cards {
if cardID == 0 {
return nil, fmt.Errorf("%w: record=%d dungeon=%d slot=%d card=0",
ErrLukeCardDisplayShapeInvalid, index, sanctum.DungeonID, slot)
}
}
}
writer := packetWriter{buf: make([]byte, 0, LukeCardDisplayBodySize(display.Sanctums))}
writer.writeByte(display.CardSetIndex)
writer.writeByte(byte(len(display.Sanctums)))
for _, sanctum := range display.Sanctums {
writer.writeUint16(uint16(sanctum.DungeonID))
writer.writeByte(byte(sanctum.Pattern))
writer.writeByte(byte(len(sanctum.Cards)))
for _, cardID := range sanctum.Cards {
writer.writeUint32(cardID)
writer.writeByte(lukeCardDisplaySlotPadding)
}
}
var openWindow byte
if display.OpenWindow {
openWindow = 1
}
writer.writeByte(openWindow)
return writer.bytes(), nil
}