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

@@ -0,0 +1,268 @@
package onlineevent
import (
"context"
"encoding/json"
"fmt"
"math"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const PCRoomTimePointPVFPath = "etc/pcroomtimepoint.etc"
const PCRoomTimePointMetadataKey = "online_pcroom_timepoint_228_v1"
// Current EXE 0x1BD4670/0x3885270 loads these rewards independently of
// chn_AttendanceEvent.evt. Durations and period requirements are incremental.
type PCRoomTimePointCatalog struct {
Daily [4]AttendancePVFItemReward
Period [4]AttendancePVFItemReward
Seconds [4]uint32
Counts [4]uint32
UIPath string
}
func LoadPCRoomTimePointCatalog(ctx context.Context, source dnfpvf.Source) (*PCRoomTimePointCatalog, error) {
if source == nil {
return nil, dnfpvf.ErrSourceRequired
}
if err := ctx.Err(); err != nil {
return nil, err
}
text, err := source.ReadText(PCRoomTimePointPVFPath)
if err != nil {
return nil, err
}
doc, err := dnfpvf.Parse(PCRoomTimePointPVFPath, text)
if err != nil {
return nil, err
}
c := &PCRoomTimePointCatalog{}
for name, want := range map[string]string{"daily reward autoget": "false", "daily reward loop": "1", "period reward loop": "4"} {
tokens, ok := doc.Section(name)
if !ok || len(tokens) != 1 || tokens[0].Value != want {
return nil, fmt.Errorf("pcroom [%s] differs from current manual four-stage edition", name)
}
}
for _, spec := range []struct {
name string
width int
}{{"daily reward items", 6}, {"period reward item", 5}} {
tokens, ok := doc.Section(spec.name)
if !ok || len(tokens) != 4*spec.width {
return nil, fmt.Errorf("pcroom [%s] has invalid shape", spec.name)
}
var cumulative uint64
for i := 0; i < 4; i++ {
row := tokens[i*spec.width : (i+1)*spec.width]
for j := 0; j < 4; j++ {
if row[j].Kind != dnfpvf.TokenInt || row[j].Int <= 0 || row[j].Int > math.MaxUint32 {
return nil, fmt.Errorf("pcroom [%s] row %d integer %d invalid", spec.name, i, j)
}
}
for j := 4; j < spec.width; j++ {
if row[j].Value != "true" {
return nil, fmt.Errorf("pcroom [%s] row %d unsupported flag", spec.name, i)
}
}
r := AttendancePVFItemReward{ItemID: row[1].Int, Count: row[2].Int}
if spec.width == 6 {
if row[0].Int != 1 || row[3].Int%1000 != 0 {
return nil, fmt.Errorf("pcroom daily group/duration invalid")
}
cumulative += uint64(row[3].Int / 1000)
if cumulative > secondsPerDay {
return nil, fmt.Errorf("pcroom daily duration exceeds service day")
}
c.Daily[i], c.Seconds[i] = r, uint32(cumulative)
} else {
if row[0].Int != int64(i+1) {
return nil, fmt.Errorf("pcroom period slot invalid")
}
cumulative += uint64(row[3].Int)
if cumulative > math.MaxInt32 {
return nil, fmt.Errorf("pcroom period count overflows client")
}
c.Period[i], c.Counts[i] = r, uint32(cumulative)
}
}
}
ui, ok := doc.Section("ui file")
if !ok || len(ui) != 1 || strings.ToLower(ui[0].Value) != "ui/event/pcroomtimepoint/pcroomtimepointrewarditeminfowindow_chn.xui" {
return nil, fmt.Errorf("pcroom native UI path differs from verified current client")
}
c.UIPath = ui[0].Value
if _, err := source.ReadText(c.UIPath); err != nil {
return nil, err
}
paths, err := resolveAttendanceRewardDefinitions(ctx, source, append(append([]AttendancePVFItemReward{}, c.Daily[:]...), c.Period[:]...))
if err != nil {
return nil, err
}
attachAttendanceDefinitionPaths(c.Daily[:], paths)
attachAttendanceDefinitionPaths(c.Period[:], paths)
return c, nil
}
type pcroomTimePointState struct {
Version int `json:"version"`
Date string `json:"date"`
DailyMask byte `json:"daily_mask"`
DayComplete bool `json:"day_complete"`
Total uint32 `json:"total"`
PeriodMask byte `json:"period_mask"`
}
type PCRoomTimePointSnapshot struct {
Seconds uint32
Total uint32
DailyMask byte
PeriodMask uint32
MailIDs []string
}
type PCRoomTimePointCommand struct {
AccountID, CharacterID string
Now time.Time
// Reuse the existing authoritative interval-union clock, including its
// 06:00 boundary, cross-character accounting and already persisted time.
ClockDefinition Definition
DailyClaim byte
PeriodClaim int // -1 means no period claim; 0..3 are native button indices.
Deliver func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error)
}
// SettlePCRoomTimePoint counts one fully completed service day, independently
// of clicking the fourth reward. Mail and masks commit in the same transaction.
// The current PVF edition is kept open locally; counts stop at its final tier.
func SettlePCRoomTimePoint(ctx context.Context, repos dnfrepo.Group, c *PCRoomTimePointCatalog, cmd PCRoomTimePointCommand) (PCRoomTimePointSnapshot, error) {
if c == nil || repos.MailboxAssets == nil || cmd.AccountID == "" || cmd.CharacterID == "" || cmd.Now.IsZero() || cmd.Deliver == nil {
return PCRoomTimePointSnapshot{}, ErrOwnerUnavailable
}
if cmd.DailyClaim&^byte(15) != 0 || cmd.PeriodClaim < -1 || cmd.PeriodClaim > 3 || (cmd.DailyClaim != 0 && cmd.PeriodClaim >= 0) {
return PCRoomTimePointSnapshot{}, ErrRewardInvalid
}
if err := cmd.ClockDefinition.Validate(); err != nil {
return PCRoomTimePointSnapshot{}, err
}
var result PCRoomTimePointSnapshot
err := repos.MailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(accounts dnfrepo.AccountRepository, _ dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, mailboxes dnfrepo.MailboxRepository) error {
character, found, err := characters.Load(ctx, cmd.CharacterID)
if err != nil {
return err
}
if !found || character.AccountID != cmd.AccountID {
return ErrAccountMismatch
}
account, found, err := accounts.Load(ctx, cmd.AccountID)
if err != nil {
return err
}
if !found {
return ErrAccountNotFound
}
clock, err := parseState(account.Metadata[metadataKey(cmd.ClockDefinition.ID)], cmd.ClockDefinition)
if err != nil {
return err
}
date, _ := calendarDay(cmd.ClockDefinition.calendar(), cmd.ClockDefinition.boundary(), cmd.Now)
if clock.CalendarDate > date {
return ErrObservationStale
}
var seconds uint32
if clock.CalendarDate == date {
seconds = uint32(clock.OnlineSeconds)
}
state := pcroomTimePointState{Version: 1, Date: date}
raw := account.Metadata[PCRoomTimePointMetadataKey]
if raw != "" {
decoder := json.NewDecoder(strings.NewReader(raw))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&state); err != nil {
return fmt.Errorf("%w: pcroom %v", ErrStateInvalid, err)
}
if err := rejectTrailingJSON(decoder); err != nil {
return err
}
if _, err := time.Parse(time.DateOnly, state.Date); err != nil {
return ErrStateInvalid
}
if state.Version != 1 || state.DailyMask > 15 || state.PeriodMask > 15 || state.Total > c.Counts[3] {
return ErrStateInvalid
}
for i, n := range c.Counts {
if state.PeriodMask&(1<<i) != 0 && state.Total < n {
return ErrStateInvalid
}
}
if state.Date > date {
return ErrObservationStale
}
if state.Date != date {
state.Date = date
state.DailyMask = 0
state.DayComplete = false
}
} else if clock.CalendarDate == date {
// One-time cutover: retain today's already delivered legacy rewards as
// claimed, so switching the UI does not grant a second set that day.
for i, stage := range cmd.ClockDefinition.Stages {
if i < 4 {
if _, ok := clock.Claims[stage.ID]; ok {
state.DailyMask |= 1 << i
}
}
}
}
if !state.DayComplete && seconds >= c.Seconds[3] {
state.DayComplete = true
if state.Total < c.Counts[3] {
state.Total++
}
}
result = PCRoomTimePointSnapshot{Seconds: seconds, Total: state.Total, DailyMask: state.DailyMask, PeriodMask: uint32(state.PeriodMask)}
deliver := func(reward AttendancePVFItemReward, key string) error {
id, err := cmd.Deliver(mailboxes, reward, PCRoomTimePointMetadataKey+":"+key)
if err != nil {
return err
}
if id == "" {
return fmt.Errorf("pcroom reward mail missing")
}
result.MailIDs = append(result.MailIDs, id)
return nil
}
for i, reward := range c.Daily {
bit := byte(1 << i)
if cmd.DailyClaim&bit == 0 || state.DailyMask&bit != 0 || seconds < c.Seconds[i] {
continue
}
if err := deliver(reward, fmt.Sprintf("daily:%s:%d", date, i)); err != nil {
return err
}
state.DailyMask |= bit
}
if i := cmd.PeriodClaim; i >= 0 && state.Total >= c.Counts[i] && state.PeriodMask&(1<<i) == 0 {
if err := deliver(c.Period[i], fmt.Sprintf("period:%d", i)); err != nil {
return err
}
state.PeriodMask |= 1 << i
}
result.DailyMask, result.PeriodMask = state.DailyMask, uint32(state.PeriodMask)
encoded, err := json.Marshal(state)
if err != nil {
return err
}
if string(encoded) == raw {
return nil
}
return dnfrepo.SaveAccountMetadataEntry(ctx, accounts, account, PCRoomTimePointMetadataKey, string(encoded), cmd.Now)
})
if err != nil {
return PCRoomTimePointSnapshot{}, err
}
return result, nil
}