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

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
}