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

408 lines
14 KiB
Go

package onlineevent
import (
"context"
"errors"
"fmt"
"path"
"strings"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
const (
// AttendanceEventPVFPath is the only attendance table proved in the
// current production Script.pvf. Loading it does not imply that the event
// is enabled, dated, or attached to a client command.
AttendanceEventPVFPath = "event/chn_event/chn_attendanceevent.evt"
attendanceStackableListPath = "stackable/stackable.lst"
attendanceRewardCount = 4
)
var (
ErrAttendancePVFMalformed = errors.New("online-event attendance PVF is malformed")
ErrAttendanceRewardUnresolved = errors.New("online-event attendance reward item is unresolved")
)
const (
processTimeSecondsSection = "process time seconds"
rewardItemSection = "reward item"
processSecondsForMaxCountSection = "process seconds for max count"
rewardItemForSumSection = "reward item for sum"
rewardActivityForSumSection = "reward activity for sum"
)
// AttendancePVFItemReward is one item/count pair copied directly from an
// attendance PVF section. DefinitionPath is resolved through stackable.lst
// and verified readable while the catalog is loaded.
type AttendancePVFItemReward struct {
ItemID int64
Count int64
DefinitionPath string
}
// AttendancePVFSnapshot is a detached view of the five proved PVF sections.
// ProcessDurationsSeconds deliberately preserves each raw duration; it is not
// converted to cumulative time. SumThresholds likewise retains the source
// values without supplying reset, date, activation, or protocol semantics.
type AttendancePVFSnapshot struct {
ProcessDurationsSeconds []int64
RewardItems []AttendancePVFItemReward
SumThresholds []int64
RewardItemsForSum []AttendancePVFItemReward
RewardActivityForSum []int64
}
// AttendancePVFCatalog is an immutable, protocol-independent projection of
// chn_attendanceevent.evt. It is intentionally not converted into Definition:
// the current PVF does not prove an event ID, dates, enabled state, daily reset
// rule, or whether the raw process durations are cumulative.
type AttendancePVFCatalog struct {
snapshot AttendancePVFSnapshot
}
// LoadAttendancePVFCatalog parses the five proved attendance sections and
// verifies all eight reward item definitions through stackable.lst. It does
// not register a handler or publish any client packet.
func LoadAttendancePVFCatalog(ctx context.Context, source dnfpvf.Source) (*AttendancePVFCatalog, error) {
if source == nil {
return nil, dnfpvf.ErrSourceRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return nil, err
}
text, err := source.ReadText(AttendanceEventPVFPath)
if err != nil {
return nil, fmt.Errorf("read online-event attendance PVF %q: %w", AttendanceEventPVFPath, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
document, err := dnfpvf.Parse(AttendanceEventPVFPath, text)
if err != nil {
return nil, fmt.Errorf("parse online-event attendance PVF %q: %w", AttendanceEventPVFPath, err)
}
if err := validateAttendanceSectionLayout(document); err != nil {
return nil, err
}
processDurations, err := attendanceIntegerSection(document, processTimeSecondsSection, attendanceRewardCount)
if err != nil {
return nil, err
}
if err := requirePositiveAttendanceValues(processTimeSecondsSection, processDurations); err != nil {
return nil, err
}
rewardValues, err := attendanceIntegerSection(document, rewardItemSection, attendanceRewardCount*2)
if err != nil {
return nil, err
}
rewards, err := parseAttendanceRewards(rewardItemSection, rewardValues)
if err != nil {
return nil, err
}
sumThresholds, err := attendanceIntegerSection(document, processSecondsForMaxCountSection, attendanceRewardCount)
if err != nil {
return nil, err
}
if err := requirePositiveAttendanceValues(processSecondsForMaxCountSection, sumThresholds); err != nil {
return nil, err
}
sumRewardValues, err := attendanceIntegerSection(document, rewardItemForSumSection, attendanceRewardCount*2)
if err != nil {
return nil, err
}
sumRewards, err := parseAttendanceRewards(rewardItemForSumSection, sumRewardValues)
if err != nil {
return nil, err
}
rewardActivityForSum, err := attendanceIntegerSection(document, rewardActivityForSumSection, 1)
if err != nil {
return nil, err
}
allRewards := make([]AttendancePVFItemReward, 0, len(rewards)+len(sumRewards))
allRewards = append(allRewards, rewards...)
allRewards = append(allRewards, sumRewards...)
definitionPaths, err := resolveAttendanceRewardDefinitions(ctx, source, allRewards)
if err != nil {
return nil, err
}
attachAttendanceDefinitionPaths(rewards, definitionPaths)
attachAttendanceDefinitionPaths(sumRewards, definitionPaths)
return &AttendancePVFCatalog{snapshot: AttendancePVFSnapshot{
ProcessDurationsSeconds: append([]int64(nil), processDurations...),
RewardItems: append([]AttendancePVFItemReward(nil), rewards...),
SumThresholds: append([]int64(nil), sumThresholds...),
RewardItemsForSum: append([]AttendancePVFItemReward(nil), sumRewards...),
RewardActivityForSum: append([]int64(nil), rewardActivityForSum...),
}}, nil
}
// Snapshot returns copies of every slice so callers cannot mutate the loaded
// catalog.
func (c *AttendancePVFCatalog) Snapshot() AttendancePVFSnapshot {
if c == nil {
return AttendancePVFSnapshot{}
}
snapshot := c.snapshot
snapshot.ProcessDurationsSeconds = append([]int64(nil), snapshot.ProcessDurationsSeconds...)
snapshot.RewardItems = append([]AttendancePVFItemReward(nil), snapshot.RewardItems...)
snapshot.SumThresholds = append([]int64(nil), snapshot.SumThresholds...)
snapshot.RewardItemsForSum = append([]AttendancePVFItemReward(nil), snapshot.RewardItemsForSum...)
snapshot.RewardActivityForSum = append([]int64(nil), snapshot.RewardActivityForSum...)
return snapshot
}
func attendanceIntegerSection(document *dnfpvf.Document, name string, exactCount int) ([]int64, error) {
if document == nil {
return nil, fmt.Errorf("%w: path=%s section=[%s] document is nil", ErrAttendancePVFMalformed, AttendanceEventPVFPath, name)
}
sectionCount := 0
for _, section := range document.Sections {
if strings.EqualFold(strings.TrimSpace(section.Name), name) {
sectionCount++
}
}
if sectionCount != 1 {
return nil, fmt.Errorf("%w: path=%s section=[%s] count=%d want=1", ErrAttendancePVFMalformed, AttendanceEventPVFPath, name, sectionCount)
}
tokens, ok := document.Section(name)
if !ok {
return nil, fmt.Errorf("%w: path=%s section=[%s] is unreadable", ErrAttendancePVFMalformed, AttendanceEventPVFPath, name)
}
if len(tokens) != exactCount {
return nil, fmt.Errorf("%w: path=%s section=[%s] values=%d want=%d", ErrAttendancePVFMalformed, AttendanceEventPVFPath, name, len(tokens), exactCount)
}
values := make([]int64, len(tokens))
for index, token := range tokens {
if token.Kind != dnfpvf.TokenInt {
return nil, fmt.Errorf(
"%w: path=%s section=[%s] index=%d token=%q at %d:%d is not an integer",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
name,
index,
token.Raw,
token.Line,
token.Column,
)
}
values[index] = token.Int
}
return values, nil
}
func validateAttendanceSectionLayout(document *dnfpvf.Document) error {
if document == nil {
return fmt.Errorf("%w: path=%s document is nil", ErrAttendancePVFMalformed, AttendanceEventPVFPath)
}
names := []string{
processTimeSecondsSection,
rewardItemSection,
processSecondsForMaxCountSection,
rewardItemForSumSection,
rewardActivityForSumSection,
}
expected := make([]string, 0, len(names)*2)
for _, name := range names {
expected = append(expected, strings.ToLower(name), "/"+strings.ToLower(name))
}
if len(document.Sections) != len(expected) {
return fmt.Errorf(
"%w: path=%s section_markers=%d want=%d",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
len(document.Sections),
len(expected),
)
}
for index, section := range document.Sections {
name := strings.ToLower(strings.TrimSpace(section.Name))
if name != expected[index] {
return fmt.Errorf(
"%w: path=%s section_marker[%d]=[%s] want=[%s]",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
index,
section.Name,
expected[index],
)
}
markerIndex := section.Start - 1
if markerIndex < 0 || markerIndex >= len(document.Tokens) ||
document.Tokens[markerIndex].Kind != dnfpvf.TokenSection ||
!strings.EqualFold(strings.TrimSpace(document.Tokens[markerIndex].Value), strings.TrimSpace(section.Name)) {
return fmt.Errorf(
"%w: path=%s section_marker[%d] token range is invalid",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
index,
)
}
if index == 0 && markerIndex != 0 {
return fmt.Errorf(
"%w: path=%s has %d root tokens before the first section",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
markerIndex,
)
}
if strings.HasPrefix(name, "/") && section.Start != section.End {
return fmt.Errorf(
"%w: path=%s closing section=[%s] contains %d values",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
section.Name,
section.End-section.Start,
)
}
}
return nil
}
func requirePositiveAttendanceValues(section string, values []int64) error {
for index, value := range values {
if value <= 0 {
return fmt.Errorf("%w: path=%s section=[%s] index=%d value=%d is not positive", ErrAttendancePVFMalformed, AttendanceEventPVFPath, section, index, value)
}
}
return nil
}
func parseAttendanceRewards(section string, values []int64) ([]AttendancePVFItemReward, error) {
if len(values) != attendanceRewardCount*2 {
return nil, fmt.Errorf("%w: path=%s section=[%s] values=%d want=%d", ErrAttendancePVFMalformed, AttendanceEventPVFPath, section, len(values), attendanceRewardCount*2)
}
rewards := make([]AttendancePVFItemReward, 0, attendanceRewardCount)
for index := 0; index < len(values); index += 2 {
itemID, count := values[index], values[index+1]
if itemID <= 0 || count <= 0 {
return nil, fmt.Errorf(
"%w: path=%s section=[%s] pair=%d item_id=%d count=%d",
ErrAttendancePVFMalformed,
AttendanceEventPVFPath,
section,
index/2,
itemID,
count,
)
}
rewards = append(rewards, AttendancePVFItemReward{ItemID: itemID, Count: count})
}
return rewards, nil
}
func resolveAttendanceRewardDefinitions(
ctx context.Context,
source dnfpvf.Source,
rewards []AttendancePVFItemReward,
) (map[int64]string, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
listText, err := source.ReadText(attendanceStackableListPath)
if err != nil {
return nil, fmt.Errorf("read online-event reward list %q: %w", attendanceStackableListPath, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
listDocument, err := dnfpvf.Parse(attendanceStackableListPath, listText)
if err != nil {
return nil, fmt.Errorf("parse online-event reward list %q: %w", attendanceStackableListPath, err)
}
wanted := make(map[int64]struct{}, len(rewards))
wantedOrder := make([]int64, 0, len(rewards))
for _, reward := range rewards {
if _, duplicate := wanted[reward.ItemID]; !duplicate {
wantedOrder = append(wantedOrder, reward.ItemID)
}
wanted[reward.ItemID] = struct{}{}
}
listed := make(map[int64]string, len(wanted))
for _, entry := range dnfpvf.ParseList(listDocument) {
if _, needed := wanted[entry.ID]; !needed {
continue
}
if previous, duplicate := listed[entry.ID]; duplicate {
return nil, fmt.Errorf(
"%w: list=%s item_id=%d duplicate paths=%q,%q",
ErrAttendancePVFMalformed,
attendanceStackableListPath,
entry.ID,
previous,
entry.Path,
)
}
listed[entry.ID] = entry.Path
}
resolved := make(map[int64]string, len(wanted))
for _, itemID := range wantedOrder {
if err := ctx.Err(); err != nil {
return nil, err
}
listedPath, ok := listed[itemID]
if !ok {
return nil, fmt.Errorf("%w: list=%s item_id=%d", ErrAttendanceRewardUnresolved, attendanceStackableListPath, itemID)
}
definitionPath, err := attendanceStackableDefinitionPath(listedPath)
if err != nil {
return nil, fmt.Errorf("%w: list=%s item_id=%d path=%q: %v", ErrAttendanceRewardUnresolved, attendanceStackableListPath, itemID, listedPath, err)
}
if _, err := source.ReadText(definitionPath); err != nil {
return nil, fmt.Errorf("%w: item_id=%d definition=%s: %v", ErrAttendanceRewardUnresolved, itemID, definitionPath, err)
}
if err := ctx.Err(); err != nil {
return nil, err
}
resolved[itemID] = definitionPath
}
return resolved, nil
}
func attendanceStackableDefinitionPath(listedPath string) (string, error) {
clean := strings.TrimSpace(strings.ReplaceAll(listedPath, "\\", "/"))
if strings.HasPrefix(clean, "/") || (len(clean) >= 2 && clean[1] == ':') {
return "", errors.New("definition path is absolute")
}
clean = strings.TrimPrefix(clean, "./")
for _, segment := range strings.Split(clean, "/") {
if segment == ".." {
return "", errors.New("definition path escapes stackable root")
}
}
clean = path.Clean(clean)
if clean == "" || clean == "." {
return "", errors.New("empty definition path")
}
if clean == ".." || strings.HasPrefix(clean, "../") {
return "", errors.New("definition path escapes stackable root")
}
root := path.Dir(attendanceStackableListPath)
if !strings.HasPrefix(strings.ToLower(clean), strings.ToLower(root)+"/") {
clean = path.Join(root, clean)
}
if !strings.HasPrefix(strings.ToLower(clean), strings.ToLower(root)+"/") {
return "", errors.New("definition path escapes stackable root")
}
if !strings.EqualFold(path.Ext(clean), ".stk") {
return "", errors.New("definition path is not a stackable .stk file")
}
return clean, nil
}
func attachAttendanceDefinitionPaths(rewards []AttendancePVFItemReward, paths map[int64]string) {
for index := range rewards {
rewards[index].DefinitionPath = paths[rewards[index].ItemID]
}
}