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

214 lines
7.6 KiB
Go

package dnfbridge
import (
"context"
"strconv"
dnfcharacterdata "longheng.io/server/internal/modules/dnf/characterdata"
dnffatigue "longheng.io/server/internal/modules/dnf/fatigue"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
func (s *Service) resolveSelectedCharacter(
ctx context.Context,
session *gameSession,
request []byte,
) (uint16, string, dnfrepo.CharacterRecord, bool, int) {
parsed := parseSelectCharacterRequest(request)
slot := parsed.slot
if !parsed.clear && len(request) > 0 {
s.logGameEvent(session, "game-upper-select-character-request-opaque",
"body_len", len(request),
"parsed_slot", slot,
"source", parsed.source)
} else if parsed.source == "nopack_key4_slot" || parsed.source == "nopack_plain_slot" {
s.logGameEvent(session, "game-upper-select-character-request-decoded",
"body_len", len(request),
"slot", slot,
"source", parsed.source)
}
if repos, ok := s.repositoryGroup(); ok && repos.Character != nil {
characters, err := s.listCharacters(ctx, repos, s.accountIDForSession(session))
if err != nil {
s.logGameEvent(session, "game-upper-select-character-list-failed", "slot", slot, "error", err)
} else if len(characters) > 0 {
if selected, selectedBy, ok := selectCharacterRecord(characters, parsed); ok {
if repos.Equipment != nil {
s.attachEquipmentSummary(ctx, repos, &selected)
}
// listCharacters is a roster projection. Restore the complete
// structured map before persisting level-stat repairs so a
// partial row cannot erase unrelated character state.
charIDStr := strconv.FormatUint(uint64(numericCharacterID(selected)), 10)
if fullChar, found, err := repos.Character.Load(ctx, charIDStr); err == nil && found && fullChar.Stats != nil {
selected.Stats = fullChar.Stats
}
if stats, ok := s.characterPVFStatsForUserInfo(ctx, session, selected, true); ok {
needsRepair := characterPVFStatsNeedRepair(selected, stats)
applyCharacterPVFStats(&selected, stats)
if needsRepair {
if err := repairSelectedCharacterPVFStats(ctx, repos.Character, selected); err != nil {
s.logGameEvent(session, "game-upper-select-character-stat-repair-failed",
"character_id", selected.CharacterID,
"level", selected.Level,
"error", err)
}
}
}
charID := uint16(numericCharacterID(selected))
if charID == 0 {
charID = 1
}
charName := selected.Name
if charName == "" {
charName = csharpSelectCharacterName(charID)
}
s.logGameEvent(session, "game-upper-select-character-resolved",
"request_slot", slot,
"record_slot", selected.Slot,
"char_id", charID,
"char_name", charName,
"job", selected.Job,
"level", selected.Level,
"source", parsed.source,
"selected_by", selectedBy,
"count", len(characters))
return charID, charName, selected, true, selected.Slot
}
s.logGameEvent(session, "game-upper-select-character-unresolved",
"request_slot", slot,
"source", parsed.source,
"count", len(characters),
"reason", "no_verified_slot_or_char_id")
}
}
charID := parsed.charID
return charID, csharpSelectCharacterName(charID), dnfrepo.CharacterRecord{}, false, slot
}
func selectCharacterRecord(
characters []dnfrepo.CharacterRecord,
parsed selectCharacterRequest,
) (dnfrepo.CharacterRecord, string, bool) {
if parsed.slot >= 0 && parsed.slot < maxCharacterSlots {
for _, character := range characters {
if character.Slot == parsed.slot {
return character, "slot", true
}
}
}
if parsed.charID != 0 && selectRequestHasAuthoritativeCharID(parsed) {
for _, character := range characters {
if numericCharacterID(character) == int(parsed.charID) {
return character, "char_id", true
}
}
}
return dnfrepo.CharacterRecord{}, "", false
}
func selectRequestHasAuthoritativeCharID(parsed selectCharacterRequest) bool {
switch parsed.source {
case "unknown", "opaque", "fallback", "nopack_key4_slot", "nopack_plain_slot":
return false
default:
return true
}
}
func csharpSelectCharacterName(charID uint16) string {
if charID == 0 {
return ""
}
return "dnf_" + strconv.Itoa(int(charID))
}
func (s *Service) selectedCharacterForEnter(
ctx context.Context,
session *gameSession,
) (uint16, string, dnfrepo.CharacterRecord, bool) {
if session == nil {
return 0, "", dnfrepo.CharacterRecord{}, false
}
characterID, characterName, character, found := s.characterForEnter(ctx, session, session.selectedCharacterID)
if !found {
return characterID, characterName, character, false
}
if character.Stats != nil {
limit := character.Stats["fatigue_limit"]
if limit == dnffatigue.DefaultLimit || limit == dnffatigue.BlackDiamondLimit {
if blackDiamond, err := s.reconcileCurrentBlackDiamondSelection(ctx, session, character); err != nil {
s.logGameEvent(session, "game-black-diamond-selected-character-read-reconcile-deferred",
"char_id", characterID,
"reason", err)
} else if blackDiamond.Found {
character = blackDiamond.Character
}
}
}
// Daily quests reset on the same 06:00 boundary as fatigue, and for the
// same reason they have to be reconciled from this read: a server that is
// not running at 06:00 never fires the scheduled sweep.
s.reconcileCurrentDailyQuestSelectionIfNeeded(ctx, session, characterID)
reconciled, changed, err := s.reconcileCurrentDailyFatigueSelectionIfNeeded(ctx, session, character)
if err != nil {
s.logGameEvent(session, "game-daily-fatigue-selected-character-read-reconcile-deferred",
"char_id", characterID,
"reason", err)
return characterID, characterName, character, true
}
if changed {
character = reconciled
s.logGameEvent(session, "game-daily-fatigue-selected-character-read-reconciled",
"char_id", characterID,
"fatigue", character.Stats["fatigue"],
"fatigue_limit", character.Stats["fatigue_limit"])
}
return characterID, characterName, character, true
}
func (s *Service) characterForEnter(
ctx context.Context,
session *gameSession,
characterID uint16,
) (uint16, string, dnfrepo.CharacterRecord, bool) {
// projectionRepositories 在 ctx 上没有批量缓存时与 repositoryGroup 完全一致。
// 这里的聚合读、回填探针与 attachEquipmentSummary 都只按演员角色 ID 取值,
// 与接收者无关,因此可以在一次扇出里只做一遍。
if repos, ok := s.projectionRepositories(ctx); ok && repos.Character != nil {
if characterID != 0 {
characterKey := strconv.Itoa(int(characterID))
aggregate, found, err := dnfcharacterdata.NewLoader(repos).Load(ctx, characterKey, dnfcharacterdata.PartEquipment)
if err != nil {
s.logGameEvent(session, "game-upper-enter-select-dungeon-aggregate-load-failed", "character_id", characterID, "error", err)
} else if found {
loaded := aggregate.Character
if aggregate.Presence.Equipment {
if summary := equipmentRosterSummary(aggregate.Equipment); len(summary) > 0 {
loaded.Roster.Entry.EquipSummary = summary
}
}
s.attachEquipmentSummary(ctx, repos, &loaded)
charName := loaded.Name
if charName == "" {
charName = csharpSelectCharacterName(characterID)
}
return characterID, charName, loaded, true
}
loaded, found, err := repos.Character.Load(ctx, characterKey)
if err != nil {
s.logGameEvent(session, "game-upper-enter-select-dungeon-load-failed", "character_id", characterID, "error", err)
} else if found {
s.attachEquipmentSummary(ctx, repos, &loaded)
charName := loaded.Name
if charName == "" {
charName = csharpSelectCharacterName(characterID)
}
return characterID, charName, loaded, true
}
}
}
return characterID, csharpSelectCharacterName(characterID), dnfrepo.CharacterRecord{}, false
}