服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
304 lines
12 KiB
Go
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)
|
|
}
|