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,37 @@
|
||||
package creaturestate
|
||||
|
||||
import "sync"
|
||||
|
||||
// petEggProbe is installed by dnfbridge with a runtime-PVF backed predicate
|
||||
// (an egg is the only creature item that carries an `[output index]` section).
|
||||
//
|
||||
// Leaving it unset means "cannot prove this is an egg", which keeps the
|
||||
// pre-existing behaviour exactly -- isolated package tests and any caller that
|
||||
// was never wired stay on the old path instead of silently changing meaning.
|
||||
var (
|
||||
petEggProbeMu sync.RWMutex
|
||||
petEggProbe func(itemID int64) bool
|
||||
)
|
||||
|
||||
// SetPetEggProbe installs the process-wide egg predicate. dnfbridge calls this
|
||||
// once when it first routes an aligned command; later installs replace it.
|
||||
func SetPetEggProbe(probe func(itemID int64) bool) {
|
||||
petEggProbeMu.Lock()
|
||||
petEggProbe = probe
|
||||
petEggProbeMu.Unlock()
|
||||
}
|
||||
|
||||
// IsPetEgg reports whether an item template is a pet egg.
|
||||
//
|
||||
// An egg is NOT a pet: it owns no pet record and must not carry a creature
|
||||
// serial. Treating one as a directly granted creature used to mint a pet entry
|
||||
// for it (and stamp creature_serial_or_handle on the row), after which the
|
||||
// item-delete gate saw a "live pet binding" and refused -- the field report
|
||||
// 「宠物蛋无法丢弃」. It also matters that a real hatched pet never has an
|
||||
// `[output index]`, so this predicate cannot accidentally free a live pet.
|
||||
func IsPetEgg(itemID int64) bool {
|
||||
petEggProbeMu.RLock()
|
||||
probe := petEggProbe
|
||||
petEggProbeMu.RUnlock()
|
||||
return probe != nil && itemID > 0 && probe(itemID)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package creaturestate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
// 线上 Script.pvf 里 2681466 = `equipment/creature/egg_SDCharacterCreature_ft.equ`
|
||||
// (宠物蛋 (迷你气功师),带 [output index] 2681467);
|
||||
// 400990167 / 400990168 是直接发放的真宠物(无 [output index])。
|
||||
const (
|
||||
testPetEggItemID int64 = 2681466
|
||||
testRealPetItemID int64 = 400990167
|
||||
testPetEggSerial uint32 = 2
|
||||
)
|
||||
|
||||
// TestReconcileHealsPetEggBinding 覆盖现场:蛋被当成"直接发放的宠物",拿到了
|
||||
// serial 并建出了宠物记录,于是丢弃侧按"已绑定宠物"拒绝(「宠物蛋无法丢弃」)。
|
||||
// 对账必须自愈:清掉行上的绑定 extra、删掉误建的宠物记录。
|
||||
func TestReconcileHealsPetEggBinding(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const characterID = "77"
|
||||
|
||||
SetPetEggProbe(func(itemID int64) bool { return itemID == testPetEggItemID })
|
||||
defer SetPetEggProbe(nil)
|
||||
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: characterID, Slots: map[string]dnfrepo.ItemStack{
|
||||
"7:0": {ItemID: testPetEggItemID, Count: 1, Extra: map[string]string{
|
||||
"creature_serial_or_handle": "2",
|
||||
"creature_key": "2",
|
||||
"item_kind": "equipment",
|
||||
"equipment_type": "[creature]",
|
||||
"pvf_path": "equipment/creature/egg_SDCharacterCreature_ft.equ",
|
||||
}},
|
||||
"7:1": {ItemID: testRealPetItemID, Count: 1, Extra: map[string]string{
|
||||
"creature_serial_or_handle": "1",
|
||||
"item_kind": "equipment",
|
||||
"equipment_type": "[creature]",
|
||||
"pvf_path": "equipment/creature/chn_2018_SpringFestival/chn_400990167.equ",
|
||||
}},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: characterID, Entries: map[string]dnfrepo.EquipmentEntry{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{CharacterID: characterID, Entries: map[string]dnfrepo.PetEntry{
|
||||
"2": {PetKey: "2", CreatureKey: 2, ItemID: testPetEggItemID, SourceListType: 7, SourceSlotIndex: 0},
|
||||
"1": {PetKey: "1", CreatureKey: 1, ItemID: testRealPetItemID, SourceListType: 7, SourceSlotIndex: 1},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ReconcileInventory(ctx, characterID, repos.Inventory, repos.Equipment, repos.Pet); err != nil {
|
||||
t.Fatalf("ReconcileInventory: %v", err)
|
||||
}
|
||||
|
||||
inventory, found, err := repos.Inventory.Load(ctx, characterID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load inventory found=%t err=%v", found, err)
|
||||
}
|
||||
egg := inventory.Slots["7:0"]
|
||||
if !stackLacksPetBinding(egg) {
|
||||
t.Fatalf("蛋行仍带宠物绑定 extra: %+v", egg.Extra)
|
||||
}
|
||||
|
||||
petRecord, found, err := repos.Pet.Load(ctx, characterID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load pets found=%t err=%v", found, err)
|
||||
}
|
||||
if _, still := petRecord.Entries["2"]; still {
|
||||
t.Fatalf("蛋的宠物记录没有被清理: %+v", petRecord.Entries)
|
||||
}
|
||||
// 真宠物必须不受影响。
|
||||
if _, ok := petRecord.Entries["1"]; !ok {
|
||||
t.Fatalf("真宠物的记录被误删: %+v", petRecord.Entries)
|
||||
}
|
||||
if got := inventory.Slots["7:1"].Extra["creature_serial_or_handle"]; got != "1" {
|
||||
t.Fatalf("真宠物的 serial 被误改: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestReconcileKeepsEggBehaviourWithoutProbe 钉住回退路径:判据未装配(隔离测试、
|
||||
// 未接线的调用方)时必须完全等于改动前的行为。
|
||||
func TestReconcileKeepsEggBehaviourWithoutProbe(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const characterID = "78"
|
||||
|
||||
SetPetEggProbe(nil)
|
||||
defer SetPetEggProbe(nil)
|
||||
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: characterID, Slots: map[string]dnfrepo.ItemStack{
|
||||
"7:0": {ItemID: testPetEggItemID, Count: 1, Extra: map[string]string{
|
||||
"item_kind": "equipment",
|
||||
"equipment_type": "[creature]",
|
||||
"pvf_path": "equipment/creature/egg_SDCharacterCreature_ft.equ",
|
||||
}},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: characterID, Entries: map[string]dnfrepo.EquipmentEntry{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{CharacterID: characterID, Entries: map[string]dnfrepo.PetEntry{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := ReconcileInventory(ctx, characterID, repos.Inventory, repos.Equipment, repos.Pet); err != nil {
|
||||
t.Fatalf("ReconcileInventory: %v", err)
|
||||
}
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, characterID)
|
||||
if got := inventory.Slots["7:0"].Extra["creature_serial_or_handle"]; got == "" {
|
||||
t.Fatal("未装配判据时不应改变行为(旧行为会给蛋分配 serial)")
|
||||
}
|
||||
}
|
||||
|
||||
func stackLacksPetBinding(stack dnfrepo.ItemStack) bool {
|
||||
if _, ok := stackSerial(stack.Extra); ok {
|
||||
return false
|
||||
}
|
||||
for _, key := range []string{"creature_serial_or_handle", "creature_key", "creature_serial", "pet_serial"} {
|
||||
if stack.Extra[key] != "" {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
package creaturestate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrFeedEntryInvalid = errors.New("pet feed creature state is invalid")
|
||||
ErrFeedDeltaInvalid = errors.New("pet feed satiety delta is invalid")
|
||||
)
|
||||
|
||||
// FeedUpdate is the pure, capped satiety change from one PVF-proven feed.
|
||||
// Fractional satiety is retained so feeding cannot erase elapsed-time state.
|
||||
type FeedUpdate struct {
|
||||
BeforeSatiety byte
|
||||
BeforeSatietyMicros int64
|
||||
AfterSatiety byte
|
||||
AfterSatietyMicros int64
|
||||
RequestedDelta int64
|
||||
AppliedDelta int
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// ApplyFeed increases satiety by the PVF [feed] int-data amount and caps the
|
||||
// authoritative micro gauge at 100. Source-item consumption remains owned by
|
||||
// the caller's character-pet transaction.
|
||||
func ApplyFeed(entry dnfrepo.PetEntry, satietyDelta int64) (FeedUpdate, error) {
|
||||
if entry.ItemID <= 0 || entry.Satiety > 100 || entry.SatietyMicros < 0 || entry.SatietyMicros > fullSatietyMicros {
|
||||
return FeedUpdate{}, fmt.Errorf("%w: item=%d satiety=%d micros=%d", ErrFeedEntryInvalid, entry.ItemID, entry.Satiety, entry.SatietyMicros)
|
||||
}
|
||||
if satietyDelta <= 0 || satietyDelta > 100 {
|
||||
return FeedUpdate{}, fmt.Errorf("%w: %d", ErrFeedDeltaInvalid, satietyDelta)
|
||||
}
|
||||
beforeMicros := entry.SatietyMicros
|
||||
if beforeMicros == 0 && entry.Satiety > 0 {
|
||||
beforeMicros = int64(entry.Satiety) * 1_000_000
|
||||
}
|
||||
afterMicros := beforeMicros + satietyDelta*1_000_000
|
||||
if afterMicros > fullSatietyMicros {
|
||||
afterMicros = fullSatietyMicros
|
||||
}
|
||||
afterSatiety := byte(afterMicros / 1_000_000)
|
||||
return FeedUpdate{
|
||||
BeforeSatiety: entry.Satiety,
|
||||
BeforeSatietyMicros: beforeMicros,
|
||||
AfterSatiety: afterSatiety,
|
||||
AfterSatietyMicros: afterMicros,
|
||||
RequestedDelta: satietyDelta,
|
||||
AppliedDelta: int(afterSatiety) - int(entry.Satiety),
|
||||
Changed: afterMicros != beforeMicros,
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
package creaturestate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestApplyFeedPreservesFractionalSatietyAndCapsAtFull(t *testing.T) {
|
||||
entry := dnfrepo.PetEntry{ItemID: 400990168, Satiety: 2, SatietyMicros: 2_567_020}
|
||||
update, err := ApplyFeed(entry, 30)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !update.Changed || update.AfterSatiety != 32 || update.AfterSatietyMicros != 32_567_020 || update.AppliedDelta != 30 {
|
||||
t.Fatalf("feed update = %+v", update)
|
||||
}
|
||||
entry.Satiety = update.AfterSatiety
|
||||
entry.SatietyMicros = update.AfterSatietyMicros
|
||||
update, err = ApplyFeed(entry, 100)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if update.AfterSatiety != 100 || update.AfterSatietyMicros != 100_000_000 || update.AppliedDelta != 68 {
|
||||
t.Fatalf("capped feed update = %+v", update)
|
||||
}
|
||||
if _, err := ApplyFeed(entry, 0); !errors.Is(err, ErrFeedDeltaInvalid) {
|
||||
t.Fatalf("zero feed delta error = %v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
package creaturestate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrInventoryNotFound = errors.New("creature inventory record not found")
|
||||
ErrStateInvalid = errors.New("creature inventory state is invalid")
|
||||
)
|
||||
|
||||
const (
|
||||
petListType byte = 7
|
||||
petInventorySlotMax int16 = 139
|
||||
petEquipmentSlot int16 = 26
|
||||
fullSatietyMicros = int64(100_000_000)
|
||||
)
|
||||
|
||||
type inventoryCreature struct {
|
||||
slot int16
|
||||
stack dnfrepo.ItemStack
|
||||
}
|
||||
|
||||
// ReconcileInventory creates typed state only for PVF-proven direct creature
|
||||
// grants, then synchronizes every known inventory creature's durable slot.
|
||||
// Callers must provide transaction-scoped repositories.
|
||||
func ReconcileInventory(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
inventories dnfrepo.InventoryRepository,
|
||||
equipment dnfrepo.EquipmentRepository,
|
||||
pets dnfrepo.PetRepository,
|
||||
) (bool, error) {
|
||||
if inventories == nil || equipment == nil || pets == nil {
|
||||
return false, fmt.Errorf("%w: repositories unavailable", ErrStateInvalid)
|
||||
}
|
||||
inventory, found, err := inventories.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !found {
|
||||
return false, ErrInventoryNotFound
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
if inventory.Slots == nil {
|
||||
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
|
||||
equipmentRecord, found, err := equipment.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !found {
|
||||
equipmentRecord = dnfrepo.EquipmentRecord{CharacterID: characterID, Entries: make(map[string]dnfrepo.EquipmentEntry)}
|
||||
} else {
|
||||
equipmentRecord = dnfrepo.CloneEquipment(equipmentRecord)
|
||||
}
|
||||
|
||||
petRecord, petRecordFound, err := pets.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !petRecordFound {
|
||||
petRecord = dnfrepo.PetRecord{CharacterID: characterID, Entries: make(map[string]dnfrepo.PetEntry)}
|
||||
} else {
|
||||
petRecord = dnfrepo.ClonePet(petRecord)
|
||||
petRecord.CharacterID = characterID
|
||||
if petRecord.Entries == nil {
|
||||
petRecord.Entries = make(map[string]dnfrepo.PetEntry)
|
||||
}
|
||||
}
|
||||
|
||||
creatures := inventoryCreatures(inventory)
|
||||
entriesBySerial := make(map[uint32]string, len(petRecord.Entries))
|
||||
used := make(map[uint32]struct{}, len(creatures)+len(petRecord.Entries)+1)
|
||||
for key, entry := range petRecord.Entries {
|
||||
serial, ok := petEntrySerial(key, entry)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if previous, duplicate := entriesBySerial[serial]; duplicate && previous != key {
|
||||
return false, fmt.Errorf("%w: duplicate pet serial=%d keys=%s,%s", ErrStateInvalid, serial, previous, key)
|
||||
}
|
||||
entriesBySerial[serial] = key
|
||||
used[serial] = struct{}{}
|
||||
}
|
||||
|
||||
containerSerials := make(map[uint32]string, len(creatures)+1)
|
||||
for _, creature := range creatures {
|
||||
if serial, ok := stackSerial(creature.stack.Extra); ok {
|
||||
location := fmt.Sprintf("7:%d", creature.slot)
|
||||
if previous, duplicate := containerSerials[serial]; duplicate && previous != location {
|
||||
return false, fmt.Errorf("%w: duplicate creature serial=%d locations=%s,%s", ErrStateInvalid, serial, previous, location)
|
||||
}
|
||||
containerSerials[serial] = location
|
||||
used[serial] = struct{}{}
|
||||
}
|
||||
}
|
||||
if worn, ok := equipmentRecord.Entries[strconv.Itoa(int(petEquipmentSlot))]; ok && worn.ItemID > 0 {
|
||||
if serial, ok := equipmentSerial(worn); ok {
|
||||
location := "3:26"
|
||||
if previous, duplicate := containerSerials[serial]; duplicate && previous != location {
|
||||
return false, fmt.Errorf("%w: duplicate creature serial=%d locations=%s,%s", ErrStateInvalid, serial, previous, location)
|
||||
}
|
||||
containerSerials[serial] = location
|
||||
used[serial] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
inventoryChanged := false
|
||||
equipmentChanged := false
|
||||
petChanged := false
|
||||
for _, creature := range creatures {
|
||||
stack := creature.stack
|
||||
// 蛋不是宠物。历史上 `equipment/creature/` 下的蛋被 isDirectCreatureGrant 当成
|
||||
// "直接发放的宠物",于是分配了 serial、写了 creature_serial_or_handle,还建出
|
||||
// 宠物记录;丢弃侧随后按"已绑定宠物"拒绝 —— 现场就是「宠物蛋无法丢弃」。
|
||||
// 这里既不再新建,也顺手自愈历史脏数据(见 healPetEggRow)。
|
||||
if IsPetEgg(stack.ItemID) {
|
||||
healedInventory, healedPet := healPetEggRow(&inventory, &petRecord, entriesBySerial, used, creature, stack)
|
||||
inventoryChanged = inventoryChanged || healedInventory
|
||||
petChanged = petChanged || healedPet
|
||||
continue
|
||||
}
|
||||
serial, hasSerial := stackSerial(stack.Extra)
|
||||
entryKey := ""
|
||||
if hasSerial {
|
||||
entryKey = entriesBySerial[serial]
|
||||
}
|
||||
if entryKey == "" {
|
||||
if !isDirectCreatureGrant(stack) {
|
||||
continue
|
||||
}
|
||||
if !hasSerial {
|
||||
serial, err = allocateSerial(used)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
stack.Extra = cloneExtra(stack.Extra)
|
||||
stack.Extra["creature_serial_or_handle"] = strconv.FormatUint(uint64(serial), 10)
|
||||
stack.Extra["creature_key"] = strconv.FormatUint(uint64(serial), 10)
|
||||
inventory.Slots[slotKey(creature.slot)] = stack
|
||||
inventoryChanged = true
|
||||
}
|
||||
entryKey = strconv.FormatUint(uint64(serial), 10)
|
||||
if existing, occupied := petRecord.Entries[entryKey]; occupied && existing.ItemID != stack.ItemID {
|
||||
return false, fmt.Errorf("%w: pet key=%s item=%d inventory_item=%d", ErrStateInvalid, entryKey, existing.ItemID, stack.ItemID)
|
||||
}
|
||||
petRecord.Entries[entryKey] = dnfrepo.PetEntry{
|
||||
PetKey: entryKey,
|
||||
CreatureKey: serial,
|
||||
ItemID: stack.ItemID,
|
||||
SourceListType: petListType,
|
||||
SourceSlotIndex: creature.slot,
|
||||
Satiety: 100,
|
||||
SatietyMicros: fullSatietyMicros,
|
||||
Level: 1,
|
||||
Extra: cloneExtra(stack.Extra),
|
||||
}
|
||||
entriesBySerial[serial] = entryKey
|
||||
used[serial] = struct{}{}
|
||||
petChanged = true
|
||||
continue
|
||||
}
|
||||
|
||||
entry := petRecord.Entries[entryKey]
|
||||
if entry.ItemID != stack.ItemID {
|
||||
return false, fmt.Errorf("%w: serial=%d pet_item=%d inventory_item=%d", ErrStateInvalid, serial, entry.ItemID, stack.ItemID)
|
||||
}
|
||||
if entry.SourceListType != petListType || entry.SourceSlotIndex != creature.slot {
|
||||
entry.SourceListType = petListType
|
||||
entry.SourceSlotIndex = creature.slot
|
||||
petRecord.Entries[entryKey] = entry
|
||||
petChanged = true
|
||||
}
|
||||
if syncPetStackEnchant(&stack, entry) {
|
||||
inventory.Slots[slotKey(creature.slot)] = stack
|
||||
inventoryChanged = true
|
||||
}
|
||||
}
|
||||
if worn, ok := equipmentRecord.Entries[strconv.Itoa(int(petEquipmentSlot))]; ok && worn.ItemID > 0 {
|
||||
if serial, serialOK := equipmentSerial(worn); serialOK {
|
||||
if key := entriesBySerial[serial]; key != "" {
|
||||
if entry, entryOK := petRecord.Entries[key]; entryOK && syncPetEquipmentEnchant(&worn, entry) {
|
||||
equipmentRecord.Entries[strconv.Itoa(int(petEquipmentSlot))] = worn
|
||||
equipmentChanged = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if inventoryChanged {
|
||||
inventory.CharacterID = characterID
|
||||
inventory.UpdatedAt = time.Now()
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if petChanged {
|
||||
petRecord.UpdatedAt = time.Now()
|
||||
if err := dnfrepo.SavePetFields(ctx, pets, petRecord, dnfrepo.PetFieldEntries); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
if equipmentChanged {
|
||||
equipmentRecord.CharacterID = characterID
|
||||
equipmentRecord.UpdatedAt = time.Now()
|
||||
if err := dnfrepo.SaveEquipmentFields(ctx, equipment, equipmentRecord, dnfrepo.EquipmentFieldEntries); err != nil {
|
||||
return false, err
|
||||
}
|
||||
}
|
||||
return inventoryChanged || equipmentChanged || petChanged, nil
|
||||
}
|
||||
|
||||
func syncPetStackEnchant(stack *dnfrepo.ItemStack, entry dnfrepo.PetEntry) bool {
|
||||
if stack == nil {
|
||||
return false
|
||||
}
|
||||
cardID, upgrade, ok := petEnchantFields(entry.Extra)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
stack.Extra = cloneExtra(stack.Extra)
|
||||
for key, value := range map[string]string{
|
||||
"pet_enchant_card_item_id": strconv.FormatUint(uint64(cardID), 10),
|
||||
"enchant_card_id": strconv.FormatUint(uint64(cardID), 10),
|
||||
"value_a": strconv.FormatUint(uint64(cardID), 10),
|
||||
"pet_enchant_upgrade_count": strconv.Itoa(int(upgrade)),
|
||||
"enchant_upgrade_count": strconv.Itoa(int(upgrade)),
|
||||
"byte_12": strconv.Itoa(int(upgrade)),
|
||||
} {
|
||||
if stack.Extra[key] != value {
|
||||
stack.Extra[key] = value
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if len(stack.RawEntry) >= 0x13 {
|
||||
raw := append([]byte(nil), stack.RawEntry...)
|
||||
if binary.LittleEndian.Uint32(raw[0x0E:0x12]) != cardID || raw[0x12] != upgrade {
|
||||
binary.LittleEndian.PutUint32(raw[0x0E:0x12], cardID)
|
||||
raw[0x12] = upgrade
|
||||
stack.RawEntry = raw
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func syncPetEquipmentEnchant(entry *dnfrepo.EquipmentEntry, pet dnfrepo.PetEntry) bool {
|
||||
if entry == nil {
|
||||
return false
|
||||
}
|
||||
cardID, upgrade, ok := petEnchantFields(pet.Extra)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
changed := false
|
||||
entry.Extra = cloneExtra(entry.Extra)
|
||||
for key, value := range map[string]string{
|
||||
"pet_enchant_card_item_id": strconv.FormatUint(uint64(cardID), 10),
|
||||
"enchant_card_id": strconv.FormatUint(uint64(cardID), 10),
|
||||
"value_a": strconv.FormatUint(uint64(cardID), 10),
|
||||
"pet_enchant_upgrade_count": strconv.Itoa(int(upgrade)),
|
||||
"enchant_upgrade_count": strconv.Itoa(int(upgrade)),
|
||||
"byte_12": strconv.Itoa(int(upgrade)),
|
||||
} {
|
||||
if entry.Extra[key] != value {
|
||||
entry.Extra[key] = value
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
if len(entry.RawEntry) >= 0x15 {
|
||||
raw := append([]byte(nil), entry.RawEntry...)
|
||||
if binary.LittleEndian.Uint32(raw[0x10:0x14]) != cardID || raw[0x14] != upgrade {
|
||||
binary.LittleEndian.PutUint32(raw[0x10:0x14], cardID)
|
||||
raw[0x14] = upgrade
|
||||
entry.RawEntry = raw
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
func petEnchantFields(extra map[string]string) (uint32, byte, bool) {
|
||||
var first uint64
|
||||
found := false
|
||||
for _, key := range []string{"pet_enchant_card_item_id", "enchant_card_id", "value_a"} {
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(extra[key]), 0, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if !found {
|
||||
first, found = value, true
|
||||
}
|
||||
if value != 0 {
|
||||
first = value
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found || first == 0 {
|
||||
return 0, 0, false
|
||||
}
|
||||
upgrade := uint64(0)
|
||||
for _, key := range []string{"pet_enchant_upgrade_count", "enchant_upgrade_count", "byte_12"} {
|
||||
if value, err := strconv.ParseUint(strings.TrimSpace(extra[key]), 0, 8); err == nil {
|
||||
upgrade = value
|
||||
break
|
||||
}
|
||||
}
|
||||
return uint32(first), byte(upgrade), true
|
||||
}
|
||||
|
||||
func inventoryCreatures(record dnfrepo.InventoryRecord) []inventoryCreature {
|
||||
out := make([]inventoryCreature, 0)
|
||||
for key, stack := range record.Slots {
|
||||
listType, slot, ok := parseSlotKey(key)
|
||||
if !ok || listType != petListType || slot < 0 || slot > petInventorySlotMax || stack.ItemID <= 0 || stack.Count <= 0 {
|
||||
continue
|
||||
}
|
||||
out = append(out, inventoryCreature{slot: slot, stack: stack})
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].slot < out[j].slot })
|
||||
return out
|
||||
}
|
||||
|
||||
// healPetEggRow 自愈历史上被误当成宠物发放的蛋行:删掉旧版本写在行上的宠物绑定
|
||||
// extra,并移除它顺手建出的宠物记录。
|
||||
//
|
||||
// 如果那条宠物记录正好是**当前出战宠物**,就整行不动 —— 蛋不可能出战,这种状态
|
||||
// 只可能来自更早的错误数据,猜着清理比留着更危险。
|
||||
func healPetEggRow(
|
||||
inventory *dnfrepo.InventoryRecord,
|
||||
petRecord *dnfrepo.PetRecord,
|
||||
entriesBySerial map[uint32]string,
|
||||
used map[uint32]struct{},
|
||||
creature inventoryCreature,
|
||||
stack dnfrepo.ItemStack,
|
||||
) (inventoryChanged bool, petChanged bool) {
|
||||
serial, hasSerial := stackSerial(stack.Extra)
|
||||
if !hasSerial {
|
||||
return false, false
|
||||
}
|
||||
entryKey := entriesBySerial[serial]
|
||||
if entryKey != "" && petRecord.EquippedKey == entryKey {
|
||||
return false, false
|
||||
}
|
||||
if entryKey != "" {
|
||||
delete(petRecord.Entries, entryKey)
|
||||
delete(entriesBySerial, serial)
|
||||
petChanged = true
|
||||
}
|
||||
// 释放 serial,避免它继续占号。
|
||||
delete(used, serial)
|
||||
stack.Extra = cloneExtra(stack.Extra)
|
||||
for _, key := range []string{
|
||||
"creature_serial_or_handle", "creature_key", "creature_serial", "pet_serial",
|
||||
} {
|
||||
delete(stack.Extra, key)
|
||||
}
|
||||
inventory.Slots[slotKey(creature.slot)] = stack
|
||||
return true, petChanged
|
||||
}
|
||||
|
||||
func isDirectCreatureGrant(stack dnfrepo.ItemStack) bool {
|
||||
if stack.Count != 1 {
|
||||
return false
|
||||
}
|
||||
kind := strings.ToLower(strings.TrimSpace(stack.Extra["item_kind"]))
|
||||
equipmentType := strings.ToLower(strings.Trim(strings.TrimSpace(stack.Extra["equipment_type"]), "[]"))
|
||||
path := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(stack.Extra["pvf_path"]), "\\", "/"))
|
||||
return kind == "equipment" && equipmentType == "creature" && strings.HasPrefix(path, "equipment/creature/")
|
||||
}
|
||||
|
||||
func allocateSerial(used map[uint32]struct{}) (uint32, error) {
|
||||
for serial := uint32(1); serial != 0; serial++ {
|
||||
if _, exists := used[serial]; !exists {
|
||||
used[serial] = struct{}{}
|
||||
return serial, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("%w: no creature serial available", ErrStateInvalid)
|
||||
}
|
||||
|
||||
func equipmentSerial(entry dnfrepo.EquipmentEntry) (uint32, bool) {
|
||||
if len(entry.RawEntry) >= 28 {
|
||||
left := binary.LittleEndian.Uint32(entry.RawEntry[5:9])
|
||||
right := binary.LittleEndian.Uint32(entry.RawEntry[24:28])
|
||||
if left != 0 && left == right {
|
||||
return left, true
|
||||
}
|
||||
}
|
||||
return stackSerial(entry.Extra)
|
||||
}
|
||||
|
||||
func stackSerial(extra map[string]string) (uint32, bool) {
|
||||
for _, key := range []string{"creature_serial_or_handle", "creature_serial", "pet_serial", "serial", "handle", "instance_value", "item_uid"} {
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(extra[key]), 0, 32)
|
||||
if err == nil && value != 0 {
|
||||
return uint32(value), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func petEntrySerial(key string, entry dnfrepo.PetEntry) (uint32, bool) {
|
||||
if entry.CreatureKey != 0 {
|
||||
return entry.CreatureKey, true
|
||||
}
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(entry.PetKey), 0, 32)
|
||||
if err != nil || value == 0 {
|
||||
value, err = strconv.ParseUint(strings.TrimSpace(key), 0, 32)
|
||||
}
|
||||
if err != nil || value == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint32(value), true
|
||||
}
|
||||
|
||||
func parseSlotKey(key string) (byte, int16, bool) {
|
||||
listRaw, slotRaw, ok := strings.Cut(key, ":")
|
||||
if !ok {
|
||||
return 0, 0, false
|
||||
}
|
||||
listType, err := strconv.ParseUint(listRaw, 10, 8)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
slot, err := strconv.ParseInt(slotRaw, 10, 16)
|
||||
if err != nil {
|
||||
return 0, 0, false
|
||||
}
|
||||
return byte(listType), int16(slot), true
|
||||
}
|
||||
|
||||
func slotKey(slot int16) string {
|
||||
return fmt.Sprintf("%d:%d", petListType, slot)
|
||||
}
|
||||
|
||||
func cloneExtra(extra map[string]string) map[string]string {
|
||||
out := make(map[string]string, len(extra)+2)
|
||||
for key, value := range extra {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
package creaturestate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestReconcileInventoryRestoresPetEnchantAliasesFromTypedState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const characterID = "77"
|
||||
const itemID int64 = 400990167
|
||||
const serial uint32 = 41
|
||||
|
||||
t.Run("worn equipment", func(t *testing.T) {
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
raw := make([]byte, 43)
|
||||
binary.LittleEndian.PutUint32(raw[5:9], serial)
|
||||
binary.LittleEndian.PutUint32(raw[24:28], serial)
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: characterID, Slots: map[string]dnfrepo.ItemStack{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: characterID, Entries: map[string]dnfrepo.EquipmentEntry{
|
||||
"26": {SlotIndex: 26, ItemID: itemID, RawEntry: raw, Extra: map[string]string{"pet_enchant_card_item_id": "0", "enchant_card_id": "0"}},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{CharacterID: characterID, EquippedKey: "41", Entries: map[string]dnfrepo.PetEntry{
|
||||
"41": {PetKey: "41", CreatureKey: serial, ItemID: itemID, SourceListType: 3, SourceSlotIndex: 26, Extra: map[string]string{"pet_enchant_card_item_id": "10008729", "pet_enchant_upgrade_count": "2"}},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
changed, err := ReconcileInventory(ctx, characterID, repos.Inventory, repos.Equipment, repos.Pet)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("ReconcileInventory changed=%t err=%v", changed, err)
|
||||
}
|
||||
equipment, found, err := repos.Equipment.Load(ctx, characterID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load equipment found=%t err=%v", found, err)
|
||||
}
|
||||
entry := equipment.Entries["26"]
|
||||
if got := binary.LittleEndian.Uint32(entry.RawEntry[0x10:0x14]); got != 10008729 || entry.RawEntry[0x14] != 2 {
|
||||
t.Fatalf("equipment enchant raw=%d/%d", got, entry.RawEntry[0x14])
|
||||
}
|
||||
if entry.Extra["pet_enchant_card_item_id"] != "10008729" {
|
||||
t.Fatalf("equipment enchant extra=%+v", entry.Extra)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("pet inventory", func(t *testing.T) {
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
raw := make([]byte, 119)
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: characterID, Slots: map[string]dnfrepo.ItemStack{
|
||||
"7:48": {ItemID: itemID, Count: 1, RawEntry: raw, Extra: map[string]string{"creature_serial_or_handle": "41", "pet_enchant_card_item_id": "0", "enchant_card_id": "0"}},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: characterID, Entries: map[string]dnfrepo.EquipmentEntry{}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{CharacterID: characterID, Entries: map[string]dnfrepo.PetEntry{
|
||||
"41": {PetKey: "41", CreatureKey: serial, ItemID: itemID, SourceListType: 7, SourceSlotIndex: 48, Extra: map[string]string{"pet_enchant_card_item_id": "10008729", "pet_enchant_upgrade_count": "2"}},
|
||||
}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
changed, err := ReconcileInventory(ctx, characterID, repos.Inventory, repos.Equipment, repos.Pet)
|
||||
if err != nil || !changed {
|
||||
t.Fatalf("ReconcileInventory changed=%t err=%v", changed, err)
|
||||
}
|
||||
inventory, found, err := repos.Inventory.Load(ctx, characterID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load inventory found=%t err=%v", found, err)
|
||||
}
|
||||
stack := inventory.Slots["7:48"]
|
||||
if stack.Extra["pet_enchant_card_item_id"] != "10008729" || binary.LittleEndian.Uint32(stack.RawEntry[0x0E:0x12]) != 10008729 || stack.RawEntry[0x12] != 2 {
|
||||
t.Fatalf("inventory enchant stack=%+v raw=%x", stack.Extra, stack.RawEntry)
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in new issue
Block a user