DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,238 @@
|
||||
package joust
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
const RoundDuration = 2 * time.Hour
|
||||
|
||||
const (
|
||||
BettingDuration = 90 * time.Minute
|
||||
QuarterFinalEnd = 95 * time.Minute
|
||||
SemiFinalEnd = 100 * time.Minute
|
||||
FinalEnd = 105 * time.Minute
|
||||
SettlementStart = 110 * time.Minute
|
||||
|
||||
// MysteryRiderStatus is the current client's explicit hidden-rider flag in
|
||||
// class-0/op1241. The eighth entrant keeps its real PVF ID for wagering
|
||||
// and settlement, but this marker makes the initial bracket render the
|
||||
// native black "mystery knight" instead of revealing that ID up front.
|
||||
MysteryRiderStatus byte = 1
|
||||
)
|
||||
|
||||
var ErrRoundUnavailable = errors.New("joust round is unavailable")
|
||||
|
||||
type BettingLedger struct {
|
||||
Round uint16
|
||||
Knight byte
|
||||
Amount uint32
|
||||
Valid bool
|
||||
}
|
||||
|
||||
type OpeningRider struct {
|
||||
ID byte
|
||||
AttackType byte
|
||||
Multiplier float32
|
||||
Wins uint16
|
||||
Losses uint16
|
||||
Status byte
|
||||
Support uint32
|
||||
}
|
||||
|
||||
type OpeningRound struct {
|
||||
Number uint16
|
||||
TotalSupport uint32
|
||||
Riders []OpeningRider
|
||||
}
|
||||
|
||||
// RoundNumberAt gives the all-day local profile a stable two-hour round key.
|
||||
// Unix time is used so restart and daylight-saving changes cannot create a
|
||||
// second identity for the same active round.
|
||||
func RoundNumberAt(now time.Time) uint16 {
|
||||
if now.IsZero() {
|
||||
return 0
|
||||
}
|
||||
seconds := now.Unix()
|
||||
if seconds < 0 {
|
||||
seconds = 0
|
||||
}
|
||||
return uint16(uint64(seconds / int64(RoundDuration/time.Second)))
|
||||
}
|
||||
|
||||
// Opening builds the eight-rider class-0/op1241 view. The original event odds
|
||||
// are total support divided by support for that rider. A local single-account
|
||||
// server has no cross-channel population to seed that pool, so each round uses
|
||||
// a deterministic background baseline. It is stable across restarts, changes
|
||||
// in the next round, and the durable local bet is overlaid on its rider.
|
||||
func (c *Catalog) Opening(round uint16, ledger BettingLedger) (OpeningRound, error) {
|
||||
ledgers := []BettingLedger(nil)
|
||||
if ledger.Valid {
|
||||
ledgers = []BettingLedger{ledger}
|
||||
}
|
||||
return c.OpeningWithLedgers(round, ledgers)
|
||||
}
|
||||
|
||||
// OpeningWithLedgers overlays every durable character ledger for the local
|
||||
// account. Pending is intentionally not part of BettingLedger: settlement
|
||||
// clears only the claim flag, while the round's public support pool and final
|
||||
// multiplier must remain stable until the next two-hour opening.
|
||||
func (c *Catalog) OpeningWithLedgers(round uint16, ledgers []BettingLedger) (OpeningRound, error) {
|
||||
if c == nil || len(c.riders) < OpeningKnightCount {
|
||||
return OpeningRound{}, ErrRoundUnavailable
|
||||
}
|
||||
selected, err := selectOpeningRiders(c.riders, round)
|
||||
if err != nil {
|
||||
return OpeningRound{}, err
|
||||
}
|
||||
opening := OpeningRound{
|
||||
Number: round,
|
||||
Riders: make([]OpeningRider, len(selected)),
|
||||
}
|
||||
for position, rider := range selected {
|
||||
support := openingBaselineSupport(round, position)
|
||||
for _, ledger := range ledgers {
|
||||
if ledger.Valid && ledger.Round == round && ledger.Knight == rider.ID {
|
||||
support += ledger.Amount
|
||||
}
|
||||
}
|
||||
opening.Riders[position] = OpeningRider{
|
||||
ID: rider.ID,
|
||||
AttackType: rider.AttackType,
|
||||
Support: support,
|
||||
}
|
||||
if position == OpeningKnightCount-1 {
|
||||
opening.Riders[position].Status = MysteryRiderStatus
|
||||
}
|
||||
opening.TotalSupport += support
|
||||
}
|
||||
if opening.TotalSupport == 0 {
|
||||
return OpeningRound{}, fmt.Errorf("%w: round=%d total support is zero", ErrRoundUnavailable, round)
|
||||
}
|
||||
for index := range opening.Riders {
|
||||
support := opening.Riders[index].Support
|
||||
if support == 0 {
|
||||
return OpeningRound{}, fmt.Errorf("%w: round=%d rider=%d support is zero", ErrRoundUnavailable, round, opening.Riders[index].ID)
|
||||
}
|
||||
multiplier := float32(opening.TotalSupport) / float32(support)
|
||||
if math.IsNaN(float64(multiplier)) || math.IsInf(float64(multiplier), 0) || multiplier <= 0 || multiplier > 999 {
|
||||
return OpeningRound{}, fmt.Errorf("%w: round=%d rider=%d multiplier=%v", ErrRoundUnavailable, round, opening.Riders[index].ID, multiplier)
|
||||
}
|
||||
opening.Riders[index].Multiplier = multiplier
|
||||
}
|
||||
return opening, nil
|
||||
}
|
||||
|
||||
type Phase byte
|
||||
|
||||
const (
|
||||
PhaseBetting Phase = iota
|
||||
PhaseQuarterFinal
|
||||
PhaseSemiFinal
|
||||
PhaseFinal
|
||||
PhaseSettled
|
||||
)
|
||||
|
||||
type Timeline struct {
|
||||
Round uint16
|
||||
Elapsed time.Duration
|
||||
Phase Phase
|
||||
State byte
|
||||
Stage byte
|
||||
}
|
||||
|
||||
// TimelineAt maps the all-day two-hour activity into the original four state
|
||||
// notices and four progressive bracket snapshots. The 90/95/100/105/110
|
||||
// minute boundaries preserve the published 2017 betting and settlement
|
||||
// cadence while keeping the event available around the clock.
|
||||
func TimelineAt(now time.Time) Timeline {
|
||||
round := RoundNumberAt(now)
|
||||
elapsed := time.Duration(now.Unix()%int64(RoundDuration/time.Second)) * time.Second
|
||||
if elapsed < 0 {
|
||||
elapsed = 0
|
||||
}
|
||||
timeline := Timeline{Round: round, Elapsed: elapsed, State: 1}
|
||||
switch {
|
||||
case elapsed < BettingDuration:
|
||||
timeline.Phase = PhaseBetting
|
||||
case elapsed < QuarterFinalEnd:
|
||||
timeline.Phase, timeline.State, timeline.Stage = PhaseQuarterFinal, 2, 0
|
||||
case elapsed < SemiFinalEnd:
|
||||
timeline.Phase, timeline.State, timeline.Stage = PhaseSemiFinal, 2, 1
|
||||
case elapsed < FinalEnd:
|
||||
timeline.Phase, timeline.State, timeline.Stage = PhaseFinal, 3, 2
|
||||
case elapsed < SettlementStart:
|
||||
timeline.Phase, timeline.State, timeline.Stage = PhaseFinal, 3, 3
|
||||
default:
|
||||
timeline.Phase, timeline.State, timeline.Stage = PhaseSettled, 4, 3
|
||||
}
|
||||
return timeline
|
||||
}
|
||||
|
||||
func NextBoundaryAfter(now time.Time) time.Time {
|
||||
timeline := TimelineAt(now)
|
||||
start := now.Add(-timeline.Elapsed)
|
||||
for _, offset := range []time.Duration{BettingDuration, QuarterFinalEnd, SemiFinalEnd, FinalEnd, SettlementStart, RoundDuration} {
|
||||
candidate := start.Add(offset)
|
||||
if candidate.After(now) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return start.Add(RoundDuration)
|
||||
}
|
||||
|
||||
func (r OpeningRound) Contains(knight byte) bool {
|
||||
for _, rider := range r.Riders {
|
||||
if rider.ID == knight {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// openingRegularKnightIDs are the seven visible 2017 CN joust entrants:
|
||||
// 理查德、爱德华、罗兰、贝奥武夫、莱奥、伊萨尔、席恩. The eighth slot is intentionally
|
||||
// filled from mysteryKnightIDs for that round. The active PVF names ID 6 as
|
||||
// 吉利特 and ID 9 as 无双飞将吕布; both are valid mystery entries. Lancelot (ID 8)
|
||||
// is the activity-dungeon lord, never a joust entrant.
|
||||
var openingRegularKnightIDs = [...]byte{1, 0, 2, 3, 4, 5, 7}
|
||||
|
||||
var mysteryKnightIDs = [...]byte{6, 9, 10, 11}
|
||||
|
||||
func selectOpeningRiders(riders []Rider, round uint16) ([]Rider, error) {
|
||||
byID := make(map[byte]Rider, len(riders))
|
||||
for _, rider := range riders {
|
||||
if _, duplicate := byID[rider.ID]; duplicate {
|
||||
return nil, fmt.Errorf("%w: duplicate roster rider=%d", ErrRoundUnavailable, rider.ID)
|
||||
}
|
||||
byID[rider.ID] = rider
|
||||
}
|
||||
selected := make([]Rider, 0, OpeningKnightCount)
|
||||
for _, id := range openingRegularKnightIDs {
|
||||
rider, found := byID[id]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: required original rider=%d missing", ErrRoundUnavailable, id)
|
||||
}
|
||||
selected = append(selected, cloneRider(rider))
|
||||
}
|
||||
// Keep the draw restart-stable, but choose only from the original mystery
|
||||
// pool rather than shuffling in unrelated normal/event NPCs.
|
||||
mysteryID := mysteryKnightIDs[(uint32(round)*1103515245+12345)%uint32(len(mysteryKnightIDs))]
|
||||
mystery, found := byID[mysteryID]
|
||||
if !found {
|
||||
return nil, fmt.Errorf("%w: required mystery rider=%d missing", ErrRoundUnavailable, mysteryID)
|
||||
}
|
||||
selected = append(selected, cloneRider(mystery))
|
||||
return selected, nil
|
||||
}
|
||||
|
||||
func openingBaselineSupport(round uint16, position int) uint32 {
|
||||
// Five is coprime to eight, so all eight ranks are distinct in every round.
|
||||
rank := (position*5 + int(round)%OpeningKnightCount) % OpeningKnightCount
|
||||
// The small round jitter changes the numeric odds while preserving distinct
|
||||
// support buckets and realistic single-digit/low-double-digit multipliers.
|
||||
jitter := uint32((uint32(round)*37 + uint32(position)*17) % 101)
|
||||
return 900 + uint32(rank)*325 + jitter
|
||||
}
|
||||
Reference in new issue
Block a user