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