Files
90CNServer/server/internal/services/dnfbridge/guild_medal_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

732 lines
26 KiB
Go

package dnfbridge
import (
"bytes"
"context"
"encoding/binary"
"errors"
"os"
"strings"
"testing"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
platformpvf "longheng.io/server/internal/platform/pvf"
)
func TestDecodeCurrentGuardianGemUseRequestMatchesCurrentEXEWriter(t *testing.T) {
// Captured from the current client on 2026-07-22 while selecting the gem
// at list38:57 for socket 3 of the worn medal.
body := []byte{0x9c, 0xad, 0xfb, 0x05, 0x39, 0x00, 0xe3, 0x5f, 0x01, 0x00, 0x03}
request, err := decodeCurrentGuardianGemUseRequest(body)
if err != nil {
t.Fatal(err)
}
if request.TargetMedalItemID != 100380060 || request.GuardianGemSourceSlot != 57 || request.GuardianGemItemID != 90083 || request.SocketIndex != 3 {
t.Fatalf("guardian-gem request = %+v", request)
}
}
func TestDecodeCurrentGuardianGemUseRequestRejectsUnprovenBodies(t *testing.T) {
valid := make([]byte, currentGuardianGemUseRequestWireSize)
binary.LittleEndian.PutUint32(valid[0:4], 100380017)
binary.LittleEndian.PutUint16(valid[4:6], 49)
binary.LittleEndian.PutUint32(valid[6:10], 90002)
for _, body := range [][]byte{
valid[:10],
append(append([]byte(nil), valid...), 0, 0, 0, 0),
func() []byte { value := append([]byte(nil), valid...); value[10] = 4; return value }(),
func() []byte {
value := append([]byte(nil), valid...)
binary.LittleEndian.PutUint16(value[4:6], 48)
return value
}(),
func() []byte {
value := append([]byte(nil), valid...)
binary.LittleEndian.PutUint32(value[0:4], 0)
return value
}(),
} {
if _, err := decodeCurrentGuardianGemUseRequest(body); err == nil {
t.Fatalf("body %x unexpectedly decoded", body)
}
}
}
func TestResolveCurrentGuardianGemUsesPVFTypeGradeAndEnchantFamily(t *testing.T) {
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"equipment/equipment.lst": "",
"stackable/stackable.lst": "90002 `flaggem/physical_defense_grade2.stk`\n90010 `material/not_a_gem.stk`\n",
"stackable/flaggem/physical_defense_grade2.stk": `
[stackable type]
` + "`[flag gem]`" + `
[grade]
2
[enchant]
[equipment physical defense]
360 360
[/enchant]
`,
"stackable/material/not_a_gem.stk": `
[stackable type]
` + "`[material]`" + `
[grade]
2
[enchant]
[equipment physical defense]
360 360
[/enchant]
`,
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
gem, err := resolveCurrentGuardianGem(catalog, 90002)
if err != nil {
t.Fatal(err)
}
if gem.ItemID != 90002 || gem.Grade != 2 || gem.EnchantFamily != "equipment physical defense" || gem.PVFPath != "stackable/flaggem/physical_defense_grade2.stk" {
t.Fatalf("guardian gem = %+v", gem)
}
if _, err := resolveCurrentGuardianGem(catalog, 90010); !errors.Is(err, errCurrentGuardianGemNotFlagGem) {
t.Fatalf("non-flag-gem error=%v", err)
}
}
func TestResolveCurrentGuardianGemRejectsAmbiguousEnchantAndInvalidGrade(t *testing.T) {
for name, text := range map[string]string{
"ambiguous": `
[stackable type]
` + "`[flag gem]`" + `
[grade]
1
[enchant]
[equipment physical defense]
270 270
[equipment magical defense]
270 270
[/enchant]
`,
"invalid_grade": `
[stackable type]
` + "`[flag gem]`" + `
[grade]
4
[enchant]
[equipment physical defense]
270 270
[/enchant]
`,
} {
t.Run(name, func(t *testing.T) {
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"equipment/equipment.lst": "",
"stackable/stackable.lst": "90000 `flaggem/test.stk`\n",
"stackable/flaggem/test.stk": text,
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
if _, err := resolveCurrentGuardianGem(catalog, 90000); err == nil {
t.Fatal("invalid guardian gem unexpectedly resolved")
}
})
}
}
func TestResolveCurrentGuardianGemMedal(t *testing.T) {
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "",
"equipment/equipment.lst": "100380017 `flag/100380017.equ`\n100000001 `weapon/100000001.equ`\n",
"equipment/flag/100380017.equ": `
[equipment type]
` + "`[flag]`" + `
`,
"equipment/weapon/100000001.equ": `
[equipment type]
` + "`[weapon]`" + `
`,
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatal(err)
}
got, err := resolveCurrentGuardianGemMedal(catalog, 100380017)
if err != nil {
t.Fatalf("resolve guild medal: %v", err)
}
if got.ItemID != 100380017 || got.PVFPath != "equipment/flag/100380017.equ" {
t.Fatalf("resolved guild medal = %#v", got)
}
if _, err := resolveCurrentGuardianGemMedal(catalog, 100000001); !errors.Is(err, errCurrentGuardianGemTargetNotMedal) {
t.Fatalf("wrong equipment type error=%v, want %v", err, errCurrentGuardianGemTargetNotMedal)
}
}
func TestCurrentGuardianGemUseSnapshotFromRealInventoryLocations(t *testing.T) {
request := currentGuardianGemUseRequest{TargetMedalItemID: 100380017, GuardianGemSourceSlot: 49, GuardianGemItemID: 90000}
var snapshot currentGuardianGemUseSnapshot
currentGuardianGemInspectInventoryMap(&snapshot, map[string]dnfrepo.ItemStack{
"38:49": {ItemID: 90000, Count: 3},
"38:32": {ItemID: 100380017, Count: 1},
"2:7": {ItemID: 90000, Count: 4},
}, request, false)
currentGuardianGemInspectInventoryMap(&snapshot, map[string]dnfrepo.ItemStack{
"2:9": {ItemID: 90000, Count: 5},
"2:32": {ItemID: 100380017, Count: 1},
}, request, true)
if snapshot.GemMainStackCount != 3 || snapshot.GemWarehouseStackCount != 0 {
t.Fatalf("gem stack snapshot = %#v", snapshot)
}
if snapshot.TargetMainMatches != 0 || snapshot.TargetWarehouseMatches != 0 || snapshot.TargetEquippedMatches != 0 {
t.Fatalf("target snapshot = %#v", snapshot)
}
}
func TestCurrentGuardianGemCommitWritesExactWornMedalRawSocketAndConsumesExactSourceSlot(t *testing.T) {
service, repositories := newCurrentGuardianGemTestService(t)
targetRaw := currentGuardianGemTestRaw(32, 100380017, 1)
targetRaw[109] = 0x5A // adjacent byte is not a guardian socket and must survive.
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"38:49": {ItemID: 90002, Count: 2},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"32": {SlotIndex: 32, ItemID: 100380017, RawEntry: targetRaw},
},
}); err != nil {
t.Fatalf("save equipment: %v", err)
}
result, err := service.commitCurrentGuardianGemUse(
&gameSession{selectedCharacterID: 77},
currentGuardianGemUseRequest{TargetMedalItemID: 100380017, GuardianGemSourceSlot: 49, GuardianGemItemID: 90002, SocketIndex: 2},
)
if err != nil {
t.Fatalf("commit guardian gem: %v", err)
}
if result.Target.Container != currentGuardianGemTargetEquipped || result.Target.ListType != 3 || result.Target.Slot != 32 || result.Source != (currentSocketChangedSlot{ListType: 38, Slot: 49}) {
t.Fatalf("guardian gem result=%+v", result)
}
inventory := mustLoadCurrentSocketInventory(t, repositories, "77")
if got := inventory.Slots["38:49"].Count; got != 1 {
t.Fatalf("gem count=%d, want=1", got)
}
equipment, found, err := repositories.Equipment.Load(context.Background(), "77")
if err != nil || !found {
t.Fatalf("load equipment found=%v err=%v", found, err)
}
raw := equipment.Entries["32"].RawEntry
if len(raw) != currentItemListEntryWireSize {
t.Fatalf("target raw length=%d", len(raw))
}
if got := binary.LittleEndian.Uint16(raw[105:107]); got != 3 {
t.Fatalf("socket 2 raw value=%d, want itemID-base=3 raw=%x", got, raw[101:110])
}
if got := binary.LittleEndian.Uint16(raw[101:103]); got != 0 {
t.Fatalf("socket 0 unexpectedly changed=%d raw=%x", got, raw[101:110])
}
if raw[109] != 0x5A {
t.Fatalf("adjacent raw byte=%02x, want 5a", raw[109])
}
if row, ok := currentItemListEntryFromEquipment(equipment.Entries["32"]); !ok || !bytes.Equal(row.data[101:110], raw[101:110]) {
t.Fatalf("refreshed target raw socket state=%x persisted=%x", row.data[101:110], raw[101:110])
}
}
func TestCurrentGuardianGemCommitCanTargetWornMedal(t *testing.T) {
service, repositories := newCurrentGuardianGemTestService(t)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{"38:49": {ItemID: 90002, Count: 1}},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"32": {SlotIndex: 32, ItemID: 100380017, RawEntry: currentGuardianGemTestRaw(32, 100380017, 1)},
},
}); err != nil {
t.Fatalf("save equipment: %v", err)
}
result, err := service.commitCurrentGuardianGemUse(
&gameSession{selectedCharacterID: 77},
currentGuardianGemUseRequest{TargetMedalItemID: 100380017, GuardianGemSourceSlot: 49, GuardianGemItemID: 90002, SocketIndex: 3},
)
if err != nil {
t.Fatalf("commit guardian gem on worn medal: %v", err)
}
if result.Target.Container != currentGuardianGemTargetEquipped || result.Target.Slot != 32 {
t.Fatalf("worn guardian gem result=%+v", result)
}
if inventory := mustLoadCurrentSocketInventory(t, repositories, "77"); len(inventory.Slots) != 0 {
t.Fatalf("one gem was not consumed from medal inventory: %+v", inventory.Slots)
}
equipment, found, err := repositories.Equipment.Load(context.Background(), "77")
if err != nil || !found {
t.Fatalf("load equipment found=%v err=%v", found, err)
}
if got := binary.LittleEndian.Uint16(equipment.Entries["32"].RawEntry[107:109]); got != 3 {
t.Fatalf("worn medal socket 3=%d, want=3", got)
}
}
func TestCurrentGuardianGemCommitReplacesOccupiedSocketAndConsumesOnlyNewGem(t *testing.T) {
service, repositories := newCurrentGuardianGemTestService(t)
targetRaw := currentGuardianGemTestRaw(32, 100380017, 1)
binary.LittleEndian.PutUint16(targetRaw[101:103], 1)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"38:49": {ItemID: 90002, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"32": {SlotIndex: 32, ItemID: 100380017, RawEntry: targetRaw},
},
}); err != nil {
t.Fatalf("save equipment: %v", err)
}
_, err := service.commitCurrentGuardianGemUse(
&gameSession{selectedCharacterID: 77},
currentGuardianGemUseRequest{TargetMedalItemID: 100380017, GuardianGemSourceSlot: 49, GuardianGemItemID: 90002, SocketIndex: 0},
)
if err != nil {
t.Fatalf("replace occupied guardian gem: %v", err)
}
inventory := mustLoadCurrentSocketInventory(t, repositories, "77")
if _, found := inventory.Slots["38:49"]; found {
t.Fatalf("replacement source gem was not consumed: %+v", inventory.Slots)
}
equipment, found, loadErr := repositories.Equipment.Load(context.Background(), "77")
if loadErr != nil || !found {
t.Fatalf("load equipment found=%v err=%v", found, loadErr)
}
if got := binary.LittleEndian.Uint16(equipment.Entries["32"].RawEntry[101:103]); got != 3 {
t.Fatalf("replacement socket value=%d, want new item id delta 3", got)
}
}
func TestCurrentGuardianGemHandlerSendsOnlyRealItemRefreshes(t *testing.T) {
service, repositories := newCurrentGuardianGemTestService(t)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"38:49": {ItemID: 90002, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"32": {SlotIndex: 32, ItemID: 100380017, RawEntry: currentGuardianGemTestRaw(32, 100380017, 1)},
},
}); err != nil {
t.Fatalf("save equipment: %v", err)
}
connection := &bufferConn{}
session := &gameSession{selectedCharacterID: 77, connID: "guardian-gem-handler-test", conn: connection}
body := make([]byte, currentGuardianGemUseRequestWireSize)
binary.LittleEndian.PutUint32(body[0:4], 100380017)
binary.LittleEndian.PutUint16(body[4:6], 49)
binary.LittleEndian.PutUint32(body[6:10], 90002)
body[10] = 0
if err := service.handleCurrentGuardianGemUse(session, body); err != nil {
t.Fatalf("handle guardian gem: %v", err)
}
target, rest := splitGameServerUpperPacket(t, connection.write.Bytes())
if target.Header.Classification != 0 || target.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(target.Body) != 3+currentItemListEntryWireSize || target.Body[0] != 3 ||
binary.LittleEndian.Uint16(target.Body[1:3]) != 1 || binary.LittleEndian.Uint16(target.Body[3:5]) != 32 ||
binary.LittleEndian.Uint32(target.Body[5:9]) != 100380017 || binary.LittleEndian.Uint16(target.Body[3+101:3+103]) != 3 {
t.Fatalf("guardian target refresh=%+v body=%x", target.Header, target.Body)
}
consumed, trailing := splitCurrentGameServerUpperPacketAuto(t, rest)
if consumed.Header.Classification != 0 || consumed.Header.MsgID != uint16(dnfenum.CmdPacketLeaveParty) ||
!bytes.Equal(consumed.Body, []byte{currentGuardianGemInventoryListType, 0, 0}) {
t.Fatalf("guardian list38 refresh=%+v body=%x", consumed.Header, consumed.Body)
}
if len(trailing) != 0 {
t.Fatalf("unexpected guardian-gem ACK/state packet=%x", trailing)
}
}
func TestSelectedGuardianGemWornMedalRefreshReplaysPersistedRawSocketWords(t *testing.T) {
service, repositories := newCurrentGuardianGemTestService(t)
medalRaw := currentGuardianGemTestRaw(32, 100380017, 1)
binary.LittleEndian.PutUint16(medalRaw[101:103], 3)
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"32": {SlotIndex: 32, ItemID: 100380017, RawEntry: medalRaw},
},
}); err != nil {
t.Fatalf("save equipment: %v", err)
}
connection := &bufferConn{}
session := &gameSession{selectedCharacterID: 77, connID: "guardian-gem-select-rehydrate", conn: connection}
if err := service.sendSelectedGuardianGemWornMedalRefresh(session, "test"); err != nil {
t.Fatalf("rehydrate guardian-gem worn medal: %v", err)
}
packet, trailing := splitGameServerUpperPacket(t, connection.write.Bytes())
if len(trailing) != 0 || packet.Header.Classification != 0 || packet.Header.MsgID != uint16(dnfenum.CmdPacketWalkoutPartyMember) ||
len(packet.Body) != 3+currentItemListEntryWireSize || packet.Body[0] != currentSocketListEquipment ||
binary.LittleEndian.Uint16(packet.Body[3:5]) != 32 || binary.LittleEndian.Uint32(packet.Body[5:9]) != 100380017 ||
binary.LittleEndian.Uint16(packet.Body[3+101:3+103]) != 3 {
t.Fatalf("guardian-gem select rehydrate=%+v body=%x", packet.Header, packet.Body)
}
}
func TestCurrentGuardianGemRawSocketOccupied(t *testing.T) {
raw := currentGuardianGemTestRaw(32, 100380017, 1)
if currentGuardianGemRawSocketOccupied(raw) {
t.Fatal("empty raw socket state reported occupied")
}
binary.LittleEndian.PutUint16(raw[107:109], 7)
if !currentGuardianGemRawSocketOccupied(raw) {
t.Fatal("nonzero raw socket state reported empty")
}
}
func TestCurrentGuardianGemCommitRejectsGemStoredOnMedalPage(t *testing.T) {
service, repositories := newCurrentGuardianGemTestService(t)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"38:7": {ItemID: 90002, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"32": {SlotIndex: 32, ItemID: 100380017, RawEntry: currentGuardianGemTestRaw(32, 100380017, 1)},
},
}); err != nil {
t.Fatalf("save equipment: %v", err)
}
_, err := service.commitCurrentGuardianGemUse(
&gameSession{selectedCharacterID: 77},
currentGuardianGemUseRequest{TargetMedalItemID: 100380017, GuardianGemSourceSlot: 49, GuardianGemItemID: 90002, SocketIndex: 0},
)
if !errors.Is(err, errCurrentGuardianGemSourceMissing) {
t.Fatalf("medal-page guardian gem error=%v, want %v", err, errCurrentGuardianGemSourceMissing)
}
inventory := mustLoadCurrentSocketInventory(t, repositories, "77")
if got := inventory.Slots["38:7"].Count; got != 1 {
t.Fatalf("rejected medal-page gem count=%d, want=1", got)
}
}
func TestCurrentGuardianGemCommitConsumesOnlyRequestedSourceSlot(t *testing.T) {
service, repositories := newCurrentGuardianGemTestService(t)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"38:49": {ItemID: 90002, Count: 4},
"38:50": {ItemID: 90002, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"32": {SlotIndex: 32, ItemID: 100380017, RawEntry: currentGuardianGemTestRaw(32, 100380017, 1)},
},
}); err != nil {
t.Fatalf("save equipment: %v", err)
}
result, err := service.commitCurrentGuardianGemUse(
&gameSession{selectedCharacterID: 77},
currentGuardianGemUseRequest{TargetMedalItemID: 100380017, GuardianGemSourceSlot: 50, GuardianGemItemID: 90002, SocketIndex: 0},
)
if err != nil {
t.Fatalf("commit exact source slot: %v", err)
}
if result.Source.Slot != 50 {
t.Fatalf("consumed source slot=%d, want=50", result.Source.Slot)
}
inventory := mustLoadCurrentSocketInventory(t, repositories, "77")
if got := inventory.Slots["38:49"].Count; got != 4 {
t.Fatalf("unselected same-id stack count=%d, want=4", got)
}
if _, found := inventory.Slots["38:50"]; found {
t.Fatalf("selected one-count source stack was not removed: %+v", inventory.Slots["38:50"])
}
}
func TestCurrentGuardianGemCommitRejectsUnwornMedalWithoutConsumingGem(t *testing.T) {
service, repositories := newCurrentGuardianGemTestService(t)
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"38:32": {ItemID: 100380017, Count: 1, RawEntry: currentGuardianGemTestRaw(32, 100380017, 1)},
"38:49": {ItemID: 90002, Count: 1},
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
_, err := service.commitCurrentGuardianGemUse(
&gameSession{selectedCharacterID: 77},
currentGuardianGemUseRequest{TargetMedalItemID: 100380017, GuardianGemSourceSlot: 49, GuardianGemItemID: 90002, SocketIndex: 0},
)
if !errors.Is(err, errCurrentGuardianGemTargetMissing) {
t.Fatalf("unworn medal error=%v, want %v", err, errCurrentGuardianGemTargetMissing)
}
if got := mustLoadCurrentSocketInventory(t, repositories, "77").Slots["38:49"].Count; got != 1 {
t.Fatalf("unworn target consumed source gem count=%d", got)
}
}
// newCurrentGuardianGemDuplicateTestService builds a catalog holding two gems
// of one attribute family and a third of another, so the "one medal, one gem
// per attribute family" rule can be exercised without the runtime Script.pvf.
// The real catalog ships 52 gems across 13 families, so same-family gems
// differing only by grade is the normal shape this rule has to reject.
func newCurrentGuardianGemDuplicateTestService(t *testing.T) (*Service, dnfrepo.Group) {
t.Helper()
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "90002 `flaggem/atk_high.stk`\n90003 `flaggem/atk_low.stk`\n90004 `flaggem/def.stk`\n",
"equipment/equipment.lst": "100380017 `flag/test_medal.equ`\n",
"stackable/flaggem/atk_high.stk": `
[stackable type]
` + "`[flag gem]`" + `
[grade]
2
[enchant]
[equipment physical attack]
360 360
[/enchant]
`,
"stackable/flaggem/atk_low.stk": `
[stackable type]
` + "`[flag gem]`" + `
[grade]
0
[enchant]
[equipment physical attack]
120 120
[/enchant]
`,
"stackable/flaggem/def.stk": `
[stackable type]
` + "`[flag gem]`" + `
[grade]
1
[enchant]
[equipment physical defense]
240 240
[/enchant]
`,
"equipment/flag/test_medal.equ": `
[equipment type]
` + "`[flag]`" + `
`,
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatalf("new guardian gem duplicate catalog: %v", err)
}
repositories := testRepositoryGroup()
return &Service{
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
pvfItemCatalog: catalog,
}, repositories
}
func TestCurrentGuardianGemRejectsDuplicateAttributeFamily(t *testing.T) {
service, repositories := newCurrentGuardianGemDuplicateTestService(t)
targetRaw := currentGuardianGemTestRaw(32, 100380017, 1)
if err := currentGuardianGemWriteRawSocket(targetRaw, 0, 90002); err != nil {
t.Fatalf("seed socket 0: %v", err)
}
if err := repositories.Inventory.Save(context.Background(), dnfrepo.InventoryRecord{
CharacterID: "77",
Slots: map[string]dnfrepo.ItemStack{
"38:49": {ItemID: 90003, Count: 3}, // same family, lower grade
"38:50": {ItemID: 90004, Count: 2}, // different family
},
}); err != nil {
t.Fatalf("save inventory: %v", err)
}
if err := repositories.Equipment.Save(context.Background(), dnfrepo.EquipmentRecord{
CharacterID: "77",
Entries: map[string]dnfrepo.EquipmentEntry{
"32": {SlotIndex: 32, ItemID: 100380017, RawEntry: targetRaw},
},
}); err != nil {
t.Fatalf("save equipment: %v", err)
}
session := &gameSession{selectedCharacterID: 77}
// Socket 0 holds 90002 (physical attack). A second physical-attack gem in
// socket 1 must be refused, must not consume the source stack and must not
// touch the raw row.
_, err := service.commitCurrentGuardianGemUse(session, currentGuardianGemUseRequest{
TargetMedalItemID: 100380017,
GuardianGemSourceSlot: 49,
GuardianGemItemID: 90003,
SocketIndex: 1,
})
if !errors.Is(err, errCurrentGuardianGemAttributeDuplicate) {
t.Fatalf("duplicate attribute error=%v, want %v", err, errCurrentGuardianGemAttributeDuplicate)
}
inventory := mustLoadCurrentSocketInventory(t, repositories, "77")
if got := inventory.Slots["38:49"].Count; got != 3 {
t.Fatalf("refused write consumed the source stack: count=%d, want=3", got)
}
equipment, found, err := repositories.Equipment.Load(context.Background(), "77")
if err != nil || !found {
t.Fatalf("load equipment found=%v err=%v", found, err)
}
if got := binary.LittleEndian.Uint16(equipment.Entries["32"].RawEntry[103:105]); got != 0 {
t.Fatalf("refused write changed socket 1: raw=%x", equipment.Entries["32"].RawEntry[101:109])
}
// Rewriting the *same* socket with another gem of the same family stays
// legal: the medal still carries exactly one gem of that family.
if _, err := service.commitCurrentGuardianGemUse(session, currentGuardianGemUseRequest{
TargetMedalItemID: 100380017,
GuardianGemSourceSlot: 49,
GuardianGemItemID: 90003,
SocketIndex: 0,
}); err != nil {
t.Fatalf("same-socket replacement: %v", err)
}
// A gem of another family is accepted and consumes exactly one gem.
if _, err := service.commitCurrentGuardianGemUse(session, currentGuardianGemUseRequest{
TargetMedalItemID: 100380017,
GuardianGemSourceSlot: 50,
GuardianGemItemID: 90004,
SocketIndex: 1,
}); err != nil {
t.Fatalf("different-family write: %v", err)
}
inventory = mustLoadCurrentSocketInventory(t, repositories, "77")
if got := inventory.Slots["38:50"].Count; got != 1 {
t.Fatalf("different-family write count=%d, want=1", got)
}
equipment, _, err = repositories.Equipment.Load(context.Background(), "77")
if err != nil {
t.Fatalf("load equipment: %v", err)
}
raw := equipment.Entries["32"].RawEntry
if got := binary.LittleEndian.Uint16(raw[101:103]); got != 4 { // 90003-89999
t.Fatalf("socket 0 raw value=%d, want 4 raw=%x", got, raw[101:109])
}
if got := binary.LittleEndian.Uint16(raw[103:105]); got != 5 { // 90004-89999
t.Fatalf("socket 1 raw value=%d, want 5 raw=%x", got, raw[101:109])
}
}
func newCurrentGuardianGemTestService(t *testing.T) (*Service, dnfrepo.Group) {
t.Helper()
source := dungeonDropCatalogTestSource{
"monster/monster.lst": "",
"stackable/stackable.lst": "90002 `flaggem/test.stk`\n",
"equipment/equipment.lst": "100380017 `flag/test_medal.equ`\n",
"stackable/flaggem/test.stk": `
[stackable type]
` + "`[flag gem]`" + `
[grade]
2
[enchant]
[equipment physical defense]
360 360
[/enchant]
`,
"equipment/flag/test_medal.equ": `
[equipment type]
` + "`[flag]`" + `
`,
}
catalog, err := newPVFDungeonDropCatalog(source)
if err != nil {
t.Fatalf("new guardian gem test catalog: %v", err)
}
repositories := testRepositoryGroup()
return &Service{
repositoryProvider: func() (dnfrepo.Group, bool) { return repositories, true },
pvfItemCatalog: catalog,
}, repositories
}
func currentGuardianGemTestRaw(slot int16, itemID uint32, count uint32) []byte {
var entry currentItemListEntry
entry.patchCore(slot, itemID, count)
return append([]byte(nil), entry.data[:]...)
}
func TestRealPVFGuardianGemCatalog(t *testing.T) {
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
if pvfPath == "" {
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to the runtime Script.pvf")
}
archive, err := platformpvf.Open(pvfPath)
if err != nil {
t.Fatal(err)
}
catalog, err := newPVFDungeonDropCatalog(archive)
if err != nil {
t.Fatal(err)
}
countByGrade := make(map[byte]int)
countByFamily := make(map[string]int)
count := 0
for itemID, reference := range catalog.itemRefs {
if reference.kind != dungeonDropItemStackable || !strings.HasPrefix(strings.ToLower(reference.path), "flaggem/") {
continue
}
gem, err := resolveCurrentGuardianGem(catalog, itemID)
if err != nil {
t.Fatalf("runtime guardian gem item=%d path=%q: %v", itemID, reference.path, err)
}
count++
countByGrade[gem.Grade]++
countByFamily[gem.EnchantFamily]++
}
if count != 52 || len(countByGrade) != 4 || len(countByFamily) != 13 {
t.Fatalf("runtime guardian gem catalog count=%d grades=%v families=%v", count, countByGrade, countByFamily)
}
for grade := byte(0); grade < 4; grade++ {
if countByGrade[grade] != 13 {
t.Fatalf("runtime guardian gem grade=%d count=%d, want 13", grade, countByGrade[grade])
}
}
for family, familyCount := range countByFamily {
if familyCount != 4 {
t.Fatalf("runtime guardian gem family=%q count=%d, want 4", family, familyCount)
}
}
}