DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,536 @@
package dnfbridge
import (
"context"
"crypto/sha256"
"encoding/binary"
"encoding/hex"
"os"
"sort"
"strconv"
"strings"
"time"
"unicode/utf16"
dnfcompanion "longheng.io/server/internal/modules/dnf/companion"
dnfequip "longheng.io/server/internal/modules/dnf/equip"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentDungeonCompanionProjectionSourceEnv = "DNF_COMPANION_P0_SOURCE_CHARACTER_ID"
currentDungeonCompanionProjectionOpcode = uint16(0x7F41)
currentDungeonCompanionProjectionMagic = uint32(0x314A5250) // PRJ1
currentDungeonCompanionProjectionVersion = byte(3)
currentDungeonCompanionProjectionFlagPresent = byte(1)
currentDungeonCompanionProjectionFlagAIPlan = byte(4)
currentDungeonCompanionProjectionHeaderSize = 72
currentDungeonCompanionProjectionMaxEquip = 32
currentDungeonCompanionProjectionMaxSkills = 128
currentDungeonCompanionProjectionMaxQuick = 16
currentDungeonCompanionProjectionObjectKey = uint16(0xFFFF)
currentDungeonCompanionMode0ObjectKeyOffset = 0x4C
currentDungeonCompanionMode1ObjectKeyOffset = 0x15
)
func currentDungeonCompanionProjectionSourceIDValue(value string) (uint16, bool) {
value = strings.TrimSpace(value)
if value == "" {
return 0, false
}
parsed, err := strconv.ParseUint(value, 10, 16)
if err != nil || parsed == 0 || parsed >= uint64(^uint16(0)) ||
strconv.FormatUint(parsed, 10) != value {
return 0, false
}
return uint16(parsed), true
}
type currentDungeonCompanionProjectionSkillPair struct {
ID uint32
Level uint32
}
type currentDungeonCompanionProjectionAIPlan struct {
Behavior byte
ComboPresetHash byte
ClassID uint16
ComboEnabled bool
}
func currentDungeonCompanionProjectionAIPlanFor(
profile dnfcompanion.AIProfile,
job byte,
) currentDungeonCompanionProjectionAIPlan {
profile = dnfcompanion.NormalizeAIProfile(profile)
behavior := byte(1)
switch profile.Behavior {
case "aggressive":
behavior = 2
}
classID := profile.ClassID
if classID == 0 {
classID = uint16(job)
}
// Keep the packet compact; the full preset name remains in GM settings.
// The client needs a stable selector until the native action ABI is proved.
var hash byte = 216
for i := 0; i < len(profile.ComboPreset); i++ {
hash ^= profile.ComboPreset[i]
hash *= 167
}
return currentDungeonCompanionProjectionAIPlan{
Behavior: behavior,
ComboPresetHash: hash,
ClassID: classID,
ComboEnabled: profile.ComboEnabled,
}
}
func currentDungeonCompanionProjectionAIPlanWord(
plan currentDungeonCompanionProjectionAIPlan,
) uint32 {
flags := plan.Behavior & 0x03
if plan.ComboEnabled {
flags |= 0x04
}
return uint32(flags) |
uint32(plan.ComboPresetHash)<<8 |
uint32(plan.ClassID)<<16
}
func currentDungeonCompanionEquipmentIDs(
record dnfrepo.EquipmentRecord,
now time.Time,
) ([]uint32, error) {
if now.IsZero() {
return nil, dnfcompanion.ErrSnapshot
}
type equipmentItem struct {
slot int16
id uint32
}
equipmentRows := make([]equipmentItem, 0, len(record.Entries))
slots := make(map[int16]struct{}, len(record.Entries))
for _, entry := range record.Entries {
if entry.SlotIndex < 0 || entry.SlotIndex > 255 ||
entry.ItemID <= 0 || entry.ItemID > int64(^uint32(0)) {
return nil, dnfcompanion.ErrSnapshot
}
if _, exists := slots[entry.SlotIndex]; exists {
return nil, dnfcompanion.ErrSnapshot
}
slots[entry.SlotIndex] = struct{}{}
if !entry.ExpireAt.IsZero() && !entry.ExpireAt.After(now) {
continue
}
itemID := entry.ItemID
if raw := strings.TrimSpace(entry.Extra[dnfequip.CloneAvatarDisplayItemIDExtraKey]); raw != "" {
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil || value <= 0 || value > int64(^uint32(0)) {
return nil, dnfcompanion.ErrSnapshot
}
itemID = value
}
equipmentRows = append(equipmentRows, equipmentItem{
slot: entry.SlotIndex,
id: uint32(itemID),
})
}
sort.Slice(equipmentRows, func(i, j int) bool {
if equipmentRows[i].slot != equipmentRows[j].slot {
return equipmentRows[i].slot < equipmentRows[j].slot
}
return equipmentRows[i].id < equipmentRows[j].id
})
if len(equipmentRows) > currentDungeonCompanionProjectionMaxEquip {
return nil, dnfcompanion.ErrSnapshot
}
equipmentIDs := make([]uint32, len(equipmentRows))
for i := range equipmentRows {
equipmentIDs[i] = equipmentRows[i].id
}
return equipmentIDs, nil
}
func buildCurrentDungeonCompanionProjectionInstanceState(
source dnfrepo.CompanionSourceState,
now time.Time,
) ([]uint32, []currentDungeonCompanionProjectionSkillPair, []currentDungeonCompanionProjectionSkillPair, error) {
if now.IsZero() || !source.HasEquipment || !source.HasSkill ||
source.Equipment.CharacterID != source.Source.CharacterID ||
source.Skill.CharacterID != source.Source.CharacterID {
return nil, nil, nil, dnfcompanion.ErrSnapshot
}
equipmentIDs, err := currentDungeonCompanionEquipmentIDs(
source.Equipment, now)
if err != nil {
return nil, nil, nil, err
}
skills := make([]currentDungeonCompanionProjectionSkillPair, 0, len(source.Skill.Skills))
for id, state := range source.Skill.Skills {
if !state.Enabled || state.Level <= 0 {
continue
}
if id <= 0 || id > int64(^uint32(0)) || state.Level > int(^uint16(0)) {
return nil, nil, nil, dnfcompanion.ErrSnapshot
}
skills = append(skills, currentDungeonCompanionProjectionSkillPair{
ID: uint32(id), Level: uint32(state.Level),
})
}
sort.Slice(skills, func(i, j int) bool { return skills[i].ID < skills[j].ID })
if len(skills) > currentDungeonCompanionProjectionMaxSkills {
return nil, nil, nil, dnfcompanion.ErrSnapshot
}
// The current EXE owns two six-slot quickbars in tree zero:
// 0..5 => A/S/D/F/G/H
// 198..203 => Q/W/E/R/T/Y
// Preserve all 12 positions, including empty slots. Compressing the vector
// would shift later keyboard bindings whenever a middle slot is empty.
quickSlotOrder := [...]int{0, 1, 2, 3, 4, 5, 198, 199, 200, 201, 202, 203}
quick := make([]currentDungeonCompanionProjectionSkillPair, len(quickSlotOrder))
if layout := source.Skill.Layouts[0]; layout != nil {
for index, slot := range quickSlotOrder {
rawID, exists := layout[slot]
if !exists || rawID == 0 {
continue
}
id := uint32(rawID)
state, ok := source.Skill.Skills[int64(id)]
if !ok || !state.Enabled || state.Level <= 0 {
continue
}
quick[index] = currentDungeonCompanionProjectionSkillPair{
ID: id, Level: uint32(state.Level),
}
}
}
// An empty quickbar is authoritative. Do not fill it from the learned
// skill vector: learned skills in the tree are not necessarily placed in a
// quickbar, and doing so makes the companion cast skills the player did not
// put in any of the twelve visible slots. Keep all twelve positions (and
// their empty 0/0 pairs) so the client key-program indexes remain stable.
if len(quick) > currentDungeonCompanionProjectionMaxQuick {
return nil, nil, nil, dnfcompanion.ErrSnapshot
}
return equipmentIDs, skills, quick, nil
}
func buildCurrentDungeonCompanionProjectionEnvelope(
source dnfrepo.CompanionSourceState,
selection dnfcompanion.Selection,
now time.Time,
options ...currentDungeonCompanionProjectionOptions,
) (dnfcompanion.VisualProjection, []byte, error) {
projection, err := dnfcompanion.BuildVisualProjection(source, selection, now)
if err != nil {
return dnfcompanion.VisualProjection{}, nil, err
}
var option currentDungeonCompanionProjectionOptions
if len(options) > 0 {
option = options[0]
}
profile := option.AIProfile
if profile.Behavior == "" {
profile = dnfcompanion.DefaultAIProfile(uint16(projection.Job))
}
if err := profile.Validate(); err != nil {
return dnfcompanion.VisualProjection{}, nil, err
}
if option.EffectiveLevel > 0 {
if option.EffectiveLevel > 90 {
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
}
projection.Level = option.EffectiveLevel
}
character := dnfrepo.CloneCharacter(source.Source)
if option.EffectiveLevel > 0 {
character.Level = option.EffectiveLevel
}
equipment := dnfrepo.CloneEquipment(source.Equipment)
rows, err := buildCurrentActorMode0AppearanceSummaryFromEquipment(equipment)
if err != nil {
return dnfcompanion.VisualProjection{}, nil, err
}
character.Roster.Entry.EquipSummary = cloneRosterEquipSummary(rows)
mode0 := buildCurrentSceneObjectListBodyInContext(
currentDungeonCompanionProjectionObjectKey,
character, true, character.Name, currentSceneObjectContext)
// mode0 always owns source identity and complete current-EXE appearance.
// Balanced mode optionally adds one mode1 stat blob after APC registration;
// original mode keeps mode1 empty and therefore remains byte-compatible.
mode1 := append([]byte(nil), option.Mode1...)
if len(mode0) <= currentDungeonCompanionMode0ObjectKeyOffset+1 ||
mode0[0] != 0 ||
binary.LittleEndian.Uint16(
mode0[currentDungeonCompanionMode0ObjectKeyOffset:currentDungeonCompanionMode0ObjectKeyOffset+2]) !=
currentDungeonCompanionProjectionObjectKey {
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
}
if len(mode1) != 0 &&
(len(mode1) <= currentDungeonCompanionMode1ObjectKeyOffset+1 ||
mode1[0] != 1 ||
binary.LittleEndian.Uint16(
mode1[currentDungeonCompanionMode1ObjectKeyOffset:currentDungeonCompanionMode1ObjectKeyOffset+2]) !=
currentDungeonCompanionProjectionObjectKey) {
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
}
fingerprintHex := projection.Fingerprint()
fingerprint, err := hex.DecodeString(fingerprintHex)
if err != nil || len(fingerprint) != 32 {
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
}
if option.EffectiveLevel > 0 || len(mode1) != 0 {
hash := sha256.New()
_, _ = hash.Write(fingerprint)
_, _ = hash.Write([]byte{byte(option.EffectiveLevel), 1})
_, _ = hash.Write(mode1)
fingerprint = hash.Sum(nil)
}
revision := binary.LittleEndian.Uint64(fingerprint[:8])
if revision == 0 {
revision = 1
}
nameUTF16 := utf16.Encode([]rune(projection.Name))
if len(nameUTF16) == 0 || len(nameUTF16) > 32 {
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
}
equipmentIDs, skills, quickSkills, err :=
buildCurrentDungeonCompanionProjectionInstanceState(source, now)
// These IDs populate the copied native AIC before APC construction. They
// select the actual worn weapon and avatars, including clone appearances.
// Use the frozen source/runtime equipment; substituting owner gear strips
// source avatars and can give the APC a weapon for the wrong profession.
if err != nil || projection.Level < 1 || projection.Level > 90 {
return dnfcompanion.VisualProjection{}, nil, dnfcompanion.ErrSnapshot
}
dynamicBytes := len(nameUTF16)*2 +
len(equipmentIDs)*4 +
len(skills)*8 +
len(quickSkills)*8
body := make([]byte,
currentDungeonCompanionProjectionHeaderSize+
dynamicBytes+len(mode0)+len(mode1))
binary.LittleEndian.PutUint32(
body[0:4], currentDungeonCompanionProjectionMagic)
body[4] = currentDungeonCompanionProjectionVersion
body[5] = currentDungeonCompanionProjectionFlagPresent |
currentDungeonCompanionProjectionFlagAIPlan
binary.LittleEndian.PutUint16(body[6:8],
uint16(mustProjectionCharacterID(projection.SourceCharacterID)))
binary.LittleEndian.PutUint64(body[8:16], revision)
binary.LittleEndian.PutUint32(body[16:20], uint32(len(mode0)))
binary.LittleEndian.PutUint32(body[20:24], uint32(len(mode1)))
copy(body[24:56], fingerprint)
body[56] = projection.Job
body[57] = projection.GrowType
binary.LittleEndian.PutUint16(body[58:60], uint16(len(nameUTF16)))
binary.LittleEndian.PutUint16(body[60:62], uint16(len(equipmentIDs)))
binary.LittleEndian.PutUint16(body[62:64], uint16(projection.Level))
binary.LittleEndian.PutUint16(body[64:66], uint16(len(skills)))
binary.LittleEndian.PutUint16(body[66:68], uint16(len(quickSkills)))
binary.LittleEndian.PutUint32(body[68:72],
currentDungeonCompanionProjectionAIPlanWord(
currentDungeonCompanionProjectionAIPlanFor(profile, projection.Job)))
cursor := currentDungeonCompanionProjectionHeaderSize
for _, unit := range nameUTF16 {
binary.LittleEndian.PutUint16(body[cursor:cursor+2], unit)
cursor += 2
}
for _, itemID := range equipmentIDs {
binary.LittleEndian.PutUint32(body[cursor:cursor+4], itemID)
cursor += 4
}
for _, skill := range skills {
binary.LittleEndian.PutUint32(body[cursor:cursor+4], skill.ID)
binary.LittleEndian.PutUint32(body[cursor+4:cursor+8], skill.Level)
cursor += 8
}
for _, skill := range quickSkills {
binary.LittleEndian.PutUint32(body[cursor:cursor+4], skill.ID)
binary.LittleEndian.PutUint32(body[cursor+4:cursor+8], skill.Level)
cursor += 8
}
copy(body[cursor:cursor+len(mode0)], mode0)
cursor += len(mode0)
copy(body[cursor:], mode1)
return projection, body, nil
}
func mustProjectionCharacterID(value string) uint64 {
parsed, _ := strconv.ParseUint(value, 10, 16)
return parsed
}
func (s *Service) currentDungeonCompanionProjectionSourceID(
ctx context.Context,
session *gameSession,
repos dnfrepo.Group,
) (uint16, string, bool, error) {
if s == nil || session == nil || session.selectedCharacterID == 0 {
return 0, "", false, nil
}
ownerID := strconv.FormatUint(
uint64(session.selectedCharacterID), 10)
if repos.Settings != nil {
scope, err := dnfcompanion.SettingsScope(ownerID)
if err != nil {
return 0, "settings", false, err
}
record, found, err := repos.Settings.Load(ctx, scope)
if err != nil {
return 0, "settings", false, err
}
if found {
source, selected, err :=
dnfcompanion.SourceCharacterIDFromSettings(
record, ownerID)
if err != nil {
return 0, "settings", false, err
}
if !selected {
return 0, "settings", false, nil
}
sourceID, ok :=
currentDungeonCompanionProjectionSourceIDValue(source)
if !ok {
return 0, "settings", false,
dnfcompanion.ErrSelection
}
return sourceID, "settings", true, nil
}
}
sourceID, enabled :=
currentDungeonCompanionProjectionSourceIDValue(
os.Getenv(
currentDungeonCompanionProjectionSourceEnv))
if !enabled {
return 0, "", false, nil
}
return sourceID, "environment_fallback", true, nil
}
func (s *Service) sendCurrentDungeonCompanionProjection(
session *gameSession,
source string,
) error {
if s == nil || session == nil ||
currentDungeonCompanionP0CaptureBudget() == 0 ||
session.selectedCharacterID == 0 {
return nil
}
repos, ok := s.repositoryGroup()
if !ok || repos.Character == nil {
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
"source", source,
"reason", "repository_unavailable")
return nil
}
selectionCtx, selectionCancel := context.WithTimeout(
context.Background(), currentRepositorySnapshotTimeout)
defer selectionCancel()
sourceID, selectionSource, enabled, selectionErr :=
s.currentDungeonCompanionProjectionSourceID(
selectionCtx, session, repos)
if selectionErr != nil {
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
"source", source,
"selection_source", selectionSource,
"reason", "source_selection_invalid",
"error", selectionErr)
return nil
}
if !enabled {
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
"source", source,
"selection_source", selectionSource,
"reason", "source_not_selected")
return nil
}
if sourceID == session.selectedCharacterID {
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
"source", source, "source_character_id", sourceID,
"selection_source", selectionSource,
"reason", "source_is_owner")
return nil
}
reader, ok := repos.Character.(dnfrepo.CompanionSourceReader)
if !ok {
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
"source", source, "source_character_id", sourceID,
"selection_source", selectionSource,
"reason", "consistent_reader_unavailable")
return nil
}
accountID := strings.TrimSpace(s.accountIDForSession(session))
if accountID == "" {
return nil
}
_, sourceOnline := s.onlineGameSession(sourceID)
ctx, cancel := context.WithTimeout(
context.Background(), currentRepositorySnapshotTimeout)
defer cancel()
state, err := reader.ReadCompanionSource(
ctx, accountID,
strconv.FormatUint(uint64(session.selectedCharacterID), 10),
strconv.FormatUint(uint64(sourceID), 10))
if err != nil {
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
"source", source, "source_character_id", sourceID,
"selection_source", selectionSource,
"reason", "consistent_read_failed", "error", err)
return nil
}
selection := dnfcompanion.Selection{
AccountID: accountID,
OwnerCharacterID: strconv.FormatUint(
uint64(session.selectedCharacterID), 10),
SourceCharacterID: strconv.FormatUint(
uint64(sourceID), 10),
OnlineStateKnown: true,
SourceOnline: sourceOnline,
}
projection, body, err :=
buildCurrentDungeonCompanionProjectionEnvelope(
state, selection, s.gameplayNow())
if err != nil {
s.logGameEvent(session, "game-dungeon-companion-projection-skipped",
"source", source, "source_character_id", sourceID,
"selection_source", selectionSource,
"source_online", sourceOnline,
"reason", "projection_invalid", "error", err)
return nil
}
weaponID := int64(0)
if projection.Weapon != nil {
weaponID = projection.Weapon.DisplayItemID
}
s.logGameEvent(session, "game-dungeon-companion-projection-send",
"source", source,
"selection_source", selectionSource,
"owner_character_id", session.selectedCharacterID,
"source_character_id", sourceID,
"name", projection.Name,
"job", projection.Job, "grow_type", projection.GrowType,
"level", projection.Level,
"weapon_item_id", weaponID,
"avatar_count", len(projection.Avatars),
"aic_equipment_count", int(binary.LittleEndian.Uint16(body[60:62])),
"aic_skill_count", int(binary.LittleEndian.Uint16(body[64:66])),
"aic_quick_skill_count", int(binary.LittleEndian.Uint16(body[66:68])),
"projection_hash", projection.Fingerprint(),
"placeholder_object_key", currentDungeonCompanionProjectionObjectKey,
"private_opcode", currentDungeonCompanionProjectionOpcode,
"body_len", len(body),
"template_mutation", false)
return s.sendGameUpperRawClass(
session, currentDungeonCompanionProjectionOpcode, body, 0)
}