DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,303 @@
|
||||
// 本文件定义 DNF 角色仓储接口、记录和字段保存入口。
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/platform/db"
|
||||
)
|
||||
|
||||
// DefaultCharacterSlotLimit is the current NoPack character-selector capacity.
|
||||
// Keep repository fallback reads aligned with the bridge roster/create owner so
|
||||
// zero-limit callers cannot miss slot conflicts above the old 16-slot boundary.
|
||||
const (
|
||||
DefaultCharacterSlotLimit = 32
|
||||
MaxCharacterSlotLimit = 60
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCharacterIDExists = errors.New("dnf character id already exists")
|
||||
ErrCharacterSlotOccupied = errors.New("dnf character slot is occupied")
|
||||
ErrCharacterCreateMissing = errors.New("dnf character create repository is missing")
|
||||
ErrCharacterSlotMissing = errors.New("dnf character slot is missing")
|
||||
ErrCharacterSlotSwapUnavailable = errors.New("dnf character slot swap repository is unavailable")
|
||||
ErrCharacterDeleteNotFound = errors.New("dnf character to delete was not found")
|
||||
ErrCharacterDeleteNameMismatch = errors.New("dnf character delete name does not match")
|
||||
ErrCharacterDeleteUnavailable = errors.New("dnf character delete repository is unavailable")
|
||||
)
|
||||
|
||||
// CharacterRepository 保存角色基础、数值和位置状态。
|
||||
type CharacterRepository interface {
|
||||
db.Store[CharacterRecord]
|
||||
ListByAccount(context.Context, string, int) ([]CharacterRecord, error)
|
||||
FindIDByName(context.Context, string) (string, bool, error)
|
||||
NextNumericID(context.Context) (int, error)
|
||||
}
|
||||
|
||||
// CharacterCreator 提供新建角色的 insert-only 写入语义。
|
||||
type CharacterCreator interface {
|
||||
CreateCharacter(context.Context, CharacterRecord) error
|
||||
}
|
||||
|
||||
// CharacterSlotSwapper persists a character-select screen slot exchange. It is
|
||||
// optional so existing test doubles and limited repositories can fall back to a
|
||||
// load/save implementation.
|
||||
type CharacterSlotSwapper interface {
|
||||
SwapCharacterSlots(context.Context, string, int, int) error
|
||||
}
|
||||
|
||||
// CharacterRosterDeleter persists a validated soft delete and compacts the
|
||||
// remaining active selector slots in one repository transaction.
|
||||
type CharacterRosterDeleter interface {
|
||||
DeleteCharacterAndCompact(context.Context, string, int, string) error
|
||||
}
|
||||
|
||||
// CharacterDeletedProfessionReader supplies the native recreation cooldown and RP history.
|
||||
type CharacterDeletedProfessionReader interface {
|
||||
LastDeletedProfession(context.Context, string, byte, byte) (CharacterRecord, bool, error)
|
||||
}
|
||||
|
||||
// CharacterRecord 是 DNF 角色仓储记录。
|
||||
type CharacterRecord struct {
|
||||
CharacterID string `json:"character_id"`
|
||||
AccountID string `json:"account_id"`
|
||||
Slot int `json:"slot,omitempty"`
|
||||
Name string `json:"name,omitempty"`
|
||||
Job string `json:"job,omitempty"`
|
||||
Level int `json:"level,omitempty"`
|
||||
Stats map[string]int64 `json:"stats,omitempty"`
|
||||
Location CharacterLocation `json:"location,omitempty"`
|
||||
Roster CharacterRoster `json:"roster,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// CharacterLocation 描述角色下线或当前所在的城镇、频道和副本位置。
|
||||
type CharacterLocation struct {
|
||||
ChannelID int `json:"channel_id,omitempty"`
|
||||
TownID int64 `json:"town_id,omitempty"`
|
||||
DungeonID int64 `json:"dungeon_id,omitempty"`
|
||||
RoomID string `json:"room_id,omitempty"`
|
||||
}
|
||||
|
||||
// CharacterRoster 保存最新 EXE 角色列表 class0/op2 已确认的头部和单角色入口字段。
|
||||
type CharacterRoster struct {
|
||||
Header CharacterRosterHeader `json:"header,omitempty"`
|
||||
Entry CharacterRosterEntry `json:"entry,omitempty"`
|
||||
// Derived from quest 2670 for the current selector; never stored in roster_json.
|
||||
PVPEntryQuestCompleted bool `json:"-"`
|
||||
}
|
||||
|
||||
// CharacterRosterHeader 对应 sub_200BEA0 mode=2 在角色入口前读取的字段。
|
||||
type CharacterRosterHeader struct {
|
||||
UnkA int64 `json:"unk_a,omitempty"`
|
||||
UnkB int64 `json:"unk_b,omitempty"`
|
||||
TotalOrSlotLimit int64 `json:"total_or_slot_limit,omitempty"`
|
||||
UsedOrRemain int64 `json:"used_or_remain,omitempty"`
|
||||
SelectedOrPage int64 `json:"selected_or_page,omitempty"`
|
||||
RosterState int64 `json:"roster_state,omitempty"`
|
||||
PageCount int64 `json:"page_count,omitempty"`
|
||||
RosterFlag int64 `json:"roster_flag,omitempty"`
|
||||
RosterValueA int64 `json:"roster_value_a,omitempty"`
|
||||
RosterValueB int64 `json:"roster_value_b,omitempty"`
|
||||
}
|
||||
|
||||
// CharacterRosterEntry 对应 sub_200B250 的单角色读取序列。
|
||||
type CharacterRosterEntry struct {
|
||||
ByteA int64 `json:"byte_a,omitempty"`
|
||||
PackedJobGrow int64 `json:"packed_job_grow,omitempty"`
|
||||
ByteC int64 `json:"byte_c,omitempty"`
|
||||
Field2CC int64 `json:"field_0x2cc,omitempty"`
|
||||
State0 int64 `json:"state0,omitempty"`
|
||||
TimeA int64 `json:"time_a,omitempty"`
|
||||
TimeB int64 `json:"time_b,omitempty"`
|
||||
EquipSummary []CharacterRosterEquipSummary `json:"equip_summary,omitempty"`
|
||||
Value0 int64 `json:"value0,omitempty"`
|
||||
Value1 int64 `json:"value1,omitempty"`
|
||||
Value2 int64 `json:"value2,omitempty"`
|
||||
ReservedA int64 `json:"reserved_a,omitempty"`
|
||||
ReservedB int64 `json:"reserved_b,omitempty"`
|
||||
LinkedIDBlock []int64 `json:"linked_id_block,omitempty"`
|
||||
Value3 int64 `json:"value3,omitempty"`
|
||||
ObjectID int64 `json:"object_id,omitempty"`
|
||||
Flag0Eq1 int64 `json:"flag0_eq_1,omitempty"`
|
||||
SpecialStatusFlag int64 `json:"special_status_flag,omitempty"`
|
||||
Value5 int64 `json:"value5,omitempty"`
|
||||
DisplayFlags int64 `json:"display_flags,omitempty"`
|
||||
ReservedC int64 `json:"reserved_c,omitempty"`
|
||||
ReservedD int64 `json:"reserved_d,omitempty"`
|
||||
Value6 int64 `json:"value6,omitempty"`
|
||||
Flag1Nonzero int64 `json:"flag1_nonzero,omitempty"`
|
||||
BoolAEq1 int64 `json:"bool_a_eq_1,omitempty"`
|
||||
BoolBEq1 int64 `json:"bool_b_eq_1,omitempty"`
|
||||
BoolCEq1 int64 `json:"bool_c_eq_1,omitempty"`
|
||||
Flag2Nonzero int64 `json:"flag2_nonzero,omitempty"`
|
||||
Flag3Nonzero int64 `json:"flag3_nonzero,omitempty"`
|
||||
Flag4Nonzero int64 `json:"flag4_nonzero,omitempty"`
|
||||
Flag5Nonzero int64 `json:"flag5_nonzero,omitempty"`
|
||||
Value7 int64 `json:"value7,omitempty"`
|
||||
Flag6Eq1 int64 `json:"flag6_eq_1,omitempty"`
|
||||
Flags []int64 `json:"flags,omitempty"`
|
||||
}
|
||||
|
||||
// CharacterRosterEquipSummary 对应 sub_20026C0 读取的角色卡片装备摘要行。
|
||||
type CharacterRosterEquipSummary struct {
|
||||
Slot int64 `json:"slot,omitempty"`
|
||||
ItemIDOrIcon int64 `json:"item_id_or_icon,omitempty"`
|
||||
RawEntry []byte `json:"raw_entry,omitempty"`
|
||||
PackedFlags int64 `json:"packed_flags,omitempty"`
|
||||
OptionalIDOrExpire int64 `json:"optional_id_or_expire,omitempty"`
|
||||
AuxValue int64 `json:"aux_value,omitempty"`
|
||||
AuxFlag int64 `json:"aux_flag,omitempty"`
|
||||
}
|
||||
|
||||
// CharacterField 表示角色记录可局部保存的字段。
|
||||
type CharacterField string
|
||||
|
||||
const (
|
||||
CharacterFieldBase CharacterField = "base"
|
||||
CharacterFieldStats CharacterField = "stats"
|
||||
CharacterFieldLocation CharacterField = "location"
|
||||
CharacterFieldRoster CharacterField = "roster"
|
||||
)
|
||||
|
||||
// SaveCharacterFields 保存角色指定字段;底层不支持局部保存时退化为整条保存。
|
||||
func SaveCharacterFields(ctx context.Context, repo CharacterRepository, record CharacterRecord, fields ...CharacterField) error {
|
||||
return db.SaveFields(ctx, repo, record, CharacterFields.Normalize, fields...)
|
||||
}
|
||||
|
||||
// CreateCharacter 只用于创建新角色;实现支持时必须避免唯一槽位冲突时覆盖旧角色。
|
||||
func CreateCharacter(ctx context.Context, repo CharacterRepository, record CharacterRecord) error {
|
||||
if repo == nil {
|
||||
return ErrCharacterCreateMissing
|
||||
}
|
||||
if creator, ok := repo.(CharacterCreator); ok {
|
||||
return creator.CreateCharacter(ctx, record)
|
||||
}
|
||||
return ErrCharacterCreateMissing
|
||||
}
|
||||
|
||||
// DeleteCharacterAndCompact validates the account, slot, and name before
|
||||
// delegating to an atomic repository implementation. The fallback supports
|
||||
// small test doubles that only expose the base store API.
|
||||
func DeleteCharacterAndCompact(ctx context.Context, repo CharacterRepository, accountID string, slot int, name string) error {
|
||||
if repo == nil {
|
||||
return ErrCharacterDeleteUnavailable
|
||||
}
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
name = strings.TrimSpace(name)
|
||||
if accountID == "" || name == "" {
|
||||
return db.ErrRecordKeyRequired
|
||||
}
|
||||
if slot < 0 || slot >= MaxCharacterSlotLimit {
|
||||
return ErrCharacterSlotMissing
|
||||
}
|
||||
if deleter, ok := repo.(CharacterRosterDeleter); ok {
|
||||
return deleter.DeleteCharacterAndCompact(ctx, accountID, slot, name)
|
||||
}
|
||||
records, err := repo.ListByAccount(ctx, accountID, MaxCharacterSlotLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
sort.SliceStable(records, func(left, right int) bool {
|
||||
return records[left].Slot < records[right].Slot
|
||||
})
|
||||
var target *CharacterRecord
|
||||
for index := range records {
|
||||
if records[index].Slot == slot {
|
||||
target = &records[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if target == nil {
|
||||
return ErrCharacterDeleteNotFound
|
||||
}
|
||||
if strings.TrimSpace(target.Name) != name {
|
||||
return ErrCharacterDeleteNameMismatch
|
||||
}
|
||||
if target.Stats == nil {
|
||||
target.Stats = make(map[string]int64)
|
||||
}
|
||||
target.Stats["delete_flag"] = 1
|
||||
target.Slot = -1
|
||||
if err := SaveCharacterFields(ctx, repo, *target, CharacterFieldBase, CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
compactSlot := 0
|
||||
for index := range records {
|
||||
if &records[index] == target {
|
||||
continue
|
||||
}
|
||||
if records[index].Slot == compactSlot {
|
||||
compactSlot++
|
||||
continue
|
||||
}
|
||||
records[index].Slot = compactSlot
|
||||
if err := SaveCharacterFields(ctx, repo, records[index], CharacterFieldBase); err != nil {
|
||||
return err
|
||||
}
|
||||
compactSlot++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SwapCharacterSlots exchanges two visible roster slots for one account.
|
||||
func SwapCharacterSlots(ctx context.Context, repo CharacterRepository, accountID string, slotA, slotB int) error {
|
||||
if repo == nil {
|
||||
return ErrCharacterCreateMissing
|
||||
}
|
||||
accountID = strings.TrimSpace(accountID)
|
||||
if accountID == "" {
|
||||
return db.ErrRecordKeyRequired
|
||||
}
|
||||
if slotA == slotB {
|
||||
return nil
|
||||
}
|
||||
if slotA < 0 || slotA >= MaxCharacterSlotLimit || slotB < 0 || slotB >= MaxCharacterSlotLimit {
|
||||
return ErrCharacterSlotMissing
|
||||
}
|
||||
if swapper, ok := repo.(CharacterSlotSwapper); ok {
|
||||
return swapper.SwapCharacterSlots(ctx, accountID, slotA, slotB)
|
||||
}
|
||||
records, err := repo.ListByAccount(ctx, accountID, MaxCharacterSlotLimit)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var left, right *CharacterRecord
|
||||
for idx := range records {
|
||||
if records[idx].Slot == slotA {
|
||||
left = &records[idx]
|
||||
}
|
||||
if records[idx].Slot == slotB {
|
||||
right = &records[idx]
|
||||
}
|
||||
}
|
||||
if left == nil || right == nil {
|
||||
return nil
|
||||
}
|
||||
left.Slot, right.Slot = slotB, slotA
|
||||
if err := SaveCharacterFields(ctx, repo, *left, CharacterFieldBase); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := SaveCharacterFields(ctx, repo, *right, CharacterFieldBase); err != nil {
|
||||
left.Slot = slotA
|
||||
_ = SaveCharacterFields(context.Background(), repo, *left, CharacterFieldBase)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CloneCharacter 拷贝角色记录,避免 Stats map 污染在线角色快照或缓存。
|
||||
func CloneCharacter(record CharacterRecord) CharacterRecord {
|
||||
record.Stats = cloneInt64Map(record.Stats)
|
||||
record.Roster = cloneCharacterRoster(record.Roster)
|
||||
return record
|
||||
}
|
||||
|
||||
func CharacterKey(record CharacterRecord) string {
|
||||
return strings.TrimSpace(record.CharacterID)
|
||||
}
|
||||
Reference in new issue
Block a user