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,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"])
|
||||
}
|
||||
}
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user