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

+529
View File
@@ -0,0 +1,529 @@
package services
import (
"fmt"
"math"
"strconv"
"strings"
"time"
"unicode/utf16"
annotationrules "pvfine/internal/annotations"
"pvfine/internal/pvf"
)
// EquipmentPreviewAttribute is one visible line in an equipment tooltip.
type EquipmentPreviewAttribute struct {
Label string `json:"label"`
Value string `json:"value"`
Negative bool `json:"negative"`
}
// EquipmentSkillLevelup is one profession-aware skill level bonus.
type EquipmentSkillLevelup struct {
Job string `json:"job"`
Skill string `json:"skill"`
Level int32 `json:"level"`
}
// EquipmentSetAbility is one bonus unlocked by wearing a number of set pieces.
type EquipmentSetAbility struct {
Pieces int32 `json:"pieces"`
BaseExplain string `json:"baseExplain"`
DetailExplain string `json:"detailExplain"`
}
// EquipmentSetPreviewDocument is the set tooltip associated with an equipment.
type EquipmentSetPreviewDocument struct {
Name string `json:"name"`
Parts []string `json:"parts"`
Abilities []EquipmentSetAbility `json:"abilities"`
}
// EquipmentPreviewDocument is the game-style data model rendered by the
// frontend. Optional sections are represented by empty strings/slices.
type EquipmentPreviewDocument struct {
Icon *ImageReference `json:"icon"`
Name string `json:"name"`
Name2 string `json:"name2"`
Rarity int32 `json:"rarity"`
RarityLabel string `json:"rarityLabel"`
QualityText string `json:"qualityText"`
EquipmentType string `json:"equipmentType"`
ItemGroupName string `json:"itemGroupName"`
AttachType string `json:"attachType"`
MinimumLevelText string `json:"minimumLevelText"`
UsableJobs []string `json:"usableJobs"`
FameText string `json:"fameText"`
BaseAttributes []EquipmentPreviewAttribute `json:"baseAttributes"`
FourDimensions []EquipmentPreviewAttribute `json:"fourDimensions"`
OtherAttributes []EquipmentPreviewAttribute `json:"otherAttributes"`
SkillLevelups []EquipmentSkillLevelup `json:"skillLevelups"`
BaseExplain string `json:"baseExplain"`
DetailExplain string `json:"detailExplain"`
BuffBasicExplain string `json:"buffBasicExplain"`
FlavorText string `json:"flavorText"`
DurabilityText string `json:"durabilityText"`
WeightText string `json:"weightText"`
PriceText string `json:"priceText"`
PartSet *EquipmentSetPreviewDocument `json:"partSet,omitempty"`
Issues []PreviewIssue `json:"issues"`
}
// ParseEQU parses the current editor text. Archive state is only used for the
// file path and relation lookups; text itself always comes from the caller so
// unsaved changes are reflected immediately.
func (s *PreviewService) ParseEQU(fileIndex int32, text string) (*EquipmentPreviewDocument, error) {
defer debugPhase("ParseEQU", fmt.Sprintf("file=%d", fileIndex))()
if s == nil || s.c == nil {
engine, err := annotationrules.LoadDefault()
if err != nil {
return nil, err
}
return buildEquipmentPreview("preview.equ", text, engine.ForVersion(""), nil), nil
}
wait := time.Now()
diagnoseCoreLock("ParseEQU", fmt.Sprintf("file=%d", fileIndex), s.c.mu.Lock)
debugLockAcquired("ParseEQU", wait)
defer s.c.mu.Unlock()
if s.c.archive == nil {
engine, err := annotationrules.LoadDefault()
if err != nil {
return nil, err
}
return buildEquipmentPreview("preview.equ", text, engine.ForVersion(""), nil), nil
}
if err := validateAnnotationIndex(s.c.archive, fileIndex); err != nil {
return nil, err
}
if s.c.annotationEngine == nil {
if s.c.annotationErr != nil {
return nil, s.c.annotationErr
}
return nil, fmt.Errorf("标注引擎未初始化")
}
finishParse := debugPhase("ParseEQU.fields-and-relations", fmt.Sprintf("file=%d", fileIndex))
doc := buildEquipmentPreview(
s.c.archive.Path(fileIndex), text, s.c.annotationEngine,
s.c.resolveAnnotationReferenceContextLocked,
)
finishParse()
// Newer clients store `<table::key>` placeholders instead of display text.
finishNames := debugPhase("ParseEQU.localized-names", fmt.Sprintf("file=%d", fileIndex))
doc.Name = resolvePreviewText(s.c.archive, doc.Name)
doc.Name2 = resolvePreviewText(s.c.archive, doc.Name2)
finishNames()
doc.BaseExplain = normalizeExplain(resolvePreviewDescription(s.c.archive, doc.BaseExplain))
doc.DetailExplain = normalizeExplain(resolvePreviewDescription(s.c.archive, doc.DetailExplain))
doc.BuffBasicExplain = normalizeExplain(resolvePreviewDescription(s.c.archive, doc.BuffBasicExplain))
doc.FlavorText = normalizeDisplayText(resolvePreviewDescription(s.c.archive, doc.FlavorText))
doc.PartSet = s.readEquipmentSetPreviewLocked(text, &doc.Issues)
return doc, nil
}
// untranslatedMark is appended to a name that only a language overlay could
// answer: this client's own localization has no text for that key (its entry is
// an empty `key=`), so the fallback text is the Korean/translated original
// rather than what the game displays.
const untranslatedMark = "(未翻译)"
// resolvePreviewText turns a raw name value into display text: an optional
// `{N=`...`}` block marker is unwrapped and `<table::key>` placeholders are
// resolved through the archive's string tables.
func resolvePreviewText(a *pvf.Archive, text string) string {
s := strings.TrimSpace(text)
if s == "" {
return text
}
if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") {
if eq := strings.IndexByte(s, '='); eq > 0 {
s = strings.Trim(strings.TrimSpace(s[eq+1:len(s)-1]), "`")
}
}
if a == nil {
return s
}
return a.ResolvePlaceholdersMarked(s, untranslatedMark)
}
// resolvePreviewDescription preserves literal whitespace while unwrapping
// exported string tokens and resolving their string-table references.
func resolvePreviewDescription(a *pvf.Archive, text string) string {
s := strings.TrimSpace(text)
if strings.HasPrefix(s, "{") && strings.HasSuffix(s, "}") {
return resolvePreviewText(a, text)
}
return a.ResolvePlaceholdersMarked(text, untranslatedMark)
}
func buildEquipmentPreview(filePath, text string, engine *annotationrules.Engine, resolver annotationrules.ContextResolver) *EquipmentPreviewDocument {
document := &EquipmentPreviewDocument{
QualityText: "最上级(100%)",
UsableJobs: make([]string, 0),
BaseAttributes: make([]EquipmentPreviewAttribute, 0),
FourDimensions: make([]EquipmentPreviewAttribute, 0),
OtherAttributes: make([]EquipmentPreviewAttribute, 0),
SkillLevelups: make([]EquipmentSkillLevelup, 0),
Issues: make([]PreviewIssue, 0),
}
view := pvf.ParseScriptView(text)
occurrences := engine.ExtractPreviewFields(filePath, view, "equ")
roleValues := make(map[string][]annotationrules.PreviewFieldValue)
for _, occurrence := range occurrences {
role := ""
if occurrence.Field.Preview != nil {
role = occurrence.Field.Preview.Role
}
if role != "" {
roleValues[role] = append(roleValues[role], occurrence)
}
}
first := func(role string) (annotationrules.PreviewFieldValue, bool) {
items := roleValues[role]
if len(items) == 0 {
return annotationrules.PreviewFieldValue{}, false
}
return items[0], true
}
if value, ok := first("name"); ok {
document.Name = joinFieldValues(value.Values)
}
if value, ok := first("name2"); ok {
document.Name2 = joinFieldValues(value.Values)
}
if value, ok := first("icon"); ok {
readEquipmentIcon(document, value, text, &document.Issues)
}
if value, ok := first("rarity"); ok {
raw := firstValue(value.Values)
if parsed, ok := strconv.ParseInt(strings.TrimSpace(raw), 10, 32); ok == nil {
document.Rarity = int32(parsed)
} else {
addPreviewIssue(&document.Issues, text, value.Start, "warning", "rarity", "稀有度不是有效整数: "+raw)
}
document.RarityLabel = enumValue(value.Field, raw, document, text, value.Start)
}
if value, ok := first("equipment-type"); ok {
document.EquipmentType = enumValue(value.Field, firstValue(value.Values), document, text, value.Start)
}
if value, ok := first("item-group-name"); ok {
document.ItemGroupName = enumValue(value.Field, firstValue(value.Values), document, text, value.Start)
}
if value, ok := first("attach-type"); ok {
document.AttachType = enumValue(value.Field, firstValue(value.Values), document, text, value.Start)
}
if value, ok := first("minimum-level"); ok {
document.MinimumLevelText = formatMinimumLevel(value, text, &document.Issues)
}
if values := roleValues["usable-jobs"]; len(values) > 0 {
for _, occurrence := range values {
for _, raw := range occurrence.Values {
raw = strings.TrimSpace(raw)
if raw == "" || strings.EqualFold(raw, "[all]") {
continue
}
document.UsableJobs = append(document.UsableJobs, enumValue(occurrence.Field, raw, document, text, occurrence.Start))
}
}
}
for _, role := range []string{"unique-option-fame", "fame"} {
if value, ok := first(role); ok {
fame, err := strconv.ParseInt(strings.TrimSpace(firstValue(value.Values)), 10, 32)
if err != nil || fame < 0 {
addPreviewIssue(&document.Issues, text, value.Start, "warning", value.Field.Target.Section, "冒险家名望不是有效的非负整数")
continue
}
document.FameText = "冒险家名望 " + strconv.FormatInt(fame, 10)
break
}
}
for _, occurrence := range roleValues["base-attribute"] {
if attribute, ok := parseEquipmentAttribute(occurrence, text, &document.Issues); ok {
document.BaseAttributes = append(document.BaseAttributes, attribute)
}
}
for _, occurrence := range roleValues["four-dimension"] {
if attribute, ok := parseEquipmentAttribute(occurrence, text, &document.Issues); ok {
document.FourDimensions = append(document.FourDimensions, attribute)
}
}
for _, occurrence := range roleValues["other-attribute"] {
if attribute, ok := parseEquipmentAttribute(occurrence, text, &document.Issues); ok {
document.OtherAttributes = append(document.OtherAttributes, attribute)
}
}
for _, occurrence := range roleValues["skill-levelup"] {
if len(occurrence.Values) < 3 {
addPreviewIssue(&document.Issues, text, occurrence.Start, "error", "skill levelup", "技能等级加成记录需要职业、技能 ID 和等级")
continue
}
job := enumValue(occurrence.Field, occurrence.Values[0], document, text, occurrence.Start)
skillID := strings.TrimSpace(occurrence.Values[1])
skill := skillID
if resolver != nil {
if reference, ok := resolver("技能", skillID, occurrence.Context); ok {
if strings.TrimSpace(reference.Name) != "" {
skill = reference.Name
}
} else {
addPreviewIssue(&document.Issues, text, occurrence.Start, "warning", "skill levelup", "未找到技能 ID: "+skillID)
}
} else if skillID != "" {
addPreviewIssue(&document.Issues, text, occurrence.Start, "warning", "skill levelup", "未配置技能关联,显示原始 ID: "+skillID)
}
level, err := strconv.ParseInt(strings.TrimSpace(occurrence.Values[2]), 10, 32)
if err != nil {
addPreviewIssue(&document.Issues, text, occurrence.Start, "warning", "skill levelup", "技能等级不是有效整数: "+occurrence.Values[2])
continue
}
document.SkillLevelups = append(document.SkillLevelups, EquipmentSkillLevelup{Job: job, Skill: skill, Level: int32(level)})
}
for _, occurrence := range roleValues["base-explain"] {
if document.BaseExplain == "" {
document.BaseExplain = normalizeExplain(joinFieldValues(occurrence.Values))
}
}
if value, ok := first("detail-explain"); ok {
document.DetailExplain = normalizeExplain(joinFieldValues(value.Values))
}
if value, ok := first("buff-basic-explain"); ok {
document.BuffBasicExplain = normalizeExplain(joinFieldValues(value.Values))
}
if value, ok := first("flavor-text"); ok {
document.FlavorText = normalizeDisplayText(joinFieldValuesPreserve(value.Values))
}
if value, ok := first("durability"); ok {
document.DurabilityText = formatDurability(value, text, &document.Issues)
}
if value, ok := first("weight"); ok {
document.WeightText = formatWeight(value, text, &document.Issues)
}
if value, ok := first("price"); ok {
document.PriceText = formatPrice(value, text, &document.Issues)
}
return document
}
func readEquipmentIcon(document *EquipmentPreviewDocument, occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) {
pathIndex := occurrence.Field.Target.ImagePathToken
imageIndex := occurrence.Field.Target.Index
if pathIndex == nil || imageIndex == nil || *pathIndex < 0 || *imageIndex < 0 ||
*pathIndex >= len(occurrence.Values) || *imageIndex >= len(occurrence.Values) {
addPreviewIssue(issues, text, occurrence.Start, "warning", "icon", "图标记录缺少图片路径或索引")
return
}
imagePath := strings.TrimSpace(occurrence.Values[*pathIndex])
parsedIndex, err := strconv.ParseInt(strings.TrimSpace(occurrence.Values[*imageIndex]), 10, 32)
if imagePath == "" || err != nil || parsedIndex < 0 {
// An empty icon is a valid, non-blocking state. Do not manufacture a
// broken image reference for the frontend.
return
}
document.Icon = &ImageReference{Path: imagePath, Index: int32(parsedIndex)}
}
func enumValue(field annotationrules.FieldDefinition, raw string, document *EquipmentPreviewDocument, text string, start int) string {
value := strings.TrimSpace(raw)
if label, ok := field.Annotation.Values[value]; ok {
return label
}
if len(field.Annotation.Values) > 0 && value != "" {
addPreviewIssue(&document.Issues, text, start, "warning", field.Annotation.Title, "未知枚举值: "+value)
}
return value
}
func parseEquipmentAttribute(occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) (EquipmentPreviewAttribute, bool) {
label := occurrence.Field.Annotation.Title
if occurrence.Field.Preview != nil && strings.TrimSpace(occurrence.Field.Preview.Label) != "" {
label = occurrence.Field.Preview.Label
}
if strings.TrimSpace(label) == "" {
return EquipmentPreviewAttribute{}, false
}
values := occurrence.Values
if len(values) == 0 {
return EquipmentPreviewAttribute{}, false
}
format := "signed-number"
if occurrence.Field.Preview != nil {
format = occurrence.Field.Preview.Format
}
if format == "range" {
if len(values) < 2 {
addPreviewIssue(issues, text, occurrence.Start, "warning", label, "范围属性缺少最大值或最小值")
return EquipmentPreviewAttribute{}, false
}
left, leftOK := parsePreviewNumber(values[0])
right, rightOK := parsePreviewNumber(values[1])
if !leftOK || !rightOK {
addPreviewIssue(issues, text, occurrence.Start, "warning", label, "范围属性包含非法数值")
return EquipmentPreviewAttribute{}, false
}
if left > right {
left, right = right, left
}
negative := left < 0 || right < 0
return EquipmentPreviewAttribute{Label: label, Value: signedNumber(left) + "-" + trimLeadingPlus(formatNumber(right)), Negative: negative}, true
}
number, ok := parsePreviewNumber(firstValue(values))
if !ok {
addPreviewIssue(issues, text, occurrence.Start, "warning", label, "属性值不是有效数值: "+firstValue(values))
return EquipmentPreviewAttribute{}, false
}
value := signedNumber(number)
if format == "percent" {
value += "%"
}
return EquipmentPreviewAttribute{Label: label, Value: value, Negative: number < 0}, true
}
func formatMinimumLevel(occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) string {
raw := firstValue(occurrence.Values)
level := strings.Fields(strings.TrimSpace(raw))
if len(level) == 0 {
return ""
}
if _, err := strconv.Atoi(strings.Trim(level[0], "()")); err != nil {
addPreviewIssue(issues, text, occurrence.Start, "warning", "minimum level", "使用等级不是有效整数: "+raw)
return ""
}
return "Lv" + strings.Trim(level[0], "()") + "以上可以使用"
}
func formatDurability(occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) string {
raw := firstValue(occurrence.Values)
number, err := strconv.ParseInt(strings.TrimSpace(raw), 10, 32)
if err != nil || number < 0 {
addPreviewIssue(issues, text, occurrence.Start, "warning", "durability", "耐久度不是有效整数: "+raw)
return ""
}
return fmt.Sprintf("%d/%d", number, number)
}
func formatWeight(occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) string {
number, ok := parsePreviewNumber(firstValue(occurrence.Values))
if !ok || number < 0 {
addPreviewIssue(issues, text, occurrence.Start, "warning", "weight", "重量不是有效数值: "+firstValue(occurrence.Values))
return ""
}
if number < 1000 {
return formatNumber(number) + "g"
}
return formatNumber(number/1000) + "kg"
}
func formatPrice(occurrence annotationrules.PreviewFieldValue, text string, issues *[]PreviewIssue) string {
number, ok := parsePreviewNumber(firstValue(occurrence.Values))
if !ok {
addPreviewIssue(issues, text, occurrence.Start, "warning", "value", "售价不是有效数值: "+firstValue(occurrence.Values))
return ""
}
return formatNumber(math.Trunc(number / 5))
}
func parsePreviewNumber(value string) (float64, bool) {
value = strings.TrimSpace(strings.TrimSuffix(value, "%"))
if value == "" {
return 0, false
}
parsed, err := strconv.ParseFloat(value, 64)
return parsed, err == nil && !math.IsNaN(parsed) && !math.IsInf(parsed, 0)
}
func signedNumber(value float64) string {
formatted := formatNumber(value)
if value >= 0 {
return "+" + formatted
}
return formatted
}
func trimLeadingPlus(value string) string {
return strings.TrimPrefix(value, "+")
}
func formatNumber(value float64) string {
if math.Trunc(value) == value {
return strconv.FormatInt(int64(value), 10)
}
return strconv.FormatFloat(value, 'f', -1, 64)
}
func joinFieldValues(values []string) string {
parts := make([]string, 0, len(values))
for _, value := range values {
if strings.TrimSpace(value) != "" {
parts = append(parts, strings.TrimSpace(value))
}
}
return strings.TrimSpace(strings.Join(parts, " "))
}
func joinFieldValuesPreserve(values []string) string {
return strings.Join(values, " ")
}
func firstValue(values []string) string {
if len(values) == 0 {
return ""
}
return values[0]
}
func normalizeExplain(value string) string {
return strings.TrimSpace(normalizeDisplayText(strings.ReplaceAll(value, "%%", "%")))
}
func normalizeDisplayText(value string) string {
value = strings.ReplaceAll(value, "\r\n", "\n")
value = strings.ReplaceAll(value, "\r", "\n")
// Some exported text stores line breaks as the two-character escape `\\n`.
value = strings.ReplaceAll(value, `\r\n`, "\n")
value = strings.ReplaceAll(value, `\r`, "\n")
value = strings.ReplaceAll(value, `\n`, "\n")
return value
}
func addPreviewIssue(issues *[]PreviewIssue, text string, offset int, severity, section, message string) {
if issues == nil {
return
}
*issues = append(*issues, PreviewIssue{
Severity: severity,
Line: int32(lineAtUTF16Offset(text, offset)),
Section: section,
Message: message,
})
}
func lineAtUTF16Offset(text string, target int) int {
if target < 0 {
return 1
}
line := 1
offset := 0
runes := []rune(text)
for i, value := range runes {
if offset >= target {
return line
}
if value == '\n' {
line++
}
if value == '\r' && i+1 < len(runes) && runes[i+1] == '\n' {
continue
}
offset += len(utf16.Encode([]rune{value}))
}
return line
}