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

752 lines
28 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
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
}