Files
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

281 lines
11 KiB
Go

package dnfbridge
import (
"context"
"errors"
"fmt"
"strconv"
dnfdungeon "longheng.io/server/internal/modules/dnf/dungeon"
dnffatigue "longheng.io/server/internal/modules/dnf/fatigue"
"longheng.io/server/internal/modules/dnf/worldmap"
)
const currentDungeonRoomFatigueCost int64 = 1
// currentDungeonTrainingRoomID is 修炼道场, the free training room. Live evidence
// 2026-09-12 13:25:33 (packet_log.txt): char 1 with fatigue=0 requested dungeon
// 5000 and the server answered
//
// game-dungeon-select-blocked dungeon_id=5000 reason=dungeon_owner_validation_failed
// error=dnf dungeon character fatigue is exhausted: fatigue=0
// game-dungeon-entry-fatigue-prompt-sent reason=255
//
// which is the client's 「疲劳值不足」 prompt. This data set's 5000 entry carries no
// "no fatigue" / "enter without fatigue" metadata, but the training room is free to
// enter and free to run, so the id is exempted explicitly the same way 11006/11007
// are special-cased for their solo party limit.
const currentDungeonTrainingRoomID int64 = 5000
// currentDungeonIsTrainingRoom reports 修炼道场, the free sandbox room.
func currentDungeonIsTrainingRoom(dungeon worldmap.Dungeon) bool {
return dungeon.ID == currentDungeonTrainingRoomID
}
// currentDungeonIgnoresFatigue reports whether a dungeon must neither require nor
// consume fatigue: the training room, plus whatever the PVF data marks as free.
func currentDungeonIgnoresFatigue(dungeon worldmap.Dungeon) bool {
if currentDungeonIsTrainingRoom(dungeon) {
return true
}
return dungeon.Metadata.NoFatigue || dungeon.Metadata.Flags["enter without fatigue"]
}
var errCurrentDungeonFatigueCommitDeferred = errors.New("dungeon fatigue commit deferred")
func currentDungeonRuntimeConsumesFatigue(runtime *runtimeDungeonState) bool {
if runtime == nil || currentDungeonRuntimeIsPractice(runtime) ||
currentDungeonIgnoresFatigue(runtime.Dungeon) {
return false
}
_, known := dnfdungeon.CharacterStat(runtime.Character.Stats, "fatigue", "fp", "疲劳")
return known
}
// consumeCurrentDungeonRoomFatigue runs after op29 and the matching room
// commit. Callers already own the dungeon transition lock, so this function
// must not reacquire session.dungeon.mu.
func (s *Service) consumeCurrentDungeonRoomFatigue(
session *gameSession,
runtime *runtimeDungeonState,
source string,
) error {
if s == nil || session == nil || runtime == nil || runtime.Session == nil || runtime.Room == nil {
return errDungeonWorldMapUnavailable
}
// 签到弹珠的 29..32 号增益是"下一次通关消耗一次"的语义:必须在疲劳早退之前
// 激活,否则练习模式/免疲劳副本里的通关不会消耗增益(也就永远发不出效果)。
if err := s.activateAttendanceMarbleDungeonBuffs(session, runtime); err != nil {
return err
}
if !currentDungeonRuntimeConsumesFatigue(runtime) {
return nil
}
// 无限疲劳:GM 疲劳模式为 free(不扣)或 lock(锁死高值)时,跳过扣减。
// refill 模式不改变扣减逻辑,只由 GM 端点即时补满,因此仍走正常扣减。
if s.gmFatigueExemptsRoom() {
return nil
}
scene, ok := runtime.Session.Scene()
if !ok {
return errDungeonWorldMapUnavailable
}
visit, err := runtime.currentDungeonRoomVisit(scene)
if err != nil {
return err
}
if visit.FatigueConsumed {
return nil
}
repositories, ok := s.repositoryGroup()
if !ok {
return fmt.Errorf("%w: %v", errCurrentDungeonFatigueCommitDeferred, dnffatigue.ErrOwnerUnavailable)
}
owner, err := dnffatigue.NewOwner(repositories)
if err != nil {
return fmt.Errorf("%w: %v", errCurrentDungeonFatigueCommitDeferred, err)
}
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
// 燃烧疲劳:每房间消耗翻倍(1 → 2 点)。
fatigueCost := currentDungeonRoomFatigueCost
if s.currentGMBurningEnabled() {
fatigueCost = gmBurningFatigueCost
}
result, err := owner.ConsumeRoom(ctx, dnffatigue.ConsumeRoomCommand{
AccountID: s.accountIDForSession(session),
CharacterID: characterID,
Amount: fatigueCost,
UpdatedAt: s.gameplayNow(),
})
if err != nil {
return fmt.Errorf("%w: %v", errCurrentDungeonFatigueCommitDeferred, err)
}
runtime.Character = result.Character
visit.FatigueConsumed = true
visit.FatigueConsumedAmount = result.Consumed
visit.FatigueBatteryConsumed = result.BatteryConsumed > 0
s.logGameEvent(session, "game-dungeon-fatigue-consumed",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"maze_index", runtime.MazeIndex,
"room", scene.Coordinate.String(),
"map_id", scene.Map.Map.ID,
"before", result.Before,
"after", result.After,
"consumed", result.Consumed,
"battery_before", result.BatteryBefore,
"battery_after", result.BatteryAfter,
"battery_consumed", result.BatteryConsumed,
"source", source)
state := currentCharacterFatigueState(runtime.Character, true)
// The last paid room keeps its blessing even though no future charges remain.
state.batteryBuff = dnffatigue.BatteryBuffCount(result.BatteryAfter, runtime.Character.Level, visit.FatigueBatteryConsumed)
return s.sendGameUpperRawClass(session, currentFatigueMsgID, buildCurrentFatigueStateBody(state), 0)
}
// gmFatigueExemptsRoom reports whether the GM fatigue mode currently suppresses
// per-room fatigue deduction. Both "free"(无限疲劳)and "lock"(锁死高值)are
// exempt; "refill" still charges normally (it only tops the pool back up on demand).
func (s *Service) gmFatigueExemptsRoom() bool {
mode := s.currentGMFatigueMode()
return mode == "free" || mode == "lock"
}
// currentDungeonRoomUsesFatigueBattery reports whether the room's fatigue
// point was paid together with one fatigue-battery charge, which grants the
// 30% ancient-spirit monster EXP bonus for kills inside that room.
func currentDungeonRoomUsesFatigueBattery(runtime *runtimeDungeonState, scene worldmap.DungeonRoomScene) bool {
if runtime == nil || runtime.Character.Level >= 90 {
return false
}
visit, err := runtime.currentDungeonRoomVisit(scene)
return err == nil && visit != nil && visit.FatigueBatteryConsumed
}
// consumeCurrentDungeonRoomFatigueAfterCommit distinguishes durable failures
// from socket failures after op29 has already committed the room. A durable
// failure is logged and deferred instead of aborting an irreversible scene
// transition; a transport failure still stops the packet stream.
func (s *Service) consumeCurrentDungeonRoomFatigueAfterCommit(
session *gameSession,
runtime *runtimeDungeonState,
source string,
) error {
err := s.consumeCurrentDungeonRoomFatigue(session, runtime, source)
if err == nil || !errors.Is(err, errCurrentDungeonFatigueCommitDeferred) {
return err
}
s.logGameEvent(session, "game-dungeon-fatigue-consume-deferred",
"char_id", session.selectedCharacterID,
"dungeon_id", runtime.Dungeon.ID,
"source", source,
"error", err)
return nil
}
// currentDungeonRetryGatesOnFatigue reports whether THIS run's remaining fatigue
// decides whether the settlement's 再次挑战 stays available.
//
// Runs that never charge fatigue must not gate on it. Three of them are not covered
// by currentDungeonRuntimeConsumesFatigue and are excluded here explicitly:
//
// - an 安徒恩 raid gate: the squad's run is owned by the raid binding, not by a
// per-character fatigue budget (runtime.raidDungeon is only set on raid runs);
// - 转职体验「领悟时间」 (7142), which the client enters straight from the 体验
// button and which is already on the weakness-exempt whitelist for the same
// "this run is free" reason (see weakness_state.go);
//
// while the rest are already covered there: 修炼道场 (5000) and any PVF dungeon
// flagged free go through currentDungeonIgnoresFatigue, and practice runs through
// currentDungeonRuntimeIsPractice.
func currentDungeonRetryGatesOnFatigue(runtime *runtimeDungeonState) bool {
if runtime == nil {
return false
}
if runtime.raidDungeon.RaidKey != 0 {
return false
}
if runtime.Dungeon.ID == int64(currentJobChangeTrialDungeonID) {
return false
}
return currentDungeonRuntimeConsumesFatigue(runtime)
}
// currentDungeonFatigueStatExhausted reads the REMAINING fatigue out of a character
// record; 0 means the character cannot enter or retry.
//
// No repository read is needed: every charged room writes the post-charge record
// back onto the connection's runtime (consumeCurrentDungeonRoomFatigue sets
// runtime.Character = result.Character), and a room charges on entry, so by the
// time the boss room is cleared this is already the final value.
func currentDungeonFatigueStatExhausted(stats map[string]int64) bool {
fatigue, known := dnfdungeon.CharacterStat(stats, "fatigue", "fp", "疲劳")
return known && fatigue <= 0
}
// currentDungeonSessionFatigueExhausted reads one session's live run fatigue.
// It takes that session's own lock and is therefore only called while no other
// session lock is held.
func currentDungeonSessionFatigueExhausted(session *gameSession) bool {
if session == nil {
return false
}
session.dungeon.mu.Lock()
runtime := session.dungeon.runtime
exhausted := runtime != nil && currentDungeonFatigueStatExhausted(runtime.Character.Stats)
session.dungeon.mu.Unlock()
return exhausted
}
// currentPartyFatigueExhaustedSlots snapshots which party slots have run out of
// fatigue, for the card-layout packet that decides whether 再次挑战 is available.
//
// MUST be called WITHOUT holding any session.dungeon.mu. It borrows the leader's
// lock only to read the roster and the leader's own record and releases it before
// touching a member's -- this codebase only ever holds one session lock at a time
// (see party_dungeon_entry_presence.go and town_enter_select.go), so nesting the
// leader's lock around a member's would introduce a new lock order.
func (s *Service) currentPartyFatigueExhaustedSlots(leader *gameSession) [dungeonCardSlotsPerSide]bool {
var slots [dungeonCardSlotsPerSide]bool
if s == nil || leader == nil || leader.selectedCharacterID == 0 {
return slots
}
leader.dungeon.mu.Lock()
runtime := leader.dungeon.runtime
gates := currentDungeonRetryGatesOnFatigue(runtime)
leaderExhausted := runtime != nil && currentDungeonFatigueStatExhausted(runtime.Character.Stats)
var roster []runtimePartyDungeonMember
if runtime != nil && runtime.partyDungeonOwner != nil {
roster = runtime.partyDungeonOwner.roster()
}
leader.dungeon.mu.Unlock()
if !gates {
return slots
}
// A solo run is a party of one: its own slot is present, the rest stay absent.
if len(roster) == 0 {
slots[0] = leaderExhausted
return slots
}
for _, member := range roster {
if member.slot >= dungeonCardSlotsPerSide {
continue
}
if member.userID == leader.selectedCharacterID {
slots[member.slot] = leaderExhausted
continue
}
memberSession, ok := s.onlineGameSession(member.userID)
if !ok || memberSession == nil {
// A member with no live session cannot report its fatigue; leave the
// slot at 1 so a disconnect never disables 再次挑战 for the others.
continue
}
slots[member.slot] = currentDungeonSessionFatigueExhausted(memberSession)
}
return slots
}