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

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

249 lines
9.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}
}