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,250 @@
package companion
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"strconv"
"strings"
"time"
"unicode/utf8"
"longheng.io/server/internal/modules/dnf/equip"
"longheng.io/server/internal/modules/dnf/repository"
)
const (
sourceCharacterSettingKey = "source_character_id"
sourceCharactersSettingKey = "source_character_ids"
)
func SettingsScope(ownerCharacterID string) (string, error) {
ownerCharacterID = strings.TrimSpace(ownerCharacterID)
if !validCharacterID(ownerCharacterID) {
return "", ErrSelection
}
return "character:" + ownerCharacterID + ":companion-v1", nil
}
func AccountSettingsScope(accountID string) (string, error) {
accountID = strings.TrimSpace(accountID)
if !validIdentity(accountID) {
return "", ErrSelection
}
return "account:" + accountID + ":companion-v1", nil
}
func SourceCharacterIDFromSettings(
record repository.SettingsRecord,
ownerCharacterID string,
) (string, bool, error) {
cfg, err := ConfigFromSettings(record, ownerCharacterID)
if err != nil {
return "", false, err
}
if !cfg.Enabled {
return "", false, nil
}
if cfg.SourceCharacterID == strings.TrimSpace(ownerCharacterID) {
return "", false, ErrSelf
}
return cfg.SourceCharacterID, true, nil
}
// VisualProjection is detached display data for one source character.
// It contains template IDs only and never owns inventory rows, native pointers,
// source cooldowns or mutable APC template state.
type VisualProjection struct {
SchemaVersion int `json:"schema_version"`
AccountID string `json:"account_id"`
OwnerCharacterID string `json:"owner_character_id"`
SourceCharacterID string `json:"source_character_id"`
Name string `json:"name"`
Job uint8 `json:"job"`
GrowType uint8 `json:"grow_type"`
Level int `json:"level"`
Weapon *VisualItem `json:"weapon,omitempty"`
Avatars []VisualItem `json:"avatars"`
}
type VisualItem struct {
Slot int16 `json:"slot"`
ItemID int64 `json:"item_id"`
DisplayItemID int64 `json:"display_item_id"`
EquipmentType string `json:"equipment_type,omitempty"`
PVFPath string `json:"pvf_path,omitempty"`
}
func BuildVisualProjection(
source repository.CompanionSourceState,
s Selection,
now time.Time,
) (VisualProjection, error) {
if !validIdentity(s.AccountID) ||
!validCharacterID(s.OwnerCharacterID) ||
!validCharacterID(s.SourceCharacterID) {
return VisualProjection{}, ErrSelection
}
if s.OwnerCharacterID == s.SourceCharacterID {
return VisualProjection{}, ErrSelf
}
if source.Owner.CharacterID != s.OwnerCharacterID ||
source.Source.CharacterID != s.SourceCharacterID ||
source.Owner.Stats["delete_flag"] != 0 ||
source.Source.Stats["delete_flag"] != 0 {
return VisualProjection{}, ErrSource
}
if source.Owner.AccountID != s.AccountID ||
source.Source.AccountID != s.AccountID {
return VisualProjection{}, ErrAccount
}
if !s.OnlineStateKnown {
return VisualProjection{}, ErrOnlineUnknown
}
if s.SourceOnline {
return VisualProjection{}, ErrOnline
}
job, err := strconv.ParseUint(source.Source.Job, 10, 8)
grow, hasGrow := source.Source.Stats["grow_type"]
if err != nil || !hasGrow || grow < 0 || grow > 255 {
return VisualProjection{}, ErrSnapshot
}
if now.IsZero() || source.Source.Level < 1 || source.Source.Level > 90 ||
!validIdentity(source.Source.Name) || !utf8.ValidString(source.Source.Name) ||
!source.HasEquipment ||
source.Equipment.CharacterID != s.SourceCharacterID {
return VisualProjection{}, ErrSnapshot
}
out := VisualProjection{
SchemaVersion: 1,
AccountID: s.AccountID,
OwnerCharacterID: s.OwnerCharacterID,
SourceCharacterID: s.SourceCharacterID,
Name: source.Source.Name,
Job: uint8(job),
GrowType: uint8(grow),
Level: source.Source.Level,
Avatars: []VisualItem{},
}
for _, entry := range source.Equipment.Entries {
if entry.ItemID <= 0 || entry.ItemID > int64(^uint32(0)) {
return VisualProjection{}, ErrSnapshot
}
if !entry.ExpireAt.IsZero() && !entry.ExpireAt.After(now) {
continue
}
item := visualItem(entry)
if visualAvatar(entry) {
out.Avatars = append(out.Avatars, item)
continue
}
// Freshly-created characters do not always persist item_kind or
// current_exe_equipment_type, while pvf_path/equipment_type are already
// authoritative enough to identify a worn weapon. Do not drop a real
// weapon solely because those derived extras have not been populated yet.
if visualWeapon(entry) {
if out.Weapon != nil {
return VisualProjection{}, ErrSnapshot
}
copy := item
out.Weapon = &copy
}
}
sort.Slice(out.Avatars, func(i, j int) bool {
if out.Avatars[i].Slot != out.Avatars[j].Slot {
return out.Avatars[i].Slot < out.Avatars[j].Slot
}
return out.Avatars[i].DisplayItemID < out.Avatars[j].DisplayItemID
})
return out, nil
}
// visualAvatar 判断一条装备记录是不是**时裝**(外观)。
//
// 不能只看 extra["item_kind"] == "avatar":本部署的 dnf90.db 里,所有装备记录的
// item_kind 都是 "equipment",时裝是靠下面这些标记体现的 ——
//
// slot 0..10 且 current_exe_equipment_type 0..10 (本部署实测:角色 1 的 10 件时裝)
// extra["avatar_ability_no"] / extra["avatar_duration_days"] 存在
//
// 旧实现只认 item_kind,导致**任何角色的时裝都被丢弃**,投影里 avatar_count 恒为 0,
// 支援兵永远是光板外壳。这里改成按槽位/标记判定,并保留 item_kind 作为兜底。
func visualAvatar(entry repository.EquipmentEntry) bool {
if strings.EqualFold(strings.TrimSpace(entry.Extra["item_kind"]), "avatar") {
return true
}
if strings.TrimSpace(entry.Extra["avatar_ability_no"]) != "" ||
strings.TrimSpace(entry.Extra["avatar_duration_days"]) != "" {
return true
}
// 实测取证(女圣职者时装 415550013 等):GM 系统邮件发放并穿上后,条目里
// 既没有 item_kind="avatar" 也没有 avatar_* 字段,只有
// equipment_type = "[hat avatar]" ← 直接带 avatar 字样
// current_exe_equipment_type = "0" ← 时裝槽 0..10
// pvf_path = .../priest/at_avatar/cap/...
// 所以把 equipment_type 也算作判定依据。
if strings.Contains(strings.ToLower(strings.TrimSpace(entry.Extra["equipment_type"])), "avatar") {
return true
}
// 时裝槽固定是 0..10;武器在 12,support/offhand 在 24,都不会落进这个区间。
if entry.SlotIndex >= 0 && entry.SlotIndex <= 10 {
if raw := strings.TrimSpace(entry.Extra["current_exe_equipment_type"]); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 && parsed <= 10 {
return true
}
}
}
return false
}
func visualWeapon(entry repository.EquipmentEntry) bool {
// Slot 24 is the stored support/offhand weapon (including Vagabond's
// second sword). It is not a second primary display weapon. Keep it in
// the detached equipment snapshot, but never reject a valid dual setup
// or substitute it for slot 12's current weapon.
if entry.SlotIndex == 24 {
return false
}
path := strings.ToLower(strings.TrimSpace(entry.Extra["pvf_path"]))
if strings.Contains(path, "/weapon/") {
return true
}
return strings.TrimSpace(entry.Extra["current_exe_equipment_type"]) == "12" &&
strings.TrimSpace(entry.Extra["equipment_type"]) == "17"
}
func visualItem(entry repository.EquipmentEntry) VisualItem {
displayID := entry.ItemID
if raw := strings.TrimSpace(entry.Extra[equip.CloneAvatarDisplayItemIDExtraKey]); raw != "" {
if parsed, err := strconv.ParseInt(raw, 10, 64); err == nil &&
parsed > 0 && parsed <= int64(^uint32(0)) {
displayID = parsed
}
}
return VisualItem{
Slot: entry.SlotIndex,
ItemID: entry.ItemID,
DisplayItemID: displayID,
EquipmentType: strings.TrimSpace(entry.Extra["equipment_type"]),
PVFPath: strings.TrimSpace(entry.Extra["pvf_path"]),
}
}
func (p VisualProjection) Clone() VisualProjection {
if p.Weapon != nil {
weapon := *p.Weapon
p.Weapon = &weapon
}
if p.Avatars != nil {
p.Avatars = append([]VisualItem(nil), p.Avatars...)
}
return p
}
func (p VisualProjection) Fingerprint() string {
body, _ := json.Marshal(p)
sum := sha256.Sum256(body)
return hex.EncodeToString(sum[:])
}