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[:]) }