服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
305 lines
11 KiB
Go
305 lines
11 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
|
dnfguardianshield "longheng.io/server/internal/modules/dnf/guardianshield"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const currentGuardianShieldDeckNotificationMsgID uint16 = 580
|
|
|
|
func buildCurrentGuardianShieldDeckNotificationBody(deck dnfguardianshield.Deck) []byte {
|
|
body := make([]byte, dnfguardianshield.DeckSlotCount*4)
|
|
for index, itemID := range deck {
|
|
binary.LittleEndian.PutUint32(body[index*4:index*4+4], itemID)
|
|
}
|
|
return body
|
|
}
|
|
|
|
// sendSelectedGuardianShieldDeckInfo publishes the current EXE's independent
|
|
// class0/op580 five-dword deck state. It is sent only after an actor generation
|
|
// is bound; it is not appended to any mode0/mode1/userinfo body.
|
|
func (s *Service) sendSelectedGuardianShieldDeckInfo(session *gameSession, source string) (bool, error) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return false, nil
|
|
}
|
|
if session.initialTownGuardianShieldDeckSent {
|
|
return false, nil
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil {
|
|
return false, fmt.Errorf("guardian shield projection character repository is unavailable")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
characterID := strconv.Itoa(int(session.selectedCharacterID))
|
|
character, found, err := repositories.Character.Load(ctx, characterID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("load guardian shield projection character: %w", err)
|
|
}
|
|
if !found {
|
|
return false, fmt.Errorf("guardian shield projection character %s not found", characterID)
|
|
}
|
|
if accountID := strings.TrimSpace(s.accountIDForSession(session)); accountID != "" &&
|
|
strings.TrimSpace(character.AccountID) != accountID {
|
|
return false, fmt.Errorf("guardian shield projection character owner mismatch")
|
|
}
|
|
growType, err := dnfguardianshield.EligibleGrowType(character)
|
|
if errors.Is(err, dnfguardianshield.ErrCharacterIneligible) {
|
|
return false, nil
|
|
}
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
deck, err := dnfguardianshield.LoadDeck(character)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
resolver, err := s.newCurrentGuardianShieldResolver(ctx)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
var questRecord dnfrepo.QuestRecord
|
|
questLoaded := false
|
|
seen := make(map[uint32]int, dnfguardianshield.DeckSlotCount)
|
|
for index, itemID := range deck {
|
|
if itemID == 0 {
|
|
continue
|
|
}
|
|
if previous, duplicate := seen[itemID]; duplicate {
|
|
return false, fmt.Errorf("guardian shield projection duplicate item=%d slots=%d,%d", itemID, previous, index)
|
|
}
|
|
seen[itemID] = index
|
|
resolution, resolveErr := resolver(ctx, itemID, growType)
|
|
if resolveErr != nil {
|
|
return false, fmt.Errorf("validate guardian shield projection slot=%d item=%d: %w", index, itemID, resolveErr)
|
|
}
|
|
if resolution.ItemID != itemID {
|
|
return false, fmt.Errorf("validate guardian shield projection slot=%d item=%d resolved=%d", index, itemID, resolution.ItemID)
|
|
}
|
|
if resolution.UnlockKind == alignedcmd.GuardianShieldUnlockQuest && !questLoaded {
|
|
if repositories.Quest == nil {
|
|
return false, dnfguardianshield.ErrQuestRepositoryRequired
|
|
}
|
|
questRecord, found, err = repositories.Quest.Load(ctx, characterID)
|
|
if err != nil {
|
|
return false, fmt.Errorf("load guardian shield projection clear quests: %w", err)
|
|
}
|
|
if !found {
|
|
questRecord = dnfrepo.QuestRecord{CharacterID: characterID}
|
|
}
|
|
if strings.TrimSpace(questRecord.CharacterID) != characterID {
|
|
return false, fmt.Errorf("guardian shield projection quest owner mismatch")
|
|
}
|
|
questLoaded = true
|
|
}
|
|
if err := dnfguardianshield.ValidateUnlock(character, questRecord, resolution); err != nil {
|
|
return false, fmt.Errorf("validate guardian shield projection slot=%d item=%d unlock: %w", index, itemID, err)
|
|
}
|
|
}
|
|
body := buildCurrentGuardianShieldDeckNotificationBody(deck)
|
|
s.logPacketEvent("game-upper-selected-guardian-shield-deck-send",
|
|
"conn_id", session.connID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"msg_id", currentGuardianShieldDeckNotificationMsgID,
|
|
"classification", 0,
|
|
"grow_type", growType,
|
|
"body_len", len(body),
|
|
"body_source", "current_exe_sub_1d81b40_five_u32_deck")
|
|
if err := s.sendGameUpperRawClass(session, currentGuardianShieldDeckNotificationMsgID, body, 0); err != nil {
|
|
return false, err
|
|
}
|
|
session.initialTownGuardianShieldDeckSent = true
|
|
return true, nil
|
|
}
|
|
|
|
// sendSelectedGuardianShieldTownAppearanceRefreshOnce rehydrates the visible
|
|
// main shield after a town actor's equipment-bearing mode1 and op580 deck have
|
|
// been accepted. Slot 24 is outside the ordinary fourteen-row mode0 appearance
|
|
// table. The current client therefore needs its complete op342 equipment group
|
|
// followed by op357's same-state redraw. The per-generation gate makes a
|
|
// transport retry resume safely without replaying an already accepted redraw.
|
|
func (s *Service) sendSelectedGuardianShieldTownAppearanceRefreshOnce(session *gameSession, source string) (bool, error) {
|
|
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
|
return false, nil
|
|
}
|
|
if session.initialTownGuardianShieldAppearanceSent {
|
|
return false, nil
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Equipment == nil {
|
|
return false, fmt.Errorf("guardian shield appearance equipment repository is unavailable")
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
record, found, err := repositories.Equipment.Load(ctx, strconv.Itoa(int(session.selectedCharacterID)))
|
|
if err != nil {
|
|
return false, fmt.Errorf("load guardian shield appearance equipment: %w", err)
|
|
}
|
|
if !found {
|
|
record = dnfrepo.EquipmentRecord{CharacterID: strconv.Itoa(int(session.selectedCharacterID))}
|
|
}
|
|
itemID, equipped := dnfguardianshield.MainProjectionItemID(record)
|
|
if !equipped {
|
|
session.initialTownGuardianShieldAppearanceSent = true
|
|
return false, nil
|
|
}
|
|
if err := s.sendSelectedActorExpandedEquipmentRefresh(session, source+"_op342"); err != nil {
|
|
return false, err
|
|
}
|
|
if err := s.sendSelectedActorUserStateRefresh(session, source+"_op357_after_op342"); err != nil {
|
|
return false, err
|
|
}
|
|
session.initialTownGuardianShieldAppearanceSent = true
|
|
s.logPacketEvent("game-upper-selected-guardian-shield-town-appearance-refreshed",
|
|
"conn_id", session.connID,
|
|
"source", source,
|
|
"char_id", session.selectedCharacterID,
|
|
"actor_slot", dnfguardianshield.ActorSlot,
|
|
"item_id", itemID,
|
|
"sequence", "equipment_mode1_op580_op342_op357")
|
|
return true, nil
|
|
}
|
|
|
|
// preSyncGuardianShieldMainProjection runs inside the select-character scene
|
|
// initialization sequence, before any mode0/list3 scene-object body is built.
|
|
// It guarantees the durable actor-slot-24 projection agrees with the deck's
|
|
// visible main shield:
|
|
//
|
|
// - deck[0] is non-zero and the slot-24 row is missing or holds another item
|
|
// (a legacy guardian whose deck was saved before the projection write
|
|
// existed): re-persist the projection so the mode0 appearance table and
|
|
// the shield warehouse render the worn shield.
|
|
// - deck[0] is zero but a valid slot-24 projection survived (a legacy deck
|
|
// reset that cleared deck[0] while leaving the equipment row): restore
|
|
// deck[0] from the projection so the two sources agree again.
|
|
//
|
|
// Only guardians are considered; other professions never carry a
|
|
// guardian_shield_deck_projection row. Repair failures are logged and deferred
|
|
// instead of blocking the select flow.
|
|
func (s *Service) preSyncGuardianShieldMainProjection(
|
|
ctx context.Context,
|
|
session *gameSession,
|
|
character dnfrepo.CharacterRecord,
|
|
hasCharacter bool,
|
|
) error {
|
|
if s == nil || session == nil || !hasCharacter {
|
|
return nil
|
|
}
|
|
growType, err := dnfguardianshield.EligibleGrowType(character)
|
|
if errors.Is(err, dnfguardianshield.ErrCharacterIneligible) {
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
return err
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.Character == nil || repositories.Equipment == nil {
|
|
return nil
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
characterID := strings.TrimSpace(character.CharacterID)
|
|
if characterID == "" {
|
|
characterID = strconv.Itoa(int(session.selectedCharacterID))
|
|
}
|
|
if characterID == "" || characterID == "0" {
|
|
return nil
|
|
}
|
|
deck, err := dnfguardianshield.LoadDeck(character)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
mainItemID := deck[dnfguardianshield.MainDeckSlot]
|
|
slotKey := strconv.Itoa(int(dnfguardianshield.ActorSlot))
|
|
|
|
record, found, err := repositories.Equipment.Load(ctx, characterID)
|
|
if err != nil {
|
|
return fmt.Errorf("load guardian shield pre-sync equipment: %w", err)
|
|
}
|
|
if !found {
|
|
record = dnfrepo.EquipmentRecord{CharacterID: characterID}
|
|
}
|
|
record = dnfrepo.CloneEquipment(record)
|
|
if record.Entries == nil {
|
|
record.Entries = make(map[string]dnfrepo.EquipmentEntry, 1)
|
|
}
|
|
|
|
if mainItemID > 0 {
|
|
if projected, projectionOK := dnfguardianshield.MainProjectionItemID(record); projectionOK && projected == mainItemID {
|
|
return nil
|
|
}
|
|
resolver, resolveErr := s.newCurrentGuardianShieldResolver(ctx)
|
|
if resolveErr != nil {
|
|
return resolveErr
|
|
}
|
|
resolution, resolveErr := resolver(ctx, mainItemID, growType)
|
|
if resolveErr != nil {
|
|
s.logGameEvent(session, "game-upper-select-guardian-shield-main-projection-resolve-deferred",
|
|
"char_id", session.selectedCharacterID,
|
|
"item_id", mainItemID,
|
|
"error", resolveErr)
|
|
return nil
|
|
}
|
|
if resolution.ItemID != mainItemID {
|
|
s.logGameEvent(session, "game-upper-select-guardian-shield-main-projection-mismatch-deferred",
|
|
"char_id", session.selectedCharacterID,
|
|
"item_id", mainItemID,
|
|
"resolved_item_id", resolution.ItemID)
|
|
return nil
|
|
}
|
|
record.Entries[slotKey] = dnfguardianshield.BuildMainProjectionEntry(mainItemID, resolution)
|
|
record.UpdatedAt = time.Now().UTC()
|
|
if err := dnfrepo.SaveEquipmentFields(ctx, repositories.Equipment, record, dnfrepo.EquipmentFieldEntries); err != nil {
|
|
return fmt.Errorf("save guardian shield main projection pre-sync: %w", err)
|
|
}
|
|
s.logGameEvent(session, "game-upper-guardian-shield-main-projection-presynced",
|
|
"char_id", session.selectedCharacterID,
|
|
"item_id", mainItemID,
|
|
"source", "select_character_scene_entry_before_mode0")
|
|
return nil
|
|
}
|
|
|
|
// Deck main slot is empty. Restore it when a valid slot-24 projection is
|
|
// still present (legacy deck reset left the equipment row behind).
|
|
projected, projectionOK := dnfguardianshield.MainProjectionItemID(record)
|
|
if !projectionOK || projected == 0 {
|
|
return nil
|
|
}
|
|
current, foundCharacter, err := repositories.Character.Load(ctx, characterID)
|
|
if err != nil {
|
|
return fmt.Errorf("load guardian shield pre-sync character: %w", err)
|
|
}
|
|
if !foundCharacter {
|
|
return nil
|
|
}
|
|
current = dnfrepo.CloneCharacter(current)
|
|
if current.Stats == nil {
|
|
current.Stats = make(map[string]int64, 1)
|
|
}
|
|
deckKey := dnfguardianshield.DeckStatKey(dnfguardianshield.MainDeckSlot)
|
|
if current.Stats[deckKey] == int64(projected) {
|
|
return nil
|
|
}
|
|
current.Stats[deckKey] = int64(projected)
|
|
current.UpdatedAt = time.Now().UTC()
|
|
if err := dnfrepo.SaveCharacterFields(ctx, repositories.Character, current, dnfrepo.CharacterFieldStats); err != nil {
|
|
return fmt.Errorf("restore guardian shield deck main from projection: %w", err)
|
|
}
|
|
s.logGameEvent(session, "game-upper-guardian-shield-deck-main-restored",
|
|
"char_id", session.selectedCharacterID,
|
|
"item_id", projected,
|
|
"source", "select_character_scene_entry_slot24_survived")
|
|
return nil
|
|
}
|