DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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)
}
})
}