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