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