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