Files
90CNServer/pvf/services/item_creation.go
T
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

323 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}