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

233 lines
8.9 KiB
Go

package dnfbridge
import (
"context"
"encoding/base64"
"encoding/binary"
"errors"
"fmt"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var errCurrentCharacterOptionRepositoryUnavailable = errors.New("character option repository is unavailable")
const (
currentCharacterOptionLengthBytes = 4
currentCharacterOptionPayloadBytes = dnfrepo.CharacterOptionSnapshotSize
currentSaveCharacterOptionBodySize = currentCharacterOptionLengthBytes + currentCharacterOptionPayloadBytes
currentCharacterOptionRestoreMsgID = uint16(390)
// Releases before the op390 proof stored the complete 516-byte body in this
// scope. Restore it once as a read-only fallback, then migrate the opaque
// payload to the canonical repository snapshot without losing user data.
legacyCharacterOptionValueKey = "body_base64"
)
func legacyCharacterOptionScope(characterID string) string {
return "character:" + strings.TrimSpace(characterID) + ":character_option"
}
func validCurrentSaveCharacterOptionBody(body []byte) bool {
return len(body) == currentSaveCharacterOptionBodySize &&
binary.LittleEndian.Uint32(body[:currentCharacterOptionLengthBytes]) == currentCharacterOptionPayloadBytes
}
func (s *Service) handleCurrentSaveCharacterOption(session *gameSession, body []byte) error {
if session == nil || session.selectedCharacterID == 0 {
return nil
}
if !validCurrentSaveCharacterOptionBody(body) {
s.logGameEvent(session, "game-save-character-option-ignored",
"msg_id", uint16(dnfenum.CmdPacketSaveCharacterOption),
"body_len", len(body),
"reason", "current_exe_op447_requires_u32_512_byte_client_option_table")
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
repositories, characterID, owned, err := s.currentCharacterOptionOwner(ctx, session)
if err != nil {
return err
}
if !owned {
return nil
}
payload := append([]byte(nil), body[currentCharacterOptionLengthBytes:]...)
if err := dnfrepo.SaveCharacterOptionSnapshot(ctx, repositories.Settings, characterID, payload, s.gameplayNow().UTC()); err != nil {
return fmt.Errorf("save character option snapshot: %w", err)
}
s.logGameEvent(session, "game-save-character-option-committed",
"msg_id", uint16(dnfenum.CmdPacketSaveCharacterOption),
"request_body_len", len(body),
"payload_len", len(payload),
"cooldown_notification_skills", currentCharacterOptionCooldownSkills(payload),
"response_sent", false,
"body_source", "current_exe_sub_1F9DC50_length_prefixed_opaque_snapshot")
return nil
}
func (s *Service) sendCurrentCharacterOptionSnapshot(session *gameSession, source string) error {
if session == nil || session.selectedCharacterID == 0 {
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
defer cancel()
repositories, characterID, owned, err := s.currentCharacterOptionOwner(ctx, session)
if err != nil {
return err
}
if !owned {
return nil
}
payload, found, err := dnfrepo.LoadCharacterOptionSnapshot(ctx, repositories.Settings, characterID)
if err != nil {
return fmt.Errorf("load character option snapshot: %w", err)
}
if !found {
payload, found, err = loadLegacyCharacterOptionSnapshot(ctx, repositories.Settings, characterID)
if err != nil {
return fmt.Errorf("load legacy character option snapshot: %w", err)
}
if found {
if err := dnfrepo.SaveCharacterOptionSnapshot(ctx, repositories.Settings, characterID, payload, s.gameplayNow().UTC()); err != nil {
return fmt.Errorf("migrate legacy character option snapshot: %w", err)
}
s.logGameEvent(session, "game-character-option-legacy-snapshot-migrated",
"source", source,
"char_id", session.selectedCharacterID,
"payload_len", len(payload))
}
}
if !found {
s.logGameEvent(session, "game-character-option-restore-skipped",
"source", source,
"char_id", session.selectedCharacterID,
"reason", "no_durable_snapshot")
return nil
}
body := make([]byte, currentSaveCharacterOptionBodySize)
binary.LittleEndian.PutUint32(body[:currentCharacterOptionLengthBytes], currentCharacterOptionPayloadBytes)
copy(body[currentCharacterOptionLengthBytes:], payload)
if err := s.sendGameUpperRawClass(session, currentCharacterOptionRestoreMsgID, body, 0); err != nil {
return err
}
s.logGameEvent(session, "game-character-option-restored",
"source", source,
"char_id", session.selectedCharacterID,
"msg_id", currentCharacterOptionRestoreMsgID,
"classification", 0,
"payload_len", len(payload),
"cooldown_notification_skills", currentCharacterOptionCooldownSkills(payload),
"body_source", "current_exe_op390_sub_1D39570_sub_1F9E520_length_prefixed_opaque_snapshot")
return nil
}
func loadLegacyCharacterOptionSnapshot(
ctx context.Context,
repository dnfrepo.SettingsRepository,
characterID string,
) ([]byte, bool, error) {
if repository == nil || strings.TrimSpace(characterID) == "" {
return nil, false, nil
}
record, found, err := repository.Load(ctx, legacyCharacterOptionScope(characterID))
if err != nil || !found {
return nil, found, err
}
encoded := strings.TrimSpace(record.Values[legacyCharacterOptionValueKey])
if encoded == "" {
return nil, false, nil
}
body, err := base64.StdEncoding.Strict().DecodeString(encoded)
if err != nil || !validCurrentSaveCharacterOptionBody(body) {
return nil, false, nil
}
return append([]byte(nil), body[currentCharacterOptionLengthBytes:]...), true, nil
}
func (s *Service) currentCharacterOptionOwner(ctx context.Context, session *gameSession) (dnfrepo.Group, string, bool, error) {
repositories, available := s.repositoryGroup()
if !available || repositories.Character == nil || repositories.Settings == nil {
return dnfrepo.Group{}, "", false, errCurrentCharacterOptionRepositoryUnavailable
}
characterID := strconv.FormatUint(uint64(session.selectedCharacterID), 10)
character, found, err := repositories.Character.Load(ctx, characterID)
if err != nil {
return dnfrepo.Group{}, "", false, fmt.Errorf("load selected character: %w", err)
}
accountID := strings.TrimSpace(s.accountIDForSession(session))
if !found || strings.TrimSpace(character.AccountID) != accountID {
s.logGameEvent(session, "game-character-option-owner-rejected",
"char_id", session.selectedCharacterID,
"character_found", found,
"account_id", accountID,
"reason", "selected_character_not_owned_by_authenticated_account")
return repositories, characterID, false, nil
}
return repositories, characterID, true, nil
}
func currentCharacterOptionCooldownSkills(payload []byte) []uint16 {
if len(payload) != currentCharacterOptionPayloadBytes {
return nil
}
const firstSlot = 2
const slotCount = 40
skills := make([]uint16, 0, slotCount)
for slot := firstSlot; slot < firstSlot+slotCount; slot++ {
skillID := binary.LittleEndian.Uint16(payload[slot*2 : slot*2+2])
if skillID != 0xffff {
skills = append(skills, skillID)
}
}
return skills
}
// Older scene code clears these fields when the character changes. Retain the
// reset as a compatibility boundary even though op390 restoration is now
// driven by the deferred-tail stage rather than the former op391 cursor.
func clearCurrentCharacterOptionRestoreSnapshot(session *gameSession) {
if session == nil {
return
}
session.initialTownCharacterOptionRestoreCharacterID = 0
session.initialTownCharacterOptionRestoreBody = nil
session.initialTownCharacterOptionRestoreAt = time.Time{}
session.initialTownCharacterOptionCorrectionSent = false
session.characterOptionActorApplyCharacterID = 0
}
// sendCurrentCharacterOptionSnapshotForActor replays the saved option table
// once the local actor exists.
//
// The scene-init send alone was not enough for the skill-cooldown reminder.
// The current EXE's op390 handler sub_1F9E520 stores the 512-byte table at
// manager+0x288 and then calls sub_1F9D8D0 to rebuild the live cooldown list -
// but sub_1F9D8D0 begins with `if ([0x52B5BB0] == 0) return`, and that global
// is the local actor. At scene-init the actor is not there yet, so the table
// was saved, sent and stored, while the reminder list stayed empty until the
// player re-opened the UI and set it again, which calls the same applier with
// the actor live. Replaying the identical body after the actor exists runs the
// applier at a point where it can do its work; the handler is idempotent, so
// the extra send only re-applies what the client already holds.
func (s *Service) sendCurrentCharacterOptionSnapshotForActor(session *gameSession, source string) error {
if session == nil || session.selectedCharacterID == 0 ||
session.characterOptionActorApplyCharacterID == session.selectedCharacterID {
return nil
}
if err := s.sendCurrentCharacterOptionSnapshot(session, source); err != nil {
return err
}
session.characterOptionActorApplyCharacterID = session.selectedCharacterID
return nil
}
func currentCharacterOptionRestoreSnapshotIsInitializationProtected(*gameSession) bool {
return false
}