服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
327 lines
11 KiB
Go
327 lines
11 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
// currentEquippedCreatureSnapshot is the one durable creature projection used
|
|
// by all current-EXE class0/op2 actor modes. Equipment slot 26 owns whether a
|
|
// creature is worn; PetRecord supplies the hatched creature's name and level.
|
|
//
|
|
// Current EXE sub_20077D0 consumes {itemID, serialOrHandle}. The second dword
|
|
// is the same value stored at raw offset 24 in the slot-26 equipment entry and
|
|
// passed as constructor field a10 by sub_1D77560.
|
|
type currentEquippedCreatureSnapshot struct {
|
|
itemID uint32
|
|
serialOrHandle uint32
|
|
name string
|
|
level byte
|
|
aliveState byte
|
|
townDisplay bool
|
|
source string
|
|
}
|
|
|
|
func (snapshot currentEquippedCreatureSnapshot) valid() bool {
|
|
return snapshot.itemID != 0 && snapshot.serialOrHandle != 0
|
|
}
|
|
|
|
func (s *Service) currentEquippedCreatureForCharacter(ctx context.Context, characterID string) currentEquippedCreatureSnapshot {
|
|
snapshot, _ := s.currentEquippedCreatureForCharacterWithError(ctx, characterID)
|
|
return snapshot
|
|
}
|
|
|
|
// currentEquippedCreatureForCharacterWithError distinguishes an authoritative
|
|
// "nothing worn" result from a transient repository failure. The staged
|
|
// post-op24 chain must not permanently skip op102 merely because one database
|
|
// read failed; ordinary snapshot readers retain the compatibility wrapper
|
|
// above when they can safely render an empty optional creature.
|
|
func (s *Service) currentEquippedCreatureForCharacterWithError(
|
|
ctx context.Context,
|
|
characterID string,
|
|
) (currentEquippedCreatureSnapshot, error) {
|
|
if s == nil || strings.TrimSpace(characterID) == "" {
|
|
return currentEquippedCreatureSnapshot{}, fmt.Errorf("equipped creature owner is unavailable")
|
|
}
|
|
repos, ok := s.projectionRepositories(ctx)
|
|
if !ok {
|
|
return currentEquippedCreatureSnapshot{}, fmt.Errorf("equipped creature repositories are unavailable")
|
|
}
|
|
if ctx == nil {
|
|
ctx = context.Background()
|
|
}
|
|
|
|
var petRecord dnfrepo.PetRecord
|
|
petFound := false
|
|
var petLoadErr error
|
|
if repos.Pet != nil {
|
|
if loaded, found, err := repos.Pet.Load(ctx, characterID); err != nil {
|
|
petLoadErr = fmt.Errorf("load equipped creature metadata for character %s: %w", characterID, err)
|
|
} else if found {
|
|
petRecord = dnfrepo.ClonePet(loaded)
|
|
petFound = true
|
|
}
|
|
}
|
|
|
|
if repos.Equipment != nil {
|
|
equipment, found, err := repos.Equipment.Load(ctx, characterID)
|
|
if err != nil {
|
|
return currentEquippedCreatureSnapshot{}, fmt.Errorf(
|
|
"load equipped creature slot for character %s: %w",
|
|
characterID,
|
|
err,
|
|
)
|
|
}
|
|
if found {
|
|
entry, worn := currentEquippedCreatureEquipmentEntry(equipment)
|
|
if !worn {
|
|
// A present equipment record is authoritative. Do not resurrect
|
|
// a stale PetRecord.EquippedKey after slot 26 was removed.
|
|
return currentEquippedCreatureSnapshot{source: "equipment_slot26_absent"}, nil
|
|
}
|
|
snapshot := currentEquippedCreatureFromEquipment(entry)
|
|
if !snapshot.valid() {
|
|
return currentEquippedCreatureSnapshot{source: "equipment_slot26_invalid_item_or_serial"}, nil
|
|
}
|
|
if petLoadErr != nil {
|
|
return currentEquippedCreatureSnapshot{}, petLoadErr
|
|
}
|
|
snapshot.source = "equipment_slot26_raw"
|
|
if petFound {
|
|
currentEquippedCreatureEnrichFromPetRecord(&snapshot, petRecord)
|
|
}
|
|
s.currentEquippedCreatureEnrichNameFromPVF(&snapshot)
|
|
return snapshot, nil
|
|
}
|
|
}
|
|
|
|
// Compatibility for imported records which predate the equipment owner.
|
|
if petLoadErr != nil {
|
|
return currentEquippedCreatureSnapshot{}, petLoadErr
|
|
}
|
|
// This fallback is used only when no EquipmentRecord exists at all.
|
|
if petFound {
|
|
if entry, found := petRecord.Entries[strings.TrimSpace(petRecord.EquippedKey)]; found {
|
|
snapshot := currentEquippedCreatureFromPetEntry(entry)
|
|
if snapshot.valid() {
|
|
snapshot.townDisplay = petRecord.TownDisplay
|
|
snapshot.source = "legacy_pet_equipped_key"
|
|
s.currentEquippedCreatureEnrichNameFromPVF(&snapshot)
|
|
return snapshot, nil
|
|
}
|
|
}
|
|
}
|
|
return currentEquippedCreatureSnapshot{}, nil
|
|
}
|
|
|
|
func currentEquippedCreatureEquipmentEntry(record dnfrepo.EquipmentRecord) (dnfrepo.EquipmentEntry, bool) {
|
|
if entry, ok := record.Entries["26"]; ok && entry.SlotIndex == 26 && entry.ItemID > 0 {
|
|
return entry, true
|
|
}
|
|
for _, entry := range record.Entries {
|
|
if entry.SlotIndex == 26 && entry.ItemID > 0 {
|
|
return entry, true
|
|
}
|
|
}
|
|
return dnfrepo.EquipmentEntry{}, false
|
|
}
|
|
|
|
func currentEquippedCreatureFromEquipment(entry dnfrepo.EquipmentEntry) currentEquippedCreatureSnapshot {
|
|
itemID, itemOK := currentCreatureWireUint32(entry.ItemID)
|
|
serial := currentEquippedCreatureSerialFromRaw(entry.RawEntry)
|
|
if serial == 0 {
|
|
serial = currentCreatureExtraUint32(entry.Extra,
|
|
"creature_serial_or_handle", "creature_serial", "pet_serial")
|
|
}
|
|
if serial == 0 && currentCreatureEquipmentSourceIsPet(entry.Extra) {
|
|
serial = currentCreatureExtraUint32(entry.Extra, "serial", "handle", "instance_value", "item_uid")
|
|
}
|
|
if !itemOK || serial == 0 {
|
|
return currentEquippedCreatureSnapshot{}
|
|
}
|
|
return currentEquippedCreatureSnapshot{
|
|
itemID: itemID,
|
|
serialOrHandle: serial,
|
|
name: currentCreatureExtraString(entry.Extra, "creature_name", "pet_name"),
|
|
level: currentCreatureExtraByte(entry.Extra, "creature_level", "pet_level"),
|
|
aliveState: 1,
|
|
}
|
|
}
|
|
|
|
func currentEquippedCreatureFromPetEntry(entry dnfrepo.PetEntry) currentEquippedCreatureSnapshot {
|
|
itemID, itemOK := currentCreatureWireUint32(entry.ItemID)
|
|
serial := currentCreatureExtraUint32(entry.Extra,
|
|
"creature_serial_or_handle", "creature_serial", "pet_serial",
|
|
"serial", "handle", "instance_value", "item_uid")
|
|
if serial == 0 {
|
|
if parsed, err := strconv.ParseUint(strings.TrimSpace(entry.PetKey), 0, 32); err == nil {
|
|
serial = uint32(parsed)
|
|
}
|
|
}
|
|
if !itemOK || serial == 0 {
|
|
return currentEquippedCreatureSnapshot{}
|
|
}
|
|
return currentEquippedCreatureSnapshot{
|
|
itemID: itemID,
|
|
serialOrHandle: serial,
|
|
name: strings.TrimSpace(entry.Name),
|
|
level: currentCreatureLevelByte(entry.Level),
|
|
aliveState: 1,
|
|
}
|
|
}
|
|
|
|
func currentEquippedCreatureEnrichFromPetRecord(snapshot *currentEquippedCreatureSnapshot, record dnfrepo.PetRecord) {
|
|
if snapshot == nil || !snapshot.valid() {
|
|
return
|
|
}
|
|
entry, found := currentEquippedCreatureMetadataEntry(*snapshot, record)
|
|
if found {
|
|
if strings.TrimSpace(entry.Name) != "" {
|
|
snapshot.name = strings.TrimSpace(entry.Name)
|
|
}
|
|
if entry.Level > 0 {
|
|
snapshot.level = currentCreatureLevelByte(entry.Level)
|
|
}
|
|
}
|
|
snapshot.townDisplay = record.TownDisplay
|
|
}
|
|
|
|
func currentEquippedCreatureMetadataEntry(snapshot currentEquippedCreatureSnapshot, record dnfrepo.PetRecord) (dnfrepo.PetEntry, bool) {
|
|
if key := strings.TrimSpace(record.EquippedKey); key != "" {
|
|
if entry, ok := record.Entries[key]; ok && currentCreatureItemMatches(entry.ItemID, snapshot.itemID) {
|
|
entrySerial := currentCreaturePetEntrySerial(entry)
|
|
if entrySerial == 0 || entrySerial == snapshot.serialOrHandle {
|
|
return entry, true
|
|
}
|
|
}
|
|
}
|
|
serialKey := strconv.FormatUint(uint64(snapshot.serialOrHandle), 10)
|
|
if entry, ok := record.Entries[serialKey]; ok && currentCreatureItemMatches(entry.ItemID, snapshot.itemID) {
|
|
return entry, true
|
|
}
|
|
var candidate dnfrepo.PetEntry
|
|
matchCount := 0
|
|
for _, entry := range record.Entries {
|
|
if !currentCreatureItemMatches(entry.ItemID, snapshot.itemID) {
|
|
continue
|
|
}
|
|
entrySerial := currentCreaturePetEntrySerial(entry)
|
|
if entrySerial == snapshot.serialOrHandle {
|
|
return entry, true
|
|
}
|
|
candidate = entry
|
|
matchCount++
|
|
}
|
|
return candidate, matchCount == 1
|
|
}
|
|
|
|
// currentEquippedCreatureEnrichNameFromPVF repairs only an absent custom name
|
|
// at the scene projection boundary. The final mode0 actor packet creates the
|
|
// live town creature object and must carry a non-empty DSTR there; op105 alone
|
|
// only populates the creature-state table. Durable renamed values remain
|
|
// authoritative, and the PVF fallback is never persisted.
|
|
func (s *Service) currentEquippedCreatureEnrichNameFromPVF(snapshot *currentEquippedCreatureSnapshot) {
|
|
if s == nil || snapshot == nil || !snapshot.valid() || strings.TrimSpace(snapshot.name) != "" {
|
|
return
|
|
}
|
|
catalog, err := s.currentPetPVFCatalog()
|
|
if err != nil || catalog == nil {
|
|
return
|
|
}
|
|
definition, err := catalog.ResolveCreature(int64(snapshot.itemID))
|
|
if err != nil {
|
|
return
|
|
}
|
|
snapshot.name = strings.TrimSpace(definition.Name)
|
|
}
|
|
|
|
func currentEquippedCreatureSerialFromRaw(raw []byte) uint32 {
|
|
// op2/sub_20077D0 consumes the constructor-a10 field repeated at +24 in a
|
|
// current pet equipment row. Do not fall back to the ordinary +5 equipment
|
|
// instance: ordinary equipment instance values are not creature serials.
|
|
if len(raw) >= 28 {
|
|
return binary.LittleEndian.Uint32(raw[24:28])
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func currentCreatureEquipmentSourceIsPet(extra map[string]string) bool {
|
|
source := strings.ToLower(currentCreatureExtraString(extra, "source", "item_kind", "equipment_kind"))
|
|
return strings.Contains(source, "pet") || strings.Contains(source, "creature")
|
|
}
|
|
|
|
func currentCreaturePetEntrySerial(entry dnfrepo.PetEntry) uint32 {
|
|
return currentCreatureExtraUint32(entry.Extra,
|
|
"creature_serial_or_handle", "creature_serial", "pet_serial",
|
|
"serial", "handle", "instance_value", "item_uid")
|
|
}
|
|
|
|
func currentCreatureWireUint32(value int64) (uint32, bool) {
|
|
if value <= 0 || value > int64(^uint32(0)) {
|
|
return 0, false
|
|
}
|
|
return uint32(value), true
|
|
}
|
|
|
|
func currentCreatureItemMatches(value int64, wire uint32) bool {
|
|
itemID, ok := currentCreatureWireUint32(value)
|
|
return ok && itemID == wire
|
|
}
|
|
|
|
func currentCreatureExtraUint32(extra map[string]string, keys ...string) uint32 {
|
|
for _, key := range keys {
|
|
raw := strings.TrimSpace(extra[key])
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
if value, err := strconv.ParseUint(raw, 0, 32); err == nil && value != 0 {
|
|
return uint32(value)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func currentCreatureExtraString(extra map[string]string, keys ...string) string {
|
|
for _, key := range keys {
|
|
if value := strings.TrimSpace(extra[key]); value != "" {
|
|
return value
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func currentCreatureExtraByte(extra map[string]string, keys ...string) byte {
|
|
for _, key := range keys {
|
|
raw := strings.TrimSpace(extra[key])
|
|
if raw == "" {
|
|
continue
|
|
}
|
|
if value, err := strconv.ParseInt(raw, 0, 64); err == nil && value > 0 {
|
|
return currentCreatureLevelByte(value)
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func currentCreatureLevelByte(value int64) byte {
|
|
if value <= 0 {
|
|
return 0
|
|
}
|
|
if value > 255 {
|
|
return 255
|
|
}
|
|
return byte(value)
|
|
}
|
|
|
|
func (r *csharpLegacyUserInfoReader) currentEquippedCreature() currentEquippedCreatureSnapshot {
|
|
if r == nil || r.service == nil {
|
|
return currentEquippedCreatureSnapshot{}
|
|
}
|
|
return r.service.currentEquippedCreatureForCharacter(r.ctx, r.characterID)
|
|
}
|