DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

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

No files matched your search

+322
View File
@@ -0,0 +1,322 @@
package services
import (
"fmt"
"strconv"
"strings"
"pvfine/internal/pvf"
pvfversion "pvfine/internal/version"
)
// ItemCreateRequest carries all relevant editor drafts, so unsaved list entries
// participate in collision checks and survive registration.
type ItemCreateRequest struct {
Path string `json:"path"`
ID string `json:"id"`
Name string `json:"name"`
Text string `json:"text"`
Revision uint64 `json:"revision"`
Drafts []ShopDraft `json:"drafts"`
}
type ItemCreationCheck struct {
Revision uint64 `json:"revision"`
ListPath string `json:"listPath"`
EntryPath string `json:"entryPath"`
Conflicts []string `json:"conflicts"`
}
type ItemCreateResult struct {
FileIndex int32 `json:"fileIndex"`
Path string `json:"path"`
ID string `json:"id"`
Files []ShopEditedFile `json:"files"`
}
func itemCreationPath(raw string) (string, string, error) {
p, err := normalizeNewFilePath(raw)
if err != nil {
return "", "", err
}
lower := strings.ToLower(p)
kind := ""
if strings.HasPrefix(lower, "equipment/") && strings.HasSuffix(lower, ".equ") {
kind = "equipment"
}
if strings.HasPrefix(lower, "stackable/") && strings.HasSuffix(lower, ".stk") {
kind = "stackable"
}
if kind == "" || strings.ContainsAny(p, "`\r\n\x00:") {
return "", "", fmt.Errorf("物品路径必须是 equipment/ 下的 .equ 或 stackable/ 下的 .stk")
}
return p, kind, nil
}
// itemCreationStageLocked only applies item-list/hash drafts; unrelated drafts
// are never silently saved by creation.
func (s *FileGUIService) itemCreationStageLocked(req ItemCreateRequest) (*pvf.Archive, *ItemCreationCheck, []int32, error) {
a := s.c.archive
if a == nil {
return nil, nil, nil, ErrNoArchive
}
if !a.WriteCapabilities().CanChangeStructure {
return nil, nil, nil, fmt.Errorf("当前归档不支持新增物品文件")
}
p, kind, err := itemCreationPath(req.Path)
if err != nil {
return nil, nil, nil, err
}
id, err := strconv.ParseUint(req.ID, 10, 31)
if err != nil || strconv.FormatUint(id, 10) != req.ID || id == 0 {
return nil, nil, nil, fmt.Errorf("物品 ID 必须是 1–2147483647 的整数,不能有前导零")
}
stage := a.CloneForBatch()
relevant := make(map[int32]bool)
lists := []int32{}
// Check both layouts, including unusual archives that contain both lists.
for _, candidate := range []string{"equipment/equipment.lst", "stackable/stackable.lst", "list/equipment.lst", "list/stackable.lst"} {
if index, ok := a.Find(candidate); ok {
relevant[index] = true
lists = append(lists, index)
if hp, ok := pvf.IndexHashCompanionPath(candidate); ok {
if hi, found := a.Find(hp); found {
relevant[hi] = true
}
}
}
}
seen := map[int32]bool{}
for _, draft := range req.Drafts {
if !relevant[draft.FileIndex] {
continue
}
if seen[draft.FileIndex] || !sameSearchPath(a.Path(draft.FileIndex), draft.Path) {
return nil, nil, nil, fmt.Errorf("登记列表草稿已变化或重复")
}
seen[draft.FileIndex] = true
if len(draft.Text) > int(s.c.editableByteLimit()) {
return nil, nil, nil, fmt.Errorf("登记列表草稿过大")
}
if err := stage.SetText(draft.FileIndex, draft.Text); err != nil {
return nil, nil, nil, err
}
}
li, ok := stage.FindList(kind + "/" + kind + ".lst")
if !ok {
return nil, nil, nil, fmt.Errorf("归档缺少 %s.lst,无法登记物品", kind)
}
check := &ItemCreationCheck{Revision: s.c.batchRevision, ListPath: stage.Path(li), EntryPath: listEntryPath(stage.Path(li), p), Conflicts: []string{}}
if _, exists := a.Find(p); exists {
check.Conflicts = append(check.Conflicts, "目标文件已存在:"+p)
}
for _, index := range lists {
pairs, err := stage.ScriptListPairs(index)
if err != nil {
return nil, nil, nil, fmt.Errorf("无法检查 %s:%w", stage.Path(index), err)
}
for _, pair := range pairs {
n, parseErr := strconv.ParseUint(pair.ID, 10, 32)
if parseErr == nil && n == id {
check.Conflicts = append(check.Conflicts, fmt.Sprintf("ID 已在 %s 中使用:%s", stage.Path(index), pair.Path))
}
candidates, _ := listPathCandidates(stage.Path(index), pair.Path)
for _, targetPath := range candidates {
if sameSearchPath(targetPath, p) {
check.Conflicts = append(check.Conflicts, "目标路径已有登记:"+stage.Path(index))
break
}
}
}
}
if stage.ContentRules().RequiresIndexHash {
hp, _ := pvf.IndexHashCompanionPath(check.ListPath)
if _, ok := stage.Find(hp); !ok {
return nil, nil, nil, fmt.Errorf("归档缺少 indexhash:%s", hp)
}
}
// Discard the collision-check staging. Only target-list drafts may be consumed.
target := map[int32]bool{li: true}
if a.ContentRules().RequiresIndexHash {
hp, _ := pvf.IndexHashCompanionPath(check.ListPath)
if hi, found := a.Find(hp); found {
target[hi] = true
}
}
stage = a.CloneForBatch()
for _, draft := range req.Drafts {
if target[draft.FileIndex] {
if err := stage.SetText(draft.FileIndex, draft.Text); err != nil {
return nil, nil, nil, err
}
}
}
return stage, check, lists, nil
}
func (s *FileGUIService) CheckItemCreation(req ItemCreateRequest) (*ItemCreationCheck, error) {
s.c.mu.Lock()
defer s.c.mu.Unlock()
_, check, _, err := s.itemCreationStageLocked(req)
return check, err
}
// CreateItem stages the script, LST and (where required) indexhash before
// publishing one version operation. No PVF disk write happens here.
func (s *FileGUIService) CreateItem(req ItemCreateRequest) (*ItemCreateResult, error) {
s.c.mu.Lock()
fail := func(err error) (*ItemCreateResult, error) { s.c.mu.Unlock(); return nil, err }
a := s.c.archive
if a == nil {
return fail(ErrNoArchive)
}
if req.Revision != s.c.batchRevision {
return fail(fmt.Errorf("归档已变化,请重新检查 ID"))
}
if err := s.c.ensureVersionReadyLocked(); err != nil {
return fail(err)
}
if strings.TrimSpace(req.Name) == "" || len([]rune(req.Name)) > 200 || strings.ContainsAny(req.Name, "\r\n\x00") {
return fail(fmt.Errorf("物品名称需为 1–200 个字符,不能包含换行"))
}
if len(req.Text) > int(s.c.editableByteLimit()) {
return fail(fmt.Errorf("参考物品文本过大"))
}
source, err := itemCreationSource(req.Text)
if err != nil {
return fail(err)
}
stage, check, _, err := s.itemCreationStageLocked(req)
if err != nil {
return fail(err)
}
if len(check.Conflicts) > 0 {
return fail(fmt.Errorf("%s", strings.Join(check.Conflicts, ";")))
}
p, _, _ := itemCreationPath(req.Path)
index, err := stage.AddFileText(p, source, pvf.TypeScript)
if err != nil {
return fail(err)
}
raw, err := stage.RawBytes(index)
if err != nil {
return fail(err)
}
doc, err := stage.ParseScriptDocument(raw)
if err != nil {
return fail(err)
}
if len(doc.Warnings()) > 0 {
return fail(fmt.Errorf("参考物品结构不完整,请先修复 DSL"))
}
names := doc.Sections([]string{"name"})
value, err := pvf.NewScriptValue(pvf.ScriptTokenQuoted, req.Name, pvf.ScriptPoolUTF16)
if err != nil {
return fail(err)
}
if len(names) > 1 {
return fail(fmt.Errorf("参考物品包含重复名称字段"))
}
if len(names) == 1 {
err = names[0].SetValues([]pvf.ScriptValue{value})
} else {
_, err = doc.AppendSection("name", []pvf.ScriptValue{value}, false)
}
if err != nil {
return fail(err)
}
raw, err = stage.EncodeScriptDocument(doc)
if err != nil {
return fail(err)
}
if err := stage.SetRawBytes(index, raw); err != nil {
return fail(err)
}
li, _ := stage.Find(check.ListPath)
if err := stage.SetListPair(li, req.ID, check.EntryPath); err != nil {
return fail(err)
}
if stage.ContentRules().RequiresIndexHash {
hp, _ := pvf.IndexHashCompanionPath(check.ListPath)
id, _ := strconv.ParseUint(req.ID, 10, 32)
if err := stage.SetIndexHashEntriesForListIDs(hp, check.ListPath, []uint32{uint32(id)}); err != nil {
return fail(err)
}
}
paths := []string{p, check.ListPath}
if stage.ContentRules().RequiresIndexHash {
hp, _ := pvf.IndexHashCompanionPath(check.ListPath)
paths = append(paths, hp)
}
// Capture consumed drafts in the result so the frontend can advance only
// those baselines and preserve every unrelated editor draft.
result := &ItemCreateResult{FileIndex: index, Path: p, ID: req.ID, Files: []ShopEditedFile{}}
for _, changedPath := range paths[1:] {
i, _ := a.Find(changedPath)
before, err := a.Text(i)
if err != nil {
return fail(err)
}
for _, d := range req.Drafts {
if d.FileIndex == i {
before = d.Text
}
}
after, err := stage.Text(i)
if err != nil {
return fail(err)
}
result.Files = append(result.Files, ShopEditedFile{FileIndex: i, Path: changedPath, BeforeText: before, Text: after})
}
var before, after pvfversion.ContentSnapshot
if s.c.versionRepo != nil {
before, err = pvfversion.ContentSnapshotFromArchive(a, paths)
if err != nil {
return fail(err)
}
after, err = pvfversion.ContentSnapshotFromArchive(stage, paths)
if err != nil {
return fail(err)
}
}
revision := s.c.batchRevision
incremental := s.c.diskIndex != nil && s.c.diskIndex.entries != nil
s.c.mu.Unlock()
var prepared *preparedArchiveIndexes
if !incremental {
prepared, err = prepareArchiveDerivedIndexes(stage)
}
s.c.mu.Lock()
if err != nil {
closePreparedArchiveIndexes(prepared)
return fail(err)
}
if s.c.archive != a || s.c.batchRevision != revision {
closePreparedArchiveIndexes(prepared)
return fail(fmt.Errorf("归档已变化,请重新检查 ID"))
}
if incremental {
err = s.c.rebuildArchiveIndexesLocked(stage)
} else {
err = s.c.installPreparedArchiveIndexesLocked(stage, prepared, true)
}
if err != nil {
closePreparedArchiveIndexes(prepared)
return fail(err)
}
if err := s.c.recordVersionMutationLocked("新建并登记物品", before, after); err != nil {
return fail(err)
}
summary := stage.MutationsSince(0)
stage.ClearMutations()
info := stage.Info()
s.c.mu.Unlock()
if err := s.c.scheduleArchiveMutations(stage, summary); err != nil {
return nil, err
}
emitEvent("archive:changed", info)
emitEvent("archive:batch-applied", map[string]any{"source": "item-create", "structural": true})
emitEvent("archive:advanced-search-stale", map[string]any{"item": true})
emitVersionState(s.c, "item-created")
return result, nil
}