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,263 @@
package adventuregroup
import (
"encoding/json"
"errors"
"fmt"
"math"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const RuntimeStateMetadataKey = "adventure_group_runtime_v1"
var (
ErrRuntimeStateInvalid = errors.New("adventure-group runtime state is invalid")
// ErrPurchaseLimitReached / ErrInsufficientShopPoints 是购买被拒的两种
// 业务原因。客户端 op1414 的结果码要按原因区分(19 = 超过购买限制、
// 22 = 购买材料不足),回一个笼统的失败码会让客户端**完全不弹提示** ——
// 所以这两种情况必须是可识别的独立错误,不能都并进 ErrRuntimeStateInvalid。
ErrPurchaseLimitReached = errors.New("adventure-group shop monthly purchase limit is reached")
ErrInsufficientShopPoints = errors.New("adventure-group shop points are insufficient")
)
type RuntimeState struct {
Version int `json:"version"`
ShopPoints [ShopPointTypeCount]uint32 `json:"shop_points"`
BraveExperience uint64 `json:"brave_experience"`
GrowthExperience uint64 `json:"growth_experience"`
Month string `json:"month"`
// CapsuleWeek 记录成长胶囊槽上一次"归属"的那一周(每周四 06:00 为界)。
// 见 Normalize:跨周才清零,且第一次见到空值是**认领当前值**而不是清零。
CapsuleWeek string `json:"capsule_week,omitempty"`
Purchases map[string]uint32 `json:"purchases,omitempty"`
Expeditions map[string]Expedition `json:"expeditions,omitempty"`
}
type Expedition struct {
Area byte `json:"area"`
State byte `json:"state"`
StartedAt int64 `json:"started_at"`
EndsAt int64 `json:"ends_at"`
Attributes []byte `json:"attributes,omitempty"`
Members []ExpeditionMember `json:"members"`
Reward uint32 `json:"reward"`
}
type ExpeditionMember struct {
// PersistentCharacterID is the durable character ID. The native client
// echoes the u16 selector, but the selector is a roster slot and moves when
// the roster is reordered, so busy detection must key on this instead.
PersistentCharacterID string `json:"persistent_character_id,omitempty"`
CharacterID uint16 `json:"character_id"`
Name string `json:"name"`
Level uint32 `json:"level"`
Job byte `json:"job"`
GrowType byte `json:"grow_type"`
Status byte `json:"status"`
}
func ParseRuntimeState(account dnfrepo.AccountRecord, config RuntimeConfig, now time.Time) (RuntimeState, error) {
state := RuntimeState{Version: 1}
value := strings.TrimSpace(account.Metadata[RuntimeStateMetadataKey])
if value != "" {
if err := json.Unmarshal([]byte(value), &state); err != nil {
return RuntimeState{}, fmt.Errorf("%w: %v", ErrRuntimeStateInvalid, err)
}
if state.Version != 1 {
return RuntimeState{}, fmt.Errorf("%w: version=%d", ErrRuntimeStateInvalid, state.Version)
}
}
if state.Purchases == nil {
state.Purchases = make(map[string]uint32)
}
if state.Expeditions == nil {
state.Expeditions = make(map[string]Expedition)
}
state.Normalize(config, now)
return state, nil
}
func (s *RuntimeState) Normalize(config RuntimeConfig, now time.Time) {
if s == nil {
return
}
s.Version = 1
month := now.In(adventureGroupCalendarLocation).Format("2006-01")
if s.Month != month {
s.Month = month
s.Purchases = make(map[string]uint32)
for _, category := range config.ShopCategories {
if category.ResetPointMonthly && int(category.Index) < len(s.ShopPoints) {
s.ShopPoints[category.Index] = 0
}
}
}
// 成长胶囊槽按周重置:客户端界面写明"成长胶囊于每周四凌晨6点重置"。
// 第一次读到(旧存档没有这个字段)只**认领**当前周,绝不清零 —— 否则升级
// 部署那一刻会把玩家攒下的一整槽胶囊静默清掉。
capsuleWeek := adventureGroupCapsuleWeek(now)
if s.CapsuleWeek == "" {
s.CapsuleWeek = capsuleWeek
} else if s.CapsuleWeek != capsuleWeek {
s.CapsuleWeek = capsuleWeek
s.GrowthExperience = 0
}
if s.Purchases == nil {
s.Purchases = make(map[string]uint32)
}
if s.Expeditions == nil {
s.Expeditions = make(map[string]Expedition)
}
for _, category := range config.ShopCategories {
if int(category.Index) < len(s.ShopPoints) && category.MaxPoint > 0 &&
s.ShopPoints[category.Index] > category.MaxPoint {
s.ShopPoints[category.Index] = category.MaxPoint
}
}
if capacity := CapsuleTotalCapacity(config.Capsule); capacity > 0 && s.GrowthExperience > capacity {
s.GrowthExperience = capacity
}
}
// adventureGroupCapsuleWeek 返回 now 所属"胶囊周"的标识:以**每周四 06:00**
// (Asia/Shanghai)为分界,标识取该周起始那一天的日期。界面文案是
// "成长胶囊于每周四凌晨6点重置。",所以周四 06:00 之前仍算上一周。
func adventureGroupCapsuleWeek(now time.Time) string {
local := now.In(adventureGroupCalendarLocation)
// time.Weekday: Sunday=0 ... Thursday=4
daysSinceThursday := (int(local.Weekday()) - int(time.Thursday) + 7) % 7
boundary := time.Date(local.Year(), local.Month(), local.Day(), 6, 0, 0, 0, adventureGroupCalendarLocation).
AddDate(0, 0, -daysSinceThursday)
if local.Before(boundary) {
boundary = boundary.AddDate(0, 0, -7)
}
return boundary.Format("2006-01-02")
}
func (s RuntimeState) Marshal() (string, error) {
data, err := json.Marshal(s)
if err != nil {
return "", fmt.Errorf("%w: %v", ErrRuntimeStateInvalid, err)
}
return string(data), nil
}
func (s RuntimeState) PurchaseCount(category byte, itemID uint32) uint32 {
return s.Purchases[purchaseKey(category, itemID)]
}
func (s *RuntimeState) Spend(category ShopCategory, product ShopProduct) error {
if s == nil || int(category.Index) >= len(s.ShopPoints) {
return ErrRuntimeStateInvalid
}
count := s.PurchaseCount(category.Index, product.ItemID)
if count >= product.MonthlyPurchaseLimit {
return ErrPurchaseLimitReached
}
if s.ShopPoints[category.Index] < product.Cost {
return ErrInsufficientShopPoints
}
if s.Purchases == nil {
// ParseRuntimeState/Normalize 一定会建好这张表;这里只是不让手工构造的
// 状态把"记一次购买"变成 panic。
s.Purchases = make(map[string]uint32)
}
s.ShopPoints[category.Index] -= product.Cost
s.Purchases[purchaseKey(category.Index, product.ItemID)] = count + 1
return nil
}
func (s *RuntimeState) AddGlory(config RuntimeConfig, value uint32) {
if s == nil || value == 0 {
return
}
category, ok := config.Shop(ShopPointGlory)
if !ok {
return
}
s.ShopPoints[ShopPointGlory] = saturatingPointAdd(s.ShopPoints[ShopPointGlory], value, category.MaxPoint)
}
// AddExperience 记入一笔**已经发给角色**的经验。
//
// - crystalExperience(任意等级获得的经验)按 PVF `[exp to get shop point]`
// 进骁勇结晶,余数带在 BraveExperience 里,封顶 `[max shop point]`。
// 角色自己的升级经验不受影响,这只是账号账本上的镜像累计
// (2026-09-24 服主口径:没满级的角色也要能攒结晶)。
// - maximumLevelExperience(其中越过等级上限的部分)再进成长胶囊槽:胶囊槽
// 是满级专属(客户端说明写着"Lv90以上的角色获得经验值时,经验值会累积在
// 成长胶囊槽中"),未满级经验一律不进。
func (s *RuntimeState) AddExperience(
config RuntimeConfig,
crystalExperience uint64,
maximumLevelExperience uint64,
) {
if s == nil || (crystalExperience == 0 && maximumLevelExperience == 0) {
return
}
if category, ok := config.Shop(ShopPointBrave); ok && category.ExperiencePerPoint > 0 && category.PointPerExperience > 0 {
total := s.BraveExperience + crystalExperience
points := total / category.ExperiencePerPoint
s.BraveExperience = total % category.ExperiencePerPoint
if points > math.MaxUint32 {
points = math.MaxUint32
}
add := uint64(category.PointPerExperience) * points
if add > math.MaxUint32 {
add = math.MaxUint32
}
s.ShopPoints[ShopPointBrave] = saturatingPointAdd(s.ShopPoints[ShopPointBrave], uint32(add), category.MaxPoint)
}
if maximumLevelExperience == 0 {
return
}
// 胶囊槽可以攒多格:总容量 = [capsule max exp] × [capsule max count]。
// 只按单格上限截断会让累计值停在正好 100%,客户端那条进度条永远 0%
// (分子的 "累计值 mod 单格" 恒为 0),现场表现为"胶囊不累计"。
if capacity := CapsuleTotalCapacity(config.Capsule); capacity > 0 {
remaining := capacity - minUint64(capacity, s.GrowthExperience)
add := maximumLevelExperience
if add > remaining {
add = remaining
}
s.GrowthExperience += add
}
}
func (s *RuntimeState) ConsumeCapsule(config RuntimeConfig) bool {
if s == nil || config.Capsule.MinimumExperience == 0 || s.GrowthExperience < config.Capsule.MinimumExperience {
return false
}
s.GrowthExperience -= config.Capsule.MinimumExperience
return true
}
func purchaseKey(category byte, itemID uint32) string {
return strconv.Itoa(int(category)) + ":" + strconv.FormatUint(uint64(itemID), 10)
}
func ExpeditionKey(area byte) string {
return strconv.Itoa(int(area))
}
func saturatingPointAdd(current, value, maximum uint32) uint32 {
total := uint64(current) + uint64(value)
if maximum > 0 && total > uint64(maximum) {
return maximum
}
if total > math.MaxUint32 {
return math.MaxUint32
}
return uint32(total)
}
func minUint64(left, right uint64) uint64 {
if left < right {
return left
}
return right
}