Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

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