服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
732 lines
26 KiB
Go
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)
|
|
}
|
|
}
|
|
}
|