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,248 @@
package equip
import (
"context"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// 线上 Script.pvf 里 [item category] 为 `clear avatar` 的 2079 份装备文档中,只有
// 这 3 件是 [aurora avatar]:113590006 / 113590008 / 113590009(转职光环装扮)。
// 113590008 就是日志里角色 1 从 490004995 开出来的那一件。
const (
testAuroraLookReplaceAvatar int64 = 113590008 // 转职光环装扮
testOrdinaryAura int64 = 101590068 // 被它覆盖的普通光环
testCloneHat int64 = 415500017 // 透明克隆时装(帽)
testOrdinaryHat int64 = 415500058 // 普通装扮(帽)
)
// newAuraLookReplaceTestOwner 按真实 PVF 分类装配 owner:
// - 普通光环 / 普通装扮:是装扮,但不是 clear avatar;
// - 转职光环 / 克隆帽:都是 clear avatar;
// - 只有转职光环命中 auroraLookReplaceClassifier。
func newAuraLookReplaceTestOwner(t *testing.T, repos dnfrepo.Group, withAuroraClassifier bool) *Owner {
t.Helper()
owner, err := NewOwnerWithPlacementValidator(repos, &recordingPlacementValidator{})
if err != nil {
t.Fatal(err)
}
owner.SetAvatarClassifier(func(itemID uint32) (bool, bool) {
switch int64(itemID) {
case testOrdinaryAura, testOrdinaryHat:
return true, false
case testAuroraLookReplaceAvatar, testCloneHat:
return true, true
default:
return false, false
}
})
if withAuroraClassifier {
owner.SetAuroraLookReplaceClassifier(func(itemID uint32) bool {
return int64(itemID) == testAuroraLookReplaceAvatar
})
}
return owner
}
// TestOwnerAuroraLookReplaceKeepsOwnAppearanceAndLinksCoveredAura 覆盖用户报的现象:
// 槽 9 已有普通光环时穿上转职光环,服务端旧代码把被覆盖光环的模板写进"外观" key,
// 于是 mode0 又渲染回原光环。现在必须是"外观保留自己 + 被覆盖光环只作为关联来源"。
func TestOwnerAuroraLookReplaceKeepsOwnAppearanceAndLinksCoveredAura(t *testing.T) {
const (
characterID = "77"
auraSlot int16 = 9
bagSlot int16 = 31
)
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: characterID,
Slots: map[string]dnfrepo.ItemStack{
"1:31": {ItemID: testAuroraLookReplaceAvatar, Count: 0, RawEntry: []byte{0xA1}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: characterID,
Entries: map[string]dnfrepo.EquipmentEntry{
"9": {SlotIndex: auraSlot, ItemID: testOrdinaryAura, RawEntry: []byte{0xC3}},
},
})
owner := newAuraLookReplaceTestOwner(t, repos, true)
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListAvatar,
SourceSlotIndex: bagSlot,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: auraSlot,
})
if err != nil {
t.Fatalf("equip aurora look-replace: %v", err)
}
if result.Mode != "equip_swap" || result.ItemID != testAuroraLookReplaceAvatar ||
result.SwappedItemID != testOrdinaryAura {
t.Fatalf("result=%+v", result)
}
worn := loadTestEquipment(t, ctx, repos, characterID).Entries["9"]
if worn.ItemID != testAuroraLookReplaceAvatar {
t.Fatalf("worn item=%d want=%d", worn.ItemID, testAuroraLookReplaceAvatar)
}
// 红线:转职光环绝不能借用被覆盖光环的外观,否则 mode0 槽 9 又发回原光环。
if got := worn.Extra[dnfrepo.CloneAvatarAppearanceExtraKey]; got != "" {
t.Fatalf("转职光环 must not borrow the covered aura appearance, got %q", got)
}
if got := worn.Extra[dnfrepo.AuraLinkedTemplateExtraKey]; got != "101590068" {
t.Fatalf("aura link=%q want=101590068", got)
}
// 关联来源必须记下被覆盖光环的落点:投影时据此实时取它的镶嵌字节。
// 玩家完全可能在穿好转职光环之后才给那件光环打徽章,所以不能穿戴时快照。
if got := worn.Extra[dnfrepo.AuraLinkedSourceExtraKey]; got != "1:31" {
t.Fatalf("aura link source=%q want=1:31", got)
}
returned := loadTestInventory(t, ctx, repos, characterID).Slots["1:31"]
if returned.ItemID != testOrdinaryAura {
t.Fatalf("returned=%+v", returned)
}
if returned.Extra[dnfrepo.CloneAvatarAppearanceExtraKey] != "" ||
returned.Extra[dnfrepo.AuraLinkedTemplateExtraKey] != "" {
t.Fatalf("returned aura kept residual keys=%+v", returned.Extra)
}
}
// TestOwnerAuroraLookReplaceInIllusionSlotRecordsNoLink 证明放进光环幻化栏(槽 11)
// 的转职光环不带关联:客户端只在槽 9 读那个关联块,槽 9 的真光环本来就在提供属性。
func TestOwnerAuroraLookReplaceInIllusionSlotRecordsNoLink(t *testing.T) {
const characterID = "77"
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: characterID,
Slots: map[string]dnfrepo.ItemStack{
"1:31": {ItemID: testAuroraLookReplaceAvatar, Count: 0, RawEntry: []byte{0xA1}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: characterID,
Entries: map[string]dnfrepo.EquipmentEntry{
"9": {SlotIndex: 9, ItemID: testOrdinaryAura, RawEntry: []byte{0xC3}},
},
})
owner := newAuraLookReplaceTestOwner(t, repos, true)
if _, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListAvatar,
SourceSlotIndex: 31,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 11,
}); err != nil {
t.Fatalf("equip into illusion slot: %v", err)
}
worn := loadTestEquipment(t, ctx, repos, characterID).Entries["11"]
if worn.ItemID != testAuroraLookReplaceAvatar {
t.Fatalf("illusion slot item=%d want=%d", worn.ItemID, testAuroraLookReplaceAvatar)
}
if worn.Extra[dnfrepo.AuraLinkedTemplateExtraKey] != "" ||
worn.Extra[dnfrepo.AuraLinkedSourceExtraKey] != "" ||
worn.Extra[dnfrepo.CloneAvatarAppearanceExtraKey] != "" {
t.Fatalf("illusion slot must stay link-free, extra=%+v", worn.Extra)
}
}
// TestOwnerAuroraLookReplaceWithoutClassifierKeepsLegacyBehaviour 钉住回退路径:
// classifier 未注入(隔离测试、宠物事务等)时必须完全等于改动前的行为,不能
// 悄悄改变任何东西。
func TestOwnerAuroraLookReplaceWithoutClassifierKeepsLegacyBehaviour(t *testing.T) {
const characterID = "77"
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: characterID,
Slots: map[string]dnfrepo.ItemStack{
"1:31": {ItemID: testAuroraLookReplaceAvatar, Count: 0, RawEntry: []byte{0xA1}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: characterID,
Entries: map[string]dnfrepo.EquipmentEntry{
"9": {SlotIndex: 9, ItemID: testOrdinaryAura, RawEntry: []byte{0xC3}},
},
})
owner := newAuraLookReplaceTestOwner(t, repos, false)
if _, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListAvatar,
SourceSlotIndex: 31,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 9,
}); err != nil {
t.Fatalf("equip without aurora classifier: %v", err)
}
worn := loadTestEquipment(t, ctx, repos, characterID).Entries["9"]
if worn.Extra[dnfrepo.CloneAvatarAppearanceExtraKey] != "101590068" {
t.Fatalf("legacy fallback must still borrow appearance, extra=%+v", worn.Extra)
}
if worn.Extra[dnfrepo.AuraLinkedTemplateExtraKey] != "" {
t.Fatalf("legacy fallback must not write the aura link, extra=%+v", worn.Extra)
}
}
// TestOwnerPlainCloneAvatarStillBorrowsAppearance 是"别修 A 坏 B"的直接护栏:
// 普通透明克隆时装的行为必须与改动前逐字节一致。
func TestOwnerPlainCloneAvatarStillBorrowsAppearance(t *testing.T) {
const (
characterID = "77"
hatSlot int16 = 3
)
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: characterID,
Slots: map[string]dnfrepo.ItemStack{
"1:19": {ItemID: testCloneHat, Count: 0, RawEntry: []byte{0xA1}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: characterID,
Entries: map[string]dnfrepo.EquipmentEntry{
"3": {SlotIndex: hatSlot, ItemID: testOrdinaryHat, RawEntry: []byte{0xC3}},
},
})
owner := newAuraLookReplaceTestOwner(t, repos, true)
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListAvatar,
SourceSlotIndex: 19,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: hatSlot,
})
if err != nil {
t.Fatalf("equip clone hat: %v", err)
}
if result.Mode != "equip_swap" || result.ItemID != testCloneHat || result.SwappedItemID != testOrdinaryHat {
t.Fatalf("result=%+v", result)
}
worn := loadTestEquipment(t, ctx, repos, characterID).Entries["3"]
if worn.ItemID != testCloneHat {
t.Fatalf("worn item=%d want=%d", worn.ItemID, testCloneHat)
}
if got := worn.Extra[dnfrepo.CloneAvatarAppearanceExtraKey]; got != "415500058" {
t.Fatalf("clone appearance=%q want=415500058", got)
}
if worn.Extra[dnfrepo.AuraLinkedTemplateExtraKey] != "" {
t.Fatalf("clone hat must not carry an aura link, extra=%+v", worn.Extra)
}
}
@@ -0,0 +1,159 @@
package equip
import (
"context"
"encoding/binary"
"testing"
"time"
"longheng.io/server/internal/modules/dnf/premium"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
// 魔王契约「自动修理」的服务端触发语义(2026-09-14):
// - 契约生效且该角色当天还有额度 -> 全身免费修满, 次数 +1;
// - 没有契约 -> 空操作(ContractSkipped), 绝不落成收费修理;
// - 当天 6 次用完 -> 同样空操作, 不修也不扣钱。
func devilAutoRepairRaw(itemID int64, durability uint16) []byte {
raw := make([]byte, currentEquipmentRawEntryWireSize)
binary.LittleEndian.PutUint32(raw[2:6], uint32(itemID))
binary.LittleEndian.PutUint16(raw[0x0B:0x0D], durability)
return raw
}
func devilAutoRepairDurability(t *testing.T, ctx context.Context, repos dnfrepo.Group, key string) uint16 {
t.Helper()
record, found, err := repos.Equipment.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load equipment found=%t err=%v", found, err)
}
entry, ok := record.Entries[key]
if !ok {
t.Fatalf("equipment key %q missing", key)
}
return binary.LittleEndian.Uint16(entry.RawEntry[0x0B:0x0D])
}
func devilAutoRepairDamage(t *testing.T, ctx context.Context, repos dnfrepo.Group, key string, durability uint16) {
t.Helper()
record, found, err := repos.Equipment.Load(ctx, "77")
if err != nil || !found {
t.Fatalf("load equipment found=%t err=%v", found, err)
}
entry := record.Entries[key]
entry.RawEntry = append([]byte(nil), entry.RawEntry...)
binary.LittleEndian.PutUint16(entry.RawEntry[0x0B:0x0D], durability)
record.Entries[key] = entry
if err := repos.Equipment.Save(ctx, record); err != nil {
t.Fatalf("save damaged equipment: %v", err)
}
}
func TestRepairAllContractAutoRepairNeverChargesAndStopsAtDailyLimit(t *testing.T) {
ctx := context.Background()
const (
characterID = "77"
accountID = "acc-devil-auto"
)
repos := dnfrepomemory.NewMemoryGroup()
saveTestCharacter(t, ctx, repos, dnfrepo.CharacterRecord{
CharacterID: characterID,
AccountID: accountID,
Stats: map[string]int64{"gold": 1234},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: characterID,
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {SlotIndex: 12, ItemID: 700, RawEntry: devilAutoRepairRaw(700, 0)},
"14": {SlotIndex: 14, ItemID: 701, RawEntry: devilAutoRepairRaw(701, 10)},
},
})
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: characterID, Slots: map[string]dnfrepo.ItemStack{}})
if err := repos.Account.Save(ctx, dnfrepo.AccountRecord{AccountID: accountID}); err != nil {
t.Fatalf("save account: %v", err)
}
resolver := repairAllTestResolver(map[int64]string{700: "[weapon]", 701: "[coat]"})
owner, err := NewOwner(repos)
if err != nil {
t.Fatalf("NewOwner error = %v", err)
}
command := RepairCommand{
SelectedCharacterID: 77,
AccountID: accountID,
SlotIndex: -1,
AutoRepair: true,
ContractAutoRepair: true,
}
// 1) 没有契约: 空操作, 不修也不扣钱。
skipped, err := owner.RepairAll(ctx, command, resolver)
if err != nil {
t.Fatalf("no-contract RepairAll error = %v", err)
}
if !skipped.ContractSkipped || skipped.FreeRepair || skipped.RepairedCount != 0 || skipped.UpdatedGold != 1234 {
t.Fatalf("no-contract result = %+v, want ContractSkipped with untouched gold", skipped)
}
if got := devilAutoRepairDurability(t, ctx, repos, "14"); got != 10 {
t.Fatalf("no-contract durability = %d, want untouched 10", got)
}
// 2) 契约生效: 全身修满, 免费, 次数 +1。
account, found, err := repos.Account.Load(ctx, accountID)
if err != nil || !found {
t.Fatalf("load account found=%t err=%v", found, err)
}
premium.Upsert(&account, premium.DevilSlotType(premium.DevilSlotAutoRepair), 7*24*60*60, 1, time.Now().UTC())
if err := repos.Account.Save(ctx, account); err != nil {
t.Fatalf("save contract account: %v", err)
}
free, err := owner.RepairAll(ctx, command, resolver)
if err != nil {
t.Fatalf("contract RepairAll error = %v", err)
}
if !free.FreeRepair || free.ContractSkipped || free.Cost != 0 || free.UpdatedGold != 1234 || free.RepairedCount != 2 {
t.Fatalf("contract result = %+v, want free repair of 2 targets at 1234 gold", free)
}
for _, key := range []string{"12", "14"} {
if got := devilAutoRepairDurability(t, ctx, repos, key); got != 20 {
t.Fatalf("slot %s durability = %d, want 20 (PVF max)", key, got)
}
}
character, _, _ := repos.Character.Load(ctx, characterID)
if used := premium.DailyUsage(character, premium.DevilSlotAutoRepair, time.Now().UTC()); used != 1 {
t.Fatalf("daily usage = %d, want 1", used)
}
if character.Stats["gold"] != 1234 {
t.Fatalf("gold = %d, want untouched 1234", character.Stats["gold"])
}
// 3) 把当天 6 次用完, 第 7 次必须是空操作: 不修、不收费。
dailyLimit := int(premium.DailyLimit(premium.DevilSlotAutoRepair))
for round := 1; round <= dailyLimit; round++ {
devilAutoRepairDamage(t, ctx, repos, "14", 5)
result, repairErr := owner.RepairAll(ctx, command, resolver)
if repairErr != nil {
t.Fatalf("round %d RepairAll error = %v", round, repairErr)
}
if round <= 5 {
if !result.FreeRepair || result.RepairedCount != 1 {
t.Fatalf("round %d result = %+v, want the remaining free repair", round, result)
}
continue
}
if !result.ContractSkipped || result.FreeRepair || result.RepairedCount != 0 {
t.Fatalf("round %d (contract still active at the cap) result = %+v", round, result)
}
}
if got := devilAutoRepairDurability(t, ctx, repos, "14"); got != 5 {
t.Fatalf("daily-limit durability = %d, want untouched 5", got)
}
character, _, _ = repos.Character.Load(ctx, characterID)
if used := premium.DailyUsage(character, premium.DevilSlotAutoRepair, time.Now().UTC()); used != premium.DailyLimit(premium.DevilSlotAutoRepair) {
t.Fatalf("daily usage = %d, want %d", used, premium.DailyLimit(premium.DevilSlotAutoRepair))
}
if character.Stats["gold"] != 1234 {
t.Fatalf("gold after the cap = %d, want untouched 1234 (never a paid fallback)", character.Stats["gold"])
}
}
+4
View File
@@ -0,0 +1,4 @@
// Package equip 负责 DNF 装备 PVF 的项目层强类型加载。
//
// 本包只从 dnf/pvf.Index 读取已解析的内存文档,不直接访问原始 PVF 文件。
package equip
@@ -0,0 +1,184 @@
package equip
import (
"context"
"encoding/binary"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestDecreaseDurabilityUpdatesRawAndExtraForExactSlot(t *testing.T) {
repositories := dnfrepomemory.NewRuntime().Group()
raw := make([]byte, 12)
binary.LittleEndian.PutUint16(raw[10:12], 45)
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {SlotIndex: 12, ItemID: 9001, RawEntry: raw, Extra: map[string]string{"durability": "45"}},
"14": {SlotIndex: 14, ItemID: 9002, RawEntry: append([]byte(nil), raw...), Extra: map[string]string{"durability": "45"}},
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.DecreaseDurability(context.Background(), DecreaseDurabilityCommand{
SelectedCharacterID: 77,
SlotIndex: 12,
Amount: 1,
Resolve: func(itemID int64) (alignedcmd.RepairCostEvidence, error) {
return alignedcmd.RepairCostEvidence{MaxDurability: 45}, nil
},
})
if err != nil || !result.Changed || result.OldDurability != 45 || result.NewDurability != 44 {
t.Fatalf("result=%+v err=%v", result, err)
}
loaded, found, err := repositories.Equipment.Load(context.Background(), "77")
if err != nil || !found {
t.Fatalf("load found=%t err=%v", found, err)
}
if got := binary.LittleEndian.Uint16(loaded.Entries["12"].RawEntry[10:12]); got != 44 || loaded.Entries["12"].Extra["durability"] != "44" {
t.Fatalf("slot12 raw=%d extra=%q", got, loaded.Entries["12"].Extra["durability"])
}
if got := binary.LittleEndian.Uint16(loaded.Entries["14"].RawEntry[10:12]); got != 45 {
t.Fatalf("slot14 durability=%d want untouched 45", got)
}
}
func TestDecreaseDurabilityClampsAtZeroAndSkipsNonRepairable(t *testing.T) {
repositories := dnfrepomemory.NewRuntime().Group()
raw := make([]byte, 12)
binary.LittleEndian.PutUint16(raw[10:12], 1)
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {SlotIndex: 12, ItemID: 9001, RawEntry: raw},
"14": {SlotIndex: 14, ItemID: 9002, RawEntry: append([]byte(nil), raw...)},
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.DecreaseDurability(context.Background(), DecreaseDurabilityCommand{
SelectedCharacterID: 77,
SlotIndex: 12,
Amount: 9,
Resolve: func(itemID int64) (alignedcmd.RepairCostEvidence, error) {
return alignedcmd.RepairCostEvidence{MaxDurability: 45}, nil
},
})
if err != nil || !result.Changed || result.NewDurability != 0 {
t.Fatalf("clamp result=%+v err=%v", result, err)
}
result, err = owner.DecreaseDurability(context.Background(), DecreaseDurabilityCommand{
SelectedCharacterID: 77,
SlotIndex: 14,
Resolve: func(itemID int64) (alignedcmd.RepairCostEvidence, error) {
return alignedcmd.RepairCostEvidence{}, nil
},
})
if err != nil || result.Changed {
t.Fatalf("nonrepairable result=%+v err=%v", result, err)
}
}
func TestDecreaseDurabilityUsesCurrentRaw77Offset(t *testing.T) {
repositories := dnfrepomemory.NewRuntime().Group()
raw := make([]byte, currentEquipmentRawEntryWireSize)
binary.LittleEndian.PutUint32(raw[2:6], 9001)
binary.LittleEndian.PutUint16(raw[0x0B:0x0D], 45)
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {SlotIndex: 12, ItemID: 9001, RawEntry: raw, Extra: map[string]string{"durability": "45", "max_durability": "45"}},
},
}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.DecreaseDurability(context.Background(), DecreaseDurabilityCommand{
SelectedCharacterID: 77,
SlotIndex: 12,
Resolve: func(itemID int64) (alignedcmd.RepairCostEvidence, error) {
return alignedcmd.RepairCostEvidence{MaxDurability: 45}, nil
},
})
if err != nil || !result.Changed || result.NewDurability != 44 {
t.Fatalf("result=%+v err=%v", result, err)
}
loaded, _, err := repositories.Equipment.Load(context.Background(), "77")
if err != nil {
t.Fatal(err)
}
if got := binary.LittleEndian.Uint16(loaded.Entries["12"].RawEntry[0x0B:0x0D]); got != 44 {
t.Fatalf("raw77 durability=%d want 44", got)
}
}
func TestRepairUsesCurrentRaw77DurabilityOffset(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewRuntime().Group()
raw := make([]byte, currentEquipmentRawEntryWireSize)
binary.LittleEndian.PutUint32(raw[2:6], 9001)
raw[0x0A] = 0x2F
binary.LittleEndian.PutUint16(raw[0x0B:0x0D], 12)
saveTestCharacter(t, ctx, repositories, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 1000}})
saveTestEquipment(t, ctx, repositories, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {SlotIndex: 12, ItemID: 9001, RawEntry: raw, Extra: map[string]string{"durability": "12"}},
},
})
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.Repair(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: 12}, repairTestResolver(45, 0, 20))
if err != nil || !result.Changed || result.OldDurability != 12 || result.NewDurability != 45 {
t.Fatalf("result=%+v err=%v", result, err)
}
loaded := loadTestEquipment(t, ctx, repositories, "77").Entries["12"]
if loaded.RawEntry[0x0A] != 0x2F || binary.LittleEndian.Uint16(loaded.RawEntry[0x0B:0x0D]) != 45 {
t.Fatalf("current raw after repair=%x", loaded.RawEntry[:0x0D])
}
}
func TestRepairAllUsesCurrentRaw77DurabilityOffset(t *testing.T) {
ctx := context.Background()
repositories := dnfrepomemory.NewRuntime().Group()
raw := make([]byte, currentEquipmentRawEntryWireSize)
binary.LittleEndian.PutUint32(raw[2:6], 9001)
raw[0x0A] = 0x1F
binary.LittleEndian.PutUint16(raw[0x0B:0x0D], 12)
saveTestCharacter(t, ctx, repositories, dnfrepo.CharacterRecord{CharacterID: "77", Stats: map[string]int64{"gold": 1000}})
saveTestEquipment(t, ctx, repositories, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"12": {SlotIndex: 12, ItemID: 9001, RawEntry: raw, Extra: map[string]string{"durability": "12"}},
},
})
saveTestInventory(t, ctx, repositories, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.RepairAll(ctx, RepairCommand{SelectedCharacterID: 77, SlotIndex: -1}, repairAllTestResolver(map[int64]string{9001: "[weapon]"}))
if err != nil || !result.Changed || result.RepairedCount != 1 {
t.Fatalf("result=%+v err=%v", result, err)
}
loaded := loadTestEquipment(t, ctx, repositories, "77").Entries["12"]
if loaded.RawEntry[0x0A] != 0x1F || binary.LittleEndian.Uint16(loaded.RawEntry[0x0B:0x0D]) != 20 {
t.Fatalf("current raw after repair-all=%x", loaded.RawEntry[:0x0D])
}
}
+53
View File
@@ -0,0 +1,53 @@
// 本文件负责从 DNF PVF 内存索引加载装备强类型表。
package equip
import (
"context"
"fmt"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
// Load 从已构建的 DNF PVF 内存索引加载装备表。
// 这里只解析静态装备字段,不校验穿戴条件,也不修改角色背包或装备状态。
func Load(ctx context.Context, index *dnfpvf.Index, options Options) (*Table, error) {
if index == nil {
return nil, ErrIndexRequired
}
if ctx == nil {
ctx = context.Background()
}
listPath := options.ListPath
if listPath == "" {
listPath = DefaultList
}
entries := index.List(listPath)
if len(entries) == 0 {
return nil, fmt.Errorf("%w: %s", ErrListEmpty, listPath)
}
table := &Table{
items: make([]Item, 0, len(entries)),
byID: make(map[int64]int, len(entries)),
byPath: make(map[string]int, len(entries)),
}
for _, entry := range entries {
if err := ctx.Err(); err != nil {
return nil, err
}
doc, ok := index.Document(entry.Path)
if !ok {
return nil, fmt.Errorf("%w: %s", ErrDocMissing, entry.Path)
}
item := parseItem(entry.ID, entry.Path, doc)
idx := len(table.items)
table.items = append(table.items, item)
if _, exists := table.byID[item.ID]; !exists {
table.byID[item.ID] = idx
}
key := pathKey(item.Path)
if key != "" {
table.byPath[key] = idx
}
}
return table, nil
}
@@ -0,0 +1,93 @@
package equip
import (
"context"
"errors"
"testing"
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
)
func TestLoadBuildsEquipTable(t *testing.T) {
index := testIndex(t)
table, err := Load(context.Background(), index, Options{})
if err != nil {
t.Fatalf("load equip: %v", err)
}
if snapshot := table.Snapshot(); snapshot.Items != 1 {
t.Fatalf("unexpected snapshot: %+v", snapshot)
}
item, ok := table.Find(1001)
if !ok {
t.Fatalf("expected item")
}
if item.Name != "short sword" || item.Kind != "weapon" || item.Slot != "weapon" || item.Level != 12 {
t.Fatalf("unexpected item: %+v", item)
}
if item.Stats["physical_attack"] != 34 || item.Stats["attack_speed"] != 1.5 {
t.Fatalf("unexpected stats: %+v", item.Stats)
}
item.Stats["physical_attack"] = 1
again, _ := table.FindPath("./EQUIPMENT/weapon/sword.equ")
if again.Stats["physical_attack"] != 34 {
t.Fatalf("table returned mutable stats: %+v", again.Stats)
}
}
func TestLoadRequiresIndex(t *testing.T) {
_, err := Load(context.Background(), nil, Options{})
if !errors.Is(err, ErrIndexRequired) {
t.Fatalf("expected ErrIndexRequired, got %v", err)
}
}
func TestLoadReportsEmptyList(t *testing.T) {
index, err := dnfpvf.Build(context.Background(), textSource{
"equipment/equipment.lst": "1001 `equipment/weapon/missing.equ`\n",
}, dnfpvf.BuildOptions{Paths: []string{"equipment/equipment.lst"}})
if err != nil {
t.Fatalf("build index: %v", err)
}
_, err = Load(context.Background(), index, Options{})
if !errors.Is(err, ErrListEmpty) {
t.Fatalf("expected ErrListEmpty, got %v", err)
}
}
func testIndex(t *testing.T) *dnfpvf.Index {
t.Helper()
index, err := dnfpvf.Build(context.Background(), textSource{
"equipment/equipment.lst": "1001 `equipment/weapon/sword.equ`\n",
"equipment/weapon/sword.equ": `
[name]
` + "`short sword`" + `
[equipment type]
` + "`weapon`" + `
[attach type]
` + "`weapon`" + `
[minimum level]
12
[rarity]
` + "`rare`" + `
[physical attack]
34
[attack speed]
1.5
`,
}, dnfpvf.BuildOptions{Lists: []string{DefaultList}})
if err != nil {
t.Fatalf("build index: %v", err)
}
return index
}
type textSource map[string]string
func (s textSource) ReadText(relativePath string) (string, error) {
for key, value := range s {
if pathKey(key) == pathKey(relativePath) {
return value, nil
}
}
return "", dnfpvf.ErrDocNotFound
}
@@ -0,0 +1,112 @@
package equip
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
var (
ErrNameTagCharacterNotFound = errors.New("name-tag character not found")
ErrNameTagAccountMismatch = errors.New("name-tag character does not belong to account")
)
type CleanupExpiredNameTagCommand struct {
AccountID string
CharacterID string
SlotIndex int16
Now time.Time
}
type CleanupExpiredNameTagResult struct {
CharacterID string
ItemID int64
ExpiredAt int64
Changed bool
EquipmentRemoved bool
}
// CleanupExpiredNameTag atomically clears expired character projection fields
// and the corresponding worn name-tag entry. Packet refresh and PVF type
// authority remain in the bridge.
func (o *Owner) CleanupExpiredNameTag(
ctx context.Context,
command CleanupExpiredNameTagCommand,
) (CleanupExpiredNameTagResult, error) {
if o == nil || o.assets == nil {
return CleanupExpiredNameTagResult{}, ErrOwnerUnavailable
}
characterID := strings.TrimSpace(command.CharacterID)
if characterID == "" {
return CleanupExpiredNameTagResult{}, ErrCharacterRequired
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return CleanupExpiredNameTagResult{}, err
}
now := command.Now.UTC()
if now.IsZero() {
now = time.Now().UTC()
}
accountID := strings.TrimSpace(command.AccountID)
result := CleanupExpiredNameTagResult{CharacterID: characterID}
err := o.assets.WithinCharacterAssets(ctx, characterID, func(
characters dnfrepo.CharacterRepository,
_ dnfrepo.InventoryRepository,
equipment dnfrepo.EquipmentRepository,
) error {
character, found, err := characters.Load(ctx, characterID)
if err != nil {
return fmt.Errorf("load name-tag character: %w", err)
}
if !found {
return fmt.Errorf("%w: character=%s", ErrNameTagCharacterNotFound, characterID)
}
if accountID != "" && strings.TrimSpace(character.AccountID) != accountID {
return fmt.Errorf("%w: account=%s character=%s", ErrNameTagAccountMismatch, accountID, characterID)
}
if character.Stats == nil {
return nil
}
result.ItemID = character.Stats["name_tag_item_id"]
result.ExpiredAt = character.Stats["name_tag_expire_time"]
if result.ItemID == 0 || result.ExpiredAt == 0 || now.Unix() < result.ExpiredAt {
return nil
}
character = dnfrepo.CloneCharacter(character)
character.Stats["name_tag_item_id"] = 0
character.Stats["name_tag_expire_time"] = 0
character.UpdatedAt = now
if err := dnfrepo.SaveCharacterFields(ctx, characters, character, dnfrepo.CharacterFieldStats); err != nil {
return fmt.Errorf("clear expired name-tag character fields: %w", err)
}
worn, found, err := equipment.Load(ctx, characterID)
if err != nil {
return fmt.Errorf("load expired name-tag equipment: %w", err)
}
if found {
worn = dnfrepo.CloneEquipment(worn)
slotKey := strconv.Itoa(int(command.SlotIndex))
if _, exists := worn.Entries[slotKey]; exists {
delete(worn.Entries, slotKey)
worn.UpdatedAt = now
if err := dnfrepo.SaveEquipmentFields(ctx, equipment, worn, dnfrepo.EquipmentFieldEntries); err != nil {
return fmt.Errorf("remove expired name-tag equipment: %w", err)
}
result.EquipmentRemoved = true
}
}
result.Changed = true
return nil
})
return result, err
}
@@ -0,0 +1,119 @@
package equip
import (
"context"
"errors"
"strconv"
"testing"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerCleanupExpiredNameTagCommitsCharacterAndEquipment(t *testing.T) {
ctx := context.Background()
repositories := seededExpiredNameTagRepositories(t, ctx)
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
now := time.Unix(2_000_000_000, 0).UTC()
result, err := owner.CleanupExpiredNameTag(ctx, CleanupExpiredNameTagCommand{
AccountID: "account-1",
CharacterID: "19",
SlotIndex: 30,
Now: now,
})
if err != nil {
t.Fatal(err)
}
if !result.Changed || !result.EquipmentRemoved || result.ItemID != 9003 {
t.Fatalf("result=%+v", result)
}
character, _, _ := repositories.Character.Load(ctx, "19")
if character.Stats["name_tag_item_id"] != 0 ||
character.Stats["name_tag_expire_time"] != 0 ||
!character.UpdatedAt.Equal(now) {
t.Fatalf("character=%+v", character)
}
equipment, _, _ := repositories.Equipment.Load(ctx, "19")
if _, exists := equipment.Entries[strconv.Itoa(30)]; exists || !equipment.UpdatedAt.Equal(now) {
t.Fatalf("equipment=%+v", equipment)
}
}
func TestOwnerCleanupExpiredNameTagPreservesLiveCard(t *testing.T) {
ctx := context.Background()
repositories := seededExpiredNameTagRepositories(t, ctx)
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
result, err := owner.CleanupExpiredNameTag(ctx, CleanupExpiredNameTagCommand{
AccountID: "account-1",
CharacterID: "19",
SlotIndex: 30,
Now: time.Unix(1_800_000_000, 0).UTC(),
})
if err != nil {
t.Fatal(err)
}
if result.Changed || result.EquipmentRemoved {
t.Fatalf("result=%+v", result)
}
character, _, _ := repositories.Character.Load(ctx, "19")
if character.Stats["name_tag_item_id"] != 9003 {
t.Fatalf("character=%+v", character)
}
equipment, _, _ := repositories.Equipment.Load(ctx, "19")
if _, exists := equipment.Entries[strconv.Itoa(30)]; !exists {
t.Fatalf("equipment=%+v", equipment)
}
}
func TestOwnerCleanupExpiredNameTagRejectsCrossAccountCharacter(t *testing.T) {
ctx := context.Background()
repositories := seededExpiredNameTagRepositories(t, ctx)
owner, err := NewOwner(repositories)
if err != nil {
t.Fatal(err)
}
_, err = owner.CleanupExpiredNameTag(ctx, CleanupExpiredNameTagCommand{
AccountID: "account-2",
CharacterID: "19",
SlotIndex: 30,
Now: time.Unix(2_000_000_000, 0).UTC(),
})
if !errors.Is(err, ErrNameTagAccountMismatch) {
t.Fatalf("err=%v", err)
}
character, _, _ := repositories.Character.Load(ctx, "19")
if character.Stats["name_tag_item_id"] != 9003 {
t.Fatalf("cross-account cleanup committed: %+v", character)
}
}
func seededExpiredNameTagRepositories(t *testing.T, ctx context.Context) dnfrepo.Group {
t.Helper()
repositories := dnfrepomemory.NewMemoryGroup()
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
AccountID: "account-1",
CharacterID: "19",
Stats: map[string]int64{
"name_tag_item_id": 9003,
"name_tag_expire_time": 1_900_000_000,
},
}); err != nil {
t.Fatal(err)
}
if err := repositories.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
CharacterID: "19",
Entries: map[string]dnfrepo.EquipmentEntry{
strconv.Itoa(30): {SlotIndex: 30, ItemID: 9003},
},
}); err != nil {
t.Fatal(err)
}
return repositories
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,231 @@
package equip
import (
"context"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerMoveEquipsArtifactToColorTarget(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"7:140": {ItemID: 2747155, Count: 1, RawEntry: make([]byte, 0x77), Extra: map[string]string{"item_kind": "equipment", "pvf_path": "equipment/creature/artifact_red/china_artifact_R1.equ"}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
validator := &recordingPlacementValidator{}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListPet,
SourceSlotIndex: 140,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 27,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "equip" || result.ItemID != 2747155 || !result.Changed {
t.Fatalf("result = %+v", result)
}
if len(validator.placements) != 1 || validator.placements[0].ItemID != 2747155 || validator.placements[0].TargetSlotIndex != 27 {
t.Fatalf("artifact must pass PVF placement validation: %+v", validator.placements)
}
if _, ok := loadTestInventory(t, ctx, repos, "77").Slots["7:140"]; ok {
t.Fatalf("artifact source slot should be empty")
}
entry := loadTestEquipment(t, ctx, repos, "77").Entries["27"]
if entry.ItemID != 2747155 || entry.SlotIndex != 27 {
t.Fatalf("worn artifact entry = %+v", entry)
}
petRecord, ok, err := repos.Pet.Load(ctx, "77")
if err != nil || !ok || petRecord.Artifacts["red"].ItemID != 2747155 {
t.Fatalf("artifact projection ok=%t err=%v record=%+v", ok, err, petRecord)
}
}
func TestOwnerMoveUnequipsArtifactToSharedRange(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{}})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"28": {SlotIndex: 28, ItemID: 2747156, RawEntry: make([]byte, 0x77)},
},
})
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
CharacterID: "77",
Artifacts: map[string]dnfrepo.ItemStack{
"blue": {ItemID: 2747156, Count: 1, RawEntry: make([]byte, 0x77)},
},
}); err != nil {
t.Fatalf("Save pet error = %v", err)
}
owner, err := NewOwnerWithPlacementValidator(repos, &recordingPlacementValidator{})
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListEquipment,
SourceSlotIndex: 28,
MoveCount: 1,
DestinationListType: wireListPet,
DestinationSlotIndex: 140,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "unequip" || !result.Changed {
t.Fatalf("result = %+v", result)
}
if _, ok := loadTestEquipment(t, ctx, repos, "77").Entries["28"]; ok {
t.Fatalf("worn artifact slot should be empty")
}
stack, ok := loadTestInventory(t, ctx, repos, "77").Slots["7:140"]
if !ok || stack.ItemID != 2747156 || stack.Count != 1 {
t.Fatalf("returned artifact stack = %+v found=%t", stack, ok)
}
petRecord, ok, err := repos.Pet.Load(ctx, "77")
if err != nil || !ok {
t.Fatalf("load pet ok=%t err=%v", ok, err)
}
if _, exists := petRecord.Artifacts["blue"]; exists {
t.Fatalf("blue artifact projection survived unequip: %+v", petRecord.Artifacts)
}
}
func TestOwnerMoveSwapsArtifactAndWritesDisplacedBack(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"7:140": {ItemID: 2747157, Count: 1, RawEntry: make([]byte, 0x77), Extra: map[string]string{"item_kind": "equipment"}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"27": {SlotIndex: 27, ItemID: 2747155, RawEntry: make([]byte, 0x77)},
},
})
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
CharacterID: "77",
Artifacts: map[string]dnfrepo.ItemStack{
"red": {ItemID: 2747155, Count: 1, RawEntry: make([]byte, 0x77)},
},
}); err != nil {
t.Fatalf("Save pet error = %v", err)
}
owner, err := NewOwnerWithPlacementValidator(repos, &recordingPlacementValidator{})
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListPet,
SourceSlotIndex: 140,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 27,
})
if err != nil {
t.Fatalf("Move error = %v", err)
}
if result.Mode != "equip_swap" || result.SwappedItemID != 2747155 || !result.Changed {
t.Fatalf("result = %+v", result)
}
entry := loadTestEquipment(t, ctx, repos, "77").Entries["27"]
if entry.ItemID != 2747157 {
t.Fatalf("worn artifact after swap = %+v", entry)
}
stack, ok := loadTestInventory(t, ctx, repos, "77").Slots["7:140"]
if !ok || stack.ItemID != 2747155 {
t.Fatalf("displaced artifact stack = %+v found=%t", stack, ok)
}
petRecord, ok, err := repos.Pet.Load(ctx, "77")
if err != nil || !ok || petRecord.Artifacts["red"].ItemID != 2747157 {
t.Fatalf("swapped artifact projection ok=%t err=%v record=%+v", ok, err, petRecord)
}
}
func TestOwnerMoveArtifactRejectsValidatorMismatchWithoutMutation(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"7:140": {ItemID: 2747155, Count: 1, RawEntry: make([]byte, 0x77)},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
validator := &recordingPlacementValidator{err: errArtifactColorMismatchForTest}
owner, err := NewOwnerWithPlacementValidator(repos, validator)
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
if _, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListPet,
SourceSlotIndex: 140,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 28,
}); err == nil {
t.Fatalf("red artifact to blue target must be rejected")
}
if stack := loadTestInventory(t, ctx, repos, "77").Slots["7:140"]; stack.Count != 1 {
t.Fatalf("rejected artifact move mutated inventory: %+v", stack)
}
if _, ok := loadTestEquipment(t, ctx, repos, "77").Entries["28"]; ok {
t.Fatalf("rejected artifact move mutated equipment")
}
}
func TestOwnerMoveArtifactRejectsCreatureSourceRange(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"7:139": {ItemID: 2747155, Count: 1, RawEntry: make([]byte, 0x77)},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{CharacterID: "77", Entries: map[string]dnfrepo.EquipmentEntry{}})
owner, err := NewOwnerWithPlacementValidator(repos, &recordingPlacementValidator{})
if err != nil {
t.Fatalf("NewOwnerWithPlacementValidator error = %v", err)
}
if _, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListPet,
SourceSlotIndex: 139,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: 27,
}); err == nil {
t.Fatalf("creature source range to artifact target must be rejected")
}
}
var errArtifactColorMismatchForTest = errorString("artifact color target mismatch")
type errorString string
func (e errorString) Error() string { return string(e) }
@@ -0,0 +1,104 @@
package equip
import (
"context"
"testing"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestOwnerMovePersistsInheritsAndClearsCloneAvatarAppearance(t *testing.T) {
const (
ordinaryAvatar int64 = 415500058
cloneAvatarA int64 = 415500017
cloneAvatarB int64 = 415500027
wearSlot int16 = 3
)
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"1:19": {ItemID: cloneAvatarA, Count: 0, RawEntry: []byte{0xA1}},
"1:20": {ItemID: cloneAvatarB, Count: 0, RawEntry: []byte{0xB2}},
},
})
saveTestEquipment(t, ctx, repos, dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"3": {SlotIndex: wearSlot, ItemID: ordinaryAvatar, RawEntry: []byte{0xC3}},
},
})
owner, err := NewOwnerWithPlacementValidator(repos, &recordingPlacementValidator{})
if err != nil {
t.Fatal(err)
}
owner.SetAvatarClassifier(func(itemID uint32) (bool, bool) {
switch int64(itemID) {
case ordinaryAvatar:
return true, false
case cloneAvatarA, cloneAvatarB:
return true, true
default:
return false, false
}
})
equip := func(slot int16) MoveResult {
t.Helper()
result, moveErr := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListAvatar,
SourceSlotIndex: slot,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: wearSlot,
})
if moveErr != nil {
t.Fatalf("equip avatar slot %d: %v", slot, moveErr)
}
return result
}
if result := equip(19); result.Mode != "equip_swap" || result.ItemID != cloneAvatarA || result.SwappedItemID != ordinaryAvatar {
t.Fatalf("first clone result=%+v", result)
}
worn := loadTestEquipment(t, ctx, repos, "77").Entries["3"]
if worn.ItemID != cloneAvatarA || worn.Extra[dnfrepo.CloneAvatarAppearanceExtraKey] != "415500058" {
t.Fatalf("first clone worn=%+v", worn)
}
if returned := loadTestInventory(t, ctx, repos, "77").Slots["1:19"]; returned.ItemID != ordinaryAvatar || returned.Extra[dnfrepo.CloneAvatarAppearanceExtraKey] != "" {
t.Fatalf("returned ordinary avatar=%+v", returned)
}
if result := equip(20); result.Mode != "equip_swap" || result.ItemID != cloneAvatarB || result.SwappedItemID != cloneAvatarA {
t.Fatalf("second clone result=%+v", result)
}
worn = loadTestEquipment(t, ctx, repos, "77").Entries["3"]
if worn.ItemID != cloneAvatarB || worn.Extra[dnfrepo.CloneAvatarAppearanceExtraKey] != "415500058" {
t.Fatalf("inherited clone worn=%+v", worn)
}
if returned := loadTestInventory(t, ctx, repos, "77").Slots["1:20"]; returned.ItemID != cloneAvatarA || returned.Extra[dnfrepo.CloneAvatarAppearanceExtraKey] != "" {
t.Fatalf("returned first clone=%+v", returned)
}
result, err := owner.Move(ctx, MoveCommand{
SelectedCharacterID: 77,
SourceListType: wireListAvatar,
SourceSlotIndex: 21,
SourceInstanceValue: 0,
MoveCount: 1,
DestinationListType: wireListEquipment,
DestinationSlotIndex: wearSlot,
DestinationInstanceValue: int32(cloneAvatarB),
})
if err != nil || result.Mode != "unequip" {
t.Fatalf("unequip clone result=%+v err=%v", result, err)
}
returned := loadTestInventory(t, ctx, repos, "77").Slots["1:21"]
if returned.ItemID != cloneAvatarB || returned.Extra[dnfrepo.CloneAvatarAppearanceExtraKey] != "" {
t.Fatalf("unequipped clone retained appearance=%+v", returned)
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,77 @@
// 本文件负责把通用 PVF Document 转成装备领域字段。
package equip
import dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
// parseItem 把单个装备 PVF 文档收敛成稳定字段。
// 装备穿戴、耐久、强化和交易限制不在 loader 中判断,后续交给 equip owner。
func parseItem(id int64, itemPath string, doc *dnfpvf.Document) Item {
item := Item{
ID: id,
Path: itemPath,
Name: firstText(doc, "name", "display name"),
Kind: firstText(doc, "equipment type", "type", "kind"),
Slot: firstText(doc, "attach type", "slot", "part"),
Level: firstInt(doc, "minimum level", "required level", "level"),
Icon: firstText(doc, "icon", "icon path"),
Stats: stats(doc),
}
item.Rarity = firstText(doc, "rarity", "grade")
if item.Name == "" {
item.Name = item.Path
}
return item
}
// stats 提取装备基础数值,启动后查询装备属性不再重新解析 PVF 文本。
func stats(doc *dnfpvf.Document) map[string]float64 {
names := map[string][]string{
"physical_attack": {"physical attack", "physical weapon attack"},
"magical_attack": {"magical attack", "magical weapon attack"},
"independent_attack": {"independent attack"},
"strength": {"strength"},
"intelligence": {"intelligence"},
"vitality": {"vitality"},
"spirit": {"spirit"},
"attack_speed": {"attack speed"},
"cast_speed": {"cast speed"},
"move_speed": {"move speed"},
}
out := make(map[string]float64)
for key, aliases := range names {
if value, ok := firstNumber(doc, aliases...); ok {
out[key] = value
}
}
if len(out) == 0 {
return nil
}
return out
}
func firstText(doc *dnfpvf.Document, names ...string) string {
for _, name := range names {
if value, ok := doc.Text(name); ok {
return value
}
}
return ""
}
func firstInt(doc *dnfpvf.Document, names ...string) int64 {
for _, name := range names {
if value, ok := doc.Int(name); ok {
return value
}
}
return 0
}
func firstNumber(doc *dnfpvf.Document, names ...string) (float64, bool) {
for _, name := range names {
if value, ok := doc.Number(name); ok {
return value, true
}
}
return 0, false
}
+22
View File
@@ -0,0 +1,22 @@
package equip
import (
"path"
"strings"
)
func pathKey(value string) string {
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
for strings.HasPrefix(value, "./") || strings.HasPrefix(value, "/") {
value = strings.TrimPrefix(value, "./")
value = strings.TrimPrefix(value, "/")
}
if value == "" {
return ""
}
clean := strings.TrimSuffix(path.Clean(value), "/")
if clean == "." {
return ""
}
return strings.ToLower(clean)
}
@@ -0,0 +1,94 @@
package equip
import (
"context"
"fmt"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// syncPetArtifactEquipmentMove projects the already-persisted list-7 / actor
// target 27..29 mutation into PetRecord.Artifacts before the surrounding
// CharacterPetUnitOfWork commits. The actor EquipmentRecord remains the worn
// endpoint authority; PetRecord is its durable pet-status projection.
func (o *Owner) syncPetArtifactEquipmentMove(ctx context.Context, characterID string, result MoveResult) error {
if o == nil || o.pets == nil {
return ErrPetRepositoryRequired
}
target, ok := petArtifactTargetFromMoveResult(result)
if !ok {
return fmt.Errorf("%w: artifact result endpoints=(%d,%d)->(%d,%d)",
ErrPetOwnershipMismatch,
result.SourceListType,
result.SourceSlotIndex,
result.DestinationListType,
result.DestinationSlotIndex,
)
}
kind, ok := petArtifactProjectionKey(target)
if !ok {
return fmt.Errorf("%w: artifact target=%d", ErrPetOwnershipMismatch, target)
}
_, equipment, err := o.loadMoveRecords(ctx, characterID)
if err != nil {
return err
}
record, found, err := o.pets.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
record = dnfrepo.PetRecord{CharacterID: characterID}
}
record = dnfrepo.ClonePet(record)
record.CharacterID = characterID
if record.Artifacts == nil {
record.Artifacts = make(map[string]dnfrepo.ItemStack)
}
equipped, equippedFound := equipment.Entries[entryKey(target)]
if equippedFound {
if equipped.SlotIndex != target || equipped.ItemID <= 0 || len(equipped.RawEntry) == 0 {
return fmt.Errorf("%w: artifact target=%d entry=%+v", ErrPetOwnershipMismatch, target, equipped)
}
if result.Mode != "equip" && result.Mode != "equip_swap" && result.Mode != "unequip_swap" {
return fmt.Errorf("%w: artifact target=%d mode=%s remains occupied", ErrPetOwnershipMismatch, target, result.Mode)
}
if equipped.ItemID != result.ItemID && equipped.ItemID != result.SwappedItemID {
return fmt.Errorf("%w: artifact target=%d item=%d result=(%d,%d)", ErrPetOwnershipMismatch, target, equipped.ItemID, result.ItemID, result.SwappedItemID)
}
record.Artifacts[kind] = stackFromEntry(equipped)
} else {
if result.Mode != "unequip" {
return fmt.Errorf("%w: artifact target=%d mode=%s is empty", ErrPetOwnershipMismatch, target, result.Mode)
}
delete(record.Artifacts, kind)
}
record.UpdatedAt = time.Now()
return dnfrepo.SavePetFields(ctx, o.pets, record, dnfrepo.PetFieldEntries)
}
func petArtifactTargetFromMoveResult(result MoveResult) (int16, bool) {
if result.SourceListType == wireListEquipment && isPetArtifactTarget(result.SourceSlotIndex) && result.DestinationListType == wireListPet {
return result.SourceSlotIndex, true
}
if result.SourceListType == wireListPet && result.DestinationListType == wireListEquipment && isPetArtifactTarget(result.DestinationSlotIndex) {
return result.DestinationSlotIndex, true
}
return 0, false
}
func petArtifactProjectionKey(target int16) (string, bool) {
switch target {
case 27:
return "red", true
case 28:
return "blue", true
case 29:
return "green", true
default:
return "", false
}
}
@@ -0,0 +1,31 @@
package equip
import (
"encoding/binary"
"testing"
)
func TestPetSerialFromEquippedRawUsesAuthoritativeOffset24(t *testing.T) {
raw := make([]byte, 28)
binary.LittleEndian.PutUint32(raw[5:9], 111)
binary.LittleEndian.PutUint32(raw[24:28], 222)
if got := petSerialFromEquippedRaw(raw); got != 222 {
t.Fatalf("serial=%d want=222", got)
}
ordinarySlot24 := make([]byte, 9)
binary.LittleEndian.PutUint32(ordinarySlot24[5:9], 333)
if got := petSerialFromEquippedRaw(ordinarySlot24); got != 0 {
t.Fatalf("ordinary slot24 +5 instance was accepted as creature serial: %d", got)
}
}
func TestBuildPetCreatureEquipEntryRepeatsSerialAtCurrentCreatureOffset(t *testing.T) {
raw := buildPetCreatureEquipEntry(24, 63000, 37)
if len(raw) < 28 || raw[0] != 24 ||
binary.LittleEndian.Uint32(raw[1:5]) != 63000 ||
binary.LittleEndian.Uint32(raw[5:9]) != 37 ||
binary.LittleEndian.Uint32(raw[24:28]) != 37 {
t.Fatalf("pet raw layout=%x", raw)
}
}
@@ -0,0 +1,282 @@
package equip
import (
"context"
"errors"
"fmt"
"strconv"
"strings"
"time"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
)
// syncPetEquipmentMove runs after the list-7/slot-26 mutation but before its
// CharacterPetUnitOfWork callback returns. It updates only location/equipped
// ownership fields in PetRecord; name, level, experience, satiety, evolution
// mode, and all opaque persisted metadata remain untouched.
func (o *Owner) syncPetEquipmentMove(ctx context.Context, characterID string, result MoveResult) error {
if o == nil || o.pets == nil {
return ErrPetRepositoryRequired
}
petSlot, ok := petInventorySlotFromMoveResult(result)
if !ok {
return fmt.Errorf("%w: result endpoints=(%d,%d)->(%d,%d)",
ErrPetOwnershipMismatch,
result.SourceListType,
result.SourceSlotIndex,
result.DestinationListType,
result.DestinationSlotIndex,
)
}
inventory, equipment, err := o.loadMoveRecords(ctx, characterID)
if err != nil {
return err
}
record, found, err := o.pets.Load(ctx, characterID)
if err != nil {
return err
}
if !found {
return fmt.Errorf("%w: character=%s", ErrPetRecordNotFound, characterID)
}
record = dnfrepo.ClonePet(record)
record.CharacterID = characterID
if len(record.Entries) == 0 {
return fmt.Errorf("%w: character=%s", ErrPetEntryNotFound, characterID)
}
petStack, petStackFound := inventory.Slots[inventoryKey(wireListPet, petSlot)]
equipped, equippedFound := equipment.Entries[entryKey(petCreatureWornSlot)]
if equippedFound && equipped.SlotIndex != petCreatureWornSlot {
return fmt.Errorf("%w: equipped key=%d entry=%d", ErrMoveSlotMismatch, petCreatureWornSlot, equipped.SlotIndex)
}
var inventoryKey string
if petStackFound {
serial := petStackSerial(petStack)
inventoryKey, err = findPetEntryKey(record, serial, petStack.ItemID)
if err != nil {
return err
}
}
var equippedKey string
if equippedFound {
serial := petEquipmentSerial(equipped)
equippedKey, err = findPetEntryKey(record, serial, equipped.ItemID)
if err != nil && errors.Is(err, ErrPetEntryNotFound) {
// A direct creature grant (for example a GM-mail creature item) can
// be equipped before the login-time pet reconciliation has created
// its PetRecord entry. Register it here from the equipped instance so
// the first equip succeeds instead of surfacing as "inventory full".
entry, registerErr := newDirectPetEntry(equipped, serial)
if registerErr != nil {
return err
}
if occupied, exists := record.Entries[entry.PetKey]; exists && occupied.ItemID != entry.ItemID {
return fmt.Errorf("%w: pet key=%s item=%d equipped_item=%d",
ErrPetOwnershipMismatch, entry.PetKey, occupied.ItemID, entry.ItemID)
}
record.Entries[entry.PetKey] = entry
equippedKey = entry.PetKey
} else if err != nil {
return err
}
}
if inventoryKey != "" && inventoryKey == equippedKey {
return fmt.Errorf("%w: pet=%s occupies list7 and slot%d", ErrPetOwnershipMismatch, inventoryKey, petCreatureWornSlot)
}
if err := validatePetMoveProjection(
result,
record.EquippedKey,
inventoryKey,
equippedKey,
petStackFound,
equippedFound,
petStack.ItemID,
equipped.ItemID,
); err != nil {
return err
}
if petStackFound {
entry := record.Entries[inventoryKey]
entry.SourceListType = wireListPet
entry.SourceSlotIndex = petSlot
record.Entries[inventoryKey] = entry
}
if equippedFound {
entry := record.Entries[equippedKey]
entry.SourceListType = wireListEquipment
entry.SourceSlotIndex = petCreatureWornSlot
record.Entries[equippedKey] = entry
record.EquippedKey = equippedKey
} else {
record.EquippedKey = ""
}
record.UpdatedAt = time.Now()
return dnfrepo.SavePetFields(ctx, o.pets, record, dnfrepo.PetFieldEntries, dnfrepo.PetFieldEquipped)
}
func petInventorySlotFromMoveResult(result MoveResult) (int16, bool) {
if result.SourceListType == wireListPet && result.DestinationListType != wireListPet {
return result.SourceSlotIndex, result.SourceSlotIndex >= 0
}
if result.DestinationListType == wireListPet && result.SourceListType != wireListPet {
return result.DestinationSlotIndex, result.DestinationSlotIndex >= 0
}
return 0, false
}
func validatePetMoveProjection(
result MoveResult,
previousEquippedKey string,
inventoryKey string,
equippedKey string,
inventoryFound bool,
equippedFound bool,
inventoryItemID int64,
equippedItemID int64,
) error {
previousEquippedKey = strings.TrimSpace(previousEquippedKey)
switch result.Mode {
case "equip":
if inventoryFound || !equippedFound || result.ItemID <= 0 || result.SwappedItemID != 0 || equippedItemID != result.ItemID {
return fmt.Errorf("%w: invalid equip projection", ErrPetOwnershipMismatch)
}
if previousEquippedKey != "" {
return fmt.Errorf("%w: persisted equipped=%s but slot%d was empty", ErrPetOwnershipMismatch, previousEquippedKey, petCreatureWornSlot)
}
case "equip_swap":
if !inventoryFound || !equippedFound || inventoryKey == "" || equippedKey == "" || result.SwappedItemID <= 0 ||
inventoryItemID != result.SwappedItemID || equippedItemID != result.ItemID {
return fmt.Errorf("%w: invalid equip swap projection", ErrPetOwnershipMismatch)
}
if previousEquippedKey != "" && previousEquippedKey != inventoryKey {
return fmt.Errorf("%w: persisted equipped=%s swapped=%s", ErrPetOwnershipMismatch, previousEquippedKey, inventoryKey)
}
case "unequip":
if !inventoryFound || equippedFound || inventoryKey == "" || result.ItemID <= 0 || result.SwappedItemID != 0 || inventoryItemID != result.ItemID {
return fmt.Errorf("%w: invalid unequip projection", ErrPetOwnershipMismatch)
}
if previousEquippedKey != "" && previousEquippedKey != inventoryKey {
return fmt.Errorf("%w: persisted equipped=%s removed=%s", ErrPetOwnershipMismatch, previousEquippedKey, inventoryKey)
}
case "unequip_swap":
if !inventoryFound || !equippedFound || inventoryKey == "" || equippedKey == "" || result.SwappedItemID <= 0 ||
inventoryItemID != result.ItemID || equippedItemID != result.SwappedItemID {
return fmt.Errorf("%w: invalid unequip swap projection", ErrPetOwnershipMismatch)
}
if previousEquippedKey != "" && previousEquippedKey != inventoryKey {
return fmt.Errorf("%w: persisted equipped=%s removed=%s", ErrPetOwnershipMismatch, previousEquippedKey, inventoryKey)
}
default:
return fmt.Errorf("%w: unsupported pet move mode=%q", ErrPetOwnershipMismatch, result.Mode)
}
return nil
}
func newDirectPetEntry(equipment dnfrepo.EquipmentEntry, serial int64) (dnfrepo.PetEntry, error) {
if serial <= 0 {
serial = firstExtraInt(equipment.Extra, 0, "item_serial", "item_uid")
}
if serial <= 0 || serial > int64(^uint32(0)) || equipment.ItemID <= 0 {
return dnfrepo.PetEntry{}, fmt.Errorf("%w: serial=%d item=%d", ErrPetOwnershipMismatch, serial, equipment.ItemID)
}
key := strconv.FormatInt(serial, 10)
return dnfrepo.PetEntry{
PetKey: key,
CreatureKey: uint32(serial),
ItemID: equipment.ItemID,
SourceListType: wireListEquipment,
SourceSlotIndex: petCreatureWornSlot,
Satiety: 100,
SatietyMicros: 100000000,
Level: 1,
Extra: cloneExtra(equipment.Extra),
}, nil
}
func petStackSerial(stack dnfrepo.ItemStack) int64 {
return firstExtraInt(stack.Extra, 0,
"creature_serial_or_handle",
"creature_serial",
"pet_serial",
"serial",
"handle",
"instance_value",
"item_uid",
"item_serial",
)
}
func petEquipmentSerial(entry dnfrepo.EquipmentEntry) int64 {
if serial := petSerialFromEquippedRaw(entry.RawEntry); serial > 0 {
return serial
}
return firstExtraInt(entry.Extra, 0,
"creature_serial_or_handle",
"creature_serial",
"pet_serial",
"serial",
"handle",
"instance_value",
"item_uid",
"item_serial",
)
}
func findPetEntryKey(record dnfrepo.PetRecord, serial int64, itemID int64) (string, error) {
if serial <= 0 || serial > int64(^uint32(0)) || itemID <= 0 {
return "", fmt.Errorf("%w: serial=%d item=%d", ErrPetOwnershipMismatch, serial, itemID)
}
directKey := strconv.FormatInt(serial, 10)
if entry, ok := record.Entries[directKey]; ok {
entrySerial, valid := petEntrySerial(entry)
if entry.ItemID != itemID || (valid && entrySerial != serial) {
return "", fmt.Errorf("%w: key=%s serial=%d item=%d", ErrPetOwnershipMismatch, directKey, serial, itemID)
}
}
matchedKey := ""
for key, entry := range record.Entries {
entrySerial, valid := petEntrySerial(entry)
if key == directKey && !valid {
entrySerial, valid = serial, true
}
if !valid || entrySerial != serial {
continue
}
if entry.ItemID != itemID {
return "", fmt.Errorf("%w: serial=%d inventory_item=%d pet_item=%d", ErrPetOwnershipMismatch, serial, itemID, entry.ItemID)
}
if matchedKey != "" {
return "", fmt.Errorf("%w: duplicate serial=%d keys=%s,%s", ErrPetOwnershipMismatch, serial, matchedKey, key)
}
matchedKey = key
}
if matchedKey == "" {
return "", fmt.Errorf("%w: serial=%d item=%d", ErrPetEntryNotFound, serial, itemID)
}
return matchedKey, nil
}
func petEntrySerial(entry dnfrepo.PetEntry) (int64, bool) {
if entry.CreatureKey != 0 {
return int64(entry.CreatureKey), true
}
if parsed, err := strconv.ParseUint(strings.TrimSpace(entry.PetKey), 0, 32); err == nil && parsed != 0 {
return int64(parsed), true
}
value := firstExtraInt(entry.Extra, 0,
"creature_serial_or_handle",
"creature_serial",
"pet_serial",
"serial",
"handle",
"instance_value",
"item_uid",
)
return value, value > 0 && value <= int64(^uint32(0))
}
@@ -0,0 +1,72 @@
// 本文件负责修理费用的 86JP 领域公式(EquipmentRepairPriceProvider)。
// PVF 证据由 dnfbridge resolver 提供,这里只做确定性计算。
package equip
import (
"longheng.io/server/internal/modules/dnf/alignedcmd"
)
// repairAllEligibleTypes is the 86JP repair-all type filter: only these PVF
// [equipment type] categories are touched by a repair-all request; charms,
// avatars (except aurora), titles, pet gear and medals are skipped.
var repairAllEligibleTypes = map[string]bool{
"[weapon]": true,
"[coat]": true,
"[pants]": true,
"[hat]": true,
"[shoulder]": true,
"[waist]": true,
"[shoes]": true,
"[amulet]": true,
"[wrist]": true,
"[ring]": true,
"[support]": true,
"[aurora avatar]": true,
"[magic stone]": true,
}
// RepairAllEligible reports whether one PVF equipment type participates in a
// repair-all scan (86JP ItemMetadataResolver.IsRepairAllEligible).
func RepairAllEligible(equipmentType string) bool {
return repairAllEligibleTypes[equipmentType]
}
// CalcRepairCost mirrors 86JP EquipmentRepairPriceProvider.CalcRepairCost,
// including its C# evaluation order: the (repairPrice*(grade+5))/10 factor is
// computed with integer arithmetic before the float rate chain, and every
// rate multiply is float32 like the C# float math. The final cost truncates
// to int64. Zero lost durability, non-positive repair price or max durability
// yields zero cost.
func CalcRepairCost(evidence alignedcmd.RepairCostEvidence, currentDurability int64, upgradeLevel int, quickRepair bool) int64 {
if evidence.MaxDurability <= 0 || evidence.RepairPrice <= 0 || currentDurability >= evidence.MaxDurability || currentDurability < 0 {
return 0
}
lost := evidence.MaxDurability - currentDurability
scaledPrice := evidence.RepairPrice * (evidence.Grade + 5) / 10
base := float32(scaledPrice) * float32(evidence.RepairCostRate) / float32(evidence.MaxDurability) * float32(lost)
cost := base * float32(upgradeRepairRate(evidence.UpgradeRates, upgradeLevel))
if quickRepair {
cost *= float32(evidence.QuickRepairRate)
}
if cost <= 0 {
return 0
}
return int64(cost)
}
func upgradeRepairRate(rates []float64, upgradeLevel int) float32 {
if len(rates) == 0 {
return 1
}
if upgradeLevel < 0 {
upgradeLevel = 0
}
if upgradeLevel > len(rates)-1 {
upgradeLevel = len(rates) - 1
}
rate := rates[upgradeLevel]
if rate <= 0 {
return 1
}
return float32(rate)
}
@@ -0,0 +1,95 @@
// 本文件定义 DNF 装备表的内存视图和查询接口。
package equip
import "errors"
const DefaultList = "equipment/equipment.lst"
var (
ErrIndexRequired = errors.New("dnf equip index is required")
ErrListEmpty = errors.New("dnf equip list is empty")
ErrDocMissing = errors.New("dnf equip document is missing")
)
type Options struct {
ListPath string
}
type Item struct {
ID int64 `json:"id"`
Path string `json:"path"`
Name string `json:"name"`
Kind string `json:"kind,omitempty"`
Slot string `json:"slot,omitempty"`
Rarity string `json:"rarity,omitempty"`
Level int64 `json:"level,omitempty"`
Icon string `json:"icon,omitempty"`
Stats map[string]float64 `json:"stats,omitempty"`
}
type Table struct {
items []Item
byID map[int64]int
byPath map[string]int
}
type Snapshot struct {
Items int `json:"items"`
}
// Items 返回装备表副本,避免调用方改坏内存索引。
func (t *Table) Items() []Item {
if t == nil || len(t.items) == 0 {
return nil
}
out := make([]Item, len(t.items))
for idx, item := range t.items {
out[idx] = cloneItem(item)
}
return out
}
// Find 按 `.lst` 中的装备 id 查询内存表。
func (t *Table) Find(id int64) (Item, bool) {
if t == nil {
return Item{}, false
}
idx, ok := t.byID[id]
if !ok {
return Item{}, false
}
return cloneItem(t.items[idx]), true
}
// FindPath 按 PVF 相对路径查询内存表。
func (t *Table) FindPath(itemPath string) (Item, bool) {
if t == nil {
return Item{}, false
}
idx, ok := t.byPath[pathKey(itemPath)]
if !ok {
return Item{}, false
}
return cloneItem(t.items[idx]), true
}
// Snapshot 返回装备表当前规模,用于启动日志和 debug 面板。
func (t *Table) Snapshot() Snapshot {
if t == nil {
return Snapshot{}
}
return Snapshot{Items: len(t.items)}
}
// cloneItem 复制 map,保证加载后的表只读。
func cloneItem(item Item) Item {
if len(item.Stats) == 0 {
return item
}
stats := make(map[string]float64, len(item.Stats))
for key, value := range item.Stats {
stats[key] = value
}
item.Stats = stats
return item
}