服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
752 lines
28 KiB
Go
752 lines
28 KiB
Go
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
|
||
}
|