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