服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
201 lines
6.1 KiB
Go
201 lines
6.1 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
"longheng.io/server/internal/modules/dnf/worldmap"
|
|
)
|
|
|
|
type dungeonMazeSelection struct {
|
|
Index int
|
|
Reason string
|
|
QuestID int64
|
|
}
|
|
|
|
// selectDungeonMaze mirrors the PVF domain rule used by df_game_r's
|
|
// GetAppropriateMaze flow: an active quest connection wins first, then a
|
|
// cleared-quest connection, then one of the non-quest mazes. The current op16
|
|
// writer does not carry a proved maze index, so the choice is owned by the
|
|
// server's real PVF and persisted quest state rather than by an opaque request
|
|
// field.
|
|
func selectDungeonMaze(
|
|
mazes []worldmap.Maze,
|
|
difficulty byte,
|
|
record dnfrepo.QuestRecord,
|
|
choose func(int) (int, error),
|
|
) (dungeonMazeSelection, error) {
|
|
if len(mazes) == 0 {
|
|
return dungeonMazeSelection{}, worldmap.ErrMazeNotFound
|
|
}
|
|
active, cleared := dungeonQuestStateSets(record)
|
|
if selected, ok, err := selectQuestConnectedMaze(mazes, active, 0, difficulty, choose); ok || err != nil {
|
|
selected.Reason = "active_quest_connection"
|
|
return selected, err
|
|
}
|
|
if selected, ok, err := selectQuestConnectedMaze(mazes, cleared, 1, difficulty, choose); ok || err != nil {
|
|
selected.Reason = "cleared_quest_connection"
|
|
return selected, err
|
|
}
|
|
|
|
defaults := make([]int, 0, len(mazes))
|
|
for index := range mazes {
|
|
if len(mazes[index].QuestConnection) == 0 {
|
|
defaults = append(defaults, index)
|
|
}
|
|
}
|
|
if len(defaults) == 0 {
|
|
return dungeonMazeSelection{Index: 0, Reason: "pvf_first_maze_no_default"}, nil
|
|
}
|
|
index, err := chooseDungeonWeightedMaze(mazes, defaults, choose)
|
|
if err != nil {
|
|
return dungeonMazeSelection{}, fmt.Errorf("choose non-quest maze: %w", err)
|
|
}
|
|
return dungeonMazeSelection{Index: index, Reason: "pvf_non_quest_maze"}, nil
|
|
}
|
|
|
|
// chooseDungeonWeightedMaze follows a dungeon's optional PVF [maze chance
|
|
// rate] values. Time Rift declares eight normal routes at 11875 each and two
|
|
// special routes at 2500 each, so treating the ten mazes as uniform makes an
|
|
// unsupported special route four times too likely. Legacy dungeons without
|
|
// any rate keep the original candidate-count chooser shape.
|
|
func chooseDungeonWeightedMaze(
|
|
mazes []worldmap.Maze,
|
|
candidates []int,
|
|
choose func(int) (int, error),
|
|
) (int, error) {
|
|
weighted := false
|
|
for _, index := range candidates {
|
|
if index >= 0 && index < len(mazes) && mazes[index].ChanceRate.Set {
|
|
weighted = true
|
|
break
|
|
}
|
|
}
|
|
if !weighted {
|
|
return chooseDungeonCandidate(candidates, choose)
|
|
}
|
|
|
|
total := int64(0)
|
|
weights := make([]int64, len(candidates))
|
|
for position, index := range candidates {
|
|
if index < 0 || index >= len(mazes) {
|
|
return 0, fmt.Errorf("maze candidate index %d outside [0,%d)", index, len(mazes))
|
|
}
|
|
weight := int64(1)
|
|
if mazes[index].ChanceRate.Set {
|
|
weight = mazes[index].ChanceRate.Value
|
|
}
|
|
if weight <= 0 || weight > int64(^uint(0)>>1)-total {
|
|
return 0, fmt.Errorf("invalid maze chance rate=%d maze=%d", weight, index)
|
|
}
|
|
weights[position] = weight
|
|
total += weight
|
|
}
|
|
if total <= 0 || total > int64(^uint(0)>>1) {
|
|
return 0, fmt.Errorf("invalid total maze chance rate=%d", total)
|
|
}
|
|
if choose == nil {
|
|
return 0, fmt.Errorf("%w: weighted_candidates=%d total_weight=%d", errDungeonChoiceRequired, len(candidates), total)
|
|
}
|
|
roll, err := choose(int(total))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if roll < 0 || int64(roll) >= total {
|
|
return 0, fmt.Errorf("dungeon weighted maze roll %d outside [0,%d)", roll, total)
|
|
}
|
|
remaining := int64(roll)
|
|
for position, weight := range weights {
|
|
if remaining < weight {
|
|
return candidates[position], nil
|
|
}
|
|
remaining -= weight
|
|
}
|
|
return 0, fmt.Errorf("dungeon weighted maze roll %d did not resolve", roll)
|
|
}
|
|
|
|
func selectQuestConnectedMaze(
|
|
mazes []worldmap.Maze,
|
|
questIDs map[int64]struct{},
|
|
requiredType int64,
|
|
difficulty byte,
|
|
choose func(int) (int, error),
|
|
) (dungeonMazeSelection, bool, error) {
|
|
if len(questIDs) == 0 {
|
|
return dungeonMazeSelection{}, false, nil
|
|
}
|
|
candidates := make([]int, 0, 1)
|
|
for index := range mazes {
|
|
connection := mazes[index].QuestConnection
|
|
if len(connection) < 2 || connection[0] != requiredType {
|
|
continue
|
|
}
|
|
questID := connection[1]
|
|
if _, exists := questIDs[questID]; !exists {
|
|
continue
|
|
}
|
|
if requiredType == 0 && len(connection) >= 3 && connection[2] >= 0 && int64(difficulty) < connection[2] {
|
|
continue
|
|
}
|
|
candidates = append(candidates, index)
|
|
}
|
|
if len(candidates) == 0 {
|
|
return dungeonMazeSelection{}, false, nil
|
|
}
|
|
index, err := chooseDungeonCandidate(candidates, choose)
|
|
if err != nil {
|
|
return dungeonMazeSelection{}, true, fmt.Errorf("choose quest-connected maze: %w", err)
|
|
}
|
|
return dungeonMazeSelection{Index: index, QuestID: mazes[index].QuestConnection[1]}, true, nil
|
|
}
|
|
|
|
func chooseDungeonCandidate(candidates []int, choose func(int) (int, error)) (int, error) {
|
|
if len(candidates) == 0 {
|
|
return 0, worldmap.ErrMazeNotFound
|
|
}
|
|
if len(candidates) == 1 {
|
|
return candidates[0], nil
|
|
}
|
|
if choose == nil {
|
|
return 0, fmt.Errorf("%w: candidates=%d", errDungeonChoiceRequired, len(candidates))
|
|
}
|
|
position, err := choose(len(candidates))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if position < 0 || position >= len(candidates) {
|
|
return 0, fmt.Errorf("dungeon candidate position %d outside [0,%d)", position, len(candidates))
|
|
}
|
|
return candidates[position], nil
|
|
}
|
|
|
|
func dungeonQuestStateSets(record dnfrepo.QuestRecord) (active map[int64]struct{}, cleared map[int64]struct{}) {
|
|
active = make(map[int64]struct{})
|
|
cleared = make(map[int64]struct{})
|
|
collect := func(states map[int64]dnfrepo.QuestState) {
|
|
for questID, state := range states {
|
|
if questID <= 0 {
|
|
continue
|
|
}
|
|
switch normalizeDungeonQuestStatus(state.Status) {
|
|
case "active", "accepted", "inprogress", "progress":
|
|
active[questID] = struct{}{}
|
|
case "complete", "completed", "cleared", "finished", "done":
|
|
cleared[questID] = struct{}{}
|
|
}
|
|
}
|
|
}
|
|
collect(record.States)
|
|
collect(record.Progress)
|
|
return active, cleared
|
|
}
|
|
|
|
func normalizeDungeonQuestStatus(value string) string {
|
|
value = strings.ToLower(strings.TrimSpace(value))
|
|
value = strings.ReplaceAll(value, "_", "")
|
|
value = strings.ReplaceAll(value, "-", "")
|
|
value = strings.ReplaceAll(value, " ", "")
|
|
return value
|
|
}
|