Files
90CNServer/server/internal/services/dnfbridge/gameplay_module_joust.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

412 lines
15 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package dnfbridge
// 骑士马战(joust)活动已按运营要求取消。
//
// 变更范围:
// - 不再注册 op1291/op1292/op1293(信息 / 押注 / 战绩)路由,
// 即下面的 joustGameplayModule() 已从 gameplay_modules.go 摘除;
// - 不再启动活动边界调度器,见 service.go;
// - 登录快照不再推送 op1240 活动状态与 op1241 骑手名单,
// 见 online_event_spend_time.go;
// - op108 活动目录不再下发事件 2365 的基础行与 active 行,
// 见 online_event_spend_time_protocol.go。
//
// 效果:客户端收不到任何马战使能数据,owner 609 的原生竞猜入口保持关闭,
// 服务端不再写入押注账本、结算或发放竞猜奖励邮件。
//
// 本文件及 joust_*.go、internal/modules/dnf/joust 的实现整体保留为死代码,
// 只是不再被注册或调用,便于日后按需恢复。
import (
"context"
"fmt"
"math"
"sort"
"strconv"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfjoust "longheng.io/server/internal/modules/dnf/joust"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
const (
currentJoustHistoryRecordCount = 500
currentJoustStatePushMsgID = 1240
currentJoustRosterPushMsgID = 1241
currentJoustPoolPushMsgID = 1242
currentJoustMatchPushMsgID = 1243
currentJoustRosterCount = 8
currentJoustRosterRecordSize = 11
currentJoustStateOpening = 1
)
// joustGameplayModule 曾在 gameplay_modules.go 中注册 op1291/op1292/op1293。
// 活动取消后该注册已摘除;成员路由表保留在下方注释中以便恢复:
//
// LegacyHandlers: op1291 -> handleCurrentJoustInfo
// op1292 -> (*Service).handleCurrentJoustBetting
// op1293 -> handleCurrentJoustHistory
// UpperHandlers: 同上,均包 defaultClassGameplayHandler
// LegacyNormalizers: stripCurrentJoustOpaqueQueryPrefix / ...BettingPrefix
// Current NoPack's sub_E72300 and sub_F2B9D0 write exactly 13 bytes from a
// native-generated opaque buffer for op1291 and op1293. Neither query appends
// a gameplay field. Strip only that exact observed shape; every other length
// remains visible to the strict handlers and fails closed.
func stripCurrentJoustOpaqueQueryPrefix(body []byte) []byte {
if len(body) != 13 {
return body
}
return nil
}
// The live current client sent op1292 as the same 13-byte native transport
// prefix followed by the nine business bytes written by sub_F296C0. Preserve
// only the proved 22-byte shape and expose {u8 knight,u32 slot,u32 amount} to
// the strict betting decoder.
func stripCurrentJoustOpaqueBettingPrefix(body []byte) []byte {
const (
opaquePrefixSize = 13
businessSize = 9
)
if len(body) != opaquePrefixSize+businessSize {
return body
}
return append([]byte(nil), body[opaquePrefixSize:]...)
}
// Current NoPack sends both queries with an empty logical body. The native
// handlers consume a u32 result after the upper success envelope. Result zero
// is the proved success value.
func handleCurrentJoustInfo(service *Service, session *gameSession, body []byte) error {
if len(body) != 0 {
service.logGameEvent(session, "game-joust-info-deferred",
"body_len", len(body),
"reason", "current_exe_joust_info_body_mismatch")
return nil
}
var response packetWriter
response.writeUint32(0)
// Never replay op1240 here. Current EXE reacts to its "betting started"
// notification by issuing op1291 again, so an op1240 replay creates an
// unbounded request/notification loop that floods the UI. The initial-town
// snapshot also arrives before the native joust window has been constructed:
// its correct op1242 personal-support field is therefore not rendered until
// a wager causes a later roster/pool push. Once the window asks op1291, its
// result ACK is the safe readiness boundary. Republish only op1241/op1242
// (and the current progressive bracket, when applicable) after that ACK.
if err := service.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketJoustInfo), response.bytes()); err != nil {
return err
}
if session == nil || session.selectedCharacterID == 0 {
return nil
}
if err := service.sendCurrentJoustOpeningRoster(session); err != nil {
service.logGameEvent(session, "game-joust-info-refresh-skipped",
"reason", err.Error(),
"refresh", "post_op1291_roster_pool")
}
return nil
}
func (service *Service) sendCurrentJoustOpeningState(session *gameSession) error {
if service == nil || session == nil {
return nil
}
if session.selectedCharacterID != 0 {
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
service.joustOperationMu.Lock()
result, settleErr := service.settleCurrentJoustForSession(ctx, session, service.gameplayNow().UTC())
service.joustOperationMu.Unlock()
if settleErr != nil {
service.logGameEvent(session, "game-joust-reconnect-settlement-deferred", "reason", settleErr.Error())
} else if result.Settled && result.Won && result.MailID != "" {
if err := service.sendMailboxAlarmToOnlineRecipient(session.selectedCharacterID); err != nil {
// The settlement and mail are already durable. Mailbox open will
// refresh the native list, so a notice failure is never retried as
// a second payout.
service.logGameEvent(session, "game-joust-settlement-mail-alarm-deferred", "mail_id", result.MailID, "reason", err.Error())
}
}
}
return service.sendGameUpperRawClass(
session,
currentJoustStatePushMsgID,
buildCurrentJoustState(dnfjoust.TimelineAt(service.gameplayNow())),
0,
)
}
// buildCurrentJoustOpeningState matches sub_E727F0 exactly: u16 round then u8
// state. State 1 initializes the type-73 activity and its owner-608 companion;
// zero leaves owner 609's native open gate disabled even when event 2365 is in
// the active-event map.
func buildCurrentJoustOpeningState(round uint16) []byte {
return buildCurrentJoustState(dnfjoust.Timeline{Round: round, State: 1})
}
func buildCurrentJoustState(timeline dnfjoust.Timeline) []byte {
var body packetWriter
body.writeUint16(timeline.Round)
body.writeByte(timeline.State)
return body.bytes()
}
func (service *Service) sendCurrentJoustOpeningRoster(session *gameSession) error {
if service == nil || session == nil {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
now := service.gameplayNow().UTC()
opening, err := service.currentJoustOpening(ctx, session, now)
if err != nil {
return err
}
roster, err := buildCurrentJoustOpeningRoster(opening)
if err != nil {
return err
}
if err := service.sendGameUpperRawClass(session, currentJoustRosterPushMsgID, roster, 0); err != nil {
return err
}
personalSupport, err := service.currentJoustPersonalSupport(ctx, session, opening.Number)
if err != nil {
return err
}
pool, err := buildCurrentJoustPool(opening, personalSupport)
if err != nil {
return err
}
if err := service.sendGameUpperRawClass(session, currentJoustPoolPushMsgID, pool, 0); err != nil {
return err
}
timeline := dnfjoust.TimelineAt(now)
if timeline.Phase == dnfjoust.PhaseBetting {
return nil
}
catalog, err := service.currentJoustCatalog(ctx)
if err != nil {
return err
}
tournament, err := catalog.TournamentFor(opening.Number)
if err != nil {
return err
}
matchBody, err := buildCurrentJoustMatchSnapshot(opening.Number, timeline.Stage, tournament)
if err != nil {
return err
}
return service.sendGameUpperRawClass(session, currentJoustMatchPushMsgID, matchBody, 0)
}
// buildCurrentJoustOpeningRoster returns the exact 90-byte layout consumed by
// sub_E72490: u16 round followed by eight packed 11-byte records. Current EXE
// proves each record as {u8 knight,u8 attack_type,f32 multiplier,u16 wins,
// u16 losses,u8 status}: sub_F2C990 reads the float at offset 2, sub_F2BF70
// reads the two history counters at offsets 6/8 and the status at offset 10.
func buildCurrentJoustOpeningRoster(opening dnfjoust.OpeningRound) ([]byte, error) {
if len(opening.Riders) != currentJoustRosterCount {
return nil, fmt.Errorf("joust opening riders=%d want=%d", len(opening.Riders), currentJoustRosterCount)
}
var body packetWriter
body.writeUint16(opening.Number)
for _, rider := range opening.Riders {
if rider.Multiplier <= 0 || math.IsNaN(float64(rider.Multiplier)) || math.IsInf(float64(rider.Multiplier), 0) {
return nil, fmt.Errorf("joust rider=%d invalid multiplier=%v", rider.ID, rider.Multiplier)
}
body.writeByte(rider.ID)
body.writeByte(rider.AttackType)
body.writeUint32(math.Float32bits(rider.Multiplier))
body.writeUint16(rider.Wins)
body.writeUint16(rider.Losses)
body.writeByte(rider.Status)
}
if len(body.bytes()) != 2+currentJoustRosterCount*currentJoustRosterRecordSize {
return nil, fmt.Errorf("joust roster size=%d", len(body.bytes()))
}
return body.bytes(), nil
}
// buildCurrentJoustPool matches sub_E72540's exact 46-byte read: u16 round,
// u32 selected-character support, then eight packed {u8 knight,u32 support}
// public-pool records. sub_F297B0 subtracts the selected-character value from
// PVF [max betting], so writing the public total there makes the UI negative.
func buildCurrentJoustPool(opening dnfjoust.OpeningRound, personalSupport uint32) ([]byte, error) {
if len(opening.Riders) != currentJoustRosterCount || opening.TotalSupport == 0 {
return nil, fmt.Errorf("joust pool riders=%d total=%d", len(opening.Riders), opening.TotalSupport)
}
if personalSupport > dnfjoust.MaximumBetPerRound {
return nil, fmt.Errorf("joust personal support=%d", personalSupport)
}
var body packetWriter
body.writeUint16(opening.Number)
body.writeUint32(personalSupport)
for _, rider := range opening.Riders {
if rider.Support == 0 {
return nil, fmt.Errorf("joust rider=%d has zero support", rider.ID)
}
body.writeByte(rider.ID)
body.writeUint32(rider.Support)
}
if len(body.bytes()) != 46 {
return nil, fmt.Errorf("joust pool size=%d", len(body.bytes()))
}
return body.bytes(), nil
}
func (service *Service) currentJoustPersonalSupport(
ctx context.Context,
session *gameSession,
round uint16,
) (uint32, error) {
if service == nil || session == nil || session.selectedCharacterID == 0 {
return 0, nil
}
repositories, ok := service.repositoryGroup()
if !ok || repositories.Character == nil {
return 0, nil
}
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil {
return 0, err
}
if !found {
return 0, nil
}
storedRound, _, amount, valid := currentJoustBettingLedgerForCharacter(character)
if !valid || storedRound != round {
return 0, nil
}
return amount, nil
}
func (service *Service) currentJoustOpening(
ctx context.Context,
session *gameSession,
now time.Time,
) (dnfjoust.OpeningRound, error) {
catalog, err := service.currentJoustCatalog(ctx)
if err != nil {
return dnfjoust.OpeningRound{}, err
}
round := dnfjoust.RoundNumberAt(now)
accountID := service.accountIDForSession(session)
return service.currentJoustOpeningForRound(ctx, catalog, accountID, round)
}
func (service *Service) currentJoustOpeningForRound(
ctx context.Context,
catalog *dnfjoust.Catalog,
accountID string,
round uint16,
) (dnfjoust.OpeningRound, error) {
ledgers := make([]dnfjoust.BettingLedger, 0, 8)
if repositories, ok := service.repositoryGroup(); ok && repositories.Character != nil {
characters, err := repositories.Character.ListByAccount(ctx, accountID, 32)
if err != nil {
return dnfjoust.OpeningRound{}, err
}
for _, character := range characters {
ledgers = append(ledgers, currentJoustBettingLedgersForCharacter(character)...)
}
}
opening, err := catalog.OpeningWithLedgers(round, ledgers)
if err != nil {
return dnfjoust.OpeningRound{}, err
}
counters, err := service.currentJoustRecordedWinLoss(ctx, accountID, catalog)
if err != nil {
return dnfjoust.OpeningRound{}, err
}
for index := range opening.Riders {
opening.Riders[index].Wins = counters[opening.Riders[index].ID][0]
opening.Riders[index].Losses = counters[opening.Riders[index].ID][1]
}
return opening, nil
}
func currentJoustBettingLedgersForCharacter(character dnfrepo.CharacterRecord) []dnfjoust.BettingLedger {
if character.Stats == nil {
return nil
}
roundValue := character.Stats[dnfjoust.RoundStat]
if roundValue < 0 || roundValue > math.MaxUint16 {
return nil
}
round := uint16(roundValue)
bets, _, valid := dnfjoust.CurrentRoundBets(character.Stats, round)
if !valid || len(bets) == 0 {
return nil
}
knights := make([]int, 0, len(bets))
for knight := range bets {
knights = append(knights, int(knight))
}
sort.Ints(knights)
ledgers := make([]dnfjoust.BettingLedger, 0, len(knights))
for _, knight := range knights {
amount := bets[byte(knight)]
if amount != 0 {
ledgers = append(ledgers, dnfjoust.BettingLedger{Round: round, Knight: byte(knight), Amount: amount, Valid: true})
}
}
return ledgers
}
// buildCurrentJoustMatchSnapshot matches sub_E72600/sub_F34350: u16 round,
// u8 stage and seven packed DWORD matches. Each DWORD is winner id/profile then
// loser id/profile. The profile byte is the current EXE's 0..3 opponent attack
// type timeline key, not a random entry inside the seven-value PVF timeline.
// The four quarter-finals, two semi-finals and final are always present; stage
// controls the client's progressive reveal animation.
func buildCurrentJoustMatchSnapshot(round uint16, stage byte, tournament dnfjoust.Tournament) ([]byte, error) {
if stage > 3 || tournament.Round != round {
return nil, fmt.Errorf("joust match snapshot round=%d tournament=%d stage=%d", round, tournament.Round, stage)
}
var body packetWriter
body.writeUint16(round)
body.writeByte(stage)
for _, match := range tournament.Matches {
body.writeByte(match.Winner)
body.writeByte(match.WinnerAction)
body.writeByte(match.Loser)
body.writeByte(match.LoserAction)
}
if len(body.bytes()) != 31 {
return nil, fmt.Errorf("joust match snapshot size=%d", len(body.bytes()))
}
return body.bytes(), nil
}
// The current client always reads the complete fixed 500-entry history array
// after a zero result. Each entry is u16 sequence, u8 winner, f32 multiplier. A zero
// sequence is the native empty-list sentinel; the remaining fixed bytes still
// have to be present for the parser to stay aligned.
func handleCurrentJoustHistory(service *Service, session *gameSession, body []byte) error {
if len(body) != 0 {
service.logGameEvent(session, "game-joust-history-deferred",
"body_len", len(body),
"reason", "current_exe_joust_history_body_mismatch")
return nil
}
var response packetWriter
response.writeUint32(0)
records, err := service.currentJoustHistory(context.Background(), session, service.gameplayNow().UTC())
if err != nil {
service.logGameEvent(session, "game-joust-history-fallback", "reason", err.Error())
}
for _, record := range records {
response.writeUint16(record.Round)
response.writeByte(record.Winner)
response.writeUint32(math.Float32bits(record.Multiplier))
}
if remaining := currentJoustHistoryRecordCount - len(records); remaining > 0 {
response.writeBytes(make([]byte, remaining*7))
}
return service.sendGameUpperSuccess(session, uint16(dnfenum.CmdPacketJoustMatchHistory), response.bytes())
}