DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

@@ -0,0 +1,751 @@
package dnfbridge
import (
"context"
"fmt"
"sort"
"strconv"
"strings"
"time"
"golang.org/x/text/encoding/simplifiedchinese"
dnfhellparty "longheng.io/server/internal/modules/dnf/hellparty"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/worldmap"
)
const (
gmGameplaySettingsScope = "global:dnf90:gm-gameplay"
gmHellEpicDropRatePercentKey = "hell_epic_drop_rate_percent"
gmHellEpicDropRateDefaultPercent = 100
gmHellEpicDropRateMinimumPercent = 0
gmHellEpicDropRateMaximumPercent = 15000
// gmEpicDropRatePotionTestMultiplierKey 是"城镇增益药剂"的**临时测试倍率**。
//
// 服主 2026-10-03 要求:"我需要测试它真的生效,先改一下倍率吧?后面再改回从 pvf 中读取。"
// 药剂真实倍率只有 2 倍(PVF 1136 的 [param] 100 ⇒ 100+100=200%),肉眼根本看不出
// "史诗是不是真的掉得更多了",所以给它一个能被 GM 随手拧大的旋钮:
//
// 0 = 关闭(生产口径:用 PVF 表里查出来的倍数)
// 2000 = 测试用 20 倍
//
// 它**只替换倍数**,不绕过任何前置条件:药剂窗口没过期(statbuff.ActiveEpicDropRate)
// 才可能生效,所以这个旋钮本身仍然在测"药剂到底有没有用"。
// 测完把它设回 0 即可,不需要重新编译。
gmEpicDropRatePotionTestMultiplierKey = "epic_drop_rate_potion_test_multiplier_percent"
gmEpicDropRatePotionTestMultiplierDefaultPercent = 0
gmEpicDropRatePotionTestMultiplierMinimumPercent = 0
gmEpicDropRatePotionTestMultiplierMaximumPercent = 100000
// 疲劳与燃烧疲劳的 GM 配置。复用同一份 settings scope,键名独立。
// FatigueMode 取值:
// "none" = 正常扣疲劳(默认)
// "free" = 无限疲劳:进副本不消耗疲劳
// "lock" = 疲劳锁死高值(显示一个超大数,实际等同无限)
// "一键回满"是独立按钮(见 GMRefillFatigue),不作为持久化模式。
gmFatigueModeKey = "fatigue_mode"
gmFatigueModeDefault = "none"
gmBurningEnabledKey = "burning_enabled"
gmBurningEnabledDefault = false
// gmBurningFatigueCost 是燃烧疲劳开启时,每个房间额外消耗的疲劳点数
// (相对正常 1 点翻倍为 2 点)。见 dungeon_fatigue_runtime.go 的
// currentDungeonRoomFatigueCost 常量。
gmBurningFatigueCost = int64(2)
)
// validGMFatigueModes 是允许的疲劳模式白名单,非法值一律回落到 none。
var validGMFatigueModes = map[string]bool{
"none": true,
"free": true,
"lock": true,
}
type GMDungeonAccessState struct {
Character GMCharacterSummary `json:"character"`
DifficultyDungeons int `json:"difficulty_dungeons"`
DifficultyDungeonsUnlocked int `json:"difficulty_dungeons_unlocked"`
DifficultyDungeonsUpdated int `json:"difficulty_dungeons_updated,omitempty"`
HellAreas int `json:"hell_areas"`
HellPermissionQuests int `json:"hell_permission_quests"`
HellPermissionQuestsCleared int `json:"hell_permission_quests_cleared"`
HellPermissionQuestsUpdated int `json:"hell_permission_quests_updated,omitempty"`
}
type GMGameplaySettings struct {
HellEpicDropRatePercent int `json:"hell_epic_drop_rate_percent"`
MinimumPercent int `json:"minimum_percent"`
MaximumPercent int `json:"maximum_percent"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
// EpicDropRatePotionTestMultiplierPercent 是"城镇增益药剂"的临时测试倍率
// (0 = 关闭,按 PVF 表查出来的倍数走)。见上面常量的说明。
EpicDropRatePotionTestMultiplierPercent int `json:"epic_drop_rate_potion_test_multiplier_percent"`
PotionTestMultiplierMinimumPercent int `json:"potion_test_multiplier_minimum_percent"`
PotionTestMultiplierMaximumPercent int `json:"potion_test_multiplier_maximum_percent"`
// FatigueMode 疲劳模式:none / free / lock / refill(见常量区说明)。
FatigueMode string `json:"fatigue_mode"`
// BurningEnabled 燃烧疲劳:开启后每房间消耗 2 点疲劳,通关经验翻倍。
BurningEnabled bool `json:"burning_enabled"`
}
type gmDungeonDifficultyTarget struct {
DungeonID uint32
MaxState byte
}
func gmDungeonDifficultyTargets(table *worldmap.Table) []gmDungeonDifficultyTarget {
if table == nil {
return nil
}
targets := make([]gmDungeonDifficultyTarget, 0)
seen := make(map[uint32]struct{})
appendDungeon := func(dungeon worldmap.Dungeon) {
if dungeon.ID <= 0 || uint64(dungeon.ID) > uint64(^uint32(0)) {
return
}
id := uint32(dungeon.ID)
if _, exists := seen[id]; exists {
return
}
maxState := currentDungeonMaxClearState(dungeon)
if maxState == 0 {
return
}
seen[id] = struct{}{}
targets = append(targets, gmDungeonDifficultyTarget{DungeonID: id, MaxState: maxState})
}
for _, dungeon := range table.Dungeons() {
appendDungeon(dungeon)
}
sort.SliceStable(targets, func(left, right int) bool {
return targets[left].DungeonID < targets[right].DungeonID
})
return targets
}
func gmHellPermissionQuestIDs(table *worldmap.Table) ([]int64, int) {
if table == nil {
return nil, 0
}
seen := make(map[int64]struct{})
questIDs := make([]int64, 0)
hellAreas := 0
for _, area := range table.Areas() {
if !area.HellDungeon.Set || area.HellDungeon.Value == 0 || len(area.HellQuestIDs) == 0 {
continue
}
hellAreas++
for _, questID := range area.HellQuestIDs {
if questID <= 0 {
continue
}
if _, exists := seen[questID]; exists {
continue
}
seen[questID] = struct{}{}
questIDs = append(questIDs, questID)
}
}
sort.SliceStable(questIDs, func(left, right int) bool { return questIDs[left] < questIDs[right] })
return questIDs, hellAreas
}
type gmHellPermissionAreaUnlock struct {
AreaID int64
QuestID int64
}
// gmHellPermissionAreaUnlocks lists one durable permission row per gated Hell
// area: the area id with the first positive [hell quest] that proves it.
func gmHellPermissionAreaUnlocks(table *worldmap.Table) []gmHellPermissionAreaUnlock {
if table == nil {
return nil
}
unlocks := make([]gmHellPermissionAreaUnlock, 0)
for _, area := range table.Areas() {
if area.ID <= 0 || !area.HellDungeon.Set || area.HellDungeon.Value == 0 {
continue
}
for _, questID := range area.HellQuestIDs {
if questID <= 0 {
continue
}
unlocks = append(unlocks, gmHellPermissionAreaUnlock{AreaID: area.ID, QuestID: questID})
break
}
}
sort.SliceStable(unlocks, func(left, right int) bool { return unlocks[left].AreaID < unlocks[right].AreaID })
return unlocks
}
func (s *Service) GMDungeonAccess(ctx context.Context, characterID string) (GMDungeonAccessState, error) {
characterID, _, err := gmCharacterID(characterID)
if err != nil {
return GMDungeonAccessState{}, err
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil || repositories.DungeonPermission == nil || repositories.Quest == nil {
return GMDungeonAccessState{}, ErrGMUnavailable
}
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil {
return GMDungeonAccessState{}, err
}
if !found {
return GMDungeonAccessState{}, ErrGMCharacterNotFound
}
table, _, err := s.dungeonWorldMap()
if err != nil {
return GMDungeonAccessState{}, err
}
targets := gmDungeonDifficultyTargets(table)
permission, _, err := repositories.DungeonPermission.Load(ctx, characterID)
if err != nil {
return GMDungeonAccessState{}, err
}
persisted := make(map[uint32]byte, len(permission.Entries))
for _, entry := range permission.Entries {
if entry.ClearState > persisted[entry.DungeonID] {
persisted[entry.DungeonID] = entry.ClearState
}
}
unlocked := 0
for _, target := range targets {
if persisted[target.DungeonID] >= target.MaxState {
unlocked++
}
}
hellQuestIDs, hellAreas := gmHellPermissionQuestIDs(table)
questRecord, _, err := repositories.Quest.Load(ctx, characterID)
if err != nil {
return GMDungeonAccessState{}, err
}
hellCleared := 0
for _, questID := range hellQuestIDs {
if state, found := gmQuestState(questRecord, questID); found && gmQuestStatusCompleted(state.Status) {
hellCleared++
}
}
return GMDungeonAccessState{
Character: s.gmCharacterSummary(character),
DifficultyDungeons: len(targets),
DifficultyDungeonsUnlocked: unlocked,
HellAreas: hellAreas,
HellPermissionQuests: len(hellQuestIDs),
HellPermissionQuestsCleared: hellCleared,
}, nil
}
func (s *Service) GMUnlockAllDungeonDifficulties(ctx context.Context, characterID string) (GMDungeonAccessState, error) {
characterID, numericID, err := gmCharacterID(characterID)
if err != nil {
return GMDungeonAccessState{}, err
}
if _, online := s.onlineGameSession(numericID); online {
return GMDungeonAccessState{}, ErrGMCharacterOnline
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Character == nil || repositories.DungeonPermission == nil {
return GMDungeonAccessState{}, ErrGMUnavailable
}
if _, found, loadErr := repositories.Character.Load(ctx, characterID); loadErr != nil {
return GMDungeonAccessState{}, loadErr
} else if !found {
return GMDungeonAccessState{}, ErrGMCharacterNotFound
}
table, _, err := s.dungeonWorldMap()
if err != nil {
return GMDungeonAccessState{}, err
}
updated := 0
for _, target := range gmDungeonDifficultyTargets(table) {
_, changed, upsertErr := repositories.DungeonPermission.UpsertMax(ctx, characterID, target.DungeonID, target.MaxState)
if upsertErr != nil {
return GMDungeonAccessState{}, fmt.Errorf("unlock dungeon %d difficulty: %w", target.DungeonID, upsertErr)
}
if changed {
updated++
}
}
state, err := s.GMDungeonAccess(ctx, characterID)
state.DifficultyDungeonsUpdated = updated
return state, err
}
func (s *Service) GMUnlockAllHellChallenges(ctx context.Context, characterID string) (GMDungeonAccessState, error) {
characterID, numericID, err := gmCharacterID(characterID)
if err != nil {
return GMDungeonAccessState{}, err
}
if _, online := s.onlineGameSession(numericID); online {
return GMDungeonAccessState{}, ErrGMCharacterOnline
}
table, _, err := s.dungeonWorldMap()
if err != nil {
return GMDungeonAccessState{}, err
}
questIDs, _ := gmHellPermissionQuestIDs(table)
areaUnlocks := gmHellPermissionAreaUnlocks(table)
repositories, ok := s.repositoryGroup()
if !ok || repositories.CharacterSettlement == nil {
return GMDungeonAccessState{}, ErrGMUnavailable
}
updated := 0
err = repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
if tx.Character == nil || tx.Quest == nil || tx.HellPartyPermission == nil {
return ErrGMUnavailable
}
if _, found, loadErr := tx.Character.Load(ctx, characterID); loadErr != nil {
return loadErr
} else if !found {
return ErrGMCharacterNotFound
}
record, found, loadErr := tx.Quest.Load(ctx, characterID)
if loadErr != nil {
return loadErr
}
if !found {
record = dnfrepo.QuestRecord{CharacterID: characterID}
} else {
record = dnfrepo.CloneQuest(record)
}
if record.States == nil {
record.States = make(map[int64]dnfrepo.QuestState)
}
now := time.Now().UTC()
for _, questID := range questIDs {
state, completed := gmQuestState(record, questID)
if completed && gmQuestStatusCompleted(state.Status) {
continue
}
if state.Extra == nil {
state.Extra = make(map[string]string)
}
state.Status = "completed"
state.ProgressValue = 0
state.UpdatedAt = now
state.Extra["gm_all_hell_unlocked"] = "true"
state.Extra["gm_rewards_granted"] = "false"
record.States[questID] = state
delete(record.Progress, questID)
updated++
}
// The dungeon-entry gate reads the durable region permission, which the
// normal quest finish path writes. Completing the quests here bypasses
// that path, so the same rows must be written explicitly. This also
// repairs characters whose quests were already marked completed by an
// earlier unlock that never wrote them.
for _, unlock := range areaUnlocks {
if _, _, upsertErr := tx.HellPartyPermission.Upsert(ctx, characterID, unlock.AreaID, unlock.QuestID); upsertErr != nil {
return upsertErr
}
}
if updated == 0 {
return nil
}
record.UpdatedAt = now
return dnfrepo.SaveQuestFields(ctx, tx.Quest, record, dnfrepo.QuestFieldStates, dnfrepo.QuestFieldProgress)
})
if err != nil {
return GMDungeonAccessState{}, err
}
state, err := s.GMDungeonAccess(ctx, characterID)
state.HellPermissionQuestsUpdated = updated
return state, err
}
func (s *Service) GMGameplaySettings(ctx context.Context) (GMGameplaySettings, error) {
s.gmGameplaySettingsMu.Lock()
defer s.gmGameplaySettingsMu.Unlock()
return s.gmGameplaySettingsLocked(ctx)
}
func (s *Service) gmGameplaySettingsLocked(ctx context.Context) (GMGameplaySettings, error) {
if s.gmGameplaySettingsReady {
return newGMGameplaySettings(s.gmHellEpicDropRatePct, s.gmEpicDropRatePotionTestMultiplierPct,
s.gmFatigueMode, s.gmBurningEnabled, s.gmGameplaySettingsAt), nil
}
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMGameplaySettings{}, ErrGMUnavailable
}
record, found, err := repositories.Settings.Load(ctx, gmGameplaySettingsScope)
if err != nil {
return GMGameplaySettings{}, err
}
percent := gmHellEpicDropRateDefaultPercent
testMultiplier := gmEpicDropRatePotionTestMultiplierDefaultPercent
if found {
raw := strings.TrimSpace(record.Values[gmHellEpicDropRatePercentKey])
if raw != "" {
parsed, parseErr := strconv.Atoi(raw)
if parseErr != nil || parsed < gmHellEpicDropRateMinimumPercent || parsed > gmHellEpicDropRateMaximumPercent {
return GMGameplaySettings{}, fmt.Errorf("%w: stored hell epic drop rate is invalid", ErrGMInvalidRequest)
}
percent = parsed
}
rawTest := strings.TrimSpace(record.Values[gmEpicDropRatePotionTestMultiplierKey])
if rawTest != "" {
parsed, parseErr := strconv.Atoi(rawTest)
if parseErr != nil || parsed < gmEpicDropRatePotionTestMultiplierMinimumPercent ||
parsed > gmEpicDropRatePotionTestMultiplierMaximumPercent {
return GMGameplaySettings{}, fmt.Errorf("%w: stored epic potion test multiplier is invalid", ErrGMInvalidRequest)
}
testMultiplier = parsed
}
}
fatigueMode := gmFatigueModeFromRecord(record, found)
burningEnabled := gmBurningEnabledFromRecord(record, found)
s.gmHellEpicDropRatePct = percent
s.gmEpicDropRatePotionTestMultiplierPct = testMultiplier
s.gmFatigueMode = fatigueMode
s.gmBurningEnabled = burningEnabled
s.gmGameplaySettingsAt = record.UpdatedAt
s.gmGameplaySettingsReady = true
return newGMGameplaySettings(percent, testMultiplier, fatigueMode, burningEnabled, record.UpdatedAt), nil
}
// gmFatigueModeFromRecord 解析疲劳模式,非法或缺失一律回落 none。
func gmFatigueModeFromRecord(record dnfrepo.SettingsRecord, found bool) string {
if !found {
return gmFatigueModeDefault
}
mode := strings.TrimSpace(record.Values[gmFatigueModeKey])
if !validGMFatigueModes[mode] {
return gmFatigueModeDefault
}
return mode
}
// gmBurningEnabledFromRecord 解析燃烧疲劳开关,非法或缺失一律 false。
func gmBurningEnabledFromRecord(record dnfrepo.SettingsRecord, found bool) bool {
if !found {
return gmBurningEnabledDefault
}
raw := strings.TrimSpace(record.Values[gmBurningEnabledKey])
return raw == "1" || raw == "true"
}
// gmGameplaySettingsFromRecord 从一个设置记录里解析出两个值(掉落率 + 药剂测试倍率)。
// 加载与写入两条路都走它,避免"只写了一个键、另一个键的缓存还是旧值"这类偏差。
func gmGameplaySettingsFromRecord(record dnfrepo.SettingsRecord, found bool) (int, int, error) {
percent := gmHellEpicDropRateDefaultPercent
testMultiplier := gmEpicDropRatePotionTestMultiplierDefaultPercent
if !found {
return percent, testMultiplier, nil
}
raw := strings.TrimSpace(record.Values[gmHellEpicDropRatePercentKey])
if raw != "" {
parsed, parseErr := strconv.Atoi(raw)
if parseErr != nil || parsed < gmHellEpicDropRateMinimumPercent || parsed > gmHellEpicDropRateMaximumPercent {
return 0, 0, fmt.Errorf("%w: stored hell epic drop rate is invalid", ErrGMInvalidRequest)
}
percent = parsed
}
rawTest := strings.TrimSpace(record.Values[gmEpicDropRatePotionTestMultiplierKey])
if rawTest != "" {
parsed, parseErr := strconv.Atoi(rawTest)
if parseErr != nil || parsed < gmEpicDropRatePotionTestMultiplierMinimumPercent ||
parsed > gmEpicDropRatePotionTestMultiplierMaximumPercent {
return 0, 0, fmt.Errorf("%w: stored epic potion test multiplier is invalid", ErrGMInvalidRequest)
}
testMultiplier = parsed
}
return percent, testMultiplier, nil
}
func (s *Service) GMSetHellEpicDropRate(ctx context.Context, percent int) (GMGameplaySettings, error) {
if percent < gmHellEpicDropRateMinimumPercent || percent > gmHellEpicDropRateMaximumPercent {
return GMGameplaySettings{}, fmt.Errorf("%w: hell epic drop rate percent must be between %d and %d", ErrGMInvalidRequest, gmHellEpicDropRateMinimumPercent, gmHellEpicDropRateMaximumPercent)
}
s.gmGameplaySettingsMu.Lock()
defer s.gmGameplaySettingsMu.Unlock()
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMGameplaySettings{}, ErrGMUnavailable
}
record, found, err := repositories.Settings.Load(ctx, gmGameplaySettingsScope)
if err != nil {
return GMGameplaySettings{}, err
}
if !found {
record = dnfrepo.SettingsRecord{Scope: gmGameplaySettingsScope}
} else {
record = dnfrepo.CloneSettings(record)
}
if record.Values == nil {
record.Values = make(map[string]string)
}
record.Values[gmHellEpicDropRatePercentKey] = strconv.Itoa(percent)
record.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil {
return GMGameplaySettings{}, err
}
storedPercent, testMultiplier, err := gmGameplaySettingsFromRecord(record, true)
if err != nil {
return GMGameplaySettings{}, err
}
fatigueMode := gmFatigueModeFromRecord(record, true)
burningEnabled := gmBurningEnabledFromRecord(record, true)
s.gmHellEpicDropRatePct = storedPercent
s.gmEpicDropRatePotionTestMultiplierPct = testMultiplier
s.gmFatigueMode = fatigueMode
s.gmBurningEnabled = burningEnabled
s.gmGameplaySettingsAt = record.UpdatedAt
s.gmGameplaySettingsReady = true
return newGMGameplaySettings(storedPercent, testMultiplier, fatigueMode, burningEnabled, record.UpdatedAt), nil
}
// GMSetEpicDropRatePotionTestMultiplier 拧"城镇增益药剂临时测试倍率"那个旋钮(0 = 关闭)。
//
// 只改倍数,不动 GM 全局掉落率,也不绕过"药剂窗口必须还在生效"这个前置条件。
func (s *Service) GMSetEpicDropRatePotionTestMultiplier(ctx context.Context, percent int) (GMGameplaySettings, error) {
if percent < gmEpicDropRatePotionTestMultiplierMinimumPercent ||
percent > gmEpicDropRatePotionTestMultiplierMaximumPercent {
return GMGameplaySettings{}, fmt.Errorf(
"%w: epic potion test multiplier must be between %d and %d",
ErrGMInvalidRequest, gmEpicDropRatePotionTestMultiplierMinimumPercent,
gmEpicDropRatePotionTestMultiplierMaximumPercent)
}
s.gmGameplaySettingsMu.Lock()
defer s.gmGameplaySettingsMu.Unlock()
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMGameplaySettings{}, ErrGMUnavailable
}
record, found, err := repositories.Settings.Load(ctx, gmGameplaySettingsScope)
if err != nil {
return GMGameplaySettings{}, err
}
if !found {
record = dnfrepo.SettingsRecord{Scope: gmGameplaySettingsScope}
} else {
record = dnfrepo.CloneSettings(record)
}
if record.Values == nil {
record.Values = make(map[string]string)
}
record.Values[gmEpicDropRatePotionTestMultiplierKey] = strconv.Itoa(percent)
record.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil {
return GMGameplaySettings{}, err
}
storedPercent, testMultiplier, err := gmGameplaySettingsFromRecord(record, true)
if err != nil {
return GMGameplaySettings{}, err
}
fatigueMode := gmFatigueModeFromRecord(record, true)
burningEnabled := gmBurningEnabledFromRecord(record, true)
s.gmHellEpicDropRatePct = storedPercent
s.gmEpicDropRatePotionTestMultiplierPct = testMultiplier
s.gmFatigueMode = fatigueMode
s.gmBurningEnabled = burningEnabled
s.gmGameplaySettingsAt = record.UpdatedAt
s.gmGameplaySettingsReady = true
return newGMGameplaySettings(storedPercent, testMultiplier, fatigueMode, burningEnabled, record.UpdatedAt), nil
}
func newGMGameplaySettings(percent int, testMultiplier int, fatigueMode string, burningEnabled bool, updatedAt time.Time) GMGameplaySettings {
return GMGameplaySettings{
HellEpicDropRatePercent: percent,
MinimumPercent: gmHellEpicDropRateMinimumPercent,
MaximumPercent: gmHellEpicDropRateMaximumPercent,
EpicDropRatePotionTestMultiplierPercent: testMultiplier,
PotionTestMultiplierMinimumPercent: gmEpicDropRatePotionTestMultiplierMinimumPercent,
PotionTestMultiplierMaximumPercent: gmEpicDropRatePotionTestMultiplierMaximumPercent,
FatigueMode: fatigueMode,
BurningEnabled: burningEnabled,
UpdatedAt: updatedAt,
}
}
func scaleGMHellEpicRarityRate(base int64, percent int) int64 {
if base <= 0 || percent <= 0 {
return 0
}
value := base * int64(percent) / 100
if value > int64(dnfhellparty.RarityRollDenominator) {
value = int64(dnfhellparty.RarityRollDenominator)
}
return value
}
// GMSetFatigueMode 设置疲劳模式(none / free / lock / refill)。只写 settings,
// refill 模式的"立即补满"由 GMSetFatigueMode 内部触发一次回满动作(见实现)。
func (s *Service) GMSetFatigueMode(ctx context.Context, mode string) (GMGameplaySettings, error) {
mode = strings.TrimSpace(mode)
if !validGMFatigueModes[mode] {
return GMGameplaySettings{}, fmt.Errorf("%w: fatigue mode must be one of none/free/lock", ErrGMInvalidRequest)
}
s.gmGameplaySettingsMu.Lock()
defer s.gmGameplaySettingsMu.Unlock()
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMGameplaySettings{}, ErrGMUnavailable
}
record, found, err := repositories.Settings.Load(ctx, gmGameplaySettingsScope)
if err != nil {
return GMGameplaySettings{}, err
}
if !found {
record = dnfrepo.SettingsRecord{Scope: gmGameplaySettingsScope}
} else {
record = dnfrepo.CloneSettings(record)
}
if record.Values == nil {
record.Values = make(map[string]string)
}
record.Values[gmFatigueModeKey] = mode
record.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil {
return GMGameplaySettings{}, err
}
storedPercent, testMultiplier, err := gmGameplaySettingsFromRecord(record, true)
if err != nil {
return GMGameplaySettings{}, err
}
fatigueMode := gmFatigueModeFromRecord(record, true)
burningEnabled := gmBurningEnabledFromRecord(record, true)
s.gmHellEpicDropRatePct = storedPercent
s.gmEpicDropRatePotionTestMultiplierPct = testMultiplier
s.gmFatigueMode = fatigueMode
s.gmBurningEnabled = burningEnabled
s.gmGameplaySettingsAt = record.UpdatedAt
s.gmGameplaySettingsReady = true
return newGMGameplaySettings(storedPercent, testMultiplier, fatigueMode, burningEnabled, record.UpdatedAt), nil
}
// GMSetBurning 开/关燃烧疲劳。燃烧开启后每房间消耗 2 点疲劳、通关经验翻倍,
// 具体生效点见 dungeon_fatigue_runtime.go 与 dungeon_settlement_reward_owner.go。
func (s *Service) GMSetBurning(ctx context.Context, enabled bool) (GMGameplaySettings, error) {
s.gmGameplaySettingsMu.Lock()
defer s.gmGameplaySettingsMu.Unlock()
repositories, ok := s.repositoryGroup()
if !ok || repositories.Settings == nil {
return GMGameplaySettings{}, ErrGMUnavailable
}
record, found, err := repositories.Settings.Load(ctx, gmGameplaySettingsScope)
if err != nil {
return GMGameplaySettings{}, err
}
if !found {
record = dnfrepo.SettingsRecord{Scope: gmGameplaySettingsScope}
} else {
record = dnfrepo.CloneSettings(record)
}
if record.Values == nil {
record.Values = make(map[string]string)
}
if enabled {
record.Values[gmBurningEnabledKey] = "true"
} else {
record.Values[gmBurningEnabledKey] = "false"
}
record.UpdatedAt = time.Now().UTC()
if err := dnfrepo.SaveSettingsFields(ctx, repositories.Settings, record, dnfrepo.SettingsFieldValues); err != nil {
return GMGameplaySettings{}, err
}
storedPercent, testMultiplier, err := gmGameplaySettingsFromRecord(record, true)
if err != nil {
return GMGameplaySettings{}, err
}
fatigueMode := gmFatigueModeFromRecord(record, true)
burningEnabled := gmBurningEnabledFromRecord(record, true)
s.gmHellEpicDropRatePct = storedPercent
s.gmEpicDropRatePotionTestMultiplierPct = testMultiplier
s.gmFatigueMode = fatigueMode
s.gmBurningEnabled = burningEnabled
s.gmGameplaySettingsAt = record.UpdatedAt
s.gmGameplaySettingsReady = true
s.broadcastCurrentBurningNotice(enabled)
return newGMGameplaySettings(storedPercent, testMultiplier, fatigueMode, burningEnabled, record.UpdatedAt), nil
}
// broadcastCurrentBurningNotice 在燃烧疲劳开/关时,给所有在线角色推一条系统提示
// (class-0 op12 mode 0 系统通知),让玩家知道燃烧状态已切换,不必靠猜。
//
// 推送失败只记日志,不影响开关本身的结果返回;离线角色下次上线自然看不到
// 这条一次性提示,但开关状态已持久化,进图即按新状态扣疲劳/结算经验。
func (s *Service) broadcastCurrentBurningNotice(enabled bool) {
state := "关闭"
if enabled {
state = "开启"
}
message := fmt.Sprintf("燃烧疲劳已%s:每房间消耗 2 点疲劳,打怪与通关经验双倍。", state)
encoded, err := simplifiedchinese.GB18030.NewEncoder().Bytes([]byte(message))
if err != nil {
s.logWarn("dnf90 GM burning notice encode failed", "error", err)
return
}
delivered := 0
for _, session := range s.currentOnlineChatSessions() {
if session == nil {
continue
}
body := buildCurrentTownChatNotification(0, session.selectedCharacterID, encoded)
if err := s.sendGameUpperRawClassCodec(session, currentTownChatNotificationMsgID, body, 0, true); err != nil {
s.logWarn("dnf90 GM burning notice send failed",
"character_id", session.selectedCharacterID, "error", err)
continue
}
delivered++
}
s.logGameEvent(nil, "game-gm-burning-notice-broadcast",
"enabled", enabled, "delivered", delivered)
}
func gmAdjustHellEpicRarity(rarity int64, baseEpicRate int64, percent int, roll uint32) (int64, bool) {
if baseEpicRate < 0 {
baseEpicRate = 0
}
denominator := int64(dnfhellparty.RarityRollDenominator)
if baseEpicRate > denominator {
baseEpicRate = denominator
}
target := scaleGMHellEpicRarityRate(baseEpicRate, percent)
if rarity == 4 {
if target >= baseEpicRate {
return rarity, true
}
if baseEpicRate == 0 {
return rarity, false
}
return rarity, int64(roll) < target*denominator/baseEpicRate
}
if target <= baseEpicRate || baseEpicRate >= denominator {
return rarity, true
}
extraThreshold := (target - baseEpicRate) * denominator / (denominator - baseEpicRate)
if int64(roll) < extraThreshold {
return 4, true
}
return rarity, true
}
func (s *Service) currentGMHellEpicDropRatePercent() int {
settings, err := s.GMGameplaySettings(context.Background())
if err != nil {
s.logWarn("dnf90 GM hell epic drop setting unavailable; using 100% PVF default", "error", err)
return gmHellEpicDropRateDefaultPercent
}
return settings.HellEpicDropRatePercent
}
// currentGMFatigueMode 返回当前疲劳模式(none/free/lock/refill)。读取失败回落 none。
func (s *Service) currentGMFatigueMode() string {
settings, err := s.GMGameplaySettings(context.Background())
if err != nil || settings.FatigueMode == "" {
return gmFatigueModeDefault
}
return settings.FatigueMode
}
// currentGMBurningEnabled 返回燃烧疲劳开关状态。读取失败回落 false。
func (s *Service) currentGMBurningEnabled() bool {
settings, err := s.GMGameplaySettings(context.Background())
if err != nil {
return gmBurningEnabledDefault
}
return settings.BurningEnabled
}