Files
90CNServer/server/internal/modules/dnf/inventory/chronicle_owner_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

577 lines
27 KiB
Go

package inventory
import (
"context"
"encoding/binary"
"encoding/hex"
"testing"
"longheng.io/server/internal/modules/dnf/alignedcmd"
"longheng.io/server/internal/modules/dnf/dnfenum"
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
)
func TestDecodeChronicleGrowthRequestCurrentEXEBody(t *testing.T) {
body, err := hex.DecodeString("5400ee059a001900972cea0601a000ef0c0000")
if err != nil {
t.Fatal(err)
}
req, err := DecodeChronicleGrowthRequest(body)
if err != nil {
t.Fatal(err)
}
if req.TicketSlotIndex != 84 || req.TicketItemCode != 10094062 || req.TargetSlotIndex != 25 ||
req.TargetItemCode != 116010135 || req.TargetTaggedValue != 1 || req.MaterialSlotIndex != 160 || req.MaterialItemCode != 3311 {
t.Fatalf("decoded request = %+v", req)
}
if _, err := DecodeChronicleGrowthRequest(append(body, 0)); err == nil {
t.Fatal("20-byte body accepted")
}
}
func TestDecodeChronicleEquipmentEmancipationAutoMaterialSentinel(t *testing.T) {
// Live Lv85 relic-conversion request:
// ticket=(108,10149529), target=(13,101030330), tagged=0,
// followed by the current client's multi-material sentinel (-1,0xffff).
body, err := hex.DecodeString("6c0099de9a000d00ba99050600ffffffff0000")
if err != nil {
t.Fatal(err)
}
req, err := DecodeChronicleGrowthRequest(body)
if err != nil {
t.Fatal(err)
}
if req.TicketSlotIndex != 108 || req.TicketItemCode != 10149529 ||
req.TargetSlotIndex != 13 || req.TargetItemCode != 101030330 ||
req.TargetTaggedValue != 0 || req.MaterialSlotIndex != -1 || req.MaterialItemCode != 0xffff {
t.Fatalf("decoded request = %+v", req)
}
invalid := append([]byte(nil), body...)
invalid[15] = 0xfe
if _, err := DecodeChronicleGrowthRequest(invalid); err == nil {
t.Fatal("mismatched auto-material sentinel accepted")
}
}
func TestHandlerConvertsEquipmentEmancipationAfterMoveAndSort(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "84", Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 9): {ItemID: 700, Count: 1, Extra: map[string]string{"item_kind": "equipment"}},
slotKey(listTypeMain, 13): {ItemID: 101030330, Count: 1, RawEntry: make([]byte, chronicleEquipmentRawSize), Extra: map[string]string{"item_kind": "equipment"}},
slotKey(listTypeMain, 65): {ItemID: 900, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
slotKey(listTypeMain, 108): {ItemID: 10149529, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
slotKey(listTypeMain, 121): {ItemID: 901, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
slotKey(listTypeMain, 140): {ItemID: 10149518, Count: 10, Extra: map[string]string{"item_kind": "stackable"}},
}})
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{AccountID: "dnf:1", Slots: map[string]dnfrepo.ItemStack{
dnfrepo.AccountSharedInventorySlotKey(360): {ItemID: 10100115, Count: 36},
dnfrepo.AccountSharedInventorySlotKey(361): {ItemID: 10100116, Count: 24},
dnfrepo.AccountSharedInventorySlotKey(362): {ItemID: 10099773, Count: 12},
}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
for _, move := range []MoveItemspaceRequest{
{SourceListType: listTypeMain, SourceSlotIndex: 13, MoveCount: 1, DestinationListType: listTypeMain, DestinationSlotIndex: 20},
{SourceListType: listTypeMain, SourceSlotIndex: 108, MoveCount: 1, DestinationListType: listTypeMain, DestinationSlotIndex: 100},
{SourceListType: listTypeMain, SourceSlotIndex: 140, MoveCount: 10, DestinationListType: listTypeMain, DestinationSlotIndex: 150},
} {
if result, moveErr := owner.Move(ctx, NewMoveCommand(alignedcmd.Request{AccountID: "dnf:1", SelectedCharacterID: 84}, move)); moveErr != nil || !result.Changed {
t.Fatalf("move=%+v err=%v", result, moveErr)
}
}
for _, category := range []byte{1, 2, 3} {
if _, sortErr := owner.Sort(ctx, NewSortCommand(alignedcmd.Request{AccountID: "dnf:1", SelectedCharacterID: 84}, SortItemRequest{ListType: listTypeMain, Category: category})); sortErr != nil {
t.Fatal(sortErr)
}
}
bag := loadTestInventory(t, ctx, repos, "84")
findSlot := func(itemID int64) int16 {
for key, stack := range bag.Slots {
listType, slot, ok := parseSlotKey(key)
if ok && listType == listTypeMain && stack.ItemID == itemID {
return slot
}
}
return -1
}
ticketSlot := findSlot(10149529)
targetSlot := findSlot(101030330)
if ticketSlot == 108 || targetSlot == 13 || ticketSlot < 0 || targetSlot < 0 {
t.Fatalf("sort did not produce dynamic slots ticket=%d target=%d", ticketSlot, targetSlot)
}
body := make([]byte, 19)
binary.LittleEndian.PutUint16(body[0:2], uint16(ticketSlot))
binary.LittleEndian.PutUint32(body[2:6], 10149529)
binary.LittleEndian.PutUint16(body[6:8], uint16(targetSlot))
binary.LittleEndian.PutUint32(body[8:12], 101030330)
body[12] = 0
binary.LittleEndian.PutUint16(body[13:15], 0xffff)
binary.LittleEndian.PutUint32(body[15:19], 0xffff)
result, err := NewHandler().Handle(ctx, alignedcmd.Request{
Opcode: uint16(dnfenum.CmdPacketUpgradeChronicle), Body: body,
AccountID: "dnf:1", SelectedCharacterID: 84, Repositories: repos,
ChronicleGrowthResolver: func(ticket, target int64) (alignedcmd.ChronicleGrowthResolution, error) {
resolution := testEquipmentEmancipationResolution(ticket)
resolution.Conversion.Inputs[0].ItemID = 10149518
return resolution, nil
},
})
if err != nil || !result.Handled || !result.ResponseAllowed || len(result.UpperResponses) != 1 ||
len(result.UpperResponses[0].Body) < 3 || result.UpperResponses[0].Body[0] != 1 || result.UpperResponses[0].Body[1] != 1 {
t.Fatalf("handler result=%+v err=%v", result, err)
}
after := loadTestInventory(t, ctx, repos, "84")
if got := after.Slots[slotKey(listTypeMain, targetSlot)]; got.ItemID != 104000504 || got.Count != 1 {
t.Fatalf("converted dynamic target=%+v", got)
}
}
func TestChronicleGrowthSuccessAckAndPreResponseProjection(t *testing.T) {
result := ChronicleGrowthResult{Succeeded: true, TargetSlot: 25, Consumptions: []ChronicleGrowthConsumption{
{ListType: listTypeMain, Slot: 84, Count: 1},
{ListType: listTypeMain, Slot: 160, Count: 7},
}}
if got, want := hex.EncodeToString(buildChronicleGrowthSuccessAck(result)), "0101020054000100000000a00007000000"; got != want {
t.Fatalf("op271 success ACK=%s want=%s", got, want)
}
plan := ownerAppliedChronicleGrowthResult(271, Command{Operation: "upgrade_chronicle"}, result)
if len(plan.PreResponseItemSlotRefreshes) != 1 || plan.PreResponseItemSlotRefreshes[0].SlotIndex != 25 || len(plan.ItemSlotRefreshes) != 0 {
t.Fatalf("refresh plan = %+v", plan)
}
}
func TestOwnerConvertsEquipmentEmancipationRecipeAtomically(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
sourceRaw := make([]byte, chronicleEquipmentRawSize)
sourceRaw[0x0A] = 12
sourceRaw[0x13] = 2
sourceRaw[0x14] = 12
sourceRaw[currentForgingRawOffset] = 6
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "81", Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 1): {ItemID: 10149520, Count: 2, Extra: map[string]string{"item_kind": "stackable"}},
slotKey(listTypeMain, 2): {ItemID: 104000224, Count: 1, RawEntry: sourceRaw, Extra: map[string]string{
"item_kind": "equipment", "upgrade_level": "12", "amplify_type": "2", "amplify_value": "12", "forging_level": "6",
}},
slotKey(listTypeMain, 3): {ItemID: 10149509, Count: 6},
slotKey(listTypeMain, 4): {ItemID: 10149509, Count: 8},
}})
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{AccountID: "dnf:1", Slots: map[string]dnfrepo.ItemStack{
dnfrepo.AccountSharedInventorySlotKey(360): {ItemID: 10100115, Count: 40},
dnfrepo.AccountSharedInventorySlotKey(361): {ItemID: 10100116, Count: 24},
dnfrepo.AccountSharedInventorySlotKey(362): {ItemID: 10099773, Count: 12},
}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := NewChronicleGrowthCommand(alignedcmd.Request{AccountID: "dnf:1", SelectedCharacterID: 81}, ChronicleGrowthRequest{
TicketSlotIndex: 1, TicketItemCode: 10149520, TargetSlotIndex: 2, TargetItemCode: 104000224,
MaterialSlotIndex: 4, MaterialItemCode: 10149509,
})
result, err := owner.UpgradeChronicle(ctx, cmd, func(ticket, target int64) (alignedcmd.ChronicleGrowthResolution, error) {
return testEquipmentEmancipationResolution(ticket), nil
})
if err != nil || !result.Succeeded || !result.Conversion || result.OutputItemID != 104000504 {
t.Fatalf("result=%+v err=%v", result, err)
}
record := loadTestInventory(t, ctx, repos, "81")
if got := record.Slots[slotKey(listTypeMain, 1)].Count; got != 1 {
t.Fatalf("ticket count=%d want=1", got)
}
if got := record.Slots[slotKey(listTypeMain, 3)].Count; got != 4 {
t.Fatalf("remaining split relic-fragment stack count=%d want=4", got)
}
if _, found := record.Slots[slotKey(listTypeMain, 4)]; found {
t.Fatal("client-selected relic-fragment stack was not consumed first")
}
account, found, loadErr := repos.AccountInventory.Load(ctx, "dnf:1")
if loadErr != nil || !found {
t.Fatalf("load account inventory found=%v err=%v", found, loadErr)
}
if _, found := account.Slots[dnfrepo.AccountSharedInventorySlotKey(362)]; found {
t.Fatal("rare soul stack was not consumed")
}
if _, found := account.Slots[dnfrepo.AccountSharedInventorySlotKey(361)]; found {
t.Fatal("advanced soul stack was not consumed")
}
if got := account.Slots[dnfrepo.AccountSharedInventorySlotKey(360)].Count; got != 4 {
t.Fatalf("common soul count=%d want=4", got)
}
target := record.Slots[slotKey(listTypeMain, 2)]
if target.ItemID != 104000504 || target.Count != 1 || !target.Bind || len(target.RawEntry) != chronicleEquipmentRawSize {
t.Fatalf("converted target=%+v", target)
}
_, amplify := upgradeAmplifyState(target)
if upgradeLevelOf(target) != 0 || amplify != 0 || chronicleForgingLevel(target) != 0 {
t.Fatalf("ordinary conversion inherited source upgrade state: %+v", target.Extra)
}
}
func TestOwnerConvertsEquipmentEmancipationWithAutoMaterialSentinel(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "83", Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 108): {ItemID: 10149529, Count: 1, Extra: map[string]string{"item_kind": "stackable"}},
slotKey(listTypeMain, 13): {ItemID: 101030330, Count: 1, RawEntry: make([]byte, chronicleEquipmentRawSize), Extra: map[string]string{"item_kind": "equipment"}},
slotKey(listTypeMain, 40): {ItemID: 10149518, Count: 100},
}})
if err := repos.AccountInventory.Save(ctx, dnfrepo.AccountInventoryRecord{AccountID: "dnf:1", Slots: map[string]dnfrepo.ItemStack{
dnfrepo.AccountSharedInventorySlotKey(360): {ItemID: 10100115, Count: 216},
dnfrepo.AccountSharedInventorySlotKey(361): {ItemID: 10100116, Count: 195},
dnfrepo.AccountSharedInventorySlotKey(362): {ItemID: 10099773, Count: 115},
}}); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := NewChronicleGrowthCommand(alignedcmd.Request{AccountID: "dnf:1", SelectedCharacterID: 83}, ChronicleGrowthRequest{
TicketSlotIndex: 108, TicketItemCode: 10149529, TargetSlotIndex: 13, TargetItemCode: 101030330,
TargetTaggedValue: 3, MaterialSlotIndex: -1, MaterialItemCode: 0xffff,
})
resolution := testEquipmentEmancipationResolution(10149529)
resolution.Conversion.Inputs = []alignedcmd.ChronicleEquipmentConversionMaterial{
{ItemID: 10149518, Count: 10},
{ItemID: 10099773, Count: 12},
{ItemID: 10100116, Count: 24},
{ItemID: 10100115, Count: 36},
}
result, err := owner.UpgradeChronicle(ctx, cmd, func(ticket, target int64) (alignedcmd.ChronicleGrowthResolution, error) {
return resolution, nil
})
if err != nil || !result.Succeeded || !result.Conversion || len(result.Consumptions) != 5 {
t.Fatalf("result=%+v err=%v", result, err)
}
record := loadTestInventory(t, ctx, repos, "83")
if _, found := record.Slots[slotKey(listTypeMain, 108)]; found {
t.Fatal("conversion book was not consumed")
}
if got := record.Slots[slotKey(listTypeMain, 40)].Count; got != 90 {
t.Fatalf("relic fragments=%d want=90", got)
}
account, found, loadErr := repos.AccountInventory.Load(ctx, "dnf:1")
if loadErr != nil || !found {
t.Fatalf("load account inventory found=%v err=%v", found, loadErr)
}
if got := account.Slots[dnfrepo.AccountSharedInventorySlotKey(362)].Count; got != 103 {
t.Fatalf("rare souls=%d want=103", got)
}
if got := account.Slots[dnfrepo.AccountSharedInventorySlotKey(361)].Count; got != 171 {
t.Fatalf("advanced souls=%d want=171", got)
}
if got := account.Slots[dnfrepo.AccountSharedInventorySlotKey(360)].Count; got != 180 {
t.Fatalf("common souls=%d want=180", got)
}
wantSlots := []int16{108, 40, 360, 361, 362}
for index, wantSlot := range wantSlots {
if result.Consumptions[index].ListType != listTypeMain || result.Consumptions[index].Slot != wantSlot {
t.Fatalf("consumption[%d]=%+v want list=%d slot=%d", index, result.Consumptions[index], listTypeMain, wantSlot)
}
}
}
func TestOwnerEquipmentEmancipationMissingMaterialRollsBackEverything(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
before := dnfrepo.InventoryRecord{CharacterID: "82", Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 1): {ItemID: 10149520, Count: 1},
slotKey(listTypeMain, 2): {ItemID: 104000224, Count: 1, RawEntry: make([]byte, chronicleEquipmentRawSize), Extra: map[string]string{"item_kind": "equipment"}},
slotKey(listTypeMain, 3): {ItemID: 10149509, Count: 10},
}}
saveTestInventory(t, ctx, repos, before)
beforeAccount := dnfrepo.AccountInventoryRecord{AccountID: "dnf:1", Slots: map[string]dnfrepo.ItemStack{
dnfrepo.AccountSharedInventorySlotKey(360): {ItemID: 10100115, Count: 36},
dnfrepo.AccountSharedInventorySlotKey(361): {ItemID: 10100116, Count: 24},
dnfrepo.AccountSharedInventorySlotKey(362): {ItemID: 10099773, Count: 11},
}}
if err := repos.AccountInventory.Save(ctx, beforeAccount); err != nil {
t.Fatal(err)
}
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := NewChronicleGrowthCommand(alignedcmd.Request{AccountID: "dnf:1", SelectedCharacterID: 82}, ChronicleGrowthRequest{
TicketSlotIndex: 1, TicketItemCode: 10149520, TargetSlotIndex: 2, TargetItemCode: 104000224,
MaterialSlotIndex: 3, MaterialItemCode: 10149509,
})
if _, err := owner.UpgradeChronicle(ctx, cmd, func(ticket, target int64) (alignedcmd.ChronicleGrowthResolution, error) {
return testEquipmentEmancipationResolution(ticket), nil
}); err == nil {
t.Fatal("conversion with an insufficient soul stack succeeded")
}
after := loadTestInventory(t, ctx, repos, "82")
for key, want := range before.Slots {
got, found := after.Slots[key]
if !found || got.ItemID != want.ItemID || got.Count != want.Count {
t.Fatalf("slot %s changed after rollback: got=%+v found=%v want=%+v", key, got, found, want)
}
}
afterAccount, found, loadErr := repos.AccountInventory.Load(ctx, "dnf:1")
if loadErr != nil || !found {
t.Fatalf("load account inventory found=%v err=%v", found, loadErr)
}
for key, want := range beforeAccount.Slots {
got, exists := afterAccount.Slots[key]
if !exists || got.ItemID != want.ItemID || got.Count != want.Count {
t.Fatalf("account slot %s changed after rollback: got=%+v found=%v want=%+v", key, got, exists, want)
}
}
}
func TestBuildEquipmentEmancipationOutputHonorsRetentionPolicy(t *testing.T) {
source := dnfrepo.ItemStack{ItemID: 700, Count: 1, RawEntry: make([]byte, chronicleEquipmentRawSize), Extra: map[string]string{}}
setUpgradeLevel(&source, 12)
setAmplifyState(&source, 3, 12)
source.Extra["forging_level"] = "6"
source.Extra["byte_57"] = "6"
source.RawEntry[currentForgingRawOffset] = 6
setEnchantFields(&source, 12345, 4)
conversion := *testEquipmentEmancipationResolution(10149519).Conversion
conversion.UpgradeMaximum = 10
conversion.AmplifyMaximum = 10
conversion.ForgingMaximum = 4
output := buildChronicleConversionOutput(2, source, conversion)
_, amplify := upgradeAmplifyState(output)
if upgradeLevelOf(output) != 10 || amplify != 10 || chronicleForgingLevel(output) != 4 ||
firstExtraInt(output.Extra, 0, "enchant_card_id") != 12345 || enchantUpgradeCountOf(output) != 4 {
t.Fatalf("capped conversion output=%+v", output.Extra)
}
conversion.UpgradeMaximum = -1
conversion.AmplifyMaximum = -1
conversion.ForgingMaximum = -1
conversion.PreserveAttributes = true
output = buildChronicleConversionOutput(2, source, conversion)
_, amplify = upgradeAmplifyState(output)
if upgradeLevelOf(output) != 12 || amplify != 12 || chronicleForgingLevel(output) != 6 || enchantUpgradeCountOf(output) != 4 {
t.Fatalf("preserving conversion output=%+v", output.Extra)
}
}
func testEquipmentEmancipationResolution(ticketItemID int64) alignedcmd.ChronicleGrowthResolution {
return alignedcmd.ChronicleGrowthResolution{
TicketItemID: ticketItemID,
Conversion: &alignedcmd.ChronicleEquipmentConversionResolution{
TicketType: 8,
Inputs: []alignedcmd.ChronicleEquipmentConversionMaterial{
{ItemID: 10149509, Count: 10},
{ItemID: 10099773, Count: 12},
{ItemID: 10100116, Count: 24},
{ItemID: 10100115, Count: 36},
},
OutputItemID: 104000504, OutputPVFPath: "equipment/character/gunner/weapon/revolver/104000504.equ",
OutputEquipmentType: "weapon", OutputRarity: 4, OutputGrade: 50, OutputMinimumLevel: 15,
OutputDurability: 35, OutputQualitySeed: 123456, BindOutput: true,
UpgradeMaximum: -1, AmplifyMaximum: -1, ForgingMaximum: -1,
},
}
}
func TestOwnerUpgradeChroniclePersistsRaw58AndPreservesOtherRegions(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := make([]byte, chronicleEquipmentRawSize)
raw[0x57] = 1
raw[0x58] = 0
raw[0x59] = 0x5A
copy(raw[0x6E:0x72], []byte{0x61, 0x62, 0x63, 0x64})
copy(raw[0x72:0x77], []byte{1, 2, 3, 4, 5})
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "77", Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 1): {ItemID: 10094062, Count: 2, Extra: map[string]string{"item_kind": "stackable"}},
slotKey(listTypeMain, 2): {ItemID: 700, Count: 1, RawEntry: raw, Extra: map[string]string{"item_kind": "equipment", "rarity": "5", "byte_57": "1"}},
slotKey(listTypeMain, 3): {ItemID: 3311, Count: 100},
}})
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := NewChronicleGrowthCommand(alignedcmd.Request{SelectedCharacterID: 77}, ChronicleGrowthRequest{
TicketSlotIndex: 1, TicketItemCode: 10094062, TargetSlotIndex: 2, TargetItemCode: 700,
TargetTaggedValue: 0, MaterialSlotIndex: 3, MaterialItemCode: 3311,
})
result, err := owner.UpgradeChronicle(ctx, cmd, func(ticket, target int64) (alignedcmd.ChronicleGrowthResolution, error) {
return testChronicleGrowthResolution(ticket, "coat"), nil
})
if err != nil || !result.Succeeded || result.OldLevel != 70 || result.NewLevel != 73 || result.MaterialCount != 6 {
t.Fatalf("result=%+v err=%v", result, err)
}
record := loadTestInventory(t, ctx, repos, "77")
if record.Slots[slotKey(listTypeMain, 1)].Count != 1 || record.Slots[slotKey(listTypeMain, 3)].Count != 94 {
t.Fatalf("consumptions not persisted: %+v", record.Slots)
}
target := record.Slots[slotKey(listTypeMain, 2)]
if target.RawEntry[0x58] != 3 || target.Extra["byte_58"] != "3" || target.Extra["value_58"] != "3" {
t.Fatalf("target energy not synchronized: raw=%d extra=%+v", target.RawEntry[0x58], target.Extra)
}
if target.RawEntry[0x57] != 1 || target.RawEntry[0x59] != 0x5A ||
hex.EncodeToString(target.RawEntry[0x6E:0x72]) != "61626364" ||
hex.EncodeToString(target.RawEntry[0x72:0x77]) != "0102030405" {
t.Fatalf("adjacent/special regions changed: %x", target.RawEntry[0x57:0x77])
}
}
func TestOwnerUpgradeChronicleClampsAtConditionMaximum(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := make([]byte, chronicleEquipmentRawSize)
raw[chronicleEquipmentLevelOffset] = 19
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "79", Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 1): {ItemID: 10094062, Count: 1},
slotKey(listTypeMain, 2): {ItemID: 700, Count: 1, RawEntry: raw, Extra: map[string]string{"rarity": "5"}},
slotKey(listTypeMain, 3): {ItemID: 3311, Count: 1000},
}})
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := NewChronicleGrowthCommand(alignedcmd.Request{SelectedCharacterID: 79}, ChronicleGrowthRequest{
TicketSlotIndex: 1, TicketItemCode: 10094062, TargetSlotIndex: 2, TargetItemCode: 700,
TargetTaggedValue: 19, MaterialSlotIndex: 3, MaterialItemCode: 3311,
})
result, err := owner.UpgradeChronicle(ctx, cmd, func(ticket, target int64) (alignedcmd.ChronicleGrowthResolution, error) {
return testChronicleGrowthResolution(ticket, "coat"), nil
})
if err != nil || result.NewLevel != 90 || result.OldLevel != 89 {
t.Fatalf("result=%+v err=%v", result, err)
}
record := loadTestInventory(t, ctx, repos, "79")
if record.Slots[slotKey(listTypeMain, 2)].RawEntry[chronicleEquipmentLevelOffset] != 20 {
t.Fatalf("clamped energy=%d want=20", record.Slots[slotKey(listTypeMain, 2)].RawEntry[chronicleEquipmentLevelOffset])
}
}
func TestOwnerUpgradeChronicleAllowsConsecutiveUsesWithStableClientTag(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := make([]byte, chronicleEquipmentRawSize)
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "78", Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 1): {ItemID: 10094062, Count: 2},
slotKey(listTypeMain, 2): {ItemID: 700, Count: 1, RawEntry: raw, Extra: map[string]string{"byte_58": "0", "rarity": "5"}},
slotKey(listTypeMain, 3): {ItemID: 3311, Count: 100},
}})
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := NewChronicleGrowthCommand(alignedcmd.Request{SelectedCharacterID: 78}, ChronicleGrowthRequest{
TicketSlotIndex: 1, TicketItemCode: 10094062, TargetSlotIndex: 2, TargetItemCode: 700,
TargetTaggedValue: 3, MaterialSlotIndex: 3, MaterialItemCode: 3311,
})
resolve := func(ticket, target int64) (alignedcmd.ChronicleGrowthResolution, error) {
return testChronicleGrowthResolution(ticket, "coat"), nil
}
first, err := owner.UpgradeChronicle(ctx, cmd, resolve)
if err != nil || first.OldLevel != 70 || first.NewLevel != 73 || first.MaterialCount != 6 {
t.Fatalf("first result=%+v err=%v", first, err)
}
second, err := owner.UpgradeChronicle(ctx, cmd, resolve)
if err != nil || second.OldLevel != 73 || second.NewLevel != 76 || second.MaterialCount != 6 {
t.Fatalf("second result=%+v err=%v", second, err)
}
record := loadTestInventory(t, ctx, repos, "78")
if _, ticketExists := record.Slots[slotKey(listTypeMain, 1)]; ticketExists ||
record.Slots[slotKey(listTypeMain, 3)].Count != 88 ||
record.Slots[slotKey(listTypeMain, 2)].RawEntry[0x58] != 6 {
t.Fatalf("consecutive growth state = %+v", record.Slots)
}
}
func TestOwnerUpgradeChronicleLegacyRowStartsAtPVFMinimum(t *testing.T) {
ctx := context.Background()
repos := dnfrepomemory.NewMemoryGroup()
raw := make([]byte, chronicleEquipmentRawSize)
raw[0x59] = 0x5A
saveTestInventory(t, ctx, repos, dnfrepo.InventoryRecord{CharacterID: "80", Slots: map[string]dnfrepo.ItemStack{
slotKey(listTypeMain, 1): {ItemID: 10094062, Count: 1},
slotKey(listTypeMain, 2): {ItemID: 700, Count: 1, RawEntry: raw, Extra: map[string]string{"rarity": "5"}},
slotKey(listTypeMain, 3): {ItemID: 3311, Count: 100},
}})
owner, err := NewOwner(repos)
if err != nil {
t.Fatal(err)
}
cmd := NewChronicleGrowthCommand(alignedcmd.Request{SelectedCharacterID: 80}, ChronicleGrowthRequest{
TicketSlotIndex: 1, TicketItemCode: 10094062, TargetSlotIndex: 2, TargetItemCode: 700,
TargetTaggedValue: 3, MaterialSlotIndex: 3, MaterialItemCode: 3311,
})
result, err := owner.UpgradeChronicle(ctx, cmd, func(ticket, target int64) (alignedcmd.ChronicleGrowthResolution, error) {
return testChronicleGrowthResolution(ticket, "coat"), nil
})
if err != nil || !result.Succeeded || result.OldLevel != 70 || result.NewLevel != 73 {
t.Fatalf("result=%+v err=%v", result, err)
}
record := loadTestInventory(t, ctx, repos, "80")
target := record.Slots[slotKey(listTypeMain, 2)]
if target.RawEntry[chronicleEquipmentLevelOffset] != 3 || target.Extra["byte_58"] != "3" || target.Extra["value_58"] != "3" {
t.Fatalf("legacy target energy not adopted and persisted: raw=%d extra=%+v", target.RawEntry[chronicleEquipmentLevelOffset], target.Extra)
}
if target.RawEntry[0x59] != 0x5A {
t.Fatalf("adjacent equipment data changed: raw59=%02x", target.RawEntry[0x59])
}
}
func TestChronicleRequiredMaterialUsesCurrentFormula(t *testing.T) {
ordinary := dnfrepo.ItemStack{RawEntry: make([]byte, chronicleEquipmentRawSize)}
coat := testChronicleGrowthResolution(10094062, "coat")
if got, err := chronicleRequiredMaterial(coat, ordinary, 70); err != nil || got != 6 {
t.Fatalf("ordinary coat material=%d err=%v, want 6", got, err)
}
amplified := ordinary
amplified.RawEntry = append([]byte(nil), ordinary.RawEntry...)
amplified.RawEntry[0x0A] = 12
amplified.RawEntry[0x13] = 1
amplified.RawEntry[0x14] = 1
if got, err := chronicleRequiredMaterial(coat, amplified, 70); err != nil || got != 172 {
t.Fatalf("+12 amplified coat material=%d err=%v, want 172", got, err)
}
forged := ordinary
forged.RawEntry = append([]byte(nil), ordinary.RawEntry...)
forged.RawEntry[0x57] = 2
if got, err := chronicleRequiredMaterial(coat, forged, 70); err != nil || got != 7 {
t.Fatalf("forged coat material=%d err=%v, want 7", got, err)
}
}
func testChronicleGrowthResolution(ticketItemID int64, equipmentType string) alignedcmd.ChronicleGrowthResolution {
return alignedcmd.ChronicleGrowthResolution{
TicketItemID: ticketItemID, MaterialItemID: 3311, UpgradeLevel: 3,
TargetMinimumLevel: 70, ConditionMinimum: 70, ConditionMaximum: 90,
TargetRarity: 5, EquipmentType: equipmentType,
Probability: []alignedcmd.ChronicleGrowthProbability{{MaximumLevel: 75, Weight: 100000}, {MaximumLevel: 85, Weight: 100000}},
CostPolicy: alignedcmd.ChronicleGrowthCostPolicy{
Rarity: 5, MaterialItemID: 3311, BaseCount: 3, Multiplier: 1,
PartWeights: map[string]float64{"weapon": 1.2, "coat": 1},
LevelWeights: []alignedcmd.ChronicleGrowthWeightSegment{
{Minimum: 1, Maximum: 70, MinimumWeight: 1, MaximumWeight: 2},
{Minimum: 70, Maximum: 85, MinimumWeight: 2, MaximumWeight: 3.5},
{Minimum: 85, Maximum: 200, MinimumWeight: 3.5, MaximumWeight: 50},
},
UpgradeWeights: []alignedcmd.ChronicleGrowthWeightSegment{
{Minimum: 0, Maximum: 3, MinimumWeight: 1, MaximumWeight: 1.2},
{Minimum: 3, Maximum: 6, MinimumWeight: 1.2, MaximumWeight: 1.5},
{Minimum: 6, Maximum: 10, MinimumWeight: 1.5, MaximumWeight: 2},
{Minimum: 10, Maximum: 15, MinimumWeight: 2, MaximumWeight: 10},
},
AmplifyWeights: []alignedcmd.ChronicleGrowthWeightSegment{
{Minimum: 0, Maximum: 3, MinimumWeight: 1, MaximumWeight: 1.5},
{Minimum: 3, Maximum: 6, MinimumWeight: 1.5, MaximumWeight: 2.5},
{Minimum: 6, Maximum: 10, MinimumWeight: 2.5, MaximumWeight: 4},
{Minimum: 10, Maximum: 15, MinimumWeight: 4, MaximumWeight: 66},
},
ForgingWeights: []alignedcmd.ChronicleGrowthWeightSegment{{Minimum: 0, Maximum: 2, MinimumWeight: 1, MaximumWeight: 1.2}},
},
}
}