Files
90CNServer/server/internal/modules/dnf/joust/round.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

239 lines
7.8 KiB
Go

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
}