Files
90CNServer/server/internal/modules/dnf/onlineevent/marble.go
T
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

386 lines
12 KiB
Go

package onlineevent
import (
"context"
"errors"
"fmt"
"math"
"strings"
"time"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const MarblePVFPath = "event/attendancemarble.evt"
const MarbleBoardSize = 28
const MarbleDayKey = "attendance_marble_calendar_day"
// etc/variousbufflist.etc, index 29: unique_rarity_item_drop_rate (3, 120).
const MarbleUniqueBonusPercent int64 = 120
func MarbleEquipmentRarityThresholds(thresholds [7]int64) [7]int64 {
weight := max(int64(0), min(thresholds[3], int64(1000000))-thresholds[2])
extra := weight * MarbleUniqueBonusPercent / 100
// Take the added unique probability from lower rarities; higher tiers retain
// their PVF probabilities and the cumulative denominator remains unchanged.
for i := 0; i < 3; i++ {
thresholds[i] = max(int64(0), thresholds[i]-extra)
}
return thresholds
}
var ErrMarbleState = errors.New("attendance marble state invalid")
var ErrMarbleSpent = errors.New("attendance marble earned turns exhausted")
var ErrMarbleGold = errors.New("attendance marble gold insufficient")
type MarbleSlot struct {
Index, Type, Goto, Buff int
ItemID, ItemCount, Gold int64
}
type MarbleStageReward struct {
Round int
ItemID, Count int64
}
type MarbleCatalog struct {
Slots [MarbleBoardSize]MarbleSlot
StageRewards []MarbleStageReward
ClearRewards [3][2]int64
PostalDays int
}
func ParseMarbleCatalog(doc *dnfpvf.Document) (*MarbleCatalog, error) {
if doc == nil {
return nil, ErrMarbleState
}
c := &MarbleCatalog{}
clears, err := attendanceIntegerSection(doc, "reward clear", 6)
if err != nil {
return nil, err
}
for i := range c.ClearRewards {
if clears[i*2] != int64((i+1)*4) || clears[i*2+1] != 2 {
return nil, fmt.Errorf("marble clear rewards differ from native four records")
}
c.ClearRewards[i] = [2]int64{clears[i*2], clears[i*2+1]}
}
postal, err := attendanceIntegerSection(doc, "reward postal day", 1)
if err != nil || postal[0] < 1 || postal[0] > 365 {
return nil, fmt.Errorf("marble postal duration invalid")
}
c.PostalDays = int(postal[0])
stages, err := attendanceIntegerSection(doc, "accumulate race info", 15)
if err != nil {
return nil, err
}
for i := 0; i < 3; i++ {
v := stages[i*5:]
if v[0] != int64((i+1)*4) || v[1] < 1 || v[1] > 0xffffffff || v[2] < 1 || v[2] > 0xffffffff {
return nil, fmt.Errorf("marble lap reward invalid")
}
c.StageRewards = append(c.StageRewards, MarbleStageReward{Round: int(v[0]), ItemID: v[1], Count: v[2]})
}
seen := [MarbleBoardSize]bool{}
for i := 0; i < len(doc.Tokens); i++ {
t := doc.Tokens[i]
if t.Kind != dnfpvf.TokenSection || !strings.EqualFold(t.Value, "slot") {
continue
}
fields := map[string][]int64{}
name := ""
i++
for ; i < len(doc.Tokens); i++ {
t = doc.Tokens[i]
if t.Kind == dnfpvf.TokenSection {
name = strings.ToLower(strings.TrimSpace(t.Value))
if name == "/slot" {
break
}
} else if t.Kind == dnfpvf.TokenInt {
fields[name] = append(fields[name], t.Int)
}
}
if i == len(doc.Tokens) || len(fields["index"]) != 1 || len(fields["type"]) != 1 {
return nil, ErrMarbleState
}
index, kind := int(fields["index"][0]), int(fields["type"][0])
if index < 0 || index >= MarbleBoardSize || seen[index] {
return nil, ErrMarbleState
}
slot := MarbleSlot{Index: index, Type: kind, Goto: -1}
switch kind {
case 1, 5:
v := fields["item"]
if len(v) != 2 || v[0] < 1 || v[0] > 0xffffffff || v[1] < 1 || v[1] > 0xffffffff {
return nil, ErrMarbleState
}
slot.ItemID, slot.ItemCount = v[0], v[1]
if kind == 5 {
v = fields["gold"]
if len(v) != 1 || v[0] < 1 {
return nil, ErrMarbleState
}
slot.Gold = v[0]
}
case 3:
v := fields["goto"]
if len(v) != 1 || v[0] < 0 || v[0] >= MarbleBoardSize {
return nil, ErrMarbleState
}
slot.Goto = int(v[0])
case 7:
v := fields["buff index"]
if len(v) != 1 || v[0] < 29 || v[0] > 32 {
return nil, ErrMarbleState
}
slot.Buff = int(v[0])
default:
return nil, fmt.Errorf("marble unsupported slot type %d", kind)
}
seen[index], c.Slots[index] = true, slot
}
for _, present := range seen {
if !present {
return nil, ErrMarbleState
}
}
for _, slot := range c.Slots {
if slot.Type == 3 && c.Slots[slot.Goto].Type == 3 {
return nil, ErrMarbleState
}
}
return c, nil
}
func MarbleDay(now time.Time) int64 {
now = now.In(ChinaCalendar).Add(-6 * time.Hour)
return int64(now.Year()*10000 + int(now.Month())*100 + now.Day())
}
type MarbleState struct {
Position, Round, DiceUsed, Double, DoubleChain int64
Reward4, Reward8, Reward12, CalendarDay int64
Clears, Turns, Serial int64
MailIDs []string
GoldChanged bool
GoldAfter int64
BuffIndex int
PendingBuffs [4]int64
}
func (s MarbleState) Remaining() int64 { return s.Turns - s.DiceUsed }
func ReadMarbleState(character dnfrepo.CharacterRecord, c *MarbleCatalog, now time.Time) (MarbleState, error) {
if c == nil {
return MarbleState{}, ErrMarbleState
}
v := character.Stats
for i := 29; i <= 32; i++ {
if count := v[fmt.Sprintf("attendance_marble_buff_%d", i)]; count < 0 || count > 10000 {
return MarbleState{}, ErrMarbleState
}
}
s := MarbleState{Position: v["attendance_marble_position"], Round: v["attendance_marble_round"], DiceUsed: v["attendance_marble_dice_used"], Double: v["attendance_marble_double"], DoubleChain: v["attendance_marble_double_chain"],
Reward4: v["attendance_marble_reward_4"], Reward8: v["attendance_marble_reward_8"], Reward12: v["attendance_marble_reward_12"], CalendarDay: v[MarbleDayKey], Clears: v["attendance_marble_clears"], Serial: v["attendance_marble_serial"]}
if s.CalendarDay != MarbleDay(now) {
s.CalendarDay, s.Clears, s.DiceUsed, s.Double, s.DoubleChain = MarbleDay(now), 0, 0, 0, 0
}
s.Turns = 2
for _, r := range c.ClearRewards {
if s.Clears >= r[0] {
s.Turns += r[1]
}
}
if s.Position < 0 || s.Position >= MarbleBoardSize || s.Round < 0 || s.Round > 0xffffffff || s.Clears < 0 || s.Clears > 12 || s.DiceUsed < 0 || s.DiceUsed > 8 || s.Double < 0 || s.Double > 1 || s.DoubleChain < 0 || s.DoubleChain > 5 || s.Serial < 0 || s.Reward4 < 0 || s.Reward4 > 1 || s.Reward8 < 0 || s.Reward8 > 1 || s.Reward12 < 0 || s.Reward12 > 1 {
return MarbleState{}, ErrMarbleState
}
// Earlier builds allowed three unearned turns; preserve them as spent, never negative on the wire.
if s.Turns < s.DiceUsed {
s.Turns = s.DiceUsed
}
return s, nil
}
func writeMarbleState(character *dnfrepo.CharacterRecord, s MarbleState) {
if character.Stats == nil {
character.Stats = map[string]int64{}
}
v := character.Stats
for k, value := range map[string]int64{
"position": s.Position, "round": s.Round, "dice_used": s.DiceUsed, "double": s.Double, "double_chain": s.DoubleChain,
"reward_4": s.Reward4, "reward_8": s.Reward8, "reward_12": s.Reward12, "calendar_day": s.CalendarDay, "clears": s.Clears, "serial": s.Serial,
} {
v["attendance_marble_"+k] = value
}
}
func RecordMarbleClear(character *dnfrepo.CharacterRecord, c *MarbleCatalog, now time.Time) error {
if c == nil {
return nil
}
s, err := ReadMarbleState(*character, c, now)
if err != nil {
return err
}
if s.Clears < 12 {
s.Clears++
}
writeMarbleState(character, s)
return nil
}
type MarbleCommand struct {
AccountID, CharacterID string
Now time.Time
Dice *[2]byte
CloseDouble bool
Deliver func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error)
}
func SettleMarble(ctx context.Context, repos dnfrepo.Group, c *MarbleCatalog, cmd MarbleCommand) (MarbleState, error) {
if c == nil || repos.MailboxAssets == nil || cmd.AccountID == "" || cmd.CharacterID == "" || cmd.Now.IsZero() {
return MarbleState{}, ErrOwnerUnavailable
}
var result MarbleState
err := repos.MailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(_ 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.CharacterID != cmd.CharacterID || character.AccountID != cmd.AccountID {
return ErrMarbleState
}
s, err := ReadMarbleState(character, c, cmd.Now)
if err != nil {
return err
}
writeMarbleState(&character, s)
if cmd.CloseDouble {
s.Double, s.DoubleChain = 0, 0
}
if cmd.Dice != nil {
if s.Serial == math.MaxInt64 || s.Round == math.MaxUint32 {
return ErrMarbleState
}
dice := *cmd.Dice
if dice[0] < 1 || dice[0] > 6 || dice[1] < 1 || dice[1] > 6 || cmd.Deliver == nil {
return ErrMarbleState
}
if s.Double != 0 {
s.DoubleChain++
} else {
if s.Remaining() <= 0 {
return ErrMarbleSpent
}
s.DiceUsed++
s.DoubleChain = 0
}
s.Serial++
move := s.Position + int64(dice[0]) + int64(dice[1])
s.Round += move / MarbleBoardSize
s.Position = move % MarbleBoardSize
slot := c.Slots[s.Position]
if slot.Type == 3 {
s.Position = int64(slot.Goto)
slot = c.Slots[s.Position]
}
deliver := func(id, count int64, key string) error {
mail, err := cmd.Deliver(mailboxes, AttendancePVFItemReward{ItemID: id, Count: count}, "attendance_marble:"+key)
if err != nil {
return err
}
if mail == "" {
return ErrMarbleState
}
s.MailIDs = append(s.MailIDs, mail)
return nil
}
if slot.Type == 1 || slot.Type == 5 {
if slot.Type == 5 {
gold := character.Stats["gold"]
if gold < slot.Gold {
return ErrMarbleGold
}
character.Stats["gold"] = gold - slot.Gold
s.GoldChanged, s.GoldAfter = true, gold-slot.Gold
}
if err := deliver(slot.ItemID, slot.ItemCount, fmt.Sprintf("turn:%d", s.Serial)); err != nil {
return err
}
}
if slot.Type == 7 {
key := fmt.Sprintf("attendance_marble_buff_%d", slot.Buff)
character.Stats[key] = min(character.Stats[key]+1, int64(10000))
s.BuffIndex = slot.Buff
}
for _, stage := range c.StageRewards {
var claimed *int64
switch stage.Round {
case 4:
claimed = &s.Reward4
case 8:
claimed = &s.Reward8
case 12:
claimed = &s.Reward12
}
if claimed == nil {
return ErrMarbleState
}
if s.Round >= int64(stage.Round) && *claimed == 0 {
if err := deliver(stage.ItemID, stage.Count, fmt.Sprintf("lap:%d", stage.Round)); err != nil {
return err
}
*claimed = 1
}
}
s.Double = 0
if dice[0] == dice[1] && s.DoubleChain < 5 {
s.Double = 1
}
}
writeMarbleState(&character, s)
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return err
}
for i := 29; i <= 32; i++ {
s.PendingBuffs[i-29] = character.Stats[fmt.Sprintf("attendance_marble_buff_%d", i)]
}
result = s
return nil
})
return result, err
}
// VariousBuff 29..32 use dungeon_one_match (1, 0): reserve the pending
// effects for one accepted run; its runtime retains them across room changes.
func ConsumeMarbleDungeonBuffs(ctx context.Context, repos dnfrepo.Group, accountID, characterID string) (byte, error) {
if repos.CharacterAssets == nil || accountID == "" || characterID == "" {
return 0, ErrOwnerUnavailable
}
var mask byte
err := repos.CharacterAssets.WithinCharacterAssets(ctx, characterID, func(chars dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, _ dnfrepo.EquipmentRepository) error {
ch, found, err := chars.Load(ctx, characterID)
if err != nil {
return err
}
if !found || ch.CharacterID != characterID || ch.AccountID != accountID {
return ErrMarbleState
}
for i := 29; i <= 32; i++ {
key := fmt.Sprintf("attendance_marble_buff_%d", i)
if ch.Stats[key] > 0 {
mask |= 1 << (i - 29)
ch.Stats[key]--
}
}
if mask == 0 {
return nil
}
return dnfrepo.SaveCharacterFields(ctx, chars, ch, dnfrepo.CharacterFieldStats)
})
if err != nil {
return 0, err
}
return mask, nil
}