Files
90CNServer/server/internal/modules/dnf/repository/character_repository.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

304 lines
12 KiB
Go

// 本文件定义 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)
}