package companion import ( "crypto/sha256" "encoding/hex" "encoding/json" "sort" "strconv" "time" "unicode/utf8" "longheng.io/server/internal/modules/dnf/repository" ) // Profile is developer-owned evidence, never a client-submitted capability. type Profile struct { ID string Version uint32 EvidenceID string ClientSHA256 string PVFSHA256 string Job uint8 GrowType uint8 BasicAttackVerified bool SkillLevelLimits map[int64]int } // There are no accepted native profiles yet. Tests use synthetic profiles // only to exercise policies; those profiles are not shipped in this registry. func VerifiedProfiles() []Profile { return nil } func validHash(value string) bool { decoded, err := hex.DecodeString(value) return err == nil && len(decoded) == 32 && hex.EncodeToString(decoded) == value } func (p Profile) validate(client, pvf string) error { if !validIdentity(p.ID) || p.Version == 0 || !validIdentity(p.EvidenceID) || !p.BasicAttackVerified { return ErrProfile } if !validHash(client) || !validHash(pvf) || client != p.ClientSHA256 || pvf != p.PVFSHA256 { return ErrAssets } return nil } type Selection struct { AccountID string OwnerCharacterID string SourceCharacterID string OnlineStateKnown bool SourceOnline bool ClientSHA256 string PVFSHA256 string } type EquipmentSpec struct { Slot int16 `json:"slot"` ItemID int64 `json:"item_id"` } type SkillSpec struct { ID int64 `json:"id"` Level int `json:"level"` } type Exclusion struct { Kind string `json:"kind"` ID int64 `json:"id,omitempty"` Reason string `json:"reason"` } // Snapshot contains declarative template IDs, not inventory serials, raw // item-instance bytes, live pointers, source cooldowns, wallets or quests. // It is NOT the native ABI or a claim of gear/stat projection parity. type Snapshot 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"` ProfileID string `json:"profile_id"` ProfileVersion uint32 `json:"profile_version"` ClientSHA256 string `json:"client_sha256"` PVFSHA256 string `json:"pvf_sha256"` Equipment []EquipmentSpec `json:"equipment"` Skills []SkillSpec `json:"skills"` Exclusions []Exclusion `json:"exclusions"` } // Prepare consumes a single transaction's detached source state. The caller // must keep the source offline check valid through preparation/run binding. // It does not load or save repositories and cannot enable a native adapter. func Prepare(source repository.CompanionSourceState, s Selection, p Profile, now time.Time) (Snapshot, error) { if !validIdentity(s.AccountID) || !validCharacterID(s.OwnerCharacterID) || !validCharacterID(s.SourceCharacterID) { return Snapshot{}, ErrSelection } if s.OwnerCharacterID == s.SourceCharacterID { return Snapshot{}, 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 Snapshot{}, ErrSource } if source.Owner.AccountID != s.AccountID || source.Source.AccountID != s.AccountID { return Snapshot{}, ErrAccount } if !s.OnlineStateKnown { return Snapshot{}, ErrOnlineUnknown } if s.SourceOnline { return Snapshot{}, ErrOnline } if err := p.validate(s.ClientSHA256, s.PVFSHA256); err != nil { return Snapshot{}, err } job, err := strconv.ParseUint(source.Source.Job, 10, 8) grow, hasGrow := source.Source.Stats["grow_type"] if err != nil || !hasGrow || grow < 0 || grow > 255 || uint8(job) != p.Job || uint8(grow) != p.GrowType { return Snapshot{}, ErrProfile } if now.IsZero() || source.Source.Level < 1 || source.Source.Level > 90 || !validIdentity(source.Source.Name) || !utf8.ValidString(source.Source.Name) || !source.HasEquipment || !source.HasSkill || source.Equipment.CharacterID != s.SourceCharacterID || source.Skill.CharacterID != s.SourceCharacterID { return Snapshot{}, ErrSnapshot } if len(source.Equipment.Entries) > 64 || len(source.Skill.Skills) > 1024 { return Snapshot{}, ErrSnapshot } out := Snapshot{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, ProfileID: p.ID, ProfileVersion: p.Version, ClientSHA256: s.ClientSHA256, PVFSHA256: s.PVFSHA256, Equipment: []EquipmentSpec{}, Skills: []SkillSpec{}, Exclusions: []Exclusion{ {Kind: "attributes", Reason: "native_base_stats_and_equipment_projection_not_verified"}, {Kind: "equipment", Reason: "instance_options_set_and_proc_effects_not_projected"}, }} slots := map[int16]bool{} for _, item := range source.Equipment.Entries { if item.SlotIndex < 0 || item.SlotIndex > 255 || item.ItemID <= 0 || item.ItemID > int64(^uint32(0)) || slots[item.SlotIndex] { return Snapshot{}, ErrSnapshot } slots[item.SlotIndex] = true if !item.ExpireAt.IsZero() && !item.ExpireAt.After(now) { out.Exclusions = append(out.Exclusions, Exclusion{Kind: "equipment", ID: item.ItemID, Reason: "expired"}) continue } out.Equipment = append(out.Equipment, EquipmentSpec{Slot: item.SlotIndex, ItemID: item.ItemID}) } for id, skill := range source.Skill.Skills { if id <= 0 || id > 65535 || skill.Level < 0 || skill.Level > 65535 { return Snapshot{}, ErrSnapshot } if !skill.Enabled || skill.Level == 0 { continue } limit, supported := p.SkillLevelLimits[id] if !supported || limit <= 0 || skill.Level > limit { out.Exclusions = append(out.Exclusions, Exclusion{Kind: "skill", ID: id, Reason: "native_skill_or_level_unverified"}) continue } out.Skills = append(out.Skills, SkillSpec{ID: id, Level: skill.Level}) } sort.Slice(out.Equipment, func(i, j int) bool { return out.Equipment[i].Slot < out.Equipment[j].Slot }) sort.Slice(out.Skills, func(i, j int) bool { return out.Skills[i].ID < out.Skills[j].ID }) sort.Slice(out.Exclusions, func(i, j int) bool { a, b := out.Exclusions[i], out.Exclusions[j] if a.Kind != b.Kind { return a.Kind < b.Kind } if a.ID != b.ID { return a.ID < b.ID } return a.Reason < b.Reason }) return out, nil } func (s Snapshot) Clone() Snapshot { if s.Equipment != nil { s.Equipment = append([]EquipmentSpec{}, s.Equipment...) } if s.Skills != nil { s.Skills = append([]SkillSpec{}, s.Skills...) } if s.Exclusions != nil { s.Exclusions = append([]Exclusion{}, s.Exclusions...) } return s } // Fingerprint is content addressing, NOT an authentication token. func (s Snapshot) Fingerprint() string { body, _ := json.Marshal(s) sum := sha256.Sum256(body) return hex.EncodeToString(sum[:]) }