服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
264 lines
9.6 KiB
Go
264 lines
9.6 KiB
Go
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
|
||
}
|