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

513 lines
16 KiB
Go

package services
import (
"bytes"
"errors"
"fmt"
"sort"
"pvfine/internal/pvf"
pvfversion "pvfine/internal/version"
)
const (
dropRateSectionName = "basis of rarity dicision"
dropRateGroupSize = 7
dropRateLegacyGroupSize = 6
dropRate100CNGroupSize = 8
dropRate110USGroupSize = 9
dropRateValueCount = 5
dropRateTotal = 10000 // 百分比的百分之一: 100.00% == 10000
dropRateRawPerUnit = 100 // PVF 原始刻度: 0.01% == 100
)
var (
ErrDropRateUnsupported = errors.New("基础掉率编辑器仅支持 90US/90CN/TW/110US/100CN/115CN PVF")
ErrDropRateStale = errors.New("掉率数据已过期,请重新加载")
)
type dropRateFileSpec struct {
key string
path string
offset int
groupCount int
}
var dropRateFileSpecs = []dropRateFileSpec{
{key: "hell", path: "etc/itemdropinfo_monster_hell.etc", offset: 1, groupCount: 2},
{key: "flip", path: "etc/itemdropinfo_clearreward.etc", offset: 0, groupCount: 1},
{key: "monster", path: "etc/itemdropinfo_monseter.etc", offset: 0, groupCount: 4},
{key: "elite", path: "etc/itemdropinfo_monseter_extra.etc", offset: 0, groupCount: 4},
}
// DropRateDocument is the structured view consumed by the drop-rate editor.
// Rates are percentage hundredths: 1234 means 12.34%.
type DropRateDocument struct {
PVFVersion string `json:"pvfVersion"`
Revision uint64 `json:"revision"`
Sections []*DropRateSection `json:"sections"`
}
// DropRateSection identifies one of the four supported drop files.
type DropRateSection struct {
Key string `json:"key"`
Groups []*DropRateGroup `json:"groups"`
}
// DropRateGroup contains five rarity probabilities in percentage hundredths.
type DropRateGroup struct {
Rates []int `json:"rates"`
}
// DropRateApplyRequest applies the complete editor snapshot atomically.
type DropRateApplyRequest struct {
Revision uint64 `json:"revision"`
Sections []*DropRateSection `json:"sections"`
}
// DropRateApplyResult reports the in-memory archive state after applying.
type DropRateApplyResult struct {
Revision uint64 `json:"revision"`
FileIndexes []int32 `json:"fileIndexes"`
ModifiedCount int `json:"modifiedCount"`
}
// DropService reads and applies the four structured drop-rate files without
// opening them as editor tabs. Changes remain in the archive overlay until the
// regular editor save operation writes the PVF source file.
type DropService struct{ c *core }
func NewDropService(c *core) *DropService { return &DropService{c: c} }
// Read returns the current drop-rate snapshot for a verified client family.
func (s *DropService) Read() (*DropRateDocument, error) {
s.c.mu.RLock()
defer s.c.mu.RUnlock()
if s.c.archive == nil {
return nil, ErrNoArchive
}
if err := s.c.ensureVersionReadyLocked(); err != nil {
return nil, err
}
version := s.c.archive.ClientVersion()
if !supportedDropRateVersion(version) {
return nil, ErrDropRateUnsupported
}
document, err := readDropRateDocument(s.c.archive)
if err != nil {
return nil, err
}
document.PVFVersion = version
document.Revision = s.c.batchRevision
return document, nil
}
// Apply validates and applies a complete editor snapshot atomically. The
// revision check prevents a stale modal from overwriting a newer archive edit.
func (s *DropService) Apply(request DropRateApplyRequest) (DropRateApplyResult, error) {
s.c.mu.Lock()
if s.c.archive == nil {
s.c.mu.Unlock()
return DropRateApplyResult{}, ErrNoArchive
}
if err := s.c.ensureVersionReadyLocked(); err != nil {
s.c.mu.Unlock()
return DropRateApplyResult{}, err
}
version := s.c.archive.ClientVersion()
if !supportedDropRateVersion(version) {
s.c.mu.Unlock()
return DropRateApplyResult{}, ErrDropRateUnsupported
}
if request.Revision != s.c.batchRevision {
s.c.mu.Unlock()
return DropRateApplyResult{}, ErrDropRateStale
}
replacements, err := prepareDropRateReplacements(s.c.archive, request.Sections)
if err != nil {
s.c.mu.Unlock()
return DropRateApplyResult{}, err
}
if len(replacements) == 0 {
result := DropRateApplyResult{
Revision: s.c.batchRevision,
FileIndexes: []int32{},
ModifiedCount: s.c.archive.ModifiedCount(),
}
s.c.mu.Unlock()
return result, nil
}
paths := make([]string, 0, len(replacements))
for _, replacement := range replacements {
paths = append(paths, replacement.path)
}
var before pvfversion.ContentSnapshot
if s.c.versionRepo != nil {
before, err = pvfversion.ContentSnapshotFromArchive(s.c.archive, paths)
if err != nil {
s.c.mu.Unlock()
return DropRateApplyResult{}, err
}
}
a := s.c.archive
mutationCheckpoint := a.MutationCheckpoint()
fileIndexes := make([]int32, 0, len(replacements))
for _, replacement := range replacements {
if err := a.SetRawBytes(replacement.index, replacement.raw); err != nil {
s.c.mu.Unlock()
return DropRateApplyResult{}, err
}
fileIndexes = append(fileIndexes, replacement.index)
}
if s.c.versionRepo != nil {
after, snapshotErr := pvfversion.ContentSnapshotFromArchive(a, paths)
if snapshotErr != nil {
s.c.mu.Unlock()
return DropRateApplyResult{}, snapshotErr
}
if recordErr := s.c.recordVersionMutationLocked("基础掉率", before, after); recordErr != nil {
s.c.mu.Unlock()
return DropRateApplyResult{}, recordErr
}
}
mutationSummary := a.MutationsSince(mutationCheckpoint)
s.c.markAdvancedSearchStaleLocked(a)
a.ClearMutations()
s.c.batchRevision++
s.c.batchPlan = nil
s.c.invalidateScriptLocked()
s.c.editorAnnotation = editorAnnotationCache{}
s.c.annotationRelations = make(map[string]map[string]*relationTarget)
s.c.markAdvancedSearchStaleLocked(s.c.archive)
for _, index := range fileIndexes {
delete(s.c.editorText, index)
delete(s.c.visualsByFile, index)
}
info := a.Info()
revision := s.c.batchRevision
versioned := s.c.versionRepo != nil
s.c.mu.Unlock()
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
return DropRateApplyResult{}, err
}
emitEvent("archive:advanced-search-stale", map[string]any{"dropRate": true})
emitEvent("archive:batch-applied", map[string]any{
"fileIndexes": fileIndexes, "modifiedCount": info.ModifiedCount,
"revision": revision, "source": "drop-rate", "structural": false,
})
if versioned {
emitVersionState(s.c, "drop-rate-applied")
}
return DropRateApplyResult{
Revision: revision,
FileIndexes: fileIndexes,
ModifiedCount: info.ModifiedCount,
}, nil
}
type dropRateReplacement struct {
index int32
path string
raw []byte
}
func supportedDropRateVersion(version string) bool {
return version == "90US" || version == "90CN" || version == "TW" || version == "110US" || version == "100CN" || version == "115CN"
}
func readDropRateDocument(a *pvf.Archive) (*DropRateDocument, error) {
sections := make([]*DropRateSection, 0, len(dropRateFileSpecs))
for _, spec := range dropRateFileSpecs {
section, err := readDropRateSection(a, spec)
if err != nil {
return nil, err
}
sections = append(sections, section)
}
return &DropRateDocument{Sections: sections}, nil
}
type parsedDropRateFile struct {
index int32
raw []byte
document *pvf.ScriptDocument
section *pvf.ScriptSection
values []int64
groupSize int
}
func loadDropRateFile(a *pvf.Archive, spec dropRateFileSpec) (*parsedDropRateFile, error) {
index, ok := a.Find(spec.path)
if !ok {
return nil, fmt.Errorf("找不到掉率文件: %s", spec.path)
}
if a.File(index).DataType != pvf.TypeScript {
return nil, fmt.Errorf("掉率文件不是脚本类型: %s", spec.path)
}
raw, err := a.RawBytes(index)
if err != nil {
return nil, fmt.Errorf("读取掉率文件 %s 失败: %w", spec.path, err)
}
document, err := a.ParseScriptDocument(raw)
if err != nil {
return nil, fmt.Errorf("解析掉率文件 %s 失败: %w", spec.path, err)
}
section, ok := document.Section([]string{dropRateSectionName}, 0)
if !ok {
return nil, fmt.Errorf("掉率文件缺少 section [%s]: %s", dropRateSectionName, spec.path)
}
values, err := scriptIntegerValues(section.Values(), spec.path)
if err != nil {
return nil, err
}
groupSize, err := dropRateGroupWidth(values, spec)
if err != nil {
return nil, err
}
return &parsedDropRateFile{
index: index,
raw: raw,
document: document,
section: section,
values: values,
groupSize: groupSize,
}, nil
}
func readDropRateSection(a *pvf.Archive, spec dropRateFileSpec) (*DropRateSection, error) {
file, err := loadDropRateFile(a, spec)
if err != nil {
return nil, err
}
groups := make([]*DropRateGroup, 0, spec.groupCount)
for groupIndex := 0; groupIndex < spec.groupCount; groupIndex++ {
start := spec.offset + groupIndex*file.groupSize
rates, err := decodeDropRateGroup(file.values[start : start+file.groupSize])
if err != nil {
return nil, fmt.Errorf("掉率文件 %s 第 %d 组无效: %w", spec.path, groupIndex+1, err)
}
groups = append(groups, &DropRateGroup{Rates: rates})
}
return &DropRateSection{Key: spec.key, Groups: groups}, nil
}
func prepareDropRateReplacements(a *pvf.Archive, sections []*DropRateSection) ([]dropRateReplacement, error) {
byKey := make(map[string]*DropRateSection, len(sections))
for _, section := range sections {
if section == nil || section.Key == "" {
return nil, errors.New("掉率 section 不能为空")
}
if _, exists := byKey[section.Key]; exists {
return nil, fmt.Errorf("掉率 section 重复: %s", section.Key)
}
byKey[section.Key] = section
}
if len(byKey) != len(dropRateFileSpecs) {
return nil, fmt.Errorf("掉率 section 数量无效,需要 %d 个", len(dropRateFileSpecs))
}
replacements := make([]dropRateReplacement, 0, len(dropRateFileSpecs))
for _, spec := range dropRateFileSpecs {
sectionInput, ok := byKey[spec.key]
if !ok {
return nil, fmt.Errorf("缺少掉率 section: %s", spec.key)
}
replacement, err := prepareDropRateFileReplacement(a, spec, sectionInput)
if err != nil {
return nil, err
}
if replacement != nil {
replacements = append(replacements, *replacement)
}
}
return replacements, nil
}
func prepareDropRateFileReplacement(a *pvf.Archive, spec dropRateFileSpec, sectionInput *DropRateSection) (*dropRateReplacement, error) {
if len(sectionInput.Groups) != spec.groupCount {
return nil, fmt.Errorf("掉率 section %s 有 %d 组,需要 %d 组", spec.key, len(sectionInput.Groups), spec.groupCount)
}
file, err := loadDropRateFile(a, spec)
if err != nil {
return nil, err
}
for groupIndex, group := range sectionInput.Groups {
if group == nil {
return nil, fmt.Errorf("掉率 section %s 第 %d 组为空", spec.key, groupIndex+1)
}
if err := validateDropRates(group.Rates); err != nil {
return nil, fmt.Errorf("掉率 section %s 第 %d 组无效: %w", spec.key, groupIndex+1, err)
}
start := spec.offset + groupIndex*file.groupSize
groupSlice := file.values[start : start+file.groupSize]
offset, err := detectDropRateGroupOffset(groupSlice)
if err != nil {
return nil, fmt.Errorf("掉率文件 %s 第 %d 组无效: %w", spec.path, groupIndex+1, err)
}
currentRates, err := decodeDropRateGroupWithOffset(groupSlice, offset)
if err != nil {
return nil, fmt.Errorf("掉率文件 %s 第 %d 组无效: %w", spec.path, groupIndex+1, err)
}
if equalDropRates(currentRates, group.Rates) {
continue
}
cumulative := int64(0)
for rateIndex := 0; rateIndex < dropRateValueCount-1; rateIndex++ {
cumulative += int64(group.Rates[rateIndex]) * dropRateRawPerUnit
if err := file.section.Set(pvf.ScriptValue{
Type: pvf.ScriptTokenInteger,
Value: cumulative,
}, start+offset+rateIndex); err != nil {
return nil, fmt.Errorf("修改掉率文件 %s 第 %d 组失败: %w", spec.path, groupIndex+1, err)
}
}
}
encoded, err := a.EncodeScriptDocument(file.document)
if err != nil {
return nil, fmt.Errorf("编码掉率文件 %s 失败: %w", spec.path, err)
}
if !bytes.Equal(file.raw, encoded) {
return &dropRateReplacement{index: file.index, path: spec.path, raw: encoded}, nil
}
return nil, nil
}
func equalDropRates(left, right []int) bool {
if len(left) != len(right) {
return false
}
for index := range left {
if left[index] != right[index] {
return false
}
}
return true
}
func scriptIntegerValues(values []pvf.ScriptValue, path string) ([]int64, error) {
result := make([]int64, len(values))
for index, value := range values {
if value.Type != pvf.ScriptTokenInteger {
return nil, fmt.Errorf("掉率文件 %s 第 %d 个值不是整数", path, index+1)
}
number, ok := value.Value.(int64)
if !ok {
return nil, fmt.Errorf("掉率文件 %s 第 %d 个整数值类型无效", path, index+1)
}
result[index] = number
}
return result, nil
}
func isDropTerminal(val int64) bool {
return val >= 1_000_000 && val <= 1_000_002
}
func detectDropRateGroupOffset(group []int64) (int, error) {
if len(group) < dropRateLegacyGroupSize {
return 0, fmt.Errorf("分组至少需要 %d 个值", dropRateLegacyGroupSize)
}
if isDropTerminal(group[4]) {
return 0, nil
}
if group[0] == 0 {
if isDropTerminal(group[5]) {
return 1, nil
}
return 0, fmt.Errorf("终止阈值 %d 不在兼容范围内", group[5])
}
return 0, fmt.Errorf("终止阈值 %d 不在兼容范围内", group[4])
}
// decodeDropRateGroup accepts the legacy six-value group. The sixth value is
// intentionally kept unchanged by the five-rarity editor; newer revisions
// append more thresholds, which are also retained without reinterpretation.
// Hell drop files prepend a leading 0 threshold (skipping common drops), so
// five-rarity thresholds start at index 1 and terminate at index 5.
func decodeDropRateGroup(group []int64) ([]int, error) {
offset, err := detectDropRateGroupOffset(group)
if err != nil {
return nil, err
}
return decodeDropRateGroupWithOffset(group, offset)
}
func decodeDropRateGroupWithOffset(group []int64, offset int) ([]int, error) {
previous := int64(0)
diffs := make([]int64, dropRateValueCount)
for index := range diffs {
current := group[offset+index]
if current < previous {
return nil, fmt.Errorf("累计阈值递减")
}
diffs[index] = current - previous
previous = current
}
rates := make([]int, len(diffs))
remainders := make([]int64, len(diffs))
base := 0
for index, diff := range diffs {
rates[index] = int(diff / dropRateRawPerUnit)
remainders[index] = diff % dropRateRawPerUnit
base += rates[index]
}
if base > dropRateTotal {
return nil, fmt.Errorf("量化后的掉率总和超过 100.00%%")
}
remaining := dropRateTotal - base
if remaining > len(rates) {
return nil, fmt.Errorf("量化后的掉率无法归一到 100.00%%")
}
order := []int{0, 1, 2, 3, 4}
sort.SliceStable(order, func(left, right int) bool {
if remainders[order[left]] != remainders[order[right]] {
return remainders[order[left]] > remainders[order[right]]
}
return order[left] < order[right]
})
for index := 0; index < remaining; index++ {
rates[order[index]]++
}
return rates, nil
}
func validateDropRates(rates []int) error {
if len(rates) != dropRateValueCount {
return fmt.Errorf("需要 %d 个概率值", dropRateValueCount)
}
sum := 0
for index, rate := range rates {
if rate < 0 || rate > dropRateTotal {
return fmt.Errorf("第 %d 个概率超出 0.00%%~100.00%%", index+1)
}
sum += rate
}
if sum != dropRateTotal {
return fmt.Errorf("概率总和为 %.2f%%,必须为 100.00%%", float64(sum)/100)
}
return nil
}
// Known group widths: TW=6, 90US/90CN=7, 100CN=8, 110US=9.
// Validate each section's exact layout before interpreting or changing it.
func dropRateGroupWidth(values []int64, spec dropRateFileSpec) (int, error) {
for _, width := range []int{dropRateLegacyGroupSize, dropRateGroupSize, dropRate100CNGroupSize, dropRate110USGroupSize} {
if len(values) == spec.offset+spec.groupCount*width {
return width, nil
}
}
return 0, fmt.Errorf("掉率文件 %s 的 section 数据长度为 %d,需要 %d、%d、%d 或 %d", spec.path, len(values), spec.offset+spec.groupCount*dropRateLegacyGroupSize, spec.offset+spec.groupCount*dropRateGroupSize, spec.offset+spec.groupCount*dropRate100CNGroupSize, spec.offset+spec.groupCount*dropRate110USGroupSize)
}