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