DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,304 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
petArtifactInventorySlotStart int16 = 140
|
||||
petArtifactInventorySlotEnd int16 = 188
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPetArtifactOwnerUnavailable = errors.New("pet artifact owner unavailable")
|
||||
ErrPetArtifactStackInvalid = errors.New("pet artifact stack is invalid")
|
||||
ErrPetArtifactStateInvalid = errors.New("equipped pet artifact state is invalid")
|
||||
ErrPetArtifactNotEquipped = errors.New("pet artifact is not equipped")
|
||||
ErrPetArtifactTargetOccupied = errors.New("pet artifact target slot is occupied")
|
||||
)
|
||||
|
||||
// ArtifactEquipCommand names only the authoritative list-7 inventory source.
|
||||
// The artifact color is resolved from runtime PVF, never from a client slot.
|
||||
type ArtifactEquipCommand struct {
|
||||
SelectedCharacterID uint16
|
||||
ListType byte
|
||||
SlotIndex int16
|
||||
}
|
||||
|
||||
// ArtifactUnequipCommand names the semantic color and an empty list-7 target.
|
||||
// A later protocol adapter may construct it only after the current EXE endpoint
|
||||
// has been captured; the domain itself has no historical worn-slot numbers.
|
||||
type ArtifactUnequipCommand struct {
|
||||
SelectedCharacterID uint16
|
||||
ListType byte
|
||||
SlotIndex int16
|
||||
Kind PetArtifactKind
|
||||
}
|
||||
|
||||
type ArtifactMoveResult struct {
|
||||
CharacterID string
|
||||
Kind PetArtifactKind
|
||||
ItemID int64
|
||||
SwappedItem int64
|
||||
SlotIndex int16
|
||||
Changed bool
|
||||
PetInventory map[string]dnfrepo.ItemStack
|
||||
Artifacts map[string]dnfrepo.ItemStack
|
||||
}
|
||||
|
||||
// ArtifactOwner owns the 86JP-equivalent artifact rules (one item per typed
|
||||
// color, atomic replace/unequip) while leaving current-EXE wire endpoints to a
|
||||
// separately proved protocol adapter.
|
||||
type ArtifactOwner struct {
|
||||
characterPets dnfrepo.CharacterPetUnitOfWork
|
||||
resolver PetArtifactResolver
|
||||
}
|
||||
|
||||
func NewArtifactOwner(repos dnfrepo.Group, resolver PetArtifactResolver) (*ArtifactOwner, error) {
|
||||
if repos.CharacterPets == nil || resolver == nil {
|
||||
return nil, ErrPetArtifactOwnerUnavailable
|
||||
}
|
||||
return &ArtifactOwner{characterPets: repos.CharacterPets, resolver: resolver}, nil
|
||||
}
|
||||
|
||||
func (o *ArtifactOwner) Equip(ctx context.Context, cmd ArtifactEquipCommand) (ArtifactMoveResult, error) {
|
||||
if o == nil || o.characterPets == nil || o.resolver == nil {
|
||||
return ArtifactMoveResult{}, ErrPetArtifactOwnerUnavailable
|
||||
}
|
||||
if err := validateArtifactInventoryEndpoint(cmd.SelectedCharacterID, cmd.ListType, cmd.SlotIndex); err != nil {
|
||||
return ArtifactMoveResult{}, err
|
||||
}
|
||||
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
var committed ArtifactMoveResult
|
||||
err := o.characterPets.WithinCharacterPets(ctx, characterID, func(
|
||||
inventoryRepo dnfrepo.InventoryRepository,
|
||||
_ dnfrepo.EquipmentRepository,
|
||||
petRepo dnfrepo.PetRepository,
|
||||
) error {
|
||||
inventory, ok, err := inventoryRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrInventoryNotFound
|
||||
}
|
||||
inventory = ensureInventoryRecord(dnfrepo.CloneInventory(inventory), characterID)
|
||||
|
||||
petRecord, ok, err := petRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
petRecord = dnfrepo.PetRecord{CharacterID: characterID}
|
||||
}
|
||||
petRecord = ensurePetRecord(dnfrepo.ClonePet(petRecord), characterID)
|
||||
if petRecord.Artifacts == nil {
|
||||
petRecord.Artifacts = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
if err := validateEquippedArtifacts(petRecord.Artifacts, o.resolver); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
sourceKey := slotKey(listTypePet, cmd.SlotIndex)
|
||||
source, found := inventory.Slots[sourceKey]
|
||||
if !found {
|
||||
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, listTypePet, cmd.SlotIndex)
|
||||
}
|
||||
if source.ItemID <= 0 || source.Count != 1 {
|
||||
return fmt.Errorf("%w: item=%d count=%d", ErrPetArtifactStackInvalid, source.ItemID, source.Count)
|
||||
}
|
||||
definition, err := o.resolver.ResolveArtifact(source.ItemID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
kindKey, err := artifactKindKey(definition.Kind)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
delete(inventory.Slots, sourceKey)
|
||||
swapped := petRecord.Artifacts[kindKey]
|
||||
if swapped.ItemID != 0 {
|
||||
inventory.Slots[sourceKey] = cloneArtifactStack(swapped)
|
||||
}
|
||||
petRecord.Artifacts[kindKey] = cloneArtifactStack(source)
|
||||
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dnfrepo.SavePetFields(ctx, petRepo, petRecord, dnfrepo.PetFieldEntries); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = ArtifactMoveResult{
|
||||
CharacterID: characterID,
|
||||
Kind: definition.Kind,
|
||||
ItemID: source.ItemID,
|
||||
SwappedItem: swapped.ItemID,
|
||||
SlotIndex: cmd.SlotIndex,
|
||||
Changed: true,
|
||||
PetInventory: cloneItemMap(inventory.Slots),
|
||||
Artifacts: cloneItemMap(petRecord.Artifacts),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ArtifactMoveResult{}, err
|
||||
}
|
||||
return committed, nil
|
||||
}
|
||||
|
||||
func (o *ArtifactOwner) Unequip(ctx context.Context, cmd ArtifactUnequipCommand) (ArtifactMoveResult, error) {
|
||||
if o == nil || o.characterPets == nil || o.resolver == nil {
|
||||
return ArtifactMoveResult{}, ErrPetArtifactOwnerUnavailable
|
||||
}
|
||||
if err := validateArtifactInventoryEndpoint(cmd.SelectedCharacterID, cmd.ListType, cmd.SlotIndex); err != nil {
|
||||
return ArtifactMoveResult{}, err
|
||||
}
|
||||
kindKey, err := artifactKindKey(cmd.Kind)
|
||||
if err != nil {
|
||||
return ArtifactMoveResult{}, err
|
||||
}
|
||||
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
var committed ArtifactMoveResult
|
||||
err = o.characterPets.WithinCharacterPets(ctx, characterID, func(
|
||||
inventoryRepo dnfrepo.InventoryRepository,
|
||||
_ dnfrepo.EquipmentRepository,
|
||||
petRepo dnfrepo.PetRepository,
|
||||
) error {
|
||||
inventory, ok, err := inventoryRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrInventoryNotFound
|
||||
}
|
||||
inventory = ensureInventoryRecord(dnfrepo.CloneInventory(inventory), characterID)
|
||||
|
||||
petRecord, ok, err := petRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrPetArtifactNotEquipped
|
||||
}
|
||||
petRecord = ensurePetRecord(dnfrepo.ClonePet(petRecord), characterID)
|
||||
if err := validateEquippedArtifacts(petRecord.Artifacts, o.resolver); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
equipped, found := petRecord.Artifacts[kindKey]
|
||||
if !found {
|
||||
return fmt.Errorf("%w: kind=%s", ErrPetArtifactNotEquipped, kindKey)
|
||||
}
|
||||
targetKey := slotKey(listTypePet, cmd.SlotIndex)
|
||||
if _, occupied := inventory.Slots[targetKey]; occupied {
|
||||
return fmt.Errorf("%w: list=%d slot=%d", ErrPetArtifactTargetOccupied, listTypePet, cmd.SlotIndex)
|
||||
}
|
||||
|
||||
inventory.Slots[targetKey] = cloneArtifactStack(equipped)
|
||||
delete(petRecord.Artifacts, kindKey)
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dnfrepo.SavePetFields(ctx, petRepo, petRecord, dnfrepo.PetFieldEntries); err != nil {
|
||||
return err
|
||||
}
|
||||
committed = ArtifactMoveResult{
|
||||
CharacterID: characterID,
|
||||
Kind: cmd.Kind,
|
||||
ItemID: equipped.ItemID,
|
||||
SlotIndex: cmd.SlotIndex,
|
||||
Changed: true,
|
||||
PetInventory: cloneItemMap(inventory.Slots),
|
||||
Artifacts: cloneItemMap(petRecord.Artifacts),
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ArtifactMoveResult{}, err
|
||||
}
|
||||
return committed, nil
|
||||
}
|
||||
|
||||
func EquippedArtifactItemIDs(record dnfrepo.PetRecord) ([]int64, error) {
|
||||
ids := make([]int64, 0, 3)
|
||||
for _, kind := range []PetArtifactKind{PetArtifactKindRed, PetArtifactKindBlue, PetArtifactKindGreen} {
|
||||
key, _ := artifactKindKey(kind)
|
||||
if stack, ok := record.Artifacts[key]; ok {
|
||||
if stack.ItemID <= 0 || stack.Count != 1 {
|
||||
return nil, fmt.Errorf("%w: kind=%s item=%d count=%d", ErrPetArtifactStateInvalid, key, stack.ItemID, stack.Count)
|
||||
}
|
||||
ids = append(ids, stack.ItemID)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func validateArtifactInventoryEndpoint(characterID uint16, listType byte, slotIndex int16) error {
|
||||
if characterID == 0 {
|
||||
return ErrCharacterRequired
|
||||
}
|
||||
if listType != listTypePet {
|
||||
return fmt.Errorf("%w: %d", ErrUnsupportedList, listType)
|
||||
}
|
||||
if slotIndex < petArtifactInventorySlotStart || slotIndex > petArtifactInventorySlotEnd {
|
||||
return fmt.Errorf("%w: artifact list=%d slot=%d range=%d..%d", ErrSlotNotFound, listType, slotIndex, petArtifactInventorySlotStart, petArtifactInventorySlotEnd)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateEquippedArtifacts(artifacts map[string]dnfrepo.ItemStack, resolver PetArtifactResolver) error {
|
||||
if len(artifacts) == 0 {
|
||||
return nil
|
||||
}
|
||||
if resolver == nil {
|
||||
return ErrPetArtifactOwnerUnavailable
|
||||
}
|
||||
for key, stack := range artifacts {
|
||||
kind, ok := artifactKindFromKey(key)
|
||||
if !ok || stack.ItemID <= 0 || stack.Count != 1 {
|
||||
return fmt.Errorf("%w: key=%q item=%d count=%d", ErrPetArtifactStateInvalid, key, stack.ItemID, stack.Count)
|
||||
}
|
||||
definition, err := resolver.ResolveArtifact(stack.ItemID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%w: key=%s item=%d: %v", ErrPetArtifactStateInvalid, key, stack.ItemID, err)
|
||||
}
|
||||
if definition.Kind != kind {
|
||||
return fmt.Errorf("%w: key=%s item=%d resolves=%s", ErrPetArtifactStateInvalid, key, stack.ItemID, definition.Kind)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func artifactKindKey(kind PetArtifactKind) (string, error) {
|
||||
if kind < PetArtifactKindRed || kind > PetArtifactKindGreen {
|
||||
return "", fmt.Errorf("%w: kind=%d", ErrPetArtifactStateInvalid, kind)
|
||||
}
|
||||
return kind.String(), nil
|
||||
}
|
||||
|
||||
func artifactKindFromKey(key string) (PetArtifactKind, bool) {
|
||||
switch key {
|
||||
case PetArtifactKindRed.String():
|
||||
return PetArtifactKindRed, true
|
||||
case PetArtifactKindBlue.String():
|
||||
return PetArtifactKindBlue, true
|
||||
case PetArtifactKindGreen.String():
|
||||
return PetArtifactKindGreen, true
|
||||
default:
|
||||
return PetArtifactKindInvalid, false
|
||||
}
|
||||
}
|
||||
|
||||
func cloneArtifactStack(stack dnfrepo.ItemStack) dnfrepo.ItemStack {
|
||||
stack.RawEntry = append([]byte(nil), stack.RawEntry...)
|
||||
stack.Extra = cloneExtra(stack.Extra)
|
||||
return stack
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
type fakeArtifactResolver map[int64]PetArtifactKind
|
||||
|
||||
func (resolver fakeArtifactResolver) ResolveArtifact(itemID int64) (PetArtifactDefinition, error) {
|
||||
kind, ok := resolver[itemID]
|
||||
if !ok {
|
||||
return PetArtifactDefinition{}, ErrPetPVFArtifactTypeInvalid
|
||||
}
|
||||
return PetArtifactDefinition{ItemID: itemID, Kind: kind}, nil
|
||||
}
|
||||
|
||||
func TestArtifactOwnerEquipSwapAndUnequipAreAtomic(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "71",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"7:140": {ItemID: 63500, Count: 1, RawEntry: []byte{1}, Extra: map[string]string{"serial": "100"}},
|
||||
"7:141": {ItemID: 63501, Count: 1, RawEntry: []byte{2}, Extra: map[string]string{"serial": "101"}},
|
||||
"7:142": {ItemID: 64000, Count: 1, RawEntry: []byte{3}, Extra: map[string]string{"serial": "102"}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
resolver := fakeArtifactResolver{
|
||||
63500: PetArtifactKindRed,
|
||||
63501: PetArtifactKindRed,
|
||||
64000: PetArtifactKindBlue,
|
||||
}
|
||||
owner, err := NewArtifactOwner(repos, resolver)
|
||||
if err != nil {
|
||||
t.Fatalf("NewArtifactOwner: %v", err)
|
||||
}
|
||||
|
||||
first, err := owner.Equip(ctx, ArtifactEquipCommand{SelectedCharacterID: 71, ListType: listTypePet, SlotIndex: 140})
|
||||
if err != nil {
|
||||
t.Fatalf("first Equip: %v", err)
|
||||
}
|
||||
if !first.Changed || first.Kind != PetArtifactKindRed || first.ItemID != 63500 || first.SwappedItem != 0 {
|
||||
t.Fatalf("first result = %+v", first)
|
||||
}
|
||||
|
||||
swap, err := owner.Equip(ctx, ArtifactEquipCommand{SelectedCharacterID: 71, ListType: listTypePet, SlotIndex: 141})
|
||||
if err != nil {
|
||||
t.Fatalf("swap Equip: %v", err)
|
||||
}
|
||||
if swap.ItemID != 63501 || swap.SwappedItem != 63500 {
|
||||
t.Fatalf("swap result = %+v", swap)
|
||||
}
|
||||
if stack := swap.PetInventory["7:141"]; stack.ItemID != 63500 || stack.Extra["serial"] != "100" || string(stack.RawEntry) != string([]byte{1}) {
|
||||
t.Fatalf("swapped source = %+v", stack)
|
||||
}
|
||||
|
||||
if _, err := owner.Equip(ctx, ArtifactEquipCommand{SelectedCharacterID: 71, ListType: listTypePet, SlotIndex: 142}); err != nil {
|
||||
t.Fatalf("blue Equip: %v", err)
|
||||
}
|
||||
record, ok, err := repos.Pet.Load(ctx, "71")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load pets ok=%v err=%v", ok, err)
|
||||
}
|
||||
ids, err := EquippedArtifactItemIDs(record)
|
||||
if err != nil || len(ids) != 2 || ids[0] != 63501 || ids[1] != 64000 {
|
||||
t.Fatalf("artifact ids=%v err=%v record=%+v", ids, err, record.Artifacts)
|
||||
}
|
||||
|
||||
unequipped, err := owner.Unequip(ctx, ArtifactUnequipCommand{
|
||||
SelectedCharacterID: 71,
|
||||
ListType: listTypePet,
|
||||
SlotIndex: 150,
|
||||
Kind: PetArtifactKindRed,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Unequip: %v", err)
|
||||
}
|
||||
if unequipped.ItemID != 63501 || unequipped.PetInventory["7:150"].Extra["serial"] != "101" {
|
||||
t.Fatalf("unequip result = %+v", unequipped)
|
||||
}
|
||||
record, _, _ = repos.Pet.Load(ctx, "71")
|
||||
if _, exists := record.Artifacts["red"]; exists || record.Artifacts["blue"].ItemID != 64000 {
|
||||
t.Fatalf("artifacts after unequip = %+v", record.Artifacts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactOwnerRejectsWrongRangeAndCorruptStateWithoutMutation(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "72",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"7:140": {ItemID: 63500, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
resolver := fakeArtifactResolver{63500: PetArtifactKindRed, 64000: PetArtifactKindBlue}
|
||||
owner, _ := NewArtifactOwner(repos, resolver)
|
||||
|
||||
if _, err := owner.Equip(ctx, ArtifactEquipCommand{SelectedCharacterID: 72, ListType: listTypePet, SlotIndex: 139}); !errors.Is(err, ErrSlotNotFound) {
|
||||
t.Fatalf("wrong range error = %v", err)
|
||||
}
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
||||
CharacterID: "72",
|
||||
Artifacts: map[string]dnfrepo.ItemStack{
|
||||
"red": {ItemID: 64000, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save corrupt pet state: %v", err)
|
||||
}
|
||||
if _, err := owner.Equip(ctx, ArtifactEquipCommand{SelectedCharacterID: 72, ListType: listTypePet, SlotIndex: 140}); !errors.Is(err, ErrPetArtifactStateInvalid) {
|
||||
t.Fatalf("corrupt state error = %v", err)
|
||||
}
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, "72")
|
||||
if inventory.Slots["7:140"].ItemID != 63500 {
|
||||
t.Fatalf("source mutated after failure: %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
|
||||
func TestArtifactOwnerRejectsOccupiedUnequipTarget(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "73",
|
||||
Slots: map[string]dnfrepo.ItemStack{"7:150": {ItemID: 999, Count: 1}},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
||||
CharacterID: "73",
|
||||
Artifacts: map[string]dnfrepo.ItemStack{"green": {ItemID: 64500, Count: 1}},
|
||||
}); err != nil {
|
||||
t.Fatalf("save pets: %v", err)
|
||||
}
|
||||
owner, _ := NewArtifactOwner(repos, fakeArtifactResolver{64500: PetArtifactKindGreen})
|
||||
_, err := owner.Unequip(ctx, ArtifactUnequipCommand{
|
||||
SelectedCharacterID: 73,
|
||||
ListType: listTypePet,
|
||||
SlotIndex: 150,
|
||||
Kind: PetArtifactKindGreen,
|
||||
})
|
||||
if !errors.Is(err, ErrPetArtifactTargetOccupied) {
|
||||
t.Fatalf("Unequip error = %v", err)
|
||||
}
|
||||
record, _, _ := repos.Pet.Load(ctx, "73")
|
||||
if record.Artifacts["green"].ItemID != 64500 {
|
||||
t.Fatalf("artifact mutated after failure: %+v", record.Artifacts)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
// This file owns the current-EXE creature-list body. Inventory list-7 rows
|
||||
// are delegated to inventory.BuildPetItemListRefreshBody so both paths use
|
||||
// the same proved 0x77-byte item row.
|
||||
package pet
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
dnfinventory "longheng.io/server/internal/modules/dnf/inventory"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var (
|
||||
errCreatureCountTooLarge = errors.New("creature count exceeds 255")
|
||||
errCreatureKeyInvalid = errors.New("creature key is invalid")
|
||||
errCreatureModeInvalid = errors.New("creature mode flag is invalid")
|
||||
errCreatureGrowthInvalid = errors.New("creature growth state is invalid")
|
||||
errCreatureNameTooLong = errors.New("creature name exceeds 30 bytes")
|
||||
errCreatureTailInvalid = errors.New("creature tail flag is invalid")
|
||||
)
|
||||
|
||||
// BuildCreatureListBody exposes the current NoPack class0/op105 business body
|
||||
// to the bridge's scene-lifecycle owner. Callers must still choose a proven
|
||||
// scene-ready send point; this function only owns the exact typed body.
|
||||
func BuildCreatureListBody(entries []dnfrepo.PetEntry) ([]byte, error) {
|
||||
return buildCreatureListBody(entries)
|
||||
}
|
||||
|
||||
// BuildCreatureGrowthBody follows current NoPack sub_1D5AF60 exactly. This is
|
||||
// the scene class0/op102 direction and must not be confused with the unrelated
|
||||
// C2S hatch request or its class1/status ACK, which share the numeric opcode.
|
||||
func BuildCreatureGrowthBody(entry dnfrepo.PetEntry) ([]byte, error) {
|
||||
if entry.ModeFlag > 1 {
|
||||
return nil, fmt.Errorf("%w: mode=%d", errCreatureModeInvalid, entry.ModeFlag)
|
||||
}
|
||||
if entry.Level < 1 || entry.Level > 255 || entry.Exp < 0 || entry.Exp > int64(^uint32(0)) {
|
||||
return nil, fmt.Errorf("%w: level=%d progress=%d", errCreatureGrowthInvalid, entry.Level, entry.Exp)
|
||||
}
|
||||
body := make([]byte, 6, 8)
|
||||
body[0] = byte(entry.Level)
|
||||
body[1] = entry.ModeFlag
|
||||
binary.LittleEndian.PutUint32(body[2:6], uint32(entry.Exp))
|
||||
if entry.ModeFlag == 1 {
|
||||
body = append(body, entry.Mode1Field0A, entry.Mode1Field0B)
|
||||
}
|
||||
return body, nil
|
||||
}
|
||||
|
||||
type packetWriter struct {
|
||||
buf []byte
|
||||
}
|
||||
|
||||
func (w *packetWriter) writeByte(value byte) {
|
||||
w.buf = append(w.buf, value)
|
||||
}
|
||||
|
||||
func (w *packetWriter) writeUint32(value uint32) {
|
||||
var tmp [4]byte
|
||||
binary.LittleEndian.PutUint32(tmp[:], value)
|
||||
w.buf = append(w.buf, tmp[:]...)
|
||||
}
|
||||
|
||||
func (w *packetWriter) writeRawDstr(value []byte) {
|
||||
w.writeUint32(uint32(len(value)))
|
||||
w.buf = append(w.buf, value...)
|
||||
}
|
||||
|
||||
func (w *packetWriter) bytes() []byte {
|
||||
return append([]byte(nil), w.buf...)
|
||||
}
|
||||
|
||||
func buildSuccessAck() []byte {
|
||||
return []byte{1}
|
||||
}
|
||||
|
||||
// buildRenameCreatureSuccessAck follows current NoPack sub_1D1D5C0 exactly:
|
||||
// u16 slot + u8 list_type. The response does not echo the name.
|
||||
func buildRenameCreatureSuccessAck(slotIndex int16, listType byte) []byte {
|
||||
body := make([]byte, 3)
|
||||
binary.LittleEndian.PutUint16(body[0:2], uint16(slotIndex))
|
||||
body[2] = listType
|
||||
return body
|
||||
}
|
||||
|
||||
// buildRenameCreatureNameNotificationBody follows current NoPack
|
||||
// sub_1D84FE0 exactly: u16 actor object key followed by the renamed creature
|
||||
// DSTR. This is the native in-place rename notification; it updates the
|
||||
// already-created actor/creature objects without replaying mode0 or replacing
|
||||
// the complete op105 creature table.
|
||||
func buildRenameCreatureNameNotificationBody(objectKey uint16, nameRaw []byte) []byte {
|
||||
body := make([]byte, 2+currentDstrLengthBytes+len(nameRaw))
|
||||
binary.LittleEndian.PutUint16(body[0:2], objectKey)
|
||||
binary.LittleEndian.PutUint32(body[2:6], uint32(len(nameRaw)))
|
||||
copy(body[6:], nameRaw)
|
||||
return body
|
||||
}
|
||||
|
||||
// buildCreatureListBody follows current NoPack sub_1D57AB0 exactly:
|
||||
// u8 count; repeat {u32 key,u8 satiety,u8 mode,u32 exp,
|
||||
// [mode==1:u8,u8],u8 level,DSTR rawName,u8 tailFlag}.
|
||||
func buildCreatureListBody(entries []dnfrepo.PetEntry) ([]byte, error) {
|
||||
if len(entries) > 255 {
|
||||
return nil, errCreatureCountTooLarge
|
||||
}
|
||||
ordered := append([]dnfrepo.PetEntry(nil), entries...)
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
left, leftOK := creatureWireKey(ordered[i])
|
||||
right, rightOK := creatureWireKey(ordered[j])
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
if left != right {
|
||||
return left < right
|
||||
}
|
||||
return ordered[i].PetKey < ordered[j].PetKey
|
||||
})
|
||||
|
||||
var writer packetWriter
|
||||
writer.writeByte(byte(len(ordered)))
|
||||
for _, entry := range ordered {
|
||||
key, ok := creatureWireKey(entry)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("%w: %q", errCreatureKeyInvalid, entry.PetKey)
|
||||
}
|
||||
if entry.ModeFlag > 1 {
|
||||
return nil, fmt.Errorf("%w: %d", errCreatureModeInvalid, entry.ModeFlag)
|
||||
}
|
||||
// sub_1D57AB0 passes max=30 to upper_pkt_read_wstr. Its reader accepts
|
||||
// only lengths strictly below max; a 30-byte name would be rejected
|
||||
// without consuming the bytes and desynchronize the remainder.
|
||||
if len(entry.NameRaw) >= 30 {
|
||||
return nil, fmt.Errorf("%w: %d", errCreatureNameTooLong, len(entry.NameRaw))
|
||||
}
|
||||
if entry.TailFlag > 1 {
|
||||
return nil, fmt.Errorf("%w: %d", errCreatureTailInvalid, entry.TailFlag)
|
||||
}
|
||||
|
||||
writer.writeUint32(key)
|
||||
writer.writeByte(entry.Satiety)
|
||||
writer.writeByte(entry.ModeFlag)
|
||||
writer.writeUint32(clampUint32(entry.Exp))
|
||||
if entry.ModeFlag == 1 {
|
||||
writer.writeByte(entry.Mode1Field0A)
|
||||
writer.writeByte(entry.Mode1Field0B)
|
||||
}
|
||||
writer.writeByte(clampByte(entry.Level))
|
||||
writer.writeRawDstr(entry.NameRaw)
|
||||
writer.writeByte(entry.TailFlag)
|
||||
}
|
||||
return writer.bytes(), nil
|
||||
}
|
||||
|
||||
func creatureWireKey(entry dnfrepo.PetEntry) (uint32, bool) {
|
||||
if entry.CreatureKey != 0 {
|
||||
return entry.CreatureKey, true
|
||||
}
|
||||
raw := strings.TrimSpace(entry.PetKey)
|
||||
value, err := strconv.ParseUint(raw, 10, 32)
|
||||
if err != nil || value == 0 {
|
||||
return 0, false
|
||||
}
|
||||
return uint32(value), true
|
||||
}
|
||||
|
||||
func buildPetItemListRefreshBody(slots map[string]dnfrepo.ItemStack) []byte {
|
||||
return dnfinventory.BuildPetItemListRefreshBody(slots)
|
||||
}
|
||||
|
||||
func clampUint32(value int64) uint32 {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if value > int64(^uint32(0)) {
|
||||
return ^uint32(0)
|
||||
}
|
||||
return uint32(value)
|
||||
}
|
||||
|
||||
func clampByte(value int64) byte {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if value > 255 {
|
||||
return 255
|
||||
}
|
||||
return byte(value)
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
const (
|
||||
petEquipmentListPath = "equipment/equipment.lst"
|
||||
petCreatureExperiencePath = "creature/exptable.tbl"
|
||||
MaxCreatureLevel = 50
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPetPVFSourceRequired = errors.New("pet PVF source is required")
|
||||
ErrPetPVFEquipmentUnresolved = errors.New("pet equipment is not present in equipment.lst")
|
||||
ErrPetPVFNotCreature = errors.New("pet equipment type is not creature")
|
||||
ErrPetPVFHatchOutputInvalid = errors.New("pet hatch output index is invalid")
|
||||
ErrPetPVFExperienceTableShape = errors.New("pet creature experience table is invalid")
|
||||
)
|
||||
|
||||
// PetHatchDefinition is the typed, PVF-proven egg-to-creature mapping used by
|
||||
// the hatch owner. It contains domain data only and has no packet fields.
|
||||
type PetHatchDefinition struct {
|
||||
EggItemID int64
|
||||
HatchedItemID int64
|
||||
EggPVFPath string
|
||||
HatchedPVFPath string
|
||||
// MinimumLevel is retained as typed output-item metadata. The 86JP hatch
|
||||
// transaction still creates a new creature instance at level 1.
|
||||
MinimumLevel int
|
||||
}
|
||||
|
||||
// PetHatchResolver lets the mutation owner depend on a real PVF mapping rather
|
||||
// than inventory Extra fields or request-body item-id guesses.
|
||||
type PetHatchResolver interface {
|
||||
ResolveHatch(eggItemID int64) (PetHatchDefinition, error)
|
||||
}
|
||||
|
||||
// PetCreatureDefinition proves that an equipment-list item is a current
|
||||
// runtime-PVF creature. It is deliberately small: growth persistence needs an
|
||||
// item-kind authority, not protocol fields or an inferred slot convention.
|
||||
type PetCreatureDefinition struct {
|
||||
ItemID int64
|
||||
PVFPath string
|
||||
Name string
|
||||
}
|
||||
|
||||
// PetCreatureResolver is the fail-closed item-kind boundary used before any
|
||||
// equipped creature growth or satiety mutation. Slot 24 is also a valid
|
||||
// ordinary support-weapon slot, so position and raw instance bytes alone are
|
||||
// not sufficient proof that an item is a creature.
|
||||
type PetCreatureResolver interface {
|
||||
ResolveCreature(itemID int64) (PetCreatureDefinition, error)
|
||||
}
|
||||
|
||||
// CreatureExperienceTable preserves the 50 values from
|
||||
// creature/exptable.tbl. Current pet progression is capped at level 50, so
|
||||
// LevelForExperience consumes the first 49 thresholds exactly like 86JP.
|
||||
type CreatureExperienceTable struct {
|
||||
thresholds [MaxCreatureLevel]int64
|
||||
}
|
||||
|
||||
// Thresholds returns a copy of the authoritative PVF values.
|
||||
func (t CreatureExperienceTable) Thresholds() []int64 {
|
||||
out := make([]int64, len(t.thresholds))
|
||||
copy(out, t.thresholds[:])
|
||||
return out
|
||||
}
|
||||
|
||||
// LevelForExperience maps cumulative creature experience to level 1..50.
|
||||
func (t CreatureExperienceTable) LevelForExperience(experience int64) int {
|
||||
if experience < 0 {
|
||||
experience = 0
|
||||
}
|
||||
level := 1
|
||||
for nextLevel := 2; nextLevel <= MaxCreatureLevel; nextLevel++ {
|
||||
if t.thresholds[nextLevel-2] > experience {
|
||||
break
|
||||
}
|
||||
level = nextLevel
|
||||
}
|
||||
return level
|
||||
}
|
||||
|
||||
// PVFCatalog owns immutable pet item mappings and the creature experience
|
||||
// table loaded from the runtime Script.pvf.
|
||||
type PVFCatalog struct {
|
||||
source dnfpvf.Source
|
||||
equipmentPaths map[int64]string
|
||||
experience CreatureExperienceTable
|
||||
|
||||
evolutionMu sync.Mutex
|
||||
evolutionLoaded bool
|
||||
evolutionIndex petEvolutionIndex
|
||||
evolutionErr error
|
||||
}
|
||||
|
||||
// NewPVFCatalog loads only the authoritative indexes needed for the first pet
|
||||
// domain closure: equipment item references and creature experience values.
|
||||
func NewPVFCatalog(source dnfpvf.Source) (*PVFCatalog, error) {
|
||||
if source == nil {
|
||||
return nil, ErrPetPVFSourceRequired
|
||||
}
|
||||
equipmentPaths, err := loadPetEquipmentPaths(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
experience, err := loadCreatureExperienceTable(source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &PVFCatalog{
|
||||
source: source,
|
||||
equipmentPaths: equipmentPaths,
|
||||
experience: experience,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ExperienceTable returns the immutable-by-value creature experience table.
|
||||
func (c *PVFCatalog) ExperienceTable() CreatureExperienceTable {
|
||||
if c == nil {
|
||||
return CreatureExperienceTable{}
|
||||
}
|
||||
return c.experience
|
||||
}
|
||||
|
||||
// ResolveCreature independently verifies the current item against the active
|
||||
// equipment.lst and its equipment document. The same typed proof is reused by
|
||||
// hatch/evolution code, while callers cannot inject a request-body item kind.
|
||||
func (c *PVFCatalog) ResolveCreature(itemID int64) (PetCreatureDefinition, error) {
|
||||
if c == nil || c.source == nil {
|
||||
return PetCreatureDefinition{}, ErrPetPVFSourceRequired
|
||||
}
|
||||
documentPath, document, err := c.resolveCreatureEquipment(itemID)
|
||||
if err != nil {
|
||||
return PetCreatureDefinition{}, err
|
||||
}
|
||||
name, _ := document.Text("name")
|
||||
return PetCreatureDefinition{
|
||||
ItemID: itemID,
|
||||
PVFPath: documentPath,
|
||||
Name: strings.TrimSpace(name),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ResolveHatch applies the 86JP domain rule to current runtime PVF data: the
|
||||
// source must be [creature] equipment and have a positive [output index]
|
||||
// different from itself. The output is independently resolved and verified as
|
||||
// creature equipment before the mapping can authorize a mutation.
|
||||
func (c *PVFCatalog) ResolveHatch(eggItemID int64) (PetHatchDefinition, error) {
|
||||
if c == nil || c.source == nil {
|
||||
return PetHatchDefinition{}, ErrPetPVFSourceRequired
|
||||
}
|
||||
eggPath, eggDocument, err := c.resolveCreatureEquipment(eggItemID)
|
||||
if err != nil {
|
||||
return PetHatchDefinition{}, err
|
||||
}
|
||||
outputItemID, found := eggDocument.Int("output index")
|
||||
if !found || outputItemID <= 0 || outputItemID > math.MaxUint32 || outputItemID == eggItemID {
|
||||
return PetHatchDefinition{}, fmt.Errorf("%w: item_id=%d output_index=%d", ErrPetPVFHatchOutputInvalid, eggItemID, outputItemID)
|
||||
}
|
||||
hatchedPath, hatchedDocument, err := c.resolveCreatureEquipment(outputItemID)
|
||||
if err != nil {
|
||||
return PetHatchDefinition{}, fmt.Errorf("resolve pet hatch output item_id=%d: %w", outputItemID, err)
|
||||
}
|
||||
minimumLevel := 1
|
||||
if value, found := hatchedDocument.Int("minimum level"); found {
|
||||
if value <= 0 || value > MaxCreatureLevel {
|
||||
return PetHatchDefinition{}, fmt.Errorf("%w: output item_id=%d minimum_level=%d", ErrPetPVFHatchOutputInvalid, outputItemID, value)
|
||||
}
|
||||
minimumLevel = int(value)
|
||||
}
|
||||
return PetHatchDefinition{
|
||||
EggItemID: eggItemID,
|
||||
HatchedItemID: outputItemID,
|
||||
EggPVFPath: eggPath,
|
||||
HatchedPVFPath: hatchedPath,
|
||||
MinimumLevel: minimumLevel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *PVFCatalog) resolveCreatureEquipment(itemID int64) (string, *dnfpvf.Document, error) {
|
||||
if itemID <= 0 || itemID > math.MaxUint32 {
|
||||
return "", nil, fmt.Errorf("%w: item_id=%d", ErrPetPVFEquipmentUnresolved, itemID)
|
||||
}
|
||||
listedPath, found := c.equipmentPaths[itemID]
|
||||
if !found {
|
||||
return "", nil, fmt.Errorf("%w: item_id=%d", ErrPetPVFEquipmentUnresolved, itemID)
|
||||
}
|
||||
documentPath, text, err := readPetPVFReference(c.source, "equipment", listedPath)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("read pet equipment item_id=%d path=%s: %w", itemID, listedPath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(documentPath, text)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("parse pet equipment item_id=%d path=%s: %w", itemID, documentPath, err)
|
||||
}
|
||||
equipmentType, found := document.Text("equipment type")
|
||||
if !found || !strings.Contains(normalizePetPVFType(equipmentType), "[creature]") {
|
||||
return "", nil, fmt.Errorf("%w: item_id=%d equipment_type=%q", ErrPetPVFNotCreature, itemID, equipmentType)
|
||||
}
|
||||
return documentPath, document, nil
|
||||
}
|
||||
|
||||
func loadPetEquipmentPaths(source dnfpvf.Source) (map[int64]string, error) {
|
||||
text, err := source.ReadText(petEquipmentListPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read %s: %w", petEquipmentListPath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(petEquipmentListPath, text)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse %s: %w", petEquipmentListPath, err)
|
||||
}
|
||||
entries := dnfpvf.ParseList(document)
|
||||
paths := make(map[int64]string, len(entries))
|
||||
for _, entry := range entries {
|
||||
listedPath := cleanPetPVFPath(entry.Path)
|
||||
if entry.ID <= 0 || entry.ID > math.MaxUint32 || listedPath == "" {
|
||||
continue
|
||||
}
|
||||
if previous, exists := paths[entry.ID]; exists && !strings.EqualFold(previous, listedPath) {
|
||||
return nil, fmt.Errorf("parse %s: duplicate item_id=%d paths=%q,%q", petEquipmentListPath, entry.ID, previous, listedPath)
|
||||
}
|
||||
paths[entry.ID] = listedPath
|
||||
}
|
||||
if len(paths) == 0 {
|
||||
return nil, fmt.Errorf("parse %s: no valid item references", petEquipmentListPath)
|
||||
}
|
||||
return paths, nil
|
||||
}
|
||||
|
||||
func loadCreatureExperienceTable(source dnfpvf.Source) (CreatureExperienceTable, error) {
|
||||
if source == nil {
|
||||
return CreatureExperienceTable{}, ErrPetPVFSourceRequired
|
||||
}
|
||||
text, err := source.ReadText(petCreatureExperiencePath)
|
||||
if err != nil {
|
||||
return CreatureExperienceTable{}, fmt.Errorf("read %s: %w", petCreatureExperiencePath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(petCreatureExperiencePath, text)
|
||||
if err != nil {
|
||||
return CreatureExperienceTable{}, fmt.Errorf("parse %s: %w", petCreatureExperiencePath, err)
|
||||
}
|
||||
values := make([]int64, 0, MaxCreatureLevel)
|
||||
for _, token := range document.Tokens {
|
||||
if token.Kind == dnfpvf.TokenInt {
|
||||
values = append(values, token.Int)
|
||||
}
|
||||
}
|
||||
if len(values) < MaxCreatureLevel {
|
||||
return CreatureExperienceTable{}, fmt.Errorf("%w: path=%s values=%d want_at_least=%d", ErrPetPVFExperienceTableShape, petCreatureExperiencePath, len(values), MaxCreatureLevel)
|
||||
}
|
||||
var table CreatureExperienceTable
|
||||
for index, value := range values[:MaxCreatureLevel] {
|
||||
if value <= 0 || (index > 0 && value <= values[index-1]) {
|
||||
return CreatureExperienceTable{}, fmt.Errorf("%w: path=%s index=%d value=%d", ErrPetPVFExperienceTableShape, petCreatureExperiencePath, index, value)
|
||||
}
|
||||
table.thresholds[index] = value
|
||||
}
|
||||
return table, nil
|
||||
}
|
||||
|
||||
func readPetPVFReference(source dnfpvf.Source, root, listedPath string) (string, string, error) {
|
||||
clean := cleanPetPVFPath(listedPath)
|
||||
candidates := []string{clean}
|
||||
root = cleanPetPVFPath(root)
|
||||
if root != "" && !strings.HasPrefix(strings.ToLower(clean), strings.ToLower(root)+"/") {
|
||||
candidates = append(candidates, path.Join(root, clean))
|
||||
}
|
||||
var lastErr error
|
||||
for _, candidate := range candidates {
|
||||
text, err := source.ReadText(candidate)
|
||||
if err == nil {
|
||||
return candidate, text, nil
|
||||
}
|
||||
lastErr = err
|
||||
}
|
||||
return "", "", lastErr
|
||||
}
|
||||
|
||||
func cleanPetPVFPath(value string) string {
|
||||
value = strings.TrimSpace(strings.ReplaceAll(value, "\\", "/"))
|
||||
value = strings.TrimPrefix(value, "./")
|
||||
return strings.TrimPrefix(value, "/")
|
||||
}
|
||||
|
||||
func normalizePetPVFType(value string) string {
|
||||
return strings.ToLower(strings.Join(strings.Fields(strings.TrimSpace(strings.ReplaceAll(value, "`", ""))), " "))
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPetPVFArtifactTypeInvalid = errors.New("pet artifact equipment type is invalid")
|
||||
ErrPetPVFArtifactTypeAmbiguous = errors.New("pet artifact equipment type is ambiguous")
|
||||
)
|
||||
|
||||
// PetArtifactKind is the three-way artifact identity proven by the equipment
|
||||
// document's [equipment type]. The zero value is deliberately invalid.
|
||||
type PetArtifactKind uint8
|
||||
|
||||
const (
|
||||
PetArtifactKindInvalid PetArtifactKind = iota
|
||||
PetArtifactKindRed
|
||||
PetArtifactKindBlue
|
||||
PetArtifactKindGreen
|
||||
)
|
||||
|
||||
func (kind PetArtifactKind) String() string {
|
||||
switch kind {
|
||||
case PetArtifactKindRed:
|
||||
return "red"
|
||||
case PetArtifactKindBlue:
|
||||
return "blue"
|
||||
case PetArtifactKindGreen:
|
||||
return "green"
|
||||
default:
|
||||
return "invalid"
|
||||
}
|
||||
}
|
||||
|
||||
// PetArtifactDefinition contains only typed PVF domain data. Current-EXE worn
|
||||
// slot semantics are intentionally absent: ordinary equipment traffic also
|
||||
// uses values that old 86JP assigned to artifact slots, so those old numbers
|
||||
// cannot be promoted to current-client facts.
|
||||
type PetArtifactDefinition struct {
|
||||
ItemID int64
|
||||
PVFPath string
|
||||
EquipmentType string
|
||||
Kind PetArtifactKind
|
||||
}
|
||||
|
||||
// PetArtifactResolver lets a mutation owner obtain artifact identity without
|
||||
// importing PVF/parser packages.
|
||||
type PetArtifactResolver interface {
|
||||
ResolveArtifact(itemID int64) (PetArtifactDefinition, error)
|
||||
}
|
||||
|
||||
// ResolveArtifact resolves an equipment.lst item and requires exactly one
|
||||
// [equipment type] value. Only the current runtime PVF artifact tokens are
|
||||
// accepted; path names and request slots are never used to infer the kind.
|
||||
func (c *PVFCatalog) ResolveArtifact(itemID int64) (PetArtifactDefinition, error) {
|
||||
if c == nil || c.source == nil {
|
||||
return PetArtifactDefinition{}, ErrPetPVFSourceRequired
|
||||
}
|
||||
documentPath, document, err := c.resolveEquipmentDocument(itemID)
|
||||
if err != nil {
|
||||
return PetArtifactDefinition{}, err
|
||||
}
|
||||
types := document.Texts("equipment type")
|
||||
if len(types) == 0 {
|
||||
return PetArtifactDefinition{}, fmt.Errorf("%w: item_id=%d path=%s missing equipment type", ErrPetPVFArtifactTypeInvalid, itemID, documentPath)
|
||||
}
|
||||
if len(types) != 1 {
|
||||
return PetArtifactDefinition{}, fmt.Errorf("%w: item_id=%d path=%s values=%q", ErrPetPVFArtifactTypeAmbiguous, itemID, documentPath, types)
|
||||
}
|
||||
rawType := types[0]
|
||||
kind, ok := petArtifactType(normalizePetPVFType(rawType))
|
||||
if !ok {
|
||||
return PetArtifactDefinition{}, fmt.Errorf("%w: item_id=%d path=%s equipment_type=%q", ErrPetPVFArtifactTypeInvalid, itemID, documentPath, rawType)
|
||||
}
|
||||
return PetArtifactDefinition{
|
||||
ItemID: itemID,
|
||||
PVFPath: documentPath,
|
||||
EquipmentType: normalizePetPVFType(rawType),
|
||||
Kind: kind,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func petArtifactType(equipmentType string) (PetArtifactKind, bool) {
|
||||
switch equipmentType {
|
||||
case "[artifact red]":
|
||||
return PetArtifactKindRed, true
|
||||
case "[artifact blue]":
|
||||
return PetArtifactKindBlue, true
|
||||
case "[artifact green]":
|
||||
return PetArtifactKindGreen, true
|
||||
default:
|
||||
return PetArtifactKindInvalid, false
|
||||
}
|
||||
}
|
||||
|
||||
var _ PetArtifactResolver = (*PVFCatalog)(nil)
|
||||
@@ -0,0 +1,107 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestPVFCatalogResolvesArtifactTypes(t *testing.T) {
|
||||
catalog := newPetArtifactTestCatalog(t)
|
||||
tests := []struct {
|
||||
itemID int64
|
||||
kind PetArtifactKind
|
||||
token string
|
||||
path string
|
||||
}{
|
||||
{itemID: 20, kind: PetArtifactKindRed, token: "[artifact red]", path: "equipment/creature/artifact_red/red.equ"},
|
||||
{itemID: 21, kind: PetArtifactKindBlue, token: "[artifact blue]", path: "equipment/creature/artifact_blue/blue.equ"},
|
||||
{itemID: 22, kind: PetArtifactKindGreen, token: "[artifact green]", path: "equipment/creature/artifact_green/green.equ"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
definition, err := catalog.ResolveArtifact(test.itemID)
|
||||
if err != nil {
|
||||
t.Fatalf("item_id=%d: %v", test.itemID, err)
|
||||
}
|
||||
if definition.ItemID != test.itemID || definition.Kind != test.kind ||
|
||||
definition.EquipmentType != test.token || definition.PVFPath != test.path {
|
||||
t.Fatalf("item_id=%d definition=%+v", test.itemID, definition)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVFCatalogArtifactTypeResolutionFailsClosed(t *testing.T) {
|
||||
catalog := newPetArtifactTestCatalog(t)
|
||||
if _, err := catalog.ResolveArtifact(23); !errors.Is(err, ErrPetPVFArtifactTypeInvalid) {
|
||||
t.Fatalf("creature error=%v", err)
|
||||
}
|
||||
if _, err := catalog.ResolveArtifact(24); !errors.Is(err, ErrPetPVFArtifactTypeInvalid) {
|
||||
t.Fatalf("unknown equipment error=%v", err)
|
||||
}
|
||||
if _, err := catalog.ResolveArtifact(29); !errors.Is(err, ErrPetPVFArtifactTypeAmbiguous) {
|
||||
t.Fatalf("ambiguous equipment error=%v", err)
|
||||
}
|
||||
if _, err := catalog.ResolveArtifact(999); !errors.Is(err, ErrPetPVFEquipmentUnresolved) {
|
||||
t.Fatalf("unresolved equipment error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealScriptPVFArtifactKinds(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify runtime pet artifact PVF")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := NewPVFCatalog(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
itemID int64
|
||||
kind PetArtifactKind
|
||||
path string
|
||||
}{
|
||||
{itemID: 63500, kind: PetArtifactKindRed, path: "equipment/creature/artifact_red/artifact_red.equ"},
|
||||
{itemID: 64000, kind: PetArtifactKindBlue, path: "equipment/creature/artifact_blue/artifact_blue.equ"},
|
||||
{itemID: 64500, kind: PetArtifactKindGreen, path: "equipment/creature/artifact_green/artifact_green.equ"},
|
||||
} {
|
||||
definition, err := catalog.ResolveArtifact(test.itemID)
|
||||
if err != nil {
|
||||
t.Fatalf("runtime item_id=%d: %v", test.itemID, err)
|
||||
}
|
||||
if definition.Kind != test.kind || definition.PVFPath != test.path {
|
||||
t.Fatalf("item_id=%d definition=%+v", test.itemID, definition)
|
||||
}
|
||||
t.Logf("runtime artifact kind=%s item_id=%d path=%s", test.kind, test.itemID, definition.PVFPath)
|
||||
}
|
||||
}
|
||||
|
||||
func newPetArtifactTestCatalog(t *testing.T) *PVFCatalog {
|
||||
t.Helper()
|
||||
source := petCatalogTestSource{
|
||||
petEquipmentListPath: "20 `creature/artifact_red/red.equ`\n" +
|
||||
"21 `creature/artifact_blue/blue.equ`\n" +
|
||||
"22 `creature/artifact_green/green.equ`\n" +
|
||||
"23 `creature/creature.equ`\n" +
|
||||
"24 `weapon/unknown.equ`\n" +
|
||||
"29 `creature/artifact_red/ambiguous.equ`\n",
|
||||
"equipment/creature/artifact_red/red.equ": "[equipment type] `[artifact red]`\n",
|
||||
"equipment/creature/artifact_blue/blue.equ": "[equipment type] `[artifact blue]`\n",
|
||||
"equipment/creature/artifact_green/green.equ": "[equipment type] `[artifact green]`\n",
|
||||
"equipment/creature/creature.equ": "[equipment type] `[creature]`\n",
|
||||
"equipment/weapon/unknown.equ": "[equipment type] `[weapon]`\n",
|
||||
"equipment/creature/artifact_red/ambiguous.equ": "[equipment type] `[artifact red]` `[artifact blue]`\n",
|
||||
petCreatureExperiencePath: petCatalogTestExperienceText(),
|
||||
}
|
||||
catalog, err := NewPVFCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
const petCreatureListPath = "creature/creature.lst"
|
||||
|
||||
var (
|
||||
ErrPetPVFArtifactInvalid = errors.New("pet artifact PVF definition is invalid")
|
||||
ErrPetPVFEvolutionUnresolved = errors.New("pet evolution mapping is unresolved")
|
||||
ErrPetPVFEvolutionInvalid = errors.New("pet evolution PVF definition is invalid")
|
||||
)
|
||||
|
||||
// PetSatietyModifiers is catalog-produced input for satiety calculations.
|
||||
// Its rate is deliberately private so runtime callers obtain it by resolving
|
||||
// the actual equipped artifact item ids through PVF.
|
||||
type PetSatietyModifiers struct {
|
||||
foodConsumeRatePercent int64
|
||||
}
|
||||
|
||||
// FoodConsumeRatePercent reports the summed PVF artifact modifier.
|
||||
func (m PetSatietyModifiers) FoodConsumeRatePercent() int64 {
|
||||
return m.foodConsumeRatePercent
|
||||
}
|
||||
|
||||
// FoodConsumeMultiplier follows 86JP's domain rule and preserves its lower
|
||||
// bound for artifact sets whose summed reduction is -100 percent or lower.
|
||||
func (m PetSatietyModifiers) FoodConsumeMultiplier() float64 {
|
||||
multiplier := 1 + float64(m.foodConsumeRatePercent)/100
|
||||
if multiplier <= 0 {
|
||||
return 0.01
|
||||
}
|
||||
return multiplier
|
||||
}
|
||||
|
||||
// ResolveSatietyModifiers verifies each equipped artifact against the runtime
|
||||
// equipment list/document and sums only typed artifact food-consumption rates.
|
||||
func (c *PVFCatalog) ResolveSatietyModifiers(artifactItemIDs []int64) (PetSatietyModifiers, error) {
|
||||
if c == nil || c.source == nil {
|
||||
return PetSatietyModifiers{}, ErrPetPVFSourceRequired
|
||||
}
|
||||
seen := make(map[int64]struct{}, len(artifactItemIDs))
|
||||
var total int64
|
||||
for _, itemID := range artifactItemIDs {
|
||||
if itemID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, exists := seen[itemID]; exists {
|
||||
continue
|
||||
}
|
||||
seen[itemID] = struct{}{}
|
||||
documentPath, document, err := c.resolveEquipmentDocument(itemID)
|
||||
if err != nil {
|
||||
return PetSatietyModifiers{}, err
|
||||
}
|
||||
equipmentType, found := document.Text("equipment type")
|
||||
normalizedType := normalizePetPVFType(equipmentType)
|
||||
if !found || (normalizedType != "[artifact red]" && normalizedType != "[artifact blue]" && normalizedType != "[artifact green]") {
|
||||
return PetSatietyModifiers{}, fmt.Errorf("%w: item_id=%d path=%s equipment_type=%q", ErrPetPVFArtifactInvalid, itemID, documentPath, equipmentType)
|
||||
}
|
||||
rate, _ := document.Int("creature food consume rate")
|
||||
if (rate > 0 && total > math.MaxInt64-rate) || (rate < 0 && total < math.MinInt64-rate) {
|
||||
return PetSatietyModifiers{}, fmt.Errorf("%w: food consume rate overflow item_id=%d", ErrPetPVFArtifactInvalid, itemID)
|
||||
}
|
||||
total += rate
|
||||
}
|
||||
return PetSatietyModifiers{foodConsumeRatePercent: total}, nil
|
||||
}
|
||||
|
||||
// PetEvolutionDefinition is a fully resolved current-creature -> next-creature
|
||||
// mapping. It is domain state only and contains no current-EXE packet fields.
|
||||
type PetEvolutionDefinition struct {
|
||||
CurrentItemID int64
|
||||
CurrentCreatureID int64
|
||||
TargetItemID int64
|
||||
TargetCreatureID int64
|
||||
RequiredLevel int
|
||||
RequiresQuest bool
|
||||
QuestReference string
|
||||
}
|
||||
|
||||
// PetEvolutionResolver is the fail-closed boundary used by the growth engine.
|
||||
// found=false means the creature PVF explicitly has no evolution; an absent or
|
||||
// malformed required mapping returns an error instead.
|
||||
type PetEvolutionResolver interface {
|
||||
ResolveEvolution(itemID int64) (definition PetEvolutionDefinition, found bool, err error)
|
||||
}
|
||||
|
||||
type petEvolutionIndex struct {
|
||||
creaturePaths map[int64]string
|
||||
creatureIDByBase map[string]int64
|
||||
itemByCreatureID map[int64]int64
|
||||
ambiguousCreatureID map[int64]struct{}
|
||||
}
|
||||
|
||||
// ResolveEvolution joins equipment.lst, creature.lst, the current equipment
|
||||
// document, and the current creature document. Output-index is preferred only
|
||||
// when it resolves to the PVF-declared target creature; otherwise the unique
|
||||
// creature-id item mapping is used.
|
||||
func (c *PVFCatalog) ResolveEvolution(itemID int64) (PetEvolutionDefinition, bool, error) {
|
||||
if c == nil || c.source == nil {
|
||||
return PetEvolutionDefinition{}, false, ErrPetPVFSourceRequired
|
||||
}
|
||||
currentPath, currentEquipment, err := c.resolveCreatureEquipment(itemID)
|
||||
if err != nil {
|
||||
return PetEvolutionDefinition{}, false, err
|
||||
}
|
||||
index, err := c.petEvolutionIndex()
|
||||
if err != nil {
|
||||
return PetEvolutionDefinition{}, false, err
|
||||
}
|
||||
currentCreatureID, found := index.creatureIDByBase[petPVFFileBase(currentPath)]
|
||||
if !found || currentCreatureID <= 0 {
|
||||
return PetEvolutionDefinition{}, false, fmt.Errorf("%w: item_id=%d path=%s has no creature.lst identity", ErrPetPVFEvolutionUnresolved, itemID, currentPath)
|
||||
}
|
||||
creaturePath, found := index.creaturePaths[currentCreatureID]
|
||||
if !found {
|
||||
return PetEvolutionDefinition{}, false, fmt.Errorf("%w: creature_id=%d", ErrPetPVFEvolutionUnresolved, currentCreatureID)
|
||||
}
|
||||
documentPath, text, err := readPetPVFReference(c.source, "creature", creaturePath)
|
||||
if err != nil {
|
||||
return PetEvolutionDefinition{}, false, fmt.Errorf("read pet evolution creature_id=%d path=%s: %w", currentCreatureID, creaturePath, err)
|
||||
}
|
||||
creatureDocument, err := dnfpvf.Parse(documentPath, text)
|
||||
if err != nil {
|
||||
return PetEvolutionDefinition{}, false, fmt.Errorf("parse pet evolution creature_id=%d path=%s: %w", currentCreatureID, documentPath, err)
|
||||
}
|
||||
targetCreatureID, targetFound, err := petPVFInteger(creatureDocument, "evolution creature id")
|
||||
if err != nil {
|
||||
return PetEvolutionDefinition{}, false, fmt.Errorf("%w: creature_id=%d field=evolution creature id: %v", ErrPetPVFEvolutionInvalid, currentCreatureID, err)
|
||||
}
|
||||
requiredLevel, levelFound, err := petPVFInteger(creatureDocument, "evolution level")
|
||||
if err != nil {
|
||||
return PetEvolutionDefinition{}, false, fmt.Errorf("%w: creature_id=%d field=evolution level: %v", ErrPetPVFEvolutionInvalid, currentCreatureID, err)
|
||||
}
|
||||
questReference, requiresQuest := petPVFEvolutionQuest(creatureDocument)
|
||||
if (!targetFound || targetCreatureID == 0) && (!levelFound || requiredLevel == 0) && !requiresQuest {
|
||||
return PetEvolutionDefinition{}, false, nil
|
||||
}
|
||||
if !targetFound || targetCreatureID <= 0 || targetCreatureID == currentCreatureID ||
|
||||
!levelFound || requiredLevel <= 0 || requiredLevel > MaxCreatureLevel {
|
||||
return PetEvolutionDefinition{}, false, fmt.Errorf("%w: creature_id=%d target=%d level=%d quest=%q", ErrPetPVFEvolutionInvalid, currentCreatureID, targetCreatureID, requiredLevel, questReference)
|
||||
}
|
||||
|
||||
targetItemID := int64(0)
|
||||
if outputItemID, outputFound := currentEquipment.Int("output index"); outputFound && outputItemID > 0 && outputItemID != itemID {
|
||||
if outputPath, _, outputErr := c.resolveCreatureEquipment(outputItemID); outputErr == nil {
|
||||
if resolvedID, resolved := index.creatureIDByBase[petPVFFileBase(outputPath)]; resolved && resolvedID == targetCreatureID {
|
||||
targetItemID = outputItemID
|
||||
}
|
||||
}
|
||||
}
|
||||
if targetItemID == 0 {
|
||||
if _, ambiguous := index.ambiguousCreatureID[targetCreatureID]; ambiguous {
|
||||
return PetEvolutionDefinition{}, false, fmt.Errorf("%w: target creature_id=%d has ambiguous equipment items", ErrPetPVFEvolutionUnresolved, targetCreatureID)
|
||||
}
|
||||
targetItemID = index.itemByCreatureID[targetCreatureID]
|
||||
}
|
||||
if targetItemID <= 0 || targetItemID == itemID {
|
||||
return PetEvolutionDefinition{}, false, fmt.Errorf("%w: target creature_id=%d item_id=%d", ErrPetPVFEvolutionUnresolved, targetCreatureID, targetItemID)
|
||||
}
|
||||
targetPath, _, err := c.resolveCreatureEquipment(targetItemID)
|
||||
if err != nil {
|
||||
return PetEvolutionDefinition{}, false, fmt.Errorf("resolve evolution target item_id=%d: %w", targetItemID, err)
|
||||
}
|
||||
resolvedTargetID, found := index.creatureIDByBase[petPVFFileBase(targetPath)]
|
||||
if !found || resolvedTargetID != targetCreatureID {
|
||||
return PetEvolutionDefinition{}, false, fmt.Errorf("%w: target item_id=%d creature_id=%d want=%d", ErrPetPVFEvolutionUnresolved, targetItemID, resolvedTargetID, targetCreatureID)
|
||||
}
|
||||
return PetEvolutionDefinition{
|
||||
CurrentItemID: itemID,
|
||||
CurrentCreatureID: currentCreatureID,
|
||||
TargetItemID: targetItemID,
|
||||
TargetCreatureID: targetCreatureID,
|
||||
RequiredLevel: int(requiredLevel),
|
||||
RequiresQuest: requiresQuest,
|
||||
QuestReference: questReference,
|
||||
}, true, nil
|
||||
}
|
||||
|
||||
func (c *PVFCatalog) resolveEquipmentDocument(itemID int64) (string, *dnfpvf.Document, error) {
|
||||
if itemID <= 0 || itemID > math.MaxUint32 {
|
||||
return "", nil, fmt.Errorf("%w: item_id=%d", ErrPetPVFEquipmentUnresolved, itemID)
|
||||
}
|
||||
listedPath, found := c.equipmentPaths[itemID]
|
||||
if !found {
|
||||
return "", nil, fmt.Errorf("%w: item_id=%d", ErrPetPVFEquipmentUnresolved, itemID)
|
||||
}
|
||||
documentPath, text, err := readPetPVFReference(c.source, "equipment", listedPath)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("read pet equipment item_id=%d path=%s: %w", itemID, listedPath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(documentPath, text)
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("parse pet equipment item_id=%d path=%s: %w", itemID, documentPath, err)
|
||||
}
|
||||
return documentPath, document, nil
|
||||
}
|
||||
|
||||
func (c *PVFCatalog) petEvolutionIndex() (petEvolutionIndex, error) {
|
||||
c.evolutionMu.Lock()
|
||||
defer c.evolutionMu.Unlock()
|
||||
if !c.evolutionLoaded {
|
||||
c.evolutionIndex, c.evolutionErr = loadPetEvolutionIndex(c.source, c.equipmentPaths)
|
||||
c.evolutionLoaded = true
|
||||
}
|
||||
return c.evolutionIndex, c.evolutionErr
|
||||
}
|
||||
|
||||
func loadPetEvolutionIndex(source dnfpvf.Source, equipmentPaths map[int64]string) (petEvolutionIndex, error) {
|
||||
text, err := source.ReadText(petCreatureListPath)
|
||||
if err != nil {
|
||||
return petEvolutionIndex{}, fmt.Errorf("read %s: %w", petCreatureListPath, err)
|
||||
}
|
||||
document, err := dnfpvf.Parse(petCreatureListPath, text)
|
||||
if err != nil {
|
||||
return petEvolutionIndex{}, fmt.Errorf("parse %s: %w", petCreatureListPath, err)
|
||||
}
|
||||
index := petEvolutionIndex{
|
||||
creaturePaths: make(map[int64]string),
|
||||
creatureIDByBase: make(map[string]int64),
|
||||
itemByCreatureID: make(map[int64]int64),
|
||||
ambiguousCreatureID: make(map[int64]struct{}),
|
||||
}
|
||||
for _, entry := range dnfpvf.ParseList(document) {
|
||||
creaturePath := cleanPetPVFPath(entry.Path)
|
||||
if entry.ID <= 0 || entry.ID > math.MaxUint32 || creaturePath == "" {
|
||||
continue
|
||||
}
|
||||
if previous, exists := index.creaturePaths[entry.ID]; exists && !strings.EqualFold(previous, creaturePath) {
|
||||
return petEvolutionIndex{}, fmt.Errorf("%w: duplicate creature_id=%d paths=%q,%q", ErrPetPVFEvolutionInvalid, entry.ID, previous, creaturePath)
|
||||
}
|
||||
index.creaturePaths[entry.ID] = creaturePath
|
||||
base := petPVFFileBase(creaturePath)
|
||||
if base == "" {
|
||||
continue
|
||||
}
|
||||
if previous, exists := index.creatureIDByBase[base]; exists && previous != entry.ID {
|
||||
index.creatureIDByBase[base] = 0
|
||||
continue
|
||||
}
|
||||
index.creatureIDByBase[base] = entry.ID
|
||||
}
|
||||
if len(index.creaturePaths) == 0 {
|
||||
return petEvolutionIndex{}, fmt.Errorf("%w: %s contains no creature references", ErrPetPVFEvolutionInvalid, petCreatureListPath)
|
||||
}
|
||||
for itemID, equipmentPath := range equipmentPaths {
|
||||
clean := cleanPetPVFPath(equipmentPath)
|
||||
if !strings.HasPrefix(strings.ToLower(clean), "creature/") {
|
||||
continue
|
||||
}
|
||||
creatureID := index.creatureIDByBase[petPVFFileBase(clean)]
|
||||
if creatureID <= 0 {
|
||||
continue
|
||||
}
|
||||
if previous, exists := index.itemByCreatureID[creatureID]; exists && previous != itemID {
|
||||
delete(index.itemByCreatureID, creatureID)
|
||||
index.ambiguousCreatureID[creatureID] = struct{}{}
|
||||
continue
|
||||
}
|
||||
if _, ambiguous := index.ambiguousCreatureID[creatureID]; !ambiguous {
|
||||
index.itemByCreatureID[creatureID] = itemID
|
||||
}
|
||||
}
|
||||
return index, nil
|
||||
}
|
||||
|
||||
func petPVFFileBase(value string) string {
|
||||
clean := cleanPetPVFPath(value)
|
||||
base := path.Base(clean)
|
||||
return strings.ToLower(strings.TrimSuffix(base, path.Ext(base)))
|
||||
}
|
||||
|
||||
func petPVFInteger(document *dnfpvf.Document, section string) (int64, bool, error) {
|
||||
if document == nil {
|
||||
return 0, false, nil
|
||||
}
|
||||
if value, found := document.Int(section); found {
|
||||
return value, true, nil
|
||||
}
|
||||
value, found := document.Text(section)
|
||||
if !found || strings.TrimSpace(value) == "" {
|
||||
return 0, false, nil
|
||||
}
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(strings.Trim(value, "`")), 10, 64)
|
||||
if err != nil {
|
||||
return 0, true, err
|
||||
}
|
||||
return parsed, true, nil
|
||||
}
|
||||
|
||||
func petPVFEvolutionQuest(document *dnfpvf.Document) (string, bool) {
|
||||
if document == nil {
|
||||
return "", false
|
||||
}
|
||||
tokens, found := document.Section("evolution quest")
|
||||
if !found {
|
||||
return "", false
|
||||
}
|
||||
for _, token := range tokens {
|
||||
switch token.Kind {
|
||||
case dnfpvf.TokenInt:
|
||||
value := strconv.FormatInt(token.Int, 10)
|
||||
return value, token.Int > 0
|
||||
case dnfpvf.TokenString, dnfpvf.TokenIdent:
|
||||
value := strings.TrimSpace(strings.Trim(token.Value, "`"))
|
||||
if value == "" || value == "0" || value == "-1" {
|
||||
return value, false
|
||||
}
|
||||
if parsed, err := strconv.ParseInt(value, 10, 64); err == nil {
|
||||
return value, parsed > 0
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
type petCatalogTestSource map[string]string
|
||||
|
||||
func (s petCatalogTestSource) ReadText(path string) (string, error) {
|
||||
if text, found := s[path]; found {
|
||||
return text, nil
|
||||
}
|
||||
return "", fmt.Errorf("missing %s", path)
|
||||
}
|
||||
|
||||
func TestPVFCatalogResolvesHatchAndCreatureExperience(t *testing.T) {
|
||||
source := petCatalogTestSource{
|
||||
petEquipmentListPath: "63006 `creature/egg_faras.equ`\n63000 `creature/faras.equ`\n",
|
||||
"equipment/creature/egg_faras.equ": "[equipment type] `[creature]`\n[output index] 63000\n",
|
||||
"equipment/creature/faras.equ": "[name] `Faras`\n[equipment type] `[creature]`\n[minimum level] 3\n",
|
||||
petCreatureExperiencePath: petCatalogTestExperienceText(),
|
||||
}
|
||||
catalog, err := NewPVFCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var resolver PetHatchResolver = catalog
|
||||
definition, err := resolver.ResolveHatch(63006)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if definition != (PetHatchDefinition{
|
||||
EggItemID: 63006,
|
||||
HatchedItemID: 63000,
|
||||
EggPVFPath: "equipment/creature/egg_faras.equ",
|
||||
HatchedPVFPath: "equipment/creature/faras.equ",
|
||||
MinimumLevel: 3,
|
||||
}) {
|
||||
t.Fatalf("definition=%+v", definition)
|
||||
}
|
||||
creature, err := catalog.ResolveCreature(63000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if creature.Name != "Faras" || creature.PVFPath != "equipment/creature/faras.equ" {
|
||||
t.Fatalf("creature=%+v", creature)
|
||||
}
|
||||
table := catalog.ExperienceTable()
|
||||
if got := table.LevelForExperience(0); got != 1 {
|
||||
t.Fatalf("level(exp=0)=%d", got)
|
||||
}
|
||||
if got := table.LevelForExperience(2); got != 2 {
|
||||
t.Fatalf("level(exp=2)=%d", got)
|
||||
}
|
||||
if got := table.LevelForExperience(4); got != 2 {
|
||||
t.Fatalf("level(exp=4)=%d", got)
|
||||
}
|
||||
if got := table.LevelForExperience(5); got != 3 {
|
||||
t.Fatalf("level(exp=5)=%d", got)
|
||||
}
|
||||
if got := table.LevelForExperience(1 << 60); got != MaxCreatureLevel {
|
||||
t.Fatalf("level(max exp)=%d", got)
|
||||
}
|
||||
values := table.Thresholds()
|
||||
values[0] = 999999
|
||||
if catalog.ExperienceTable().Thresholds()[0] != 2 {
|
||||
t.Fatal("experience thresholds leaked mutable catalog state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVFCatalogDefaultsMissingCreatureMinimumLevelToOne(t *testing.T) {
|
||||
source := petCatalogTestSource{
|
||||
petEquipmentListPath: "10 `egg.equ`\n11 `pet.equ`\n",
|
||||
"equipment/egg.equ": "[equipment type] `[creature]`\n[output index] 11\n",
|
||||
"equipment/pet.equ": "[equipment type] `[creature]`\n",
|
||||
petCreatureExperiencePath: petCatalogTestExperienceText(),
|
||||
}
|
||||
catalog, err := NewPVFCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, err := catalog.ResolveHatch(10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if definition.MinimumLevel != 1 {
|
||||
t.Fatalf("minimum level=%d", definition.MinimumLevel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVFCatalogRejectsUnprovedHatchMappings(t *testing.T) {
|
||||
base := petCatalogTestSource{
|
||||
petEquipmentListPath: "10 `egg.equ`\n11 `pet.equ`\n",
|
||||
"equipment/egg.equ": "[equipment type] `[creature]`\n[output index] 11\n",
|
||||
"equipment/pet.equ": "[equipment type] `[creature]`\n",
|
||||
petCreatureExperiencePath: petCatalogTestExperienceText(),
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(petCatalogTestSource)
|
||||
want error
|
||||
}{
|
||||
{
|
||||
name: "source is not creature",
|
||||
mutate: func(source petCatalogTestSource) {
|
||||
source["equipment/egg.equ"] = "[equipment type] `[weapon]`\n[output index] 11\n"
|
||||
},
|
||||
want: ErrPetPVFNotCreature,
|
||||
},
|
||||
{
|
||||
name: "missing output",
|
||||
mutate: func(source petCatalogTestSource) {
|
||||
source["equipment/egg.equ"] = "[equipment type] `[creature]`\n"
|
||||
},
|
||||
want: ErrPetPVFHatchOutputInvalid,
|
||||
},
|
||||
{
|
||||
name: "same output",
|
||||
mutate: func(source petCatalogTestSource) {
|
||||
source["equipment/egg.equ"] = "[equipment type] `[creature]`\n[output index] 10\n"
|
||||
},
|
||||
want: ErrPetPVFHatchOutputInvalid,
|
||||
},
|
||||
{
|
||||
name: "output absent from equipment list",
|
||||
mutate: func(source petCatalogTestSource) {
|
||||
source["equipment/egg.equ"] = "[equipment type] `[creature]`\n[output index] 12\n"
|
||||
},
|
||||
want: ErrPetPVFEquipmentUnresolved,
|
||||
},
|
||||
{
|
||||
name: "output is not creature",
|
||||
mutate: func(source petCatalogTestSource) {
|
||||
source["equipment/pet.equ"] = "[equipment type] `[artifact red]`\n"
|
||||
},
|
||||
want: ErrPetPVFNotCreature,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
source := make(petCatalogTestSource, len(base))
|
||||
for key, value := range base {
|
||||
source[key] = value
|
||||
}
|
||||
test.mutate(source)
|
||||
catalog, err := NewPVFCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = catalog.ResolveHatch(10)
|
||||
if !errors.Is(err, test.want) {
|
||||
t.Fatalf("error=%v want=%v", err, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVFCatalogRejectsMalformedExperienceTable(t *testing.T) {
|
||||
base := petCatalogTestSource{
|
||||
petEquipmentListPath: "10 `egg.equ`\n",
|
||||
petCreatureExperiencePath: petCatalogTestExperienceText(),
|
||||
}
|
||||
for _, text := range []string{
|
||||
"2 5 9",
|
||||
strings.Replace(petCatalogTestExperienceText(), " 5 ", " 2 ", 1),
|
||||
} {
|
||||
source := make(petCatalogTestSource, len(base))
|
||||
for key, value := range base {
|
||||
source[key] = value
|
||||
}
|
||||
source[petCreatureExperiencePath] = text
|
||||
if _, err := NewPVFCatalog(source); !errors.Is(err, ErrPetPVFExperienceTableShape) {
|
||||
t.Fatalf("error=%v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealScriptPVFPetCatalogHatchAndExperience(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify the runtime pet catalog")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := NewPVFCatalog(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
definition, err := catalog.ResolveHatch(63006)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if definition.HatchedItemID != 63000 || definition.MinimumLevel != 1 {
|
||||
t.Fatalf("real hatch definition=%+v", definition)
|
||||
}
|
||||
thresholds := catalog.ExperienceTable().Thresholds()
|
||||
if len(thresholds) != MaxCreatureLevel || thresholds[0] != 2 || thresholds[len(thresholds)-1] != 3564 {
|
||||
t.Fatalf("real experience thresholds count=%d first=%d last=%d", len(thresholds), thresholds[0], thresholds[len(thresholds)-1])
|
||||
}
|
||||
}
|
||||
|
||||
func petCatalogTestExperienceText() string {
|
||||
return "2 5 9 15 23 33 44 57 71 88 106 126 148 171 196 223 252 283 315 350 " +
|
||||
"388 429 472 517 565 615 668 725 785 849 918 993 1073 1158 1245 1335 1430 1532 1642 1759 " +
|
||||
"1879 2011 2151 2301 2464 2639 2829 3049 3294 5579\n"
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
)
|
||||
|
||||
// Command records decoded intent only. All authoritative item and creature
|
||||
// data is loaded by the owner from DB/PVF state.
|
||||
type Command struct {
|
||||
Operation string
|
||||
AccountID string
|
||||
SelectedCharacterID uint16
|
||||
ListType byte
|
||||
SlotIndex int16
|
||||
NameLength int
|
||||
RawLen int
|
||||
NeedsOwner string
|
||||
}
|
||||
|
||||
func NewRenameCommand(req alignedcmd.Request, parsed RenameCreatureRequest) Command {
|
||||
return Command{
|
||||
Operation: "rename_creature",
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
ListType: parsed.ListType,
|
||||
SlotIndex: parsed.SlotIndex,
|
||||
NameLength: len(parsed.NameRaw),
|
||||
NeedsOwner: "inventory/PetRecord serial-item-owner validation + character pet transaction + current EXE op100 ACK",
|
||||
}
|
||||
}
|
||||
|
||||
func NewHatchCommand(req alignedcmd.Request, operation string, parsed HatchCreatureRequest) Command {
|
||||
return Command{
|
||||
Operation: operation,
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
ListType: parsed.ListType,
|
||||
SlotIndex: parsed.SlotIndex,
|
||||
NeedsOwner: "runtime PVF pet catalog + inventory/equipment/pet transaction + current EXE refresh",
|
||||
}
|
||||
}
|
||||
|
||||
func NewRequestCommand(req alignedcmd.Request) Command {
|
||||
return Command{
|
||||
Operation: "request_hatched_creature",
|
||||
AccountID: strings.TrimSpace(req.AccountID),
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
RawLen: len(req.Body),
|
||||
NeedsOwner: "typed pet owner read model + current EXE op105 encoder",
|
||||
}
|
||||
}
|
||||
|
||||
func (c Command) String() string {
|
||||
base := fmt.Sprintf("account=%q char=%d", c.AccountID, c.SelectedCharacterID)
|
||||
if c.Operation == "request_hatched_creature" {
|
||||
return fmt.Sprintf("%s rawLen=%d needs=%s", base, c.RawLen, c.NeedsOwner)
|
||||
}
|
||||
if c.Operation == "rename_creature" {
|
||||
return fmt.Sprintf("%s list=%d slot=%d nameBytes=%d needs=%s", base, c.ListType, c.SlotIndex, c.NameLength, c.NeedsOwner)
|
||||
}
|
||||
return fmt.Sprintf("%s list=%d slot=%d needs=%s", base, c.ListType, c.SlotIndex, c.NeedsOwner)
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
// Package pet 承接 DNF 宠物孵化、宠物状态请求和后续佩戴显示类协议。
|
||||
package pet
|
||||
@@ -0,0 +1,239 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
const MaxCreatureExperience int64 = math.MaxInt32
|
||||
|
||||
const petSatietyScale int64 = 1_000_000
|
||||
|
||||
var (
|
||||
ErrPetGrowthCatalogRequired = errors.New("pet growth catalog is required")
|
||||
ErrPetGrowthStateInvalid = errors.New("pet growth state is invalid")
|
||||
ErrPetGrowthElapsedInvalid = errors.New("pet growth elapsed duration is invalid")
|
||||
)
|
||||
|
||||
// PetGrowthState contains the typed fields changed by the pure growth domain.
|
||||
type PetGrowthState struct {
|
||||
ItemID int64
|
||||
Experience int64
|
||||
Level int
|
||||
Satiety int
|
||||
// SatietyMicros is the authoritative continuous gauge. A zero value with a
|
||||
// positive Satiety is treated as a legacy integer-only state.
|
||||
SatietyMicros int64
|
||||
}
|
||||
|
||||
// PetEvolutionUpdate distinguishes an automatic item transition from an
|
||||
// eligible quest-gated transition.
|
||||
type PetEvolutionUpdate struct {
|
||||
Definition PetEvolutionDefinition
|
||||
Changed bool
|
||||
QuestEligible bool
|
||||
}
|
||||
|
||||
// PetGrowthUpdate is a pure before/after result; persistence is owned by the
|
||||
// caller's aggregate transaction.
|
||||
type PetGrowthUpdate struct {
|
||||
Before PetGrowthState
|
||||
After PetGrowthState
|
||||
ExperienceGained int64
|
||||
Evolution PetEvolutionUpdate
|
||||
}
|
||||
|
||||
// PetSatietyUpdate is the pure result of one elapsed-time calculation.
|
||||
type PetSatietyUpdate struct {
|
||||
Before PetGrowthState
|
||||
After PetGrowthState
|
||||
Elapsed time.Duration
|
||||
SatietyDelta int
|
||||
Changed bool
|
||||
FoodMultiplier float64
|
||||
}
|
||||
|
||||
// PetGrowthEngine applies 86JP's domain rules using only typed PVF catalog
|
||||
// inputs. It has no repository, clock, timer, or protocol responsibility.
|
||||
type PetGrowthEngine struct {
|
||||
experience CreatureExperienceTable
|
||||
evolution PetEvolutionResolver
|
||||
creatures PetCreatureResolver
|
||||
}
|
||||
|
||||
func NewPetGrowthEngine(catalog *PVFCatalog) (*PetGrowthEngine, error) {
|
||||
if catalog == nil || catalog.source == nil {
|
||||
return nil, ErrPetGrowthCatalogRequired
|
||||
}
|
||||
return &PetGrowthEngine{experience: catalog.ExperienceTable(), evolution: catalog, creatures: catalog}, nil
|
||||
}
|
||||
|
||||
func (e *PetGrowthEngine) resolveCreature(itemID int64) (PetCreatureDefinition, error) {
|
||||
if e == nil || e.creatures == nil {
|
||||
return PetCreatureDefinition{}, ErrPetGrowthCatalogRequired
|
||||
}
|
||||
definition, err := e.creatures.ResolveCreature(itemID)
|
||||
if err != nil {
|
||||
return PetCreatureDefinition{}, err
|
||||
}
|
||||
if definition.ItemID != itemID || definition.PVFPath == "" {
|
||||
return PetCreatureDefinition{}, fmt.Errorf("%w: item_id=%d definition=%+v", ErrPetGrowthCatalogRequired, itemID, definition)
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
// ApplyDungeonClear grants cumulative creature experience equal to consumed
|
||||
// fatigue only while satiety is positive. A single clear can trigger at most
|
||||
// one automatic evolution, matching the 86JP domain owner.
|
||||
func (e *PetGrowthEngine) ApplyDungeonClear(state PetGrowthState, consumedFatigue int) (PetGrowthUpdate, error) {
|
||||
return e.applyDungeonClear(state, consumedFatigue, true)
|
||||
}
|
||||
|
||||
// applyDungeonClear keeps current online progression independent from the
|
||||
// still opt-in evolution route. Runtime callers pass resolveEvolution=false
|
||||
// until the current-client evolution wire is closed; that path must not lose
|
||||
// earned experience merely because a valid creature equipment document has no
|
||||
// creature.lst identity.
|
||||
func (e *PetGrowthEngine) applyDungeonClear(state PetGrowthState, consumedFatigue int, resolveEvolution bool) (PetGrowthUpdate, error) {
|
||||
if e == nil || (resolveEvolution && e.evolution == nil) {
|
||||
return PetGrowthUpdate{}, ErrPetGrowthCatalogRequired
|
||||
}
|
||||
if err := validatePetGrowthState(state); err != nil {
|
||||
return PetGrowthUpdate{}, err
|
||||
}
|
||||
update := PetGrowthUpdate{Before: state, After: state}
|
||||
if consumedFatigue <= 0 || petSatietyMicros(state) <= 0 || state.Level >= MaxCreatureLevel {
|
||||
return update, nil
|
||||
}
|
||||
gained := int64(consumedFatigue)
|
||||
afterExperience := state.Experience
|
||||
if gained >= MaxCreatureExperience-afterExperience {
|
||||
afterExperience = MaxCreatureExperience
|
||||
} else {
|
||||
afterExperience += gained
|
||||
}
|
||||
afterLevel := e.experience.LevelForExperience(afterExperience)
|
||||
if afterLevel < state.Level {
|
||||
afterLevel = state.Level
|
||||
}
|
||||
if afterLevel > MaxCreatureLevel {
|
||||
afterLevel = MaxCreatureLevel
|
||||
}
|
||||
update.After.Experience = afterExperience
|
||||
update.After.Level = afterLevel
|
||||
update.ExperienceGained = afterExperience - state.Experience
|
||||
if !resolveEvolution || afterLevel <= state.Level {
|
||||
return update, nil
|
||||
}
|
||||
definition, found, err := e.evolution.ResolveEvolution(state.ItemID)
|
||||
if err != nil {
|
||||
return PetGrowthUpdate{}, fmt.Errorf("resolve pet evolution: %w", err)
|
||||
}
|
||||
if !found || afterLevel < definition.RequiredLevel {
|
||||
return update, nil
|
||||
}
|
||||
update.Evolution.Definition = definition
|
||||
if definition.RequiresQuest {
|
||||
update.Evolution.QuestEligible = true
|
||||
return update, nil
|
||||
}
|
||||
update.After.ItemID = definition.TargetItemID
|
||||
update.Evolution.Changed = true
|
||||
return update, nil
|
||||
}
|
||||
|
||||
// ApplyDungeonElapsed consumes one satiety point per elapsed minute multiplied
|
||||
// by the typed artifact modifier. Positive fractional remainder is truncated;
|
||||
// a still-positive creature remains visible with at least one satiety.
|
||||
func (e *PetGrowthEngine) ApplyDungeonElapsed(state PetGrowthState, elapsed time.Duration, modifiers PetSatietyModifiers) (PetSatietyUpdate, error) {
|
||||
if e == nil {
|
||||
return PetSatietyUpdate{}, ErrPetGrowthCatalogRequired
|
||||
}
|
||||
if err := validatePetGrowthState(state); err != nil {
|
||||
return PetSatietyUpdate{}, err
|
||||
}
|
||||
if elapsed < 0 {
|
||||
return PetSatietyUpdate{}, ErrPetGrowthElapsedInvalid
|
||||
}
|
||||
multiplier := modifiers.FoodConsumeMultiplier()
|
||||
update := PetSatietyUpdate{Before: state, After: state, Elapsed: elapsed, FoodMultiplier: multiplier}
|
||||
beforeMicros := petSatietyMicros(state)
|
||||
if elapsed == 0 || beforeMicros <= 0 {
|
||||
return update, nil
|
||||
}
|
||||
consumedMicros := int64(math.Round(elapsed.Seconds() / 60 * multiplier * float64(petSatietyScale)))
|
||||
afterMicros := beforeMicros - consumedMicros
|
||||
if afterMicros < 0 {
|
||||
afterMicros = 0
|
||||
}
|
||||
after := visiblePetSatietyFromMicros(afterMicros, true)
|
||||
update.After.Satiety = after
|
||||
update.After.SatietyMicros = afterMicros
|
||||
update.SatietyDelta = after - state.Satiety
|
||||
update.Changed = afterMicros != beforeMicros
|
||||
return update, nil
|
||||
}
|
||||
|
||||
// ApplyTownElapsed restores one satiety point per 360 elapsed seconds, capped
|
||||
// at 100. Artifact modifiers do not affect recovery.
|
||||
func (e *PetGrowthEngine) ApplyTownElapsed(state PetGrowthState, elapsed time.Duration) (PetSatietyUpdate, error) {
|
||||
if e == nil {
|
||||
return PetSatietyUpdate{}, ErrPetGrowthCatalogRequired
|
||||
}
|
||||
if err := validatePetGrowthState(state); err != nil {
|
||||
return PetSatietyUpdate{}, err
|
||||
}
|
||||
if elapsed < 0 {
|
||||
return PetSatietyUpdate{}, ErrPetGrowthElapsedInvalid
|
||||
}
|
||||
update := PetSatietyUpdate{Before: state, After: state, Elapsed: elapsed, FoodMultiplier: 1}
|
||||
beforeMicros := petSatietyMicros(state)
|
||||
if elapsed == 0 || beforeMicros >= 100*petSatietyScale {
|
||||
return update, nil
|
||||
}
|
||||
recoveredMicros := int64(math.Round(elapsed.Seconds() / 360 * float64(petSatietyScale)))
|
||||
afterMicros := beforeMicros + recoveredMicros
|
||||
if afterMicros > 100*petSatietyScale {
|
||||
afterMicros = 100 * petSatietyScale
|
||||
}
|
||||
after := visiblePetSatietyFromMicros(afterMicros, false)
|
||||
update.After.Satiety = after
|
||||
update.After.SatietyMicros = afterMicros
|
||||
update.SatietyDelta = after - state.Satiety
|
||||
update.Changed = afterMicros != beforeMicros
|
||||
return update, nil
|
||||
}
|
||||
|
||||
func petSatietyMicros(state PetGrowthState) int64 {
|
||||
if state.SatietyMicros > 0 || state.Satiety == 0 {
|
||||
return state.SatietyMicros
|
||||
}
|
||||
return int64(state.Satiety) * petSatietyScale
|
||||
}
|
||||
|
||||
func visiblePetSatietyFromMicros(value int64, clampAliveMinimum bool) int {
|
||||
if value <= 0 {
|
||||
return 0
|
||||
}
|
||||
if value >= 100*petSatietyScale {
|
||||
return 100
|
||||
}
|
||||
visible := int(value / petSatietyScale)
|
||||
if visible == 0 && clampAliveMinimum {
|
||||
return 1
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
func validatePetGrowthState(state PetGrowthState) error {
|
||||
if state.ItemID <= 0 || state.ItemID > math.MaxUint32 ||
|
||||
state.Experience < 0 || state.Experience > MaxCreatureExperience ||
|
||||
state.Level < 1 || state.Level > MaxCreatureLevel ||
|
||||
state.Satiety < 0 || state.Satiety > 100 ||
|
||||
state.SatietyMicros < 0 || state.SatietyMicros > 100*petSatietyScale {
|
||||
return fmt.Errorf("%w: %+v", ErrPetGrowthStateInvalid, state)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
func TestBuildCreatureGrowthBodyFollowsCurrentEXESceneOp102(t *testing.T) {
|
||||
normal, err := BuildCreatureGrowthBody(dnfrepo.PetEntry{Level: 7, Exp: 0x12345678})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []byte{7, 0, 0x78, 0x56, 0x34, 0x12}; !bytes.Equal(normal, want) {
|
||||
t.Fatalf("normal=%x want=%x", normal, want)
|
||||
}
|
||||
|
||||
mode1, err := BuildCreatureGrowthBody(dnfrepo.PetEntry{
|
||||
Level: 9, Exp: 37, ModeFlag: 1, Mode1Field0A: 2, Mode1Field0B: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := []byte{9, 1, 37, 0, 0, 0, 2, 3}; !bytes.Equal(mode1, want) {
|
||||
t.Fatalf("mode1=%x want=%x", mode1, want)
|
||||
}
|
||||
|
||||
if _, err := BuildCreatureGrowthBody(dnfrepo.PetEntry{Level: 0}); !errors.Is(err, errCreatureGrowthInvalid) {
|
||||
t.Fatalf("invalid growth error=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,398 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
equippedCreatureListType byte = 3
|
||||
equippedCreatureSlot int16 = 26
|
||||
maxGrowthClearTokenBytes = 256
|
||||
maxGrowthClearTokenHistory = 128
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPetGrowthOwnerUnavailable = errors.New("pet growth owner unavailable")
|
||||
ErrPetGrowthTransaction = errors.New("pet growth transaction unavailable")
|
||||
ErrPetGrowthTokenRequired = errors.New("authoritative pet growth clear token is required")
|
||||
ErrPetGrowthEquippedMismatch = errors.New("equipped creature persistence is inconsistent")
|
||||
)
|
||||
|
||||
// DungeonClearGrowthCommand is produced from an authoritative dungeon run.
|
||||
// ClearToken must be server-owned run identity, never a request-provided value.
|
||||
type DungeonClearGrowthCommand struct {
|
||||
SelectedCharacterID uint16
|
||||
ClearToken string
|
||||
ConsumedFatigue int
|
||||
// ApplyEvolution is intentionally opt-in. Current NoPack's incremental
|
||||
// experience route is proved, while the evolution wire is not. Online
|
||||
// callers must leave this false until that route is closed.
|
||||
ApplyEvolution bool
|
||||
}
|
||||
|
||||
// PetElapsedCommand applies elapsed wall-clock state to the currently worn
|
||||
// creature. Modifiers can only be populated by this package's typed PVF
|
||||
// catalog (the field itself is private); the zero value means no artifacts.
|
||||
type PetElapsedCommand struct {
|
||||
SelectedCharacterID uint16
|
||||
Elapsed time.Duration
|
||||
Modifiers PetSatietyModifiers
|
||||
}
|
||||
|
||||
// PetGrowthPersistenceResult describes committed domain state and contains no
|
||||
// current-client packet fields.
|
||||
type PetGrowthPersistenceResult struct {
|
||||
CharacterID string
|
||||
PetKey string
|
||||
Equipped bool
|
||||
Replayed bool
|
||||
Changed bool
|
||||
Before PetGrowthState
|
||||
After PetGrowthState
|
||||
ExperienceGained int64
|
||||
Evolution PetEvolutionUpdate
|
||||
SatietyDelta int
|
||||
}
|
||||
|
||||
// GrowthOwner atomically owns creature experience, evolution and satiety.
|
||||
type GrowthOwner struct {
|
||||
characterPets dnfrepo.CharacterPetUnitOfWork
|
||||
engine *PetGrowthEngine
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
func NewGrowthOwner(repos dnfrepo.Group, engine *PetGrowthEngine) (*GrowthOwner, error) {
|
||||
if engine == nil {
|
||||
return nil, ErrPetGrowthOwnerUnavailable
|
||||
}
|
||||
if repos.CharacterPets == nil {
|
||||
return nil, ErrPetGrowthTransaction
|
||||
}
|
||||
return &GrowthOwner{characterPets: repos.CharacterPets, engine: engine, now: time.Now}, nil
|
||||
}
|
||||
|
||||
// ApplyDungeonClear grants experience to exactly the persisted slot-26
|
||||
// creature. The durable clear token is checked across every creature entry so
|
||||
// switching the worn creature cannot replay the same run reward.
|
||||
func (o *GrowthOwner) ApplyDungeonClear(ctx context.Context, cmd DungeonClearGrowthCommand) (PetGrowthPersistenceResult, error) {
|
||||
characterID, err := growthCharacterID(cmd.SelectedCharacterID)
|
||||
if err != nil {
|
||||
return PetGrowthPersistenceResult{}, err
|
||||
}
|
||||
token := strings.TrimSpace(cmd.ClearToken)
|
||||
if token == "" || token != cmd.ClearToken || len(token) > maxGrowthClearTokenBytes {
|
||||
return PetGrowthPersistenceResult{}, ErrPetGrowthTokenRequired
|
||||
}
|
||||
if cmd.ConsumedFatigue < 0 {
|
||||
return PetGrowthPersistenceResult{}, ErrPetGrowthStateInvalid
|
||||
}
|
||||
return o.withEquipped(ctx, characterID, func(
|
||||
petRecord *dnfrepo.PetRecord,
|
||||
petKey string,
|
||||
entry *dnfrepo.PetEntry,
|
||||
equipment *dnfrepo.EquipmentRecord,
|
||||
worn *dnfrepo.EquipmentEntry,
|
||||
) (PetGrowthPersistenceResult, error) {
|
||||
for _, candidate := range petRecord.Entries {
|
||||
if candidate.AppliedClearTokens[token] {
|
||||
state, stateErr := petGrowthState(*entry)
|
||||
if stateErr != nil {
|
||||
return PetGrowthPersistenceResult{}, stateErr
|
||||
}
|
||||
return PetGrowthPersistenceResult{
|
||||
CharacterID: characterID,
|
||||
PetKey: petKey,
|
||||
Equipped: true,
|
||||
Replayed: true,
|
||||
Before: state,
|
||||
After: state,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
before, err := petGrowthState(*entry)
|
||||
if err != nil {
|
||||
return PetGrowthPersistenceResult{}, err
|
||||
}
|
||||
update, err := o.engine.applyDungeonClear(before, cmd.ConsumedFatigue, cmd.ApplyEvolution)
|
||||
if err != nil {
|
||||
return PetGrowthPersistenceResult{}, err
|
||||
}
|
||||
entry.Exp = update.After.Experience
|
||||
entry.Level = int64(update.After.Level)
|
||||
rememberGrowthClearToken(entry, token)
|
||||
if update.Evolution.Changed {
|
||||
if err := applyWornPetEvolution(entry, worn, update.Before.ItemID, update.After.ItemID); err != nil {
|
||||
return PetGrowthPersistenceResult{}, err
|
||||
}
|
||||
equipment.Entries[strconv.Itoa(int(equippedCreatureSlot))] = *worn
|
||||
}
|
||||
petRecord.Entries[petKey] = *entry
|
||||
return PetGrowthPersistenceResult{
|
||||
CharacterID: characterID,
|
||||
PetKey: petKey,
|
||||
Equipped: true,
|
||||
Changed: true,
|
||||
Before: before,
|
||||
After: update.After,
|
||||
ExperienceGained: update.ExperienceGained,
|
||||
Evolution: update.Evolution,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// rememberGrowthClearToken keeps a bounded, ordered idempotency window. Older
|
||||
// rows that only contain the legacy map are normalized deterministically on
|
||||
// their next successful clear, so existing JSON remains readable without
|
||||
// allowing the entries blob to grow forever.
|
||||
func rememberGrowthClearToken(entry *dnfrepo.PetEntry, token string) {
|
||||
if entry == nil || token == "" {
|
||||
return
|
||||
}
|
||||
active := make(map[string]bool, len(entry.AppliedClearTokens)+1)
|
||||
order := make([]string, 0, len(entry.AppliedClearTokens)+1)
|
||||
for _, candidate := range entry.AppliedClearTokenOrder {
|
||||
if candidate == "" || active[candidate] || !entry.AppliedClearTokens[candidate] {
|
||||
continue
|
||||
}
|
||||
active[candidate] = true
|
||||
order = append(order, candidate)
|
||||
}
|
||||
legacy := make([]string, 0, len(entry.AppliedClearTokens))
|
||||
for candidate, applied := range entry.AppliedClearTokens {
|
||||
if applied && candidate != "" && !active[candidate] {
|
||||
legacy = append(legacy, candidate)
|
||||
}
|
||||
}
|
||||
sort.Strings(legacy)
|
||||
for _, candidate := range legacy {
|
||||
active[candidate] = true
|
||||
order = append(order, candidate)
|
||||
}
|
||||
if !active[token] {
|
||||
active[token] = true
|
||||
order = append(order, token)
|
||||
}
|
||||
if overflow := len(order) - maxGrowthClearTokenHistory; overflow > 0 {
|
||||
order = append([]string(nil), order[overflow:]...)
|
||||
active = make(map[string]bool, len(order))
|
||||
for _, candidate := range order {
|
||||
active[candidate] = true
|
||||
}
|
||||
}
|
||||
entry.AppliedClearTokens = active
|
||||
entry.AppliedClearTokenOrder = order
|
||||
}
|
||||
|
||||
func (o *GrowthOwner) ApplyDungeonElapsed(ctx context.Context, cmd PetElapsedCommand) (PetGrowthPersistenceResult, error) {
|
||||
return o.applySatiety(ctx, cmd, true)
|
||||
}
|
||||
|
||||
func (o *GrowthOwner) ApplyTownElapsed(ctx context.Context, cmd PetElapsedCommand) (PetGrowthPersistenceResult, error) {
|
||||
return o.applySatiety(ctx, cmd, false)
|
||||
}
|
||||
|
||||
func (o *GrowthOwner) applySatiety(ctx context.Context, cmd PetElapsedCommand, dungeon bool) (PetGrowthPersistenceResult, error) {
|
||||
characterID, err := growthCharacterID(cmd.SelectedCharacterID)
|
||||
if err != nil {
|
||||
return PetGrowthPersistenceResult{}, err
|
||||
}
|
||||
return o.withEquipped(ctx, characterID, func(
|
||||
petRecord *dnfrepo.PetRecord,
|
||||
petKey string,
|
||||
entry *dnfrepo.PetEntry,
|
||||
_ *dnfrepo.EquipmentRecord,
|
||||
_ *dnfrepo.EquipmentEntry,
|
||||
) (PetGrowthPersistenceResult, error) {
|
||||
before, err := petGrowthState(*entry)
|
||||
if err != nil {
|
||||
return PetGrowthPersistenceResult{}, err
|
||||
}
|
||||
var update PetSatietyUpdate
|
||||
if dungeon {
|
||||
update, err = o.engine.ApplyDungeonElapsed(before, cmd.Elapsed, cmd.Modifiers)
|
||||
} else {
|
||||
update, err = o.engine.ApplyTownElapsed(before, cmd.Elapsed)
|
||||
}
|
||||
if err != nil {
|
||||
return PetGrowthPersistenceResult{}, err
|
||||
}
|
||||
if update.Changed {
|
||||
entry.Satiety = byte(update.After.Satiety)
|
||||
entry.SatietyMicros = update.After.SatietyMicros
|
||||
petRecord.Entries[petKey] = *entry
|
||||
}
|
||||
return PetGrowthPersistenceResult{
|
||||
CharacterID: characterID,
|
||||
PetKey: petKey,
|
||||
Equipped: true,
|
||||
Changed: update.Changed,
|
||||
Before: before,
|
||||
After: update.After,
|
||||
SatietyDelta: update.SatietyDelta,
|
||||
}, nil
|
||||
})
|
||||
}
|
||||
|
||||
type equippedGrowthMutation func(
|
||||
*dnfrepo.PetRecord,
|
||||
string,
|
||||
*dnfrepo.PetEntry,
|
||||
*dnfrepo.EquipmentRecord,
|
||||
*dnfrepo.EquipmentEntry,
|
||||
) (PetGrowthPersistenceResult, error)
|
||||
|
||||
func (o *GrowthOwner) withEquipped(ctx context.Context, characterID string, mutate equippedGrowthMutation) (PetGrowthPersistenceResult, error) {
|
||||
if o == nil || o.engine == nil {
|
||||
return PetGrowthPersistenceResult{}, ErrPetGrowthOwnerUnavailable
|
||||
}
|
||||
if o.characterPets == nil {
|
||||
return PetGrowthPersistenceResult{}, ErrPetGrowthTransaction
|
||||
}
|
||||
var result PetGrowthPersistenceResult
|
||||
err := o.characterPets.WithinCharacterPets(ctx, characterID, func(
|
||||
_ dnfrepo.InventoryRepository,
|
||||
equipmentRepo dnfrepo.EquipmentRepository,
|
||||
petRepo dnfrepo.PetRepository,
|
||||
) error {
|
||||
petRecord, petFound, err := petRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
equipment, equipmentFound, err := equipmentRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !petFound && !equipmentFound {
|
||||
result = PetGrowthPersistenceResult{CharacterID: characterID}
|
||||
return nil
|
||||
}
|
||||
petRecord = dnfrepo.ClonePet(petRecord)
|
||||
petRecord.CharacterID = characterID
|
||||
equipment = dnfrepo.CloneEquipment(equipment)
|
||||
equipment.CharacterID = characterID
|
||||
petKey := strings.TrimSpace(petRecord.EquippedKey)
|
||||
worn, wornFound := equipment.Entries[strconv.Itoa(int(equippedCreatureSlot))]
|
||||
if petKey == "" && !wornFound {
|
||||
result = PetGrowthPersistenceResult{CharacterID: characterID}
|
||||
return nil
|
||||
}
|
||||
if petKey == "" {
|
||||
// A slot-26 row without PetRecord ownership is not automatically a
|
||||
// creature. Keep ordinary/unclassified equipment a no-op, but reject a
|
||||
// real creature row that lacks the matching durable pet key.
|
||||
if _, kindErr := o.engine.resolveCreature(worn.ItemID); errors.Is(kindErr, ErrPetPVFNotCreature) {
|
||||
result = PetGrowthPersistenceResult{CharacterID: characterID}
|
||||
return nil
|
||||
} else if kindErr != nil {
|
||||
return fmt.Errorf("%w: unclassified slot26 item=%d: %w", ErrPetGrowthEquippedMismatch, worn.ItemID, kindErr)
|
||||
}
|
||||
return fmt.Errorf("%w: slot26 creature item=%d has no equipped pet key", ErrPetGrowthEquippedMismatch, worn.ItemID)
|
||||
}
|
||||
if !wornFound {
|
||||
return ErrPetGrowthEquippedMismatch
|
||||
}
|
||||
entry, entryFound := petRecord.Entries[petKey]
|
||||
if !entryFound {
|
||||
return fmt.Errorf("%w: pet_key=%s missing", ErrPetGrowthEquippedMismatch, petKey)
|
||||
}
|
||||
if err := validateWornPetEntry(petKey, entry, worn); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := o.engine.resolveCreature(entry.ItemID); err != nil {
|
||||
return fmt.Errorf("%w: pet_key=%s item=%d: %w", ErrPetGrowthEquippedMismatch, petKey, entry.ItemID, err)
|
||||
}
|
||||
result, err = mutate(&petRecord, petKey, &entry, &equipment, &worn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !result.Changed {
|
||||
return nil
|
||||
}
|
||||
now := o.now()
|
||||
petRecord.UpdatedAt = now
|
||||
if err := dnfrepo.SavePetFields(ctx, petRepo, petRecord, dnfrepo.PetFieldEntries); err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Evolution.Changed {
|
||||
equipment.UpdatedAt = now
|
||||
if err := dnfrepo.SaveEquipmentFields(ctx, equipmentRepo, equipment, dnfrepo.EquipmentFieldEntries); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func growthCharacterID(selected uint16) (string, error) {
|
||||
if selected == 0 {
|
||||
return "", ErrCharacterRequired
|
||||
}
|
||||
return strconv.FormatUint(uint64(selected), 10), nil
|
||||
}
|
||||
|
||||
func petGrowthState(entry dnfrepo.PetEntry) (PetGrowthState, error) {
|
||||
if entry.Level > math.MaxInt || entry.Exp > math.MaxInt64 {
|
||||
return PetGrowthState{}, ErrPetGrowthStateInvalid
|
||||
}
|
||||
state := PetGrowthState{
|
||||
ItemID: entry.ItemID,
|
||||
Experience: entry.Exp,
|
||||
Level: int(entry.Level),
|
||||
Satiety: int(entry.Satiety),
|
||||
SatietyMicros: entry.SatietyMicros,
|
||||
}
|
||||
if err := validatePetGrowthState(state); err != nil {
|
||||
return PetGrowthState{}, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func validateWornPetEntry(petKey string, entry dnfrepo.PetEntry, worn dnfrepo.EquipmentEntry) error {
|
||||
if entry.SourceListType != equippedCreatureListType || entry.SourceSlotIndex != equippedCreatureSlot ||
|
||||
worn.SlotIndex != equippedCreatureSlot || entry.ItemID <= 0 || entry.ItemID > math.MaxUint32 || worn.ItemID != entry.ItemID ||
|
||||
entry.CreatureKey == 0 || strconv.FormatUint(uint64(entry.CreatureKey), 10) != petKey {
|
||||
return fmt.Errorf("%w: pet_key=%s item=%d worn_item=%d source=(%d,%d)", ErrPetGrowthEquippedMismatch, petKey, entry.ItemID, worn.ItemID, entry.SourceListType, entry.SourceSlotIndex)
|
||||
}
|
||||
if len(worn.RawEntry) < 28 || int16(worn.RawEntry[0]) != equippedCreatureSlot ||
|
||||
binary.LittleEndian.Uint32(worn.RawEntry[1:5]) != uint32(entry.ItemID) ||
|
||||
binary.LittleEndian.Uint32(worn.RawEntry[5:9]) != entry.CreatureKey ||
|
||||
binary.LittleEndian.Uint32(worn.RawEntry[24:28]) != entry.CreatureKey {
|
||||
return fmt.Errorf("%w: pet_key=%s invalid slot26 raw", ErrPetGrowthEquippedMismatch, petKey)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func applyWornPetEvolution(entry *dnfrepo.PetEntry, worn *dnfrepo.EquipmentEntry, beforeItemID, afterItemID int64) error {
|
||||
if entry == nil || worn == nil || beforeItemID <= 0 || afterItemID <= 0 || afterItemID > math.MaxUint32 ||
|
||||
entry.ItemID != beforeItemID || worn.ItemID != beforeItemID {
|
||||
return ErrPetGrowthEquippedMismatch
|
||||
}
|
||||
if err := validateWornPetEntry(entry.PetKey, *entry, *worn); err != nil {
|
||||
return err
|
||||
}
|
||||
entry.ItemID = afterItemID
|
||||
worn.ItemID = afterItemID
|
||||
worn.RawEntry = append([]byte(nil), worn.RawEntry...)
|
||||
binary.LittleEndian.PutUint32(worn.RawEntry[1:5], uint32(afterItemID))
|
||||
if worn.Extra != nil {
|
||||
cloned := make(map[string]string, len(worn.Extra))
|
||||
for key, value := range worn.Extra {
|
||||
cloned[key] = value
|
||||
}
|
||||
worn.Extra = cloned
|
||||
worn.Extra["raw_entry_hex"] = hex.EncodeToString(worn.RawEntry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestGrowthOwnerDungeonClearPersistsOnceAndAtomicallyEvolvesWornCreature(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
owner := newGrowthTestOwner(t, repos)
|
||||
seedGrowthTestCreature(t, ctx, repos, 77, 37, 10, 1, 1, 100)
|
||||
|
||||
result, err := owner.ApplyDungeonClear(ctx, DungeonClearGrowthCommand{
|
||||
SelectedCharacterID: 77,
|
||||
ClearToken: "run-77-room-final",
|
||||
ConsumedFatigue: 1,
|
||||
ApplyEvolution: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Changed || result.Replayed || result.ExperienceGained != 1 ||
|
||||
result.After.ItemID != 11 || result.After.Level != 2 || result.After.Experience != 2 || !result.Evolution.Changed {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
|
||||
petRecord, ok, err := repos.Pet.Load(ctx, "77")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load pet ok=%t err=%v", ok, err)
|
||||
}
|
||||
entry := petRecord.Entries["37"]
|
||||
if entry.ItemID != 11 || entry.Level != 2 || entry.Exp != 2 || entry.Satiety != 100 ||
|
||||
string(entry.NameRaw) != "Mina" || entry.CreatureKey != 37 || !entry.AppliedClearTokens["run-77-room-final"] {
|
||||
t.Fatalf("entry=%+v", entry)
|
||||
}
|
||||
equipment, ok, err := repos.Equipment.Load(ctx, "77")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load equipment ok=%t err=%v", ok, err)
|
||||
}
|
||||
worn := equipment.Entries["26"]
|
||||
if worn.ItemID != 11 || binary.LittleEndian.Uint32(worn.RawEntry[1:5]) != 11 ||
|
||||
binary.LittleEndian.Uint32(worn.RawEntry[5:9]) != 37 ||
|
||||
binary.LittleEndian.Uint32(worn.RawEntry[24:28]) != 37 || worn.Extra["preserve"] != "yes" {
|
||||
t.Fatalf("worn=%+v raw=%x", worn, worn.RawEntry)
|
||||
}
|
||||
expectedRaw := make([]byte, 48)
|
||||
expectedRaw[0] = byte(equippedCreatureSlot)
|
||||
binary.LittleEndian.PutUint32(expectedRaw[1:5], 11)
|
||||
binary.LittleEndian.PutUint32(expectedRaw[5:9], 37)
|
||||
binary.LittleEndian.PutUint32(expectedRaw[24:28], 37)
|
||||
if !reflect.DeepEqual(worn.RawEntry, expectedRaw) {
|
||||
t.Fatalf("evolution changed raw outside item field: got=%x want=%x", worn.RawEntry, expectedRaw)
|
||||
}
|
||||
|
||||
replay, err := owner.ApplyDungeonClear(ctx, DungeonClearGrowthCommand{
|
||||
SelectedCharacterID: 77,
|
||||
ClearToken: "run-77-room-final",
|
||||
ConsumedFatigue: 99,
|
||||
ApplyEvolution: true,
|
||||
})
|
||||
if err != nil || !replay.Replayed || replay.Changed || replay.After.Experience != 2 {
|
||||
t.Fatalf("replay=%+v err=%v", replay, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRememberGrowthClearTokenBoundsAndMigratesLegacyMap(t *testing.T) {
|
||||
entry := dnfrepo.PetEntry{
|
||||
AppliedClearTokens: map[string]bool{
|
||||
"legacy-b": true,
|
||||
"legacy-a": true,
|
||||
"ignored": false,
|
||||
},
|
||||
}
|
||||
rememberGrowthClearToken(&entry, "run-000")
|
||||
if got, want := entry.AppliedClearTokenOrder[:3], []string{"legacy-a", "legacy-b", "run-000"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("legacy order = %v, want %v", got, want)
|
||||
}
|
||||
for index := 1; index <= maxGrowthClearTokenHistory+8; index++ {
|
||||
rememberGrowthClearToken(&entry, fmt.Sprintf("run-%03d", index))
|
||||
}
|
||||
if got := len(entry.AppliedClearTokenOrder); got != maxGrowthClearTokenHistory {
|
||||
t.Fatalf("order length = %d, want %d", got, maxGrowthClearTokenHistory)
|
||||
}
|
||||
if got := len(entry.AppliedClearTokens); got != maxGrowthClearTokenHistory {
|
||||
t.Fatalf("token map length = %d, want %d", got, maxGrowthClearTokenHistory)
|
||||
}
|
||||
if entry.AppliedClearTokens["legacy-a"] || entry.AppliedClearTokens["run-000"] {
|
||||
t.Fatalf("old tokens were not evicted: %#v", entry.AppliedClearTokens)
|
||||
}
|
||||
last := fmt.Sprintf("run-%03d", maxGrowthClearTokenHistory+8)
|
||||
if !entry.AppliedClearTokens[last] || entry.AppliedClearTokenOrder[len(entry.AppliedClearTokenOrder)-1] != last {
|
||||
t.Fatalf("newest token missing: last=%q map=%v", last, entry.AppliedClearTokens[last])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrowthOwnerSameClearTokenIsRaceIdempotent(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
owner := newGrowthTestOwner(t, repos)
|
||||
seedGrowthTestCreature(t, ctx, repos, 78, 38, 10, 1, 1, 100)
|
||||
|
||||
const callers = 32
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, callers)
|
||||
changed := make(chan bool, callers)
|
||||
for i := 0; i < callers; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
result, err := owner.ApplyDungeonClear(ctx, DungeonClearGrowthCommand{
|
||||
SelectedCharacterID: 78,
|
||||
ClearToken: "authoritative-run-78",
|
||||
ConsumedFatigue: 1,
|
||||
ApplyEvolution: true,
|
||||
})
|
||||
errs <- err
|
||||
changed <- result.Changed
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
close(changed)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writes := 0
|
||||
for applied := range changed {
|
||||
if applied {
|
||||
writes++
|
||||
}
|
||||
}
|
||||
if writes != 1 {
|
||||
t.Fatalf("changed writes=%d want=1", writes)
|
||||
}
|
||||
record, _, _ := repos.Pet.Load(ctx, "78")
|
||||
if got := record.Entries["38"]; got.Exp != 2 || got.Level != 2 || got.ItemID != 11 || len(got.AppliedClearTokens) != 1 {
|
||||
t.Fatalf("entry=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrowthOwnerOnlinePolicyCommitsLevelWithoutUnprovedEvolution(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
owner := newGrowthTestOwner(t, repos)
|
||||
seedGrowthTestCreature(t, ctx, repos, 88, 48, 10, 1, 1, 100)
|
||||
|
||||
result, err := owner.ApplyDungeonClear(ctx, DungeonClearGrowthCommand{
|
||||
SelectedCharacterID: 88,
|
||||
ClearToken: "online-current-exe-room",
|
||||
ConsumedFatigue: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Changed || result.ExperienceGained != 1 || result.After.Experience != 2 || result.After.Level != 2 ||
|
||||
result.After.ItemID != 10 || result.Evolution.Changed || result.Evolution.QuestEligible {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
record, _, _ := repos.Pet.Load(ctx, "88")
|
||||
equipment, _, _ := repos.Equipment.Load(ctx, "88")
|
||||
if record.Entries["48"].ItemID != 10 || equipment.Entries["26"].ItemID != 10 {
|
||||
t.Fatalf("unproved evolution leaked pet=%+v equipment=%+v", record.Entries["48"], equipment.Entries["26"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrowthOwnerOnlinePolicyDoesNotRequireEvolutionIdentity(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
source := petGrowthTestSource(false)
|
||||
delete(source, petCreatureListPath)
|
||||
catalog, err := NewPVFCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := NewPetGrowthEngine(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewGrowthOwner(repos, engine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seedGrowthTestCreature(t, ctx, repos, 89, 49, 10, 1, 1, 100)
|
||||
|
||||
result, err := owner.ApplyDungeonClear(ctx, DungeonClearGrowthCommand{
|
||||
SelectedCharacterID: 89,
|
||||
ClearToken: "online-current-exe-unmapped-evolution",
|
||||
ConsumedFatigue: 1,
|
||||
ApplyEvolution: false,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !result.Changed || result.ExperienceGained != 1 || result.After.Experience != 2 ||
|
||||
result.After.Level != 2 || result.After.ItemID != 10 ||
|
||||
result.Evolution.Changed || result.Evolution.QuestEligible {
|
||||
t.Fatalf("result=%+v", result)
|
||||
}
|
||||
record, _, _ := repos.Pet.Load(ctx, "89")
|
||||
if got := record.Entries["49"]; got.Exp != 2 || got.Level != 2 ||
|
||||
got.ItemID != 10 || !got.AppliedClearTokens["online-current-exe-unmapped-evolution"] {
|
||||
t.Fatalf("entry=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrowthOwnerPersistsDungeonConsumptionAndTownRecovery(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
owner := newGrowthTestOwner(t, repos)
|
||||
seedGrowthTestCreature(t, ctx, repos, 79, 39, 10, 1, 0, 10)
|
||||
|
||||
consumed, err := owner.ApplyDungeonElapsed(ctx, PetElapsedCommand{
|
||||
SelectedCharacterID: 79,
|
||||
Elapsed: 2 * time.Minute,
|
||||
})
|
||||
if err != nil || !consumed.Changed || consumed.SatietyDelta != -2 || consumed.After.Satiety != 8 {
|
||||
t.Fatalf("consumed=%+v err=%v", consumed, err)
|
||||
}
|
||||
recovered, err := owner.ApplyTownElapsed(ctx, PetElapsedCommand{
|
||||
SelectedCharacterID: 79,
|
||||
Elapsed: 12 * time.Minute,
|
||||
})
|
||||
if err != nil || !recovered.Changed || recovered.SatietyDelta != 2 || recovered.After.Satiety != 10 {
|
||||
t.Fatalf("recovered=%+v err=%v", recovered, err)
|
||||
}
|
||||
record, _, _ := repos.Pet.Load(ctx, "79")
|
||||
if got := record.Entries["39"]; got.Satiety != 10 || got.ItemID != 10 || got.Exp != 0 || got.Level != 1 {
|
||||
t.Fatalf("entry=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrowthOwnerFailsClosedOnWornProjectionAndLateEvolutionSave(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
owner := newGrowthTestOwner(t, repos)
|
||||
seedGrowthTestCreature(t, ctx, repos, 80, 40, 10, 1, 1, 100)
|
||||
|
||||
equipment, _, _ := repos.Equipment.Load(ctx, "80")
|
||||
broken := equipment.Entries["26"]
|
||||
broken.ItemID = 999
|
||||
equipment.Entries["26"] = broken
|
||||
if err := repos.Equipment.Save(ctx, equipment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := owner.ApplyDungeonClear(ctx, DungeonClearGrowthCommand{SelectedCharacterID: 80, ClearToken: "broken", ConsumedFatigue: 1})
|
||||
if !errors.Is(err, ErrPetGrowthEquippedMismatch) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
record, _, _ := repos.Pet.Load(ctx, "80")
|
||||
if got := record.Entries["40"]; got.Exp != 1 || len(got.AppliedClearTokens) != 0 {
|
||||
t.Fatalf("mutated entry=%+v", got)
|
||||
}
|
||||
|
||||
seedGrowthTestCreature(t, ctx, repos, 81, 41, 10, 1, 1, 100)
|
||||
failing := repos
|
||||
failing.CharacterPets = failEquipmentPetUOW{base: repos.CharacterPets}
|
||||
failingOwner := newGrowthTestOwner(t, failing)
|
||||
_, err = failingOwner.ApplyDungeonClear(ctx, DungeonClearGrowthCommand{SelectedCharacterID: 81, ClearToken: "late-fail", ConsumedFatigue: 1, ApplyEvolution: true})
|
||||
if !errors.Is(err, errGrowthEquipmentSave) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
record, _, _ = repos.Pet.Load(ctx, "81")
|
||||
if got := record.Entries["41"]; got.Exp != 1 || got.Level != 1 || got.ItemID != 10 || len(got.AppliedClearTokens) != 0 {
|
||||
t.Fatalf("pet rollback failed: %+v", got)
|
||||
}
|
||||
equipment, _, _ = repos.Equipment.Load(ctx, "81")
|
||||
if got := equipment.Entries["26"]; got.ItemID != 10 || binary.LittleEndian.Uint32(got.RawEntry[1:5]) != 10 {
|
||||
t.Fatalf("equipment rollback failed: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrowthOwnerRejectsOrdinarySlot26ItemAndMissingCreatureSerialAtOffset24(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
owner := newGrowthTestOwner(t, repos)
|
||||
ordinaryRaw := make([]byte, 28)
|
||||
ordinaryRaw[0] = byte(equippedCreatureSlot)
|
||||
binary.LittleEndian.PutUint32(ordinaryRaw[1:5], 30)
|
||||
binary.LittleEndian.PutUint32(ordinaryRaw[5:9], 777)
|
||||
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
|
||||
CharacterID: "84",
|
||||
Entries: map[string]dnfrepo.EquipmentEntry{
|
||||
"26": {SlotIndex: equippedCreatureSlot, ItemID: 30, RawEntry: ordinaryRaw},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
noPet, err := owner.ApplyTownElapsed(ctx, PetElapsedCommand{SelectedCharacterID: 84, Elapsed: time.Minute})
|
||||
if err != nil || noPet.Equipped || noPet.Changed || noPet.CharacterID != "84" {
|
||||
t.Fatalf("ordinary support weapon no-op=%+v err=%v", noPet, err)
|
||||
}
|
||||
|
||||
// Even a forged/stale PetRecord and a raw +5 instance that agree with the
|
||||
// pet key cannot turn a real PVF [support weapon] into a creature.
|
||||
seedGrowthTestCreature(t, ctx, repos, 82, 42, 30, 1, 0, 100)
|
||||
_, err = owner.ApplyTownElapsed(ctx, PetElapsedCommand{SelectedCharacterID: 82, Elapsed: time.Minute})
|
||||
if !errors.Is(err, ErrPetGrowthEquippedMismatch) || !errors.Is(err, ErrPetPVFNotCreature) {
|
||||
t.Fatalf("support weapon error=%v", err)
|
||||
}
|
||||
record, _, _ := repos.Pet.Load(ctx, "82")
|
||||
if got := record.Entries["42"]; got.Satiety != 100 {
|
||||
t.Fatalf("support weapon path mutated pet=%+v", got)
|
||||
}
|
||||
|
||||
seedGrowthTestCreature(t, ctx, repos, 83, 43, 10, 1, 0, 100)
|
||||
equipment, _, _ := repos.Equipment.Load(ctx, "83")
|
||||
worn := equipment.Entries["26"]
|
||||
binary.LittleEndian.PutUint32(worn.RawEntry[24:28], 0)
|
||||
equipment.Entries["26"] = worn
|
||||
if err := repos.Equipment.Save(ctx, equipment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = owner.ApplyTownElapsed(ctx, PetElapsedCommand{SelectedCharacterID: 83, Elapsed: time.Minute})
|
||||
if !errors.Is(err, ErrPetGrowthEquippedMismatch) {
|
||||
t.Fatalf("missing +24 creature serial error=%v", err)
|
||||
}
|
||||
record, _, _ = repos.Pet.Load(ctx, "83")
|
||||
if got := record.Entries["43"]; got.Satiety != 100 {
|
||||
t.Fatalf("invalid +24 path mutated pet=%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newGrowthTestOwner(t *testing.T, repos dnfrepo.Group) *GrowthOwner {
|
||||
t.Helper()
|
||||
engine, err := NewPetGrowthEngine(newPetGrowthTestCatalog(t, false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewGrowthOwner(repos, engine)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return owner
|
||||
}
|
||||
|
||||
func seedGrowthTestCreature(t *testing.T, ctx context.Context, repos dnfrepo.Group, characterID uint16, serial uint32, itemID, level, exp int64, satiety byte) {
|
||||
t.Helper()
|
||||
key := stringID(characterID)
|
||||
petKey := uint32ID(serial)
|
||||
raw := make([]byte, 48)
|
||||
raw[0] = byte(equippedCreatureSlot)
|
||||
binary.LittleEndian.PutUint32(raw[1:5], uint32(itemID))
|
||||
binary.LittleEndian.PutUint32(raw[5:9], serial)
|
||||
binary.LittleEndian.PutUint32(raw[24:28], serial)
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
||||
CharacterID: key,
|
||||
EquippedKey: petKey,
|
||||
TownDisplay: true,
|
||||
Entries: map[string]dnfrepo.PetEntry{
|
||||
petKey: {
|
||||
PetKey: petKey,
|
||||
CreatureKey: serial,
|
||||
ItemID: itemID,
|
||||
SourceListType: equippedCreatureListType,
|
||||
SourceSlotIndex: equippedCreatureSlot,
|
||||
NameRaw: []byte("Mina"),
|
||||
Satiety: satiety,
|
||||
Level: level,
|
||||
Exp: exp,
|
||||
Extra: map[string]string{"preserve": "pet"},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
|
||||
CharacterID: key,
|
||||
Entries: map[string]dnfrepo.EquipmentEntry{
|
||||
"26": {SlotIndex: equippedCreatureSlot, ItemID: itemID, RawEntry: raw, Extra: map[string]string{"preserve": "yes"}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func stringID(value uint16) string { return uint32ID(uint32(value)) }
|
||||
|
||||
func uint32ID(value uint32) string {
|
||||
if value == 0 {
|
||||
return "0"
|
||||
}
|
||||
var buf [10]byte
|
||||
index := len(buf)
|
||||
for value > 0 {
|
||||
index--
|
||||
buf[index] = byte('0' + value%10)
|
||||
value /= 10
|
||||
}
|
||||
return string(buf[index:])
|
||||
}
|
||||
|
||||
var errGrowthEquipmentSave = errors.New("test equipment save failure")
|
||||
|
||||
type failEquipmentPetUOW struct {
|
||||
base dnfrepo.CharacterPetUnitOfWork
|
||||
}
|
||||
|
||||
func (u failEquipmentPetUOW) WithinCharacterPets(ctx context.Context, characterID string, apply func(dnfrepo.InventoryRepository, dnfrepo.EquipmentRepository, dnfrepo.PetRepository) error) error {
|
||||
return u.base.WithinCharacterPets(ctx, characterID, func(inventory dnfrepo.InventoryRepository, equipment dnfrepo.EquipmentRepository, pets dnfrepo.PetRepository) error {
|
||||
return apply(inventory, failEquipmentRepository{EquipmentRepository: equipment}, pets)
|
||||
})
|
||||
}
|
||||
|
||||
type failEquipmentRepository struct{ dnfrepo.EquipmentRepository }
|
||||
|
||||
func (failEquipmentRepository) Save(context.Context, dnfrepo.EquipmentRecord) error {
|
||||
return errGrowthEquipmentSave
|
||||
}
|
||||
@@ -0,0 +1,253 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
platformpvf "longheng.io/server/internal/platform/pvf"
|
||||
)
|
||||
|
||||
func TestPetGrowthDungeonClearLevelsAndAutoEvolvesFromPVF(t *testing.T) {
|
||||
catalog := newPetGrowthTestCatalog(t, false)
|
||||
engine, err := NewPetGrowthEngine(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
update, err := engine.ApplyDungeonClear(PetGrowthState{
|
||||
ItemID: 10, Experience: 1, Level: 1, Satiety: 100,
|
||||
}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if update.After.Experience != 2 || update.After.Level != 2 || update.After.ItemID != 11 || update.ExperienceGained != 1 {
|
||||
t.Fatalf("update=%+v", update)
|
||||
}
|
||||
if !update.Evolution.Changed || update.Evolution.QuestEligible ||
|
||||
update.Evolution.Definition.CurrentCreatureID != 1 || update.Evolution.Definition.TargetCreatureID != 2 {
|
||||
t.Fatalf("evolution=%+v", update.Evolution)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrowthDungeonClearExposesQuestEvolutionWithoutAutoMutation(t *testing.T) {
|
||||
catalog := newPetGrowthTestCatalog(t, true)
|
||||
engine, err := NewPetGrowthEngine(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
update, err := engine.ApplyDungeonClear(PetGrowthState{
|
||||
ItemID: 10, Experience: 1, Level: 1, Satiety: 100,
|
||||
}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if update.After.ItemID != 10 || update.Evolution.Changed || !update.Evolution.QuestEligible ||
|
||||
update.Evolution.Definition.QuestReference != "quest/faras.qst" {
|
||||
t.Fatalf("update=%+v", update)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrowthDungeonClearHonorsSatietyAndExperienceBounds(t *testing.T) {
|
||||
catalog := newPetGrowthTestCatalog(t, false)
|
||||
engine, err := NewPetGrowthEngine(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
dead := PetGrowthState{ItemID: 10, Experience: 1, Level: 1, Satiety: 0}
|
||||
update, err := engine.ApplyDungeonClear(dead, 50)
|
||||
if err != nil || update.After != dead || update.ExperienceGained != 0 {
|
||||
t.Fatalf("dead update=%+v err=%v", update, err)
|
||||
}
|
||||
maxed := PetGrowthState{ItemID: 11, Experience: MaxCreatureExperience, Level: MaxCreatureLevel, Satiety: 100}
|
||||
update, err = engine.ApplyDungeonClear(maxed, math.MaxInt32)
|
||||
if err != nil || update.After != maxed || update.ExperienceGained != 0 {
|
||||
t.Fatalf("max update=%+v err=%v", update, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrowthEvolutionMissingPVFFailsBeforeReturningMutation(t *testing.T) {
|
||||
source := petGrowthTestSource(false)
|
||||
delete(source, petCreatureListPath)
|
||||
catalog, err := NewPVFCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
engine, err := NewPetGrowthEngine(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := PetGrowthState{ItemID: 10, Experience: 1, Level: 1, Satiety: 100}
|
||||
if _, err := engine.ApplyDungeonClear(state, 1); err == nil {
|
||||
t.Fatal("missing creature evolution PVF did not fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrowthSatietyUsesTypedArtifactRatesAndTownRecovery(t *testing.T) {
|
||||
catalog := newPetGrowthTestCatalog(t, false)
|
||||
engine, err := NewPetGrowthEngine(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
modifiers, err := catalog.ResolveSatietyModifiers([]int64{20, 20})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if modifiers.FoodConsumeRatePercent() != 50 || modifiers.FoodConsumeMultiplier() != 1.5 {
|
||||
t.Fatalf("modifiers=%+v multiplier=%v", modifiers, modifiers.FoodConsumeMultiplier())
|
||||
}
|
||||
state := PetGrowthState{ItemID: 10, Experience: 0, Level: 1, Satiety: 10}
|
||||
dungeon, err := engine.ApplyDungeonElapsed(state, time.Minute, modifiers)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dungeon.After.Satiety != 8 || dungeon.SatietyDelta != -2 || !dungeon.Changed {
|
||||
t.Fatalf("dungeon=%+v", dungeon)
|
||||
}
|
||||
town, err := engine.ApplyTownElapsed(dungeon.After, 360*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if town.After.Satiety != 9 || town.SatietyDelta != 1 || !town.Changed {
|
||||
t.Fatalf("town=%+v", town)
|
||||
}
|
||||
reduction, err := catalog.ResolveSatietyModifiers([]int64{21})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if reduction.FoodConsumeMultiplier() != 0.01 {
|
||||
t.Fatalf("clamped multiplier=%v", reduction.FoodConsumeMultiplier())
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrowthSatietyPreservesFractionalGaugeAcrossSettlements(t *testing.T) {
|
||||
engine, err := NewPetGrowthEngine(newPetGrowthTestCatalog(t, false))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state := PetGrowthState{ItemID: 10, Level: 1, Satiety: 100}
|
||||
first, err := engine.ApplyDungeonElapsed(state, 30*time.Second, PetSatietyModifiers{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.After.Satiety != 99 || first.After.SatietyMicros != 99_500_000 {
|
||||
t.Fatalf("first=%+v", first)
|
||||
}
|
||||
second, err := engine.ApplyDungeonElapsed(first.After, 30*time.Second, PetSatietyModifiers{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if second.After.Satiety != 99 || second.After.SatietyMicros != 99_000_000 {
|
||||
t.Fatalf("second=%+v", second)
|
||||
}
|
||||
|
||||
empty := PetGrowthState{ItemID: 10, Level: 1, Satiety: 0}
|
||||
recovery1, err := engine.ApplyTownElapsed(empty, 180*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recovery1.After.Satiety != 0 || recovery1.After.SatietyMicros != 500_000 {
|
||||
t.Fatalf("recovery1=%+v", recovery1)
|
||||
}
|
||||
recovery2, err := engine.ApplyTownElapsed(recovery1.After, 180*time.Second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if recovery2.After.Satiety != 1 || recovery2.After.SatietyMicros != 1_000_000 {
|
||||
t.Fatalf("recovery2=%+v", recovery2)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetGrowthRejectsInvalidStateElapsedAndArtifact(t *testing.T) {
|
||||
catalog := newPetGrowthTestCatalog(t, false)
|
||||
engine, err := NewPetGrowthEngine(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
invalid := PetGrowthState{ItemID: 10, Level: 1, Satiety: 101}
|
||||
if _, err := engine.ApplyTownElapsed(invalid, time.Second); !errors.Is(err, ErrPetGrowthStateInvalid) {
|
||||
t.Fatalf("state error=%v", err)
|
||||
}
|
||||
valid := PetGrowthState{ItemID: 10, Level: 1, Satiety: 10}
|
||||
if _, err := engine.ApplyTownElapsed(valid, -time.Second); !errors.Is(err, ErrPetGrowthElapsedInvalid) {
|
||||
t.Fatalf("elapsed error=%v", err)
|
||||
}
|
||||
if _, err := catalog.ResolveSatietyModifiers([]int64{10}); !errors.Is(err, ErrPetPVFArtifactInvalid) {
|
||||
t.Fatalf("artifact error=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealScriptPVFPetGrowthEvolutionAndArtifactRate(t *testing.T) {
|
||||
pvfPath := os.Getenv("DNFBRIDGE_REAL_PVF_SMOKE")
|
||||
if pvfPath == "" {
|
||||
t.Skip("set DNFBRIDGE_REAL_PVF_SMOKE to verify runtime pet growth PVF")
|
||||
}
|
||||
archive, err := platformpvf.LoadArchive(platformpvf.Options{Path: pvfPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
catalog, err := NewPVFCatalog(archive)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
evolution, found, err := catalog.ResolveEvolution(63000)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !found || evolution.CurrentCreatureID != 1 || evolution.TargetCreatureID != 2 ||
|
||||
evolution.TargetItemID != 63001 || evolution.RequiredLevel != 20 || evolution.RequiresQuest {
|
||||
t.Fatalf("real evolution=%+v found=%t", evolution, found)
|
||||
}
|
||||
modifiers, err := catalog.ResolveSatietyModifiers([]int64{2747170})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if modifiers.FoodConsumeRatePercent() != 50 || modifiers.FoodConsumeMultiplier() != 1.5 {
|
||||
t.Fatalf("real artifact modifiers=%+v", modifiers)
|
||||
}
|
||||
engine, err := NewPetGrowthEngine(catalog)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
update, err := engine.ApplyDungeonClear(PetGrowthState{
|
||||
ItemID: 63000, Experience: 314, Level: 19, Satiety: 100,
|
||||
}, 1)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if update.After.Level != 20 || update.After.ItemID != 63001 || !update.Evolution.Changed {
|
||||
t.Fatalf("real growth update=%+v", update)
|
||||
}
|
||||
}
|
||||
|
||||
func newPetGrowthTestCatalog(t *testing.T, quest bool) *PVFCatalog {
|
||||
t.Helper()
|
||||
catalog, err := NewPVFCatalog(petGrowthTestSource(quest))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return catalog
|
||||
}
|
||||
|
||||
func petGrowthTestSource(quest bool) petCatalogTestSource {
|
||||
questValue := "0"
|
||||
if quest {
|
||||
questValue = "`quest/faras.qst`"
|
||||
}
|
||||
return petCatalogTestSource{
|
||||
petEquipmentListPath: "10 `creature/faras.equ`\n11 `creature/pentabas.equ`\n" +
|
||||
"20 `creature/artifact_red/test.equ`\n21 `creature/artifact_blue/reduction.equ`\n" +
|
||||
"30 `weapon/support/test.equ`\n",
|
||||
"equipment/creature/faras.equ": "[equipment type] `[creature]`\n[output index] 11\n",
|
||||
"equipment/creature/pentabas.equ": "[equipment type] `[creature]`\n",
|
||||
"equipment/creature/artifact_red/test.equ": "[equipment type] `[artifact red]`\n[creature food consume rate] 50\n",
|
||||
"equipment/creature/artifact_blue/reduction.equ": "[equipment type] `[artifact blue]`\n[creature food consume rate] -200\n",
|
||||
"equipment/weapon/support/test.equ": "[equipment type] `[support weapon]`\n",
|
||||
petCreatureExperiencePath: petCatalogTestExperienceText(),
|
||||
petCreatureListPath: "1 `faras/faras.cre`\n2 `pentabas/pentabas.cre`\n",
|
||||
"creature/faras/faras.cre": "[evolution quest] " + questValue + "\n" +
|
||||
"[evolution creature id] 2\n[evolution level] 2\n",
|
||||
"creature/pentabas/pentabas.cre": "[evolution quest] 0\n[evolution creature id] 0\n[evolution level] 0\n",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
// 本文件负责宠物命令的协议分发。
|
||||
// 宠物孵化会改动物品栏、宠物列表和城镇显示状态,当前只解析请求体,不直接回成功包。
|
||||
package pet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||||
)
|
||||
|
||||
type handler struct{}
|
||||
|
||||
// alignedHatchResolver adapts the bridge-owned runtime-PVF boundary without
|
||||
// making the pet domain depend on bridge services or PVF parser internals.
|
||||
type alignedHatchResolver struct {
|
||||
resolve alignedcmd.PetHatchResolver
|
||||
}
|
||||
|
||||
func (resolver alignedHatchResolver) ResolveHatch(eggItemID int64) (PetHatchDefinition, error) {
|
||||
if resolver.resolve == nil {
|
||||
return PetHatchDefinition{}, ErrPetCatalogUnavailable
|
||||
}
|
||||
resolved, err := resolver.resolve(eggItemID)
|
||||
if err != nil {
|
||||
return PetHatchDefinition{}, err
|
||||
}
|
||||
return PetHatchDefinition{
|
||||
EggItemID: resolved.EggItemID,
|
||||
HatchedItemID: resolved.HatchedItemID,
|
||||
EggPVFPath: resolved.EggPVFPath,
|
||||
HatchedPVFPath: resolved.HatchedPVFPath,
|
||||
MinimumLevel: resolved.MinimumLevel,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewHandler 创建宠物协议处理器。
|
||||
func NewHandler() alignedcmd.Handler {
|
||||
return handler{}
|
||||
}
|
||||
|
||||
// Domain 返回宠物业务域。
|
||||
func (handler) Domain() dnfenum.AlignedDomain {
|
||||
return dnfenum.AlignedDomainPet
|
||||
}
|
||||
|
||||
// Handle 解析宠物请求,并在宠物 owner 与列表刷新闭合前禁止成功 ACK。
|
||||
func (handler) Handle(ctx context.Context, req alignedcmd.Request) (alignedcmd.Result, error) {
|
||||
switch dnfenum.CmdPacket(req.Opcode) {
|
||||
case dnfenum.CmdPacketRenameCreature:
|
||||
parsed, err := DecodeRenameCreatureRequest(req.Body)
|
||||
cmd := NewRenameCommand(req, parsed)
|
||||
if err != nil {
|
||||
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
|
||||
}
|
||||
owner, err := NewOwner(req.Repositories)
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
|
||||
}
|
||||
result, err := owner.Rename(ctx, RenameCommand{
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
ListType: parsed.ListType,
|
||||
SlotIndex: parsed.SlotIndex,
|
||||
NameRaw: parsed.NameRaw,
|
||||
})
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
|
||||
}
|
||||
return ownerRenameAppliedResultOpen(req.Opcode, cmd, result), nil
|
||||
case dnfenum.CmdPacketHatchCreature:
|
||||
parsed, err := DecodeHatchCreatureRequest(req.Body)
|
||||
operation := "hatch_creature"
|
||||
cmd := NewHatchCommand(req, operation, parsed)
|
||||
if err != nil {
|
||||
return blockedParsedResult(cmd.Operation, cmd.String(), err), nil
|
||||
}
|
||||
var owner *Owner
|
||||
if req.PetHatchResolver == nil {
|
||||
owner, err = NewOwner(req.Repositories)
|
||||
} else {
|
||||
owner, err = NewOwner(req.Repositories, alignedHatchResolver{resolve: req.PetHatchResolver})
|
||||
}
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
|
||||
}
|
||||
result, err := owner.Hatch(ctx, HatchCommand{
|
||||
SelectedCharacterID: req.SelectedCharacterID,
|
||||
ListType: parsed.ListType,
|
||||
SlotIndex: parsed.SlotIndex,
|
||||
})
|
||||
if err != nil {
|
||||
return ownerBlockedResult(cmd.Operation, cmd.String(), err), nil
|
||||
}
|
||||
return ownerAppliedResultOpen(req.Opcode, cmd.Operation, cmd.String(), result), nil
|
||||
case dnfenum.CmdPacketHatchCreatureEgg:
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: "hatch_creature_egg_blocked",
|
||||
Reason: "current EXE proves op173 only as a bodyless S2C refresh; old C2S hatch mapping is blocked",
|
||||
}, nil
|
||||
case dnfenum.CmdPacketRequestHatchedCreature:
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: "request_hatched_creature_blocked",
|
||||
Reason: "current NoPack has no proved C2S op174 pet request; scene class0/op174 is a generic runtime-object refresh, so the historical binding is disabled",
|
||||
}, nil
|
||||
default:
|
||||
return alignedcmd.Result{
|
||||
Handled: false,
|
||||
ResponseAllowed: false,
|
||||
Operation: "unregistered",
|
||||
Reason: fmt.Sprintf("pet 模块未登记 opcode %d", req.Opcode),
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func ownerRenameAppliedResultOpen(opcode uint16, cmd Command, result RenameResult) alignedcmd.Result {
|
||||
if result.ListType != listTypePet ||
|
||||
(result.SourceListType != currentMainInventoryListType && result.SourceListType != listTypePet) ||
|
||||
result.SlotIndex < 0 ||
|
||||
(result.SlotIndex > 139 && (result.SlotIndex < 189 || result.SlotIndex > 238)) {
|
||||
return ownerBlockedResult(cmd.Operation, cmd.String(), ErrPetStateInvalid)
|
||||
}
|
||||
itemSlotRefreshes := []alignedcmd.ItemSlotRefresh{{
|
||||
ListType: result.SourceListType,
|
||||
SlotIndex: result.SlotIndex,
|
||||
}}
|
||||
postActions := []alignedcmd.PostAction(nil)
|
||||
// A list-7 op14 row immediately after op100 is unstable on the legacy
|
||||
// client. The durable card decrement is committed first; a full list-7
|
||||
// op13 projection then makes the new count visible without replaying the
|
||||
// crashing incremental row.
|
||||
if result.SourceListType == listTypePet {
|
||||
itemSlotRefreshes = nil
|
||||
postActions = append(postActions, alignedcmd.PostActionRefreshSelectedPetInventory)
|
||||
}
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: cmd.Operation,
|
||||
Reason: fmt.Sprintf(
|
||||
"pet owner applied: %s %s char=%s key=%s serial=%d item=%d changed=%t card_remaining=%d; emitted current EXE class1/op100 semantic-creature ACK, native class0/op101 in-place name notification and main-inventory source-card row refresh",
|
||||
cmd.Operation,
|
||||
cmd.String(),
|
||||
result.CharacterID,
|
||||
result.PetKey,
|
||||
result.CreatureSerial,
|
||||
result.ItemID,
|
||||
result.Changed,
|
||||
result.RemainingCount,
|
||||
),
|
||||
UpperResponses: []alignedcmd.UpperResponse{
|
||||
class1Response(opcode, buildRenameCreatureSuccessAck(result.SlotIndex, result.ListType)),
|
||||
class0Response(101, buildRenameCreatureNameNotificationBody(cmd.SelectedCharacterID, result.NameRaw)),
|
||||
},
|
||||
ItemSlotRefreshes: itemSlotRefreshes,
|
||||
PostActions: postActions,
|
||||
}
|
||||
}
|
||||
|
||||
func blockedParsedResult(operation string, summary string, err error) alignedcmd.Result {
|
||||
if err != nil {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf("%s 请求体解析失败:%v;禁止回成功包", operation, err),
|
||||
}
|
||||
}
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf("已解析 %s:%s;宠物物品、宠物列表和城镇显示刷新闭合前禁止回包", operation, summary),
|
||||
}
|
||||
}
|
||||
|
||||
func ownerBlockedResult(operation string, summary string, err error) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf("已解析 %s:%s;pet owner 写库未完成:%v;禁止回成功包", operation, summary, err),
|
||||
}
|
||||
}
|
||||
|
||||
func ownerAppliedResult(operation string, summary string, result HatchResult) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf("已解析 %s:%s;pet owner 已写库 char=%s key=%s item=%d slot=%d changed=%t;列表刷新/城镇显示回包未闭合,禁止回成功包", operation, summary, result.CharacterID, result.PetKey, result.ItemID, result.SourceSlotIndex, result.Changed),
|
||||
}
|
||||
}
|
||||
|
||||
func ownerAppliedResultOpen(opcode uint16, operation string, summary string, result HatchResult) alignedcmd.Result {
|
||||
responses := petHatchResponses(opcode, result)
|
||||
if len(responses) == 0 {
|
||||
return ownerAppliedResult(operation, summary, result)
|
||||
}
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: true,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf(
|
||||
"pet owner applied: %s %s char=%s key=%s item=%d slot=%d changed=%t; emitted typed ACK, list-7 refresh and creature-list bodies",
|
||||
operation,
|
||||
summary,
|
||||
result.CharacterID,
|
||||
result.PetKey,
|
||||
result.ItemID,
|
||||
result.SourceSlotIndex,
|
||||
result.Changed,
|
||||
),
|
||||
UpperResponses: responses,
|
||||
}
|
||||
}
|
||||
|
||||
func ownerReadResult(operation string, summary string, result ListResult) alignedcmd.Result {
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: false,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf("已解析 %s:%s;pet owner 已读取 char=%s count=%d equipped=%q townDisplay=%t;当前 EXE 响应包体未闭合,禁止回成功包", operation, summary, result.CharacterID, result.EntryCount, result.EquippedKey, result.TownDisplay),
|
||||
}
|
||||
}
|
||||
|
||||
func ownerReadResultOpen(operation string, summary string, result ListResult) alignedcmd.Result {
|
||||
responses := petReadResponses(operation, result)
|
||||
return alignedcmd.Result{
|
||||
Handled: true,
|
||||
ResponseAllowed: len(responses) > 0,
|
||||
Operation: operation,
|
||||
Reason: fmt.Sprintf(
|
||||
"pet owner read: %s %s char=%s count=%d typed=%d equipped=%q townDisplay=%t",
|
||||
operation,
|
||||
summary,
|
||||
result.CharacterID,
|
||||
result.EntryCount,
|
||||
len(result.Entries),
|
||||
result.EquippedKey,
|
||||
result.TownDisplay,
|
||||
),
|
||||
UpperResponses: responses,
|
||||
}
|
||||
}
|
||||
|
||||
func petReadResponses(operation string, result ListResult) []alignedcmd.UpperResponse {
|
||||
if operation != "request_hatched_creature" || len(result.Entries) != result.EntryCount {
|
||||
return nil
|
||||
}
|
||||
creatureListBody, err := buildCreatureListBody(result.Entries)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return []alignedcmd.UpperResponse{
|
||||
class1Response(uint16(dnfenum.CmdPacketRequestHatchedCreature), buildSuccessAck()),
|
||||
class0Response(0x0069, creatureListBody),
|
||||
}
|
||||
}
|
||||
|
||||
func petHatchResponses(opcode uint16, result HatchResult) []alignedcmd.UpperResponse {
|
||||
if len(result.Entries) != result.EntryCount || result.EntryCount == 0 {
|
||||
return nil
|
||||
}
|
||||
creatureListBody, err := buildCreatureListBody(result.Entries)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
return []alignedcmd.UpperResponse{
|
||||
class1Response(opcode, buildSuccessAck()),
|
||||
class0Response(0x000D, buildPetItemListRefreshBody(result.PetInventory)),
|
||||
class0Response(0x0069, creatureListBody),
|
||||
}
|
||||
}
|
||||
|
||||
func class1Response(opcode uint16, body []byte) alignedcmd.UpperResponse {
|
||||
return alignedcmd.UpperResponse{
|
||||
MsgID: opcode,
|
||||
Body: body,
|
||||
Classification: dnfproto.DefaultChannelClassification,
|
||||
AllowCodec: true,
|
||||
}
|
||||
}
|
||||
|
||||
func class0Response(msgID uint16, body []byte) alignedcmd.UpperResponse {
|
||||
return alignedcmd.UpperResponse{
|
||||
MsgID: msgID,
|
||||
Body: body,
|
||||
Classification: 0,
|
||||
AllowCodec: true,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestDecodeHatchCreatureRequestExactCurrentBody(t *testing.T) {
|
||||
got, err := DecodeHatchCreatureRequest([]byte{listTypePet, 0x05, 0x00})
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeHatchCreatureRequest: %v", err)
|
||||
}
|
||||
if got.ListType != listTypePet || got.SlotIndex != 5 {
|
||||
t.Fatalf("got = %+v", got)
|
||||
}
|
||||
for _, body := range [][]byte{
|
||||
{listTypePet, 5},
|
||||
{listTypePet, 5, 0, 1},
|
||||
{0, 5, 0},
|
||||
{listTypePet, 140, 0},
|
||||
} {
|
||||
if _, err := DecodeHatchCreatureRequest(body); err == nil {
|
||||
t.Fatalf("body % X unexpectedly accepted", body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreatureListBodyUsesTypedFieldsNotRawReplay(t *testing.T) {
|
||||
entry := dnfrepo.PetEntry{
|
||||
PetKey: "123",
|
||||
CreatureKey: 123,
|
||||
ItemID: 63000,
|
||||
NameRaw: []byte("abc"),
|
||||
Satiety: 100,
|
||||
Level: 3,
|
||||
Exp: 12,
|
||||
TailFlag: 1,
|
||||
RawEntry: []byte{0xDE, 0xAD, 0xBE, 0xEF},
|
||||
}
|
||||
body, err := buildCreatureListBody([]dnfrepo.PetEntry{entry})
|
||||
if err != nil {
|
||||
t.Fatalf("buildCreatureListBody: %v", err)
|
||||
}
|
||||
want := []byte{
|
||||
1,
|
||||
123, 0, 0, 0,
|
||||
100,
|
||||
0,
|
||||
12, 0, 0, 0,
|
||||
3,
|
||||
3, 0, 0, 0, 'a', 'b', 'c',
|
||||
1,
|
||||
}
|
||||
if string(body) != string(want) {
|
||||
t.Fatalf("body = % X\nwant = % X", body, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreatureListBodyModeOneAndValidation(t *testing.T) {
|
||||
body, err := buildCreatureListBody([]dnfrepo.PetEntry{{
|
||||
PetKey: "9",
|
||||
CreatureKey: 9,
|
||||
ItemID: 63000,
|
||||
Satiety: 7,
|
||||
ModeFlag: 1,
|
||||
Mode1Field0A: 2,
|
||||
Mode1Field0B: 3,
|
||||
Level: 4,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("build mode1: %v", err)
|
||||
}
|
||||
if len(body) != 19 || body[6] != 1 || body[11] != 2 || body[12] != 3 || body[13] != 4 {
|
||||
t.Fatalf("mode1 body = % X", body)
|
||||
}
|
||||
invalid := []dnfrepo.PetEntry{{PetKey: "not-numeric", ItemID: 1, Satiety: 1, Level: 1}}
|
||||
if _, err := buildCreatureListBody(invalid); err == nil {
|
||||
t.Fatal("invalid key unexpectedly encoded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCreatureListBodyEnforcesCurrentReaderExclusiveNameLimit(t *testing.T) {
|
||||
accepted := dnfrepo.PetEntry{
|
||||
PetKey: "9",
|
||||
CreatureKey: 9,
|
||||
ItemID: 63000,
|
||||
NameRaw: []byte(strings.Repeat("a", 29)),
|
||||
Level: 1,
|
||||
}
|
||||
if _, err := buildCreatureListBody([]dnfrepo.PetEntry{accepted}); err != nil {
|
||||
t.Fatalf("29-byte name rejected: %v", err)
|
||||
}
|
||||
accepted.NameRaw = []byte(strings.Repeat("b", 30))
|
||||
if _, err := buildCreatureListBody([]dnfrepo.PetEntry{accepted}); !errors.Is(err, errCreatureNameTooLong) {
|
||||
t.Fatalf("30-byte name error = %v, want %v", err, errCreatureNameTooLong)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPetHatchResponsesUseCurrentItemRowAndTypedCreatureList(t *testing.T) {
|
||||
responses := petHatchResponses(uint16(dnfenum.CmdPacketHatchCreature), HatchResult{
|
||||
CharacterID: "61",
|
||||
PetKey: "123",
|
||||
ItemID: 63000,
|
||||
Changed: true,
|
||||
PetInventory: map[string]dnfrepo.ItemStack{
|
||||
"7:5": {ItemID: 63000, Count: 1, Extra: map[string]string{"creature_serial_or_handle": "123"}},
|
||||
},
|
||||
EntryCount: 1,
|
||||
Entries: []dnfrepo.PetEntry{{PetKey: "123", CreatureKey: 123, ItemID: 63000, Satiety: 100, Level: 1}},
|
||||
})
|
||||
if len(responses) != 3 {
|
||||
t.Fatalf("response count = %d", len(responses))
|
||||
}
|
||||
if ack := responses[0]; ack.MsgID != uint16(dnfenum.CmdPacketHatchCreature) || ack.Classification != dnfproto.DefaultChannelClassification || string(ack.Body) != string([]byte{1}) {
|
||||
t.Fatalf("ack = %+v", ack)
|
||||
}
|
||||
refresh := responses[1]
|
||||
if refresh.MsgID != 0x000D || refresh.Classification != 0 || len(refresh.Body) != 3+0x77 || refresh.Body[0] != listTypePet || binary.LittleEndian.Uint16(refresh.Body[1:3]) != 1 {
|
||||
t.Fatalf("refresh header/body = %+v % X", refresh, refresh.Body)
|
||||
}
|
||||
row := refresh.Body[3:]
|
||||
if binary.LittleEndian.Uint16(row[0:2]) != 5 || binary.LittleEndian.Uint32(row[2:6]) != 63000 || binary.LittleEndian.Uint32(row[6:10]) != 123 {
|
||||
t.Fatalf("refresh row = % X", row)
|
||||
}
|
||||
if list := responses[2]; list.MsgID != 0x0069 || list.Classification != 0 || len(list.Body) != 17 || list.Body[0] != 1 || binary.LittleEndian.Uint32(list.Body[1:5]) != 123 {
|
||||
t.Fatalf("creature list = %+v body=% X", list, list.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerBlocksOldOp173C2SHatchRoute(t *testing.T) {
|
||||
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketHatchCreatureEgg),
|
||||
Body: []byte{listTypePet, 5, 0},
|
||||
SelectedCharacterID: 61,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if !got.Handled || got.ResponseAllowed || !strings.Contains(got.Reason, "op173") {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerHatchUsesInjectedRuntimePVFResolver(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "61",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"7:5": {ItemID: 63006, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{CharacterID: "61", Entries: map[string]dnfrepo.EquipmentEntry{}}); err != nil {
|
||||
t.Fatalf("save equipment: %v", err)
|
||||
}
|
||||
|
||||
resolverCalls := 0
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketHatchCreature),
|
||||
Body: []byte{listTypePet, 5, 0},
|
||||
SelectedCharacterID: 61,
|
||||
Repositories: repos,
|
||||
PetHatchResolver: func(itemID int64) (alignedcmd.PetHatchResolution, error) {
|
||||
resolverCalls++
|
||||
if itemID != 63006 {
|
||||
t.Fatalf("resolver item = %d", itemID)
|
||||
}
|
||||
return alignedcmd.PetHatchResolution{EggItemID: 63006, HatchedItemID: 63000, EggPVFPath: "creature/egg.equ", HatchedPVFPath: "creature/pet.equ", MinimumLevel: 1}, nil
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if resolverCalls != 1 || !got.Handled || !got.ResponseAllowed || len(got.UpperResponses) != 3 {
|
||||
t.Fatalf("resolver_calls=%d result=%+v", resolverCalls, got)
|
||||
}
|
||||
inventory, found, err := repos.Inventory.Load(ctx, "61")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load inventory found=%t err=%v", found, err)
|
||||
}
|
||||
if stack := inventory.Slots["7:5"]; stack.ItemID != 63000 || stack.Count != 1 {
|
||||
t.Fatalf("hatched stack = %+v", stack)
|
||||
}
|
||||
petRecord, found, err := repos.Pet.Load(ctx, "61")
|
||||
if err != nil || !found || len(petRecord.Entries) != 1 {
|
||||
t.Fatalf("load pet found=%t entries=%d err=%v", found, len(petRecord.Entries), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRequestHatchedCreatureReturnsTypedList(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
||||
CharacterID: "66",
|
||||
Entries: map[string]dnfrepo.PetEntry{
|
||||
"123": {PetKey: "123", CreatureKey: 123, ItemID: 63000, Satiety: 100, Level: 2, Exp: 7, NameRaw: []byte("pet")},
|
||||
},
|
||||
EquippedKey: "123",
|
||||
TownDisplay: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("save pet: %v", err)
|
||||
}
|
||||
got, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketRequestHatchedCreature),
|
||||
SelectedCharacterID: 66,
|
||||
Repositories: repos,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if !got.Handled || got.ResponseAllowed || len(got.UpperResponses) != 0 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRequestHatchedCreatureReturnsEmptyTypedList(t *testing.T) {
|
||||
got, err := NewHandler().Handle(context.Background(), alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketRequestHatchedCreature),
|
||||
SelectedCharacterID: 66,
|
||||
Repositories: dnfrepomemory.NewMemoryGroup(),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if !got.Handled || got.ResponseAllowed || len(got.UpperResponses) != 0 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHatchCommandContainsNoClientItemCandidate(t *testing.T) {
|
||||
cmd := NewHatchCommand(alignedcmd.Request{AccountID: " acc ", SelectedCharacterID: 70}, "hatch_creature", HatchCreatureRequest{ListType: listTypePet, SlotIndex: 3})
|
||||
if cmd.AccountID != "acc" || cmd.SelectedCharacterID != 70 || cmd.ListType != listTypePet || cmd.SlotIndex != 3 {
|
||||
t.Fatalf("cmd = %+v", cmd)
|
||||
}
|
||||
if strings.Contains(cmd.String(), "candidate") || !strings.Contains(cmd.String(), "runtime PVF") {
|
||||
t.Fatalf("command summary = %s", cmd.String())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,443 @@
|
||||
// Package pet owns durable creature state. Mutations that span the pet
|
||||
// inventory, worn creature slots and creature record always use one
|
||||
// CharacterPetUnitOfWork.
|
||||
package pet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// Current NoPack writes and reads the creature identity as a full u32 in both
|
||||
// op19 and scene op105. The former 20-bit ceiling came from historical server
|
||||
// assumptions and rejects real current-client instance values.
|
||||
const maxCreatureSerial uint32 = ^uint32(0)
|
||||
|
||||
var (
|
||||
ErrPetOwnerUnavailable = errors.New("pet owner unavailable")
|
||||
ErrPetTransactionUnavailable = errors.New("character pet transaction unavailable")
|
||||
ErrPetCatalogUnavailable = errors.New("pet PVF catalog unavailable")
|
||||
ErrCharacterRequired = errors.New("selected character id required")
|
||||
ErrInventoryNotFound = errors.New("inventory record not found")
|
||||
ErrUnsupportedList = errors.New("pet list type is not supported")
|
||||
ErrSlotNotFound = errors.New("pet item slot not found")
|
||||
ErrPetEggStackInvalid = errors.New("pet egg stack must contain exactly one item")
|
||||
ErrPetSerialUnavailable = errors.New("pet serial is unavailable")
|
||||
ErrPetStateInvalid = errors.New("pet state is invalid")
|
||||
)
|
||||
|
||||
// HatchCommand identifies the authoritative list-7 source slot. The request
|
||||
// does not carry an item id; the owner reads the item from durable inventory
|
||||
// and resolves the egg-to-creature mapping from the runtime PVF catalog.
|
||||
type HatchCommand struct {
|
||||
SelectedCharacterID uint16
|
||||
ListType byte
|
||||
SlotIndex int16
|
||||
}
|
||||
|
||||
// ListCommand requests the current durable creature read model.
|
||||
type ListCommand struct {
|
||||
SelectedCharacterID uint16
|
||||
}
|
||||
|
||||
// HatchResult contains only committed state used by current-EXE response
|
||||
// builders.
|
||||
type HatchResult struct {
|
||||
CharacterID string
|
||||
PetKey string
|
||||
ItemID int64
|
||||
SourceSlotIndex int16
|
||||
Changed bool
|
||||
PetInventory map[string]dnfrepo.ItemStack
|
||||
EntryCount int
|
||||
Entries []dnfrepo.PetEntry
|
||||
}
|
||||
|
||||
// ListResult is the current durable creature list.
|
||||
type ListResult struct {
|
||||
CharacterID string
|
||||
EntryCount int
|
||||
Entries []dnfrepo.PetEntry
|
||||
EquippedKey string
|
||||
TownDisplay bool
|
||||
}
|
||||
|
||||
// Owner owns pet list reads and atomic hatch mutations.
|
||||
type Owner struct {
|
||||
pets dnfrepo.PetRepository
|
||||
characterPets dnfrepo.CharacterPetUnitOfWork
|
||||
hatchResolver PetHatchResolver
|
||||
}
|
||||
|
||||
// NewOwner creates an owner. A resolver is optional for read-only operations,
|
||||
// but Hatch fails closed until a runtime PVF resolver is supplied.
|
||||
func NewOwner(repos dnfrepo.Group, resolver ...PetHatchResolver) (*Owner, error) {
|
||||
if repos.Pet == nil {
|
||||
return nil, ErrPetOwnerUnavailable
|
||||
}
|
||||
var hatchResolver PetHatchResolver
|
||||
if len(resolver) > 0 {
|
||||
hatchResolver = resolver[0]
|
||||
}
|
||||
return &Owner{
|
||||
pets: repos.Pet,
|
||||
characterPets: repos.CharacterPets,
|
||||
hatchResolver: hatchResolver,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// List reads the current creature record without mutating it.
|
||||
func (o *Owner) List(ctx context.Context, cmd ListCommand) (ListResult, error) {
|
||||
if o == nil || o.pets == nil {
|
||||
return ListResult{}, ErrPetOwnerUnavailable
|
||||
}
|
||||
if cmd.SelectedCharacterID == 0 {
|
||||
return ListResult{}, ErrCharacterRequired
|
||||
}
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
record, ok, err := o.pets.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
if !ok {
|
||||
return ListResult{CharacterID: characterID}, nil
|
||||
}
|
||||
record = ensurePetRecord(dnfrepo.ClonePet(record), characterID)
|
||||
entries, err := validatedCreatureEntries(record)
|
||||
if err != nil {
|
||||
return ListResult{}, err
|
||||
}
|
||||
return ListResult{
|
||||
CharacterID: characterID,
|
||||
EntryCount: len(entries),
|
||||
Entries: entries,
|
||||
EquippedKey: record.EquippedKey,
|
||||
TownDisplay: record.TownDisplay,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Hatch atomically replaces one real creature egg in list 7 with the PVF
|
||||
// output creature and creates its typed level-1 growth record.
|
||||
func (o *Owner) Hatch(ctx context.Context, cmd HatchCommand) (HatchResult, error) {
|
||||
if o == nil || o.pets == nil {
|
||||
return HatchResult{}, ErrPetOwnerUnavailable
|
||||
}
|
||||
if o.characterPets == nil {
|
||||
return HatchResult{}, ErrPetTransactionUnavailable
|
||||
}
|
||||
if o.hatchResolver == nil {
|
||||
return HatchResult{}, ErrPetCatalogUnavailable
|
||||
}
|
||||
if cmd.SelectedCharacterID == 0 {
|
||||
return HatchResult{}, ErrCharacterRequired
|
||||
}
|
||||
if cmd.ListType != listTypePet {
|
||||
return HatchResult{}, fmt.Errorf("%w: %d", ErrUnsupportedList, cmd.ListType)
|
||||
}
|
||||
if cmd.SlotIndex < 0 || cmd.SlotIndex > 139 {
|
||||
return HatchResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.ListType, cmd.SlotIndex)
|
||||
}
|
||||
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
var committed HatchResult
|
||||
err := o.characterPets.WithinCharacterPets(ctx, characterID, func(
|
||||
inventoryRepo dnfrepo.InventoryRepository,
|
||||
equipmentRepo dnfrepo.EquipmentRepository,
|
||||
petRepo dnfrepo.PetRepository,
|
||||
) error {
|
||||
inventory, ok, err := inventoryRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return ErrInventoryNotFound
|
||||
}
|
||||
inventory = ensureInventoryRecord(dnfrepo.CloneInventory(inventory), characterID)
|
||||
|
||||
equipment, ok, err := equipmentRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
equipment = dnfrepo.EquipmentRecord{CharacterID: characterID, Entries: make(map[string]dnfrepo.EquipmentEntry)}
|
||||
} else {
|
||||
equipment = ensureEquipmentRecord(dnfrepo.CloneEquipment(equipment), characterID)
|
||||
}
|
||||
|
||||
petRecord, ok, err := petRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
petRecord = dnfrepo.PetRecord{CharacterID: characterID, Entries: make(map[string]dnfrepo.PetEntry)}
|
||||
} else {
|
||||
petRecord = ensurePetRecord(dnfrepo.ClonePet(petRecord), characterID)
|
||||
}
|
||||
|
||||
slot := slotKey(cmd.ListType, cmd.SlotIndex)
|
||||
egg, found := inventory.Slots[slot]
|
||||
if !found || egg.Count <= 0 {
|
||||
return fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.ListType, cmd.SlotIndex)
|
||||
}
|
||||
if egg.Count != 1 {
|
||||
return fmt.Errorf("%w: list=%d slot=%d count=%d", ErrPetEggStackInvalid, cmd.ListType, cmd.SlotIndex, egg.Count)
|
||||
}
|
||||
|
||||
definition, err := o.hatchResolver.ResolveHatch(egg.ItemID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve pet hatch item_id=%d: %w", egg.ItemID, err)
|
||||
}
|
||||
if definition.EggItemID != egg.ItemID || definition.HatchedItemID <= 0 || definition.HatchedItemID == egg.ItemID {
|
||||
return fmt.Errorf("%w: source=%d resolved_source=%d output=%d", ErrPetStateInvalid, egg.ItemID, definition.EggItemID, definition.HatchedItemID)
|
||||
}
|
||||
|
||||
serial, err := allocateCreatureSerial(inventory, equipment, petRecord, slot, egg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
petKey := strconv.FormatUint(uint64(serial), 10)
|
||||
|
||||
now := time.Now()
|
||||
extra := map[string]string{
|
||||
"creature_serial_or_handle": strconv.FormatUint(uint64(serial), 10),
|
||||
"creature_key": petKey,
|
||||
"hatched_from_item_id": strconv.FormatInt(egg.ItemID, 10),
|
||||
}
|
||||
if definition.EggPVFPath != "" {
|
||||
extra["hatch_egg_pvf_path"] = definition.EggPVFPath
|
||||
}
|
||||
if definition.HatchedPVFPath != "" {
|
||||
extra["creature_pvf_path"] = definition.HatchedPVFPath
|
||||
}
|
||||
inventory.Slots[slot] = dnfrepo.ItemStack{
|
||||
ItemID: definition.HatchedItemID,
|
||||
Count: 1,
|
||||
Bind: egg.Bind,
|
||||
ExpireAt: egg.ExpireAt,
|
||||
Extra: extra,
|
||||
}
|
||||
inventory.UpdatedAt = now
|
||||
|
||||
petRecord.Entries[petKey] = dnfrepo.PetEntry{
|
||||
PetKey: petKey,
|
||||
CreatureKey: serial,
|
||||
ItemID: definition.HatchedItemID,
|
||||
SourceListType: listTypePet,
|
||||
SourceSlotIndex: cmd.SlotIndex,
|
||||
Satiety: 100,
|
||||
SatietyMicros: 100 * petSatietyScale,
|
||||
ModeFlag: 0,
|
||||
Level: 1,
|
||||
Exp: 0,
|
||||
TailFlag: 0,
|
||||
Extra: cloneExtra(extra),
|
||||
}
|
||||
petRecord.UpdatedAt = now
|
||||
|
||||
entries, err := validatedCreatureEntries(petRecord)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dnfrepo.SavePetFields(ctx, petRepo, petRecord, dnfrepo.PetFieldEntries); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
committed = HatchResult{
|
||||
CharacterID: characterID,
|
||||
PetKey: petKey,
|
||||
ItemID: definition.HatchedItemID,
|
||||
SourceSlotIndex: cmd.SlotIndex,
|
||||
Changed: true,
|
||||
PetInventory: cloneItemMap(inventory.Slots),
|
||||
EntryCount: len(entries),
|
||||
Entries: entries,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return HatchResult{}, err
|
||||
}
|
||||
return committed, nil
|
||||
}
|
||||
|
||||
func ensureInventoryRecord(record dnfrepo.InventoryRecord, characterID string) dnfrepo.InventoryRecord {
|
||||
record.CharacterID = characterID
|
||||
if record.Slots == nil {
|
||||
record.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
if record.Warehouse == nil {
|
||||
record.Warehouse = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func ensureEquipmentRecord(record dnfrepo.EquipmentRecord, characterID string) dnfrepo.EquipmentRecord {
|
||||
record.CharacterID = characterID
|
||||
if record.Entries == nil {
|
||||
record.Entries = make(map[string]dnfrepo.EquipmentEntry)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func ensurePetRecord(record dnfrepo.PetRecord, characterID string) dnfrepo.PetRecord {
|
||||
record.CharacterID = characterID
|
||||
if record.Entries == nil {
|
||||
record.Entries = make(map[string]dnfrepo.PetEntry)
|
||||
}
|
||||
return record
|
||||
}
|
||||
|
||||
func validatedCreatureEntries(record dnfrepo.PetRecord) ([]dnfrepo.PetEntry, error) {
|
||||
entries := creatureEntries(record)
|
||||
for index := range entries {
|
||||
entry := &entries[index]
|
||||
if entry.CreatureKey == 0 {
|
||||
parsed, err := strconv.ParseUint(strings.TrimSpace(entry.PetKey), 10, 32)
|
||||
if err != nil || parsed == 0 || parsed > uint64(maxCreatureSerial) {
|
||||
return nil, fmt.Errorf("%w: invalid key %q", ErrPetStateInvalid, entry.PetKey)
|
||||
}
|
||||
entry.CreatureKey = uint32(parsed)
|
||||
}
|
||||
if entry.CreatureKey > maxCreatureSerial || entry.ItemID <= 0 {
|
||||
return nil, fmt.Errorf("%w: key=%d item=%d", ErrPetStateInvalid, entry.CreatureKey, entry.ItemID)
|
||||
}
|
||||
if entry.ModeFlag > 1 || entry.TailFlag > 1 || entry.Level < 1 || entry.Level > MaxCreatureLevel || entry.Exp < 0 || len(entry.NameRaw) >= 30 {
|
||||
return nil, fmt.Errorf("%w: key=%d mode=%d tail=%d level=%d exp=%d name_bytes=%d", ErrPetStateInvalid, entry.CreatureKey, entry.ModeFlag, entry.TailFlag, entry.Level, entry.Exp, len(entry.NameRaw))
|
||||
}
|
||||
if len(entry.NameRaw) == 0 && entry.Name != "" {
|
||||
nameRaw := []byte(entry.Name)
|
||||
if len(nameRaw) >= 30 {
|
||||
return nil, fmt.Errorf("%w: key=%d legacy_name_bytes=%d", ErrPetStateInvalid, entry.CreatureKey, len(nameRaw))
|
||||
}
|
||||
entry.NameRaw = nameRaw
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func creatureEntries(record dnfrepo.PetRecord) []dnfrepo.PetEntry {
|
||||
if len(record.Entries) == 0 {
|
||||
return nil
|
||||
}
|
||||
entries := make([]dnfrepo.PetEntry, 0, len(record.Entries))
|
||||
for _, entry := range record.Entries {
|
||||
entry.NameRaw = append([]byte(nil), entry.NameRaw...)
|
||||
entry.RawEntry = append([]byte(nil), entry.RawEntry...)
|
||||
entry.Extra = cloneExtra(entry.Extra)
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
sort.SliceStable(entries, func(i, j int) bool {
|
||||
left, leftOK := petEntrySerial(entries[i])
|
||||
right, rightOK := petEntrySerial(entries[j])
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
if left != right {
|
||||
return left < right
|
||||
}
|
||||
return entries[i].PetKey < entries[j].PetKey
|
||||
})
|
||||
return entries
|
||||
}
|
||||
|
||||
func allocateCreatureSerial(
|
||||
inventory dnfrepo.InventoryRecord,
|
||||
equipment dnfrepo.EquipmentRecord,
|
||||
petRecord dnfrepo.PetRecord,
|
||||
sourceSlot string,
|
||||
source dnfrepo.ItemStack,
|
||||
) (uint32, error) {
|
||||
used := make(map[uint32]struct{}, len(inventory.Slots)+len(equipment.Entries)+len(petRecord.Entries))
|
||||
for slot, stack := range inventory.Slots {
|
||||
if slot == sourceSlot {
|
||||
continue
|
||||
}
|
||||
if serial, ok := stackCreatureSerial(stack.Extra); ok {
|
||||
used[serial] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, entry := range equipment.Entries {
|
||||
if serial, ok := stackCreatureSerial(entry.Extra); ok {
|
||||
used[serial] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, entry := range petRecord.Entries {
|
||||
if serial, ok := petEntrySerial(entry); ok {
|
||||
used[serial] = struct{}{}
|
||||
}
|
||||
}
|
||||
if preferred, ok := stackCreatureSerial(source.Extra); ok {
|
||||
if _, exists := used[preferred]; !exists {
|
||||
return preferred, nil
|
||||
}
|
||||
}
|
||||
for serial := uint32(1); serial != 0; serial++ {
|
||||
if _, exists := used[serial]; !exists {
|
||||
return serial, nil
|
||||
}
|
||||
}
|
||||
return 0, ErrPetSerialUnavailable
|
||||
}
|
||||
|
||||
func stackCreatureSerial(extra map[string]string) (uint32, bool) {
|
||||
for _, key := range []string{"creature_serial_or_handle", "creature_serial", "pet_serial", "serial", "handle", "instance_value", "item_uid"} {
|
||||
raw := strings.TrimSpace(extra[key])
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
value, err := strconv.ParseUint(raw, 0, 32)
|
||||
if err == nil && value > 0 && value <= uint64(maxCreatureSerial) {
|
||||
return uint32(value), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func petEntrySerial(entry dnfrepo.PetEntry) (uint32, bool) {
|
||||
if entry.CreatureKey > 0 && entry.CreatureKey <= maxCreatureSerial {
|
||||
return entry.CreatureKey, true
|
||||
}
|
||||
value, err := strconv.ParseUint(strings.TrimSpace(entry.PetKey), 10, 32)
|
||||
if err != nil || value == 0 || value > uint64(maxCreatureSerial) {
|
||||
return 0, false
|
||||
}
|
||||
return uint32(value), true
|
||||
}
|
||||
|
||||
func slotKey(listType byte, slotIndex int16) string {
|
||||
return fmt.Sprintf("%d:%d", listType, slotIndex)
|
||||
}
|
||||
|
||||
func cloneExtra(extra map[string]string) map[string]string {
|
||||
if len(extra) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]string, len(extra))
|
||||
for key, value := range extra {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneItemMap(in map[string]dnfrepo.ItemStack) map[string]dnfrepo.ItemStack {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make(map[string]dnfrepo.ItemStack, len(in))
|
||||
for key, value := range in {
|
||||
value.RawEntry = append([]byte(nil), value.RawEntry...)
|
||||
value.Extra = cloneExtra(value.Extra)
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
type fakeHatchResolver struct {
|
||||
definition PetHatchDefinition
|
||||
err error
|
||||
}
|
||||
|
||||
func (r fakeHatchResolver) ResolveHatch(eggItemID int64) (PetHatchDefinition, error) {
|
||||
if r.err != nil {
|
||||
return PetHatchDefinition{}, r.err
|
||||
}
|
||||
definition := r.definition
|
||||
if definition.EggItemID == 0 {
|
||||
definition.EggItemID = eggItemID
|
||||
}
|
||||
return definition, nil
|
||||
}
|
||||
|
||||
func TestOwnerHatchUsesPVFMappingAndAtomicTypedState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "61",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"7:5": {
|
||||
ItemID: 63006,
|
||||
Count: 1,
|
||||
Bind: true,
|
||||
RawEntry: []byte{0xAA, 0xBB},
|
||||
Extra: map[string]string{
|
||||
"creature_serial_or_handle": "123",
|
||||
"hatched_item_id": "999999", // must be ignored
|
||||
"creature_level": "40", // must be ignored
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
owner, err := NewOwner(repos, fakeHatchResolver{definition: PetHatchDefinition{
|
||||
EggItemID: 63006,
|
||||
HatchedItemID: 63000,
|
||||
EggPVFPath: "equipment/creature/egg.equ",
|
||||
HatchedPVFPath: "equipment/creature/pet.equ",
|
||||
MinimumLevel: 30,
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner: %v", err)
|
||||
}
|
||||
got, err := owner.Hatch(ctx, HatchCommand{SelectedCharacterID: 61, ListType: listTypePet, SlotIndex: 5})
|
||||
if err != nil {
|
||||
t.Fatalf("Hatch: %v", err)
|
||||
}
|
||||
if !got.Changed || got.PetKey != "123" || got.ItemID != 63000 || got.EntryCount != 1 || len(got.Entries) != 1 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
|
||||
inventory, ok, err := repos.Inventory.Load(ctx, "61")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load inventory ok=%v err=%v", ok, err)
|
||||
}
|
||||
stack := inventory.Slots["7:5"]
|
||||
if stack.ItemID != 63000 || stack.Count != 1 || !stack.Bind || len(stack.RawEntry) != 0 || stack.Extra["creature_serial_or_handle"] != "123" || stack.Extra["hatched_from_item_id"] != "63006" {
|
||||
t.Fatalf("hatched stack = %+v", stack)
|
||||
}
|
||||
if stack.Extra["hatched_item_id"] != "" || stack.Extra["creature_level"] != "" {
|
||||
t.Fatalf("untrusted egg Extra leaked into output: %+v", stack.Extra)
|
||||
}
|
||||
|
||||
record, ok, err := repos.Pet.Load(ctx, "61")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load pet ok=%v err=%v", ok, err)
|
||||
}
|
||||
entry := record.Entries["123"]
|
||||
if entry.CreatureKey != 123 || entry.ItemID != 63000 || entry.SourceListType != listTypePet || entry.SourceSlotIndex != 5 || entry.Level != 1 || entry.Exp != 0 || entry.Satiety != 100 || entry.ModeFlag != 0 || entry.TailFlag != 0 || len(entry.NameRaw) != 0 || len(entry.RawEntry) != 0 {
|
||||
t.Fatalf("typed pet entry = %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerHatchAllocatesFreshSerialOnCollision(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "62",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"7:1": {ItemID: 63000, Count: 1, Extra: map[string]string{"creature_serial_or_handle": "1"}},
|
||||
"7:4": {ItemID: 63006, Count: 1, Extra: map[string]string{"creature_serial_or_handle": "37"}},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
||||
CharacterID: "62",
|
||||
Entries: map[string]dnfrepo.PetEntry{
|
||||
"37": {PetKey: "37", CreatureKey: 37, ItemID: 63000, Level: 4, Exp: 12, Satiety: 80},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save pet: %v", err)
|
||||
}
|
||||
owner, _ := NewOwner(repos, fakeHatchResolver{definition: PetHatchDefinition{EggItemID: 63006, HatchedItemID: 63000}})
|
||||
got, err := owner.Hatch(ctx, HatchCommand{SelectedCharacterID: 62, ListType: listTypePet, SlotIndex: 4})
|
||||
if err != nil {
|
||||
t.Fatalf("Hatch: %v", err)
|
||||
}
|
||||
if got.PetKey != "2" {
|
||||
t.Fatalf("allocated pet key = %q, want first free serial 2", got.PetKey)
|
||||
}
|
||||
record, _, _ := repos.Pet.Load(ctx, "62")
|
||||
if existing := record.Entries["37"]; existing.Level != 4 || existing.Exp != 12 || existing.Satiety != 80 {
|
||||
t.Fatalf("existing growth changed: %+v", existing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerHatchRejectsStackAndResolverFailureWithoutMutation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
count int64
|
||||
resolver fakeHatchResolver
|
||||
wantErr error
|
||||
}{
|
||||
{name: "stacked egg", count: 2, resolver: fakeHatchResolver{definition: PetHatchDefinition{EggItemID: 63006, HatchedItemID: 63000}}, wantErr: ErrPetEggStackInvalid},
|
||||
{name: "PVF failure", count: 1, resolver: fakeHatchResolver{err: ErrPetPVFNotCreature}, wantErr: ErrPetPVFNotCreature},
|
||||
}
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{CharacterID: "63", Slots: map[string]dnfrepo.ItemStack{"7:3": {ItemID: 63006, Count: tc.count}}}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
owner, _ := NewOwner(repos, tc.resolver)
|
||||
_, err := owner.Hatch(ctx, HatchCommand{SelectedCharacterID: 63, ListType: listTypePet, SlotIndex: 3})
|
||||
if !errors.Is(err, tc.wantErr) {
|
||||
t.Fatalf("Hatch error = %v, want %v", err, tc.wantErr)
|
||||
}
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, "63")
|
||||
if stack := inventory.Slots["7:3"]; stack.ItemID != 63006 || stack.Count != tc.count {
|
||||
t.Fatalf("source mutated: %+v", stack)
|
||||
}
|
||||
if record, ok, err := repos.Pet.Load(ctx, "63"); err != nil || ok || len(record.Entries) != 0 {
|
||||
t.Fatalf("pet record changed ok=%v err=%v record=%+v", ok, err, record)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerHatchRequiresCatalogAndCharacterPetTransaction(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
owner, _ := NewOwner(repos)
|
||||
if _, err := owner.Hatch(ctx, HatchCommand{SelectedCharacterID: 1, ListType: listTypePet, SlotIndex: 0}); !errors.Is(err, ErrPetCatalogUnavailable) {
|
||||
t.Fatalf("missing catalog error = %v", err)
|
||||
}
|
||||
owner, _ = NewOwner(dnfrepo.Group{Pet: repos.Pet}, fakeHatchResolver{})
|
||||
if _, err := owner.Hatch(ctx, HatchCommand{SelectedCharacterID: 1, ListType: listTypePet, SlotIndex: 0}); !errors.Is(err, ErrPetTransactionUnavailable) {
|
||||
t.Fatalf("missing transaction error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerListReturnsTypedSortedState(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
||||
CharacterID: "64",
|
||||
Entries: map[string]dnfrepo.PetEntry{
|
||||
"20": {PetKey: "20", CreatureKey: 20, ItemID: 200, Name: "legacy", Satiety: 50, Level: 2},
|
||||
"3": {PetKey: "3", CreatureKey: 3, ItemID: 100, NameRaw: []byte("typed"), Satiety: 100, Level: 1},
|
||||
},
|
||||
EquippedKey: "20",
|
||||
TownDisplay: true,
|
||||
}); err != nil {
|
||||
t.Fatalf("save pet: %v", err)
|
||||
}
|
||||
owner, _ := NewOwner(dnfrepo.Group{Pet: repos.Pet})
|
||||
got, err := owner.List(ctx, ListCommand{SelectedCharacterID: 64})
|
||||
if err != nil {
|
||||
t.Fatalf("List: %v", err)
|
||||
}
|
||||
if got.EntryCount != 2 || got.Entries[0].CreatureKey != 3 || got.Entries[1].CreatureKey != 20 || string(got.Entries[1].NameRaw) != "legacy" || got.EquippedKey != "20" || !got.TownDisplay {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerListAcceptsCurrentWireFullUint32CreatureKey(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
const currentLiveKey uint32 = 1784487991
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
||||
CharacterID: "19",
|
||||
Entries: map[string]dnfrepo.PetEntry{
|
||||
"1784487991": {
|
||||
PetKey: "1784487991",
|
||||
CreatureKey: currentLiveKey,
|
||||
ItemID: 400990167,
|
||||
Satiety: 100,
|
||||
Level: 1,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save pet: %v", err)
|
||||
}
|
||||
owner, err := NewOwner(dnfrepo.Group{Pet: repos.Pet})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner: %v", err)
|
||||
}
|
||||
got, err := owner.List(ctx, ListCommand{SelectedCharacterID: 19})
|
||||
if err != nil {
|
||||
t.Fatalf("List current u32 key: %v", err)
|
||||
}
|
||||
if got.EntryCount != 1 || got.Entries[0].CreatureKey != currentLiveKey || got.Entries[0].ItemID != 400990167 {
|
||||
t.Fatalf("result = %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/text/encoding/simplifiedchinese"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const (
|
||||
currentPetRenameCardItemID int64 = 25
|
||||
currentPetRenameCardPVFPath = "stackable/cash/creature/rename_card.stk"
|
||||
currentEquippedCreatureSlot = 26
|
||||
currentMainInventoryListType byte = 0
|
||||
)
|
||||
|
||||
var (
|
||||
ErrPetRecordNotFound = errors.New("pet record not found")
|
||||
ErrPetRenameNameInvalid = errors.New("pet rename name is invalid")
|
||||
ErrPetRenameCardInvalid = errors.New("pet rename card is invalid")
|
||||
ErrPetInventoryMismatch = errors.New("pet inventory and creature record are inconsistent")
|
||||
ErrPetRecordOwnerMismatch = errors.New("pet aggregate owner does not match selected character")
|
||||
ErrPetCreatureSerialAbsent = errors.New("pet inventory serial is absent")
|
||||
ErrPetEquippedAbsent = errors.New("equipped pet creature is absent")
|
||||
)
|
||||
|
||||
// RenameCommand identifies the pet-panel rename-card slot and carries the
|
||||
// current EXE's fixed creature semantic value in ListType. Item and creature
|
||||
// identity are deliberately absent: both are loaded from durable state.
|
||||
type RenameCommand struct {
|
||||
SelectedCharacterID uint16
|
||||
ListType byte
|
||||
SlotIndex int16
|
||||
NameRaw []byte
|
||||
}
|
||||
|
||||
// RenameResult contains only committed identity and the proved ACK fields.
|
||||
type RenameResult struct {
|
||||
CharacterID string
|
||||
PetKey string
|
||||
CreatureSerial uint32
|
||||
ItemID int64
|
||||
ListType byte
|
||||
SourceListType byte
|
||||
SlotIndex int16
|
||||
NameRaw []byte
|
||||
RemainingCount int64
|
||||
Changed bool
|
||||
}
|
||||
|
||||
// Rename validates and consumes the exact current-PVF rename card from either
|
||||
// the legacy main inventory or the pet-panel consumable range, then renames
|
||||
// the creature installed in equipment slot 26 in one character-scoped
|
||||
// transaction. Current EXE sub_31F5220 sends the card slot, a fixed creature
|
||||
// semantic value 7 and the new DSTR; the slot range identifies the source
|
||||
// container. Its op100 success handler consumes the echoed source row by one.
|
||||
func (o *Owner) Rename(ctx context.Context, cmd RenameCommand) (RenameResult, error) {
|
||||
if o == nil || o.pets == nil {
|
||||
return RenameResult{}, ErrPetOwnerUnavailable
|
||||
}
|
||||
if o.characterPets == nil {
|
||||
return RenameResult{}, ErrPetTransactionUnavailable
|
||||
}
|
||||
if cmd.SelectedCharacterID == 0 {
|
||||
return RenameResult{}, ErrCharacterRequired
|
||||
}
|
||||
if cmd.ListType != listTypePet {
|
||||
return RenameResult{}, fmt.Errorf("%w: %d", ErrUnsupportedList, cmd.ListType)
|
||||
}
|
||||
if cmd.SlotIndex < 0 || (cmd.SlotIndex > 139 && (cmd.SlotIndex < 189 || cmd.SlotIndex > 238)) {
|
||||
return RenameResult{}, fmt.Errorf("%w: list=%d slot=%d", ErrSlotNotFound, cmd.ListType, cmd.SlotIndex)
|
||||
}
|
||||
if err := validateCreatureRenameName(cmd.NameRaw); err != nil {
|
||||
return RenameResult{}, fmt.Errorf("%w: %v", ErrPetRenameNameInvalid, err)
|
||||
}
|
||||
|
||||
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
||||
var committed RenameResult
|
||||
err := o.characterPets.WithinCharacterPets(ctx, characterID, func(
|
||||
inventoryRepo dnfrepo.InventoryRepository,
|
||||
equipmentRepo dnfrepo.EquipmentRepository,
|
||||
petRepo dnfrepo.PetRepository,
|
||||
) error {
|
||||
inventory, found, err := inventoryRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrInventoryNotFound
|
||||
}
|
||||
if strings.TrimSpace(inventory.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: inventory=%q selected=%q", ErrPetRecordOwnerMismatch, inventory.CharacterID, characterID)
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
// Current EXE sub_31F5220 always writes semantic creature value 7
|
||||
// after the rename-card slot. The slot itself is a main-inventory
|
||||
// index; 7 is not the durable source collection. Slots 189..238 are
|
||||
// the pet-panel consumable range; older characters may still have the
|
||||
// card in list 0.
|
||||
sourceListType := currentMainInventoryListType
|
||||
if cmd.SlotIndex >= 189 && cmd.SlotIndex <= 238 {
|
||||
sourceListType = listTypePet
|
||||
}
|
||||
cardSlot := slotKey(sourceListType, cmd.SlotIndex)
|
||||
card, found := inventory.Slots[cardSlot]
|
||||
if !found || card.Count <= 0 {
|
||||
return fmt.Errorf("%w: list=%d slot=%d", ErrPetRenameCardInvalid, cmd.ListType, cmd.SlotIndex)
|
||||
}
|
||||
if !isCurrentPetRenameCard(card) {
|
||||
return fmt.Errorf(
|
||||
"%w: list=%d slot=%d item=%d path=%q type=%q",
|
||||
ErrPetRenameCardInvalid,
|
||||
cmd.ListType,
|
||||
cmd.SlotIndex,
|
||||
card.ItemID,
|
||||
card.Extra["pvf_path"],
|
||||
card.Extra["stackable_type"],
|
||||
)
|
||||
}
|
||||
|
||||
equipment, found, err := equipmentRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrPetEquippedAbsent
|
||||
}
|
||||
if strings.TrimSpace(equipment.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: equipment=%q selected=%q", ErrPetRecordOwnerMismatch, equipment.CharacterID, characterID)
|
||||
}
|
||||
equippedItemID, equippedSerial, err := equippedCreatureIdentity(equipment)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
petRecord, found, err := petRepo.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrPetRecordNotFound
|
||||
}
|
||||
if strings.TrimSpace(petRecord.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: pet=%q selected=%q", ErrPetRecordOwnerMismatch, petRecord.CharacterID, characterID)
|
||||
}
|
||||
petRecord = dnfrepo.ClonePet(petRecord)
|
||||
if _, err := validatedCreatureEntries(petRecord); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
petKey := strconv.FormatUint(uint64(equippedSerial), 10)
|
||||
entry, found := petRecord.Entries[petKey]
|
||||
if !found {
|
||||
return fmt.Errorf("%w: serial=%d has no PetRecord entry", ErrPetInventoryMismatch, equippedSerial)
|
||||
}
|
||||
if entry.PetKey != petKey || entry.CreatureKey != equippedSerial || entry.ItemID != equippedItemID {
|
||||
return fmt.Errorf(
|
||||
"%w: equipped_item=%d serial=%d entry_key=%q entry_serial=%d entry_item=%d",
|
||||
ErrPetInventoryMismatch,
|
||||
equippedItemID,
|
||||
equippedSerial,
|
||||
entry.PetKey,
|
||||
entry.CreatureKey,
|
||||
entry.ItemID,
|
||||
)
|
||||
}
|
||||
if equippedKey := strings.TrimSpace(petRecord.EquippedKey); equippedKey != petKey {
|
||||
return fmt.Errorf("%w: equipment_serial=%d equipped_key=%q", ErrPetInventoryMismatch, equippedSerial, equippedKey)
|
||||
}
|
||||
|
||||
currentName := entry.NameRaw
|
||||
if len(currentName) == 0 && entry.Name != "" {
|
||||
currentName = []byte(entry.Name)
|
||||
}
|
||||
changed := !bytes.Equal(currentName, cmd.NameRaw)
|
||||
if changed {
|
||||
entry.NameRaw = append([]byte(nil), cmd.NameRaw...)
|
||||
entry.Name = decodeCurrentCreatureName(cmd.NameRaw)
|
||||
petRecord.Entries[petKey] = entry
|
||||
petRecord.UpdatedAt = time.Now()
|
||||
}
|
||||
remaining := card.Count - 1
|
||||
if remaining == 0 {
|
||||
delete(inventory.Slots, cardSlot)
|
||||
} else {
|
||||
card.Count = remaining
|
||||
inventory.Slots[cardSlot] = card
|
||||
}
|
||||
inventory.UpdatedAt = time.Now()
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, inventoryRepo, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
if changed {
|
||||
if err := dnfrepo.SavePetFields(ctx, petRepo, petRecord, dnfrepo.PetFieldEntries); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
committed = RenameResult{
|
||||
CharacterID: characterID,
|
||||
PetKey: petKey,
|
||||
CreatureSerial: equippedSerial,
|
||||
ItemID: equippedItemID,
|
||||
ListType: cmd.ListType,
|
||||
SourceListType: sourceListType,
|
||||
SlotIndex: cmd.SlotIndex,
|
||||
NameRaw: append([]byte(nil), cmd.NameRaw...),
|
||||
RemainingCount: remaining,
|
||||
Changed: changed,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return RenameResult{}, err
|
||||
}
|
||||
return committed, nil
|
||||
}
|
||||
|
||||
func isCurrentPetRenameCard(stack dnfrepo.ItemStack) bool {
|
||||
if stack.ItemID != currentPetRenameCardItemID || stack.Count <= 0 || stack.Extra == nil {
|
||||
return false
|
||||
}
|
||||
path := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(stack.Extra["pvf_path"]), "\\", "/"))
|
||||
stackableType := strings.ToLower(strings.TrimSpace(stack.Extra["stackable_type"]))
|
||||
return path == currentPetRenameCardPVFPath && strings.Contains(stackableType, "[creature]")
|
||||
}
|
||||
|
||||
func equippedCreatureIdentity(record dnfrepo.EquipmentRecord) (int64, uint32, error) {
|
||||
entry, found := record.Entries[strconv.Itoa(currentEquippedCreatureSlot)]
|
||||
if !found || entry.SlotIndex != currentEquippedCreatureSlot || entry.ItemID <= 0 {
|
||||
for _, candidate := range record.Entries {
|
||||
if candidate.SlotIndex == currentEquippedCreatureSlot && candidate.ItemID > 0 {
|
||||
entry = candidate
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found || len(entry.RawEntry) < 28 {
|
||||
return 0, 0, ErrPetEquippedAbsent
|
||||
}
|
||||
serial := binary.LittleEndian.Uint32(entry.RawEntry[24:28])
|
||||
if serial == 0 || serial > maxCreatureSerial {
|
||||
return 0, 0, fmt.Errorf("%w: equipped serial=%d", ErrPetCreatureSerialAbsent, serial)
|
||||
}
|
||||
return entry.ItemID, serial, nil
|
||||
}
|
||||
|
||||
func decodeCurrentCreatureName(raw []byte) string {
|
||||
decoded, err := simplifiedchinese.GB18030.NewDecoder().Bytes(raw)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return string(decoded)
|
||||
}
|
||||
@@ -0,0 +1,436 @@
|
||||
package pet
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/binary"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestDecodeRenameCreatureRequestExactCurrentBody(t *testing.T) {
|
||||
got, err := DecodeRenameCreatureRequest(renameCreatureRequestBody(5, listTypePet, []byte("Mina")))
|
||||
if err != nil {
|
||||
t.Fatalf("DecodeRenameCreatureRequest: %v", err)
|
||||
}
|
||||
if got.SlotIndex != 5 || got.ListType != listTypePet || !bytes.Equal(got.NameRaw, []byte("Mina")) {
|
||||
t.Fatalf("request = %+v", got)
|
||||
}
|
||||
// GBK/codepage-encoded names must not be rejected as invalid UTF-8.
|
||||
gbkName := []byte{0xc7, 0xbf, 0xd6, 0xc6}
|
||||
gotGBK, err := DecodeRenameCreatureRequest(renameCreatureRequestBody(5, listTypePet, gbkName))
|
||||
if err != nil || !bytes.Equal(gotGBK.NameRaw, gbkName) {
|
||||
t.Fatalf("gbk rename request=%+v err=%v", gotGBK, err)
|
||||
}
|
||||
// The current-EXE validator rejects an empty name.
|
||||
if _, err := DecodeRenameCreatureRequest(renameCreatureRequestBody(0, listTypePet, nil)); err == nil {
|
||||
t.Fatalf("empty rename request unexpectedly accepted")
|
||||
}
|
||||
|
||||
tooLong := bytes.Repeat([]byte{'x'}, maxCreatureRenameNameBytes+1)
|
||||
declaredShort := renameCreatureRequestBody(5, listTypePet, []byte("abc"))
|
||||
binary.LittleEndian.PutUint32(declaredShort[3:7], 2)
|
||||
for _, body := range [][]byte{
|
||||
{5, 0, listTypePet},
|
||||
renameCreatureRequestBody(5, 6, []byte("x")),
|
||||
renameCreatureRequestBody(140, listTypePet, []byte("x")),
|
||||
renameCreatureRequestBody(5, listTypePet, tooLong),
|
||||
renameCreatureRequestBody(5, listTypePet, []byte{'a', 0, 'b'}),
|
||||
declaredShort,
|
||||
append(renameCreatureRequestBody(5, listTypePet, []byte("x")), 0),
|
||||
} {
|
||||
if _, err := DecodeRenameCreatureRequest(body); err == nil {
|
||||
t.Fatalf("body % X unexpectedly accepted", body)
|
||||
}
|
||||
}
|
||||
// The nine literal characters rejected by the current-EXE validator.
|
||||
for _, forbidden := range []byte{'\'', ' ', '\t', '\\', '%', '<', '>', '"', '|'} {
|
||||
if _, err := DecodeRenameCreatureRequest(renameCreatureRequestBody(5, listTypePet, []byte{'a', forbidden, 'b'})); err == nil {
|
||||
t.Fatalf("name containing %q unexpectedly accepted", forbidden)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerRenameConsumesCardAndPersistsEquippedCreatureName(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
seedRenameCreature(t, ctx, repos, "77")
|
||||
owner, err := NewOwner(repos)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner: %v", err)
|
||||
}
|
||||
|
||||
command := RenameCommand{SelectedCharacterID: 77, ListType: listTypePet, SlotIndex: 5, NameRaw: []byte("Mina")}
|
||||
first, err := owner.Rename(ctx, command)
|
||||
if err != nil {
|
||||
t.Fatalf("Rename: %v", err)
|
||||
}
|
||||
if !first.Changed || first.CharacterID != "77" || first.PetKey != "37" || first.CreatureSerial != 37 ||
|
||||
first.ItemID != 63000 || first.ListType != listTypePet || first.SourceListType != currentMainInventoryListType ||
|
||||
first.SlotIndex != 5 || first.RemainingCount != 1 {
|
||||
t.Fatalf("first result = %+v", first)
|
||||
}
|
||||
record, found, err := repos.Pet.Load(ctx, "77")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load pet found=%t err=%v", found, err)
|
||||
}
|
||||
entry := record.Entries["37"]
|
||||
if entry.Name != "Mina" || !bytes.Equal(entry.NameRaw, []byte("Mina")) || entry.Level != 4 || entry.Exp != 12 || entry.Satiety != 80 {
|
||||
t.Fatalf("renamed entry = %+v", entry)
|
||||
}
|
||||
updatedAt := record.UpdatedAt
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, "77")
|
||||
if stack := inventory.Slots["0:5"]; stack.ItemID != currentPetRenameCardItemID || stack.Count != 1 {
|
||||
t.Fatalf("rename card after first use = %+v", stack)
|
||||
}
|
||||
|
||||
second, err := owner.Rename(ctx, command)
|
||||
if err != nil {
|
||||
t.Fatalf("second Rename: %v", err)
|
||||
}
|
||||
if second.Changed || second.RemainingCount != 0 {
|
||||
t.Fatalf("second result = %+v", second)
|
||||
}
|
||||
record, _, _ = repos.Pet.Load(ctx, "77")
|
||||
if !record.UpdatedAt.Equal(updatedAt) {
|
||||
t.Fatalf("same-name use rewrote pet timestamp: before=%v after=%v", updatedAt, record.UpdatedAt)
|
||||
}
|
||||
inventory, _, _ = repos.Inventory.Load(ctx, "77")
|
||||
if _, found := inventory.Slots["0:5"]; found {
|
||||
t.Fatalf("exhausted rename card row still exists: %+v", inventory.Slots["0:5"])
|
||||
}
|
||||
if _, err := owner.Rename(ctx, command); !errors.Is(err, ErrPetRenameCardInvalid) {
|
||||
t.Fatalf("third Rename error = %v, want %v", err, ErrPetRenameCardInvalid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerRenameRejectsCardAndEquippedCreatureMismatchWithoutMutation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mutate func(*dnfrepo.InventoryRecord, *dnfrepo.EquipmentRecord, *dnfrepo.PetRecord)
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "wrong card item",
|
||||
mutate: func(inventory *dnfrepo.InventoryRecord, _ *dnfrepo.EquipmentRecord, _ *dnfrepo.PetRecord) {
|
||||
stack := inventory.Slots["0:5"]
|
||||
stack.ItemID++
|
||||
inventory.Slots["0:5"] = stack
|
||||
},
|
||||
wantErr: ErrPetRenameCardInvalid,
|
||||
},
|
||||
{
|
||||
name: "wrong card path",
|
||||
mutate: func(inventory *dnfrepo.InventoryRecord, _ *dnfrepo.EquipmentRecord, _ *dnfrepo.PetRecord) {
|
||||
stack := inventory.Slots["0:5"]
|
||||
stack.Extra["pvf_path"] = "stackable/cash/other.stk"
|
||||
inventory.Slots["0:5"] = stack
|
||||
},
|
||||
wantErr: ErrPetRenameCardInvalid,
|
||||
},
|
||||
{
|
||||
name: "missing equipped slot",
|
||||
mutate: func(_ *dnfrepo.InventoryRecord, equipment *dnfrepo.EquipmentRecord, _ *dnfrepo.PetRecord) {
|
||||
delete(equipment.Entries, "26")
|
||||
},
|
||||
wantErr: ErrPetEquippedAbsent,
|
||||
},
|
||||
{
|
||||
name: "missing equipped serial",
|
||||
mutate: func(_ *dnfrepo.InventoryRecord, equipment *dnfrepo.EquipmentRecord, _ *dnfrepo.PetRecord) {
|
||||
entry := equipment.Entries["26"]
|
||||
binary.LittleEndian.PutUint32(entry.RawEntry[24:28], 0)
|
||||
equipment.Entries["26"] = entry
|
||||
},
|
||||
wantErr: ErrPetCreatureSerialAbsent,
|
||||
},
|
||||
{
|
||||
name: "item mismatch",
|
||||
mutate: func(_ *dnfrepo.InventoryRecord, _ *dnfrepo.EquipmentRecord, record *dnfrepo.PetRecord) {
|
||||
entry := record.Entries["37"]
|
||||
entry.ItemID++
|
||||
record.Entries["37"] = entry
|
||||
},
|
||||
wantErr: ErrPetInventoryMismatch,
|
||||
},
|
||||
{
|
||||
name: "entry serial mismatch",
|
||||
mutate: func(_ *dnfrepo.InventoryRecord, _ *dnfrepo.EquipmentRecord, record *dnfrepo.PetRecord) {
|
||||
entry := record.Entries["37"]
|
||||
entry.CreatureKey = 38
|
||||
record.Entries["37"] = entry
|
||||
},
|
||||
wantErr: ErrPetInventoryMismatch,
|
||||
},
|
||||
{
|
||||
name: "equipped key mismatch",
|
||||
mutate: func(_ *dnfrepo.InventoryRecord, _ *dnfrepo.EquipmentRecord, record *dnfrepo.PetRecord) {
|
||||
record.EquippedKey = "38"
|
||||
},
|
||||
wantErr: ErrPetInventoryMismatch,
|
||||
},
|
||||
{
|
||||
name: "entry missing",
|
||||
mutate: func(_ *dnfrepo.InventoryRecord, _ *dnfrepo.EquipmentRecord, record *dnfrepo.PetRecord) {
|
||||
delete(record.Entries, "37")
|
||||
},
|
||||
wantErr: ErrPetInventoryMismatch,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
seedRenameCreature(t, ctx, repos, "77")
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, "77")
|
||||
equipment, _, _ := repos.Equipment.Load(ctx, "77")
|
||||
record, _, _ := repos.Pet.Load(ctx, "77")
|
||||
test.mutate(&inventory, &equipment, &record)
|
||||
if err := repos.Inventory.Save(ctx, inventory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Equipment.Save(ctx, equipment); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Pet.Save(ctx, record); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
owner, _ := NewOwner(repos)
|
||||
_, err := owner.Rename(ctx, RenameCommand{SelectedCharacterID: 77, ListType: listTypePet, SlotIndex: 5, NameRaw: []byte("new")})
|
||||
if !errors.Is(err, test.wantErr) {
|
||||
t.Fatalf("Rename error = %v, want %v", err, test.wantErr)
|
||||
}
|
||||
afterInventory, _, _ := repos.Inventory.Load(ctx, "77")
|
||||
if stack := afterInventory.Slots["0:5"]; stack.Count != 2 {
|
||||
t.Fatalf("failed rename consumed card: %+v", stack)
|
||||
}
|
||||
after, _, _ := repos.Pet.Load(ctx, "77")
|
||||
if entry, exists := after.Entries["37"]; exists && (entry.Name != "old" || !bytes.Equal(entry.NameRaw, []byte("old"))) {
|
||||
t.Fatalf("failed rename mutated entry = %+v", entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandlerRenameCreatureReturnsAckNativeNameNotificationAndDoesNotSwallowFailure(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
seedRenameCreature(t, ctx, repos, "77")
|
||||
|
||||
result, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketRenameCreature),
|
||||
Body: renameCreatureRequestBody(5, listTypePet, []byte("Mina")),
|
||||
AccountID: "account",
|
||||
SelectedCharacterID: 77,
|
||||
Repositories: repos,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
if !result.Handled || !result.ResponseAllowed || result.Operation != "rename_creature" ||
|
||||
len(result.UpperResponses) != 2 ||
|
||||
len(result.ItemSlotRefreshes) != 1 ||
|
||||
result.ItemSlotRefreshes[0] != (alignedcmd.ItemSlotRefresh{ListType: currentMainInventoryListType, SlotIndex: 5}) ||
|
||||
len(result.PostActions) != 0 {
|
||||
t.Fatalf("result = %+v", result)
|
||||
}
|
||||
ack := result.UpperResponses[0]
|
||||
if ack.MsgID != uint16(dnfenum.CmdPacketRenameCreature) || ack.Classification != dnfproto.DefaultChannelClassification || !ack.AllowCodec || !bytes.Equal(ack.Body, []byte{5, 0, listTypePet}) {
|
||||
t.Fatalf("ack = %+v body=% X", ack, ack.Body)
|
||||
}
|
||||
notify := result.UpperResponses[1]
|
||||
wantNotify := []byte{77, 0, 4, 0, 0, 0, 'M', 'i', 'n', 'a'}
|
||||
if notify.MsgID != 101 || notify.Classification != 0 || !notify.AllowCodec || !bytes.Equal(notify.Body, wantNotify) {
|
||||
t.Fatalf("name notification = %+v body=% X want=% X", notify, notify.Body, wantNotify)
|
||||
}
|
||||
commitErr := errors.New("rename commit failed")
|
||||
failing := repos
|
||||
failing.CharacterPets = renameCommitFailureUOW{base: repos, err: commitErr}
|
||||
failed, err := NewHandler().Handle(ctx, alignedcmd.Request{
|
||||
Opcode: uint16(dnfenum.CmdPacketRenameCreature),
|
||||
Body: renameCreatureRequestBody(5, listTypePet, []byte("Other")),
|
||||
SelectedCharacterID: 77,
|
||||
Repositories: failing,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("failed Handle returned Go error: %v", err)
|
||||
}
|
||||
if !failed.Handled || failed.ResponseAllowed || len(failed.UpperResponses) != 0 {
|
||||
t.Fatalf("commit failure was swallowed: %+v", failed)
|
||||
}
|
||||
record, _, _ := repos.Pet.Load(ctx, "77")
|
||||
if entry := record.Entries["37"]; entry.Name != "Mina" || !bytes.Equal(entry.NameRaw, []byte("Mina")) {
|
||||
t.Fatalf("commit failure mutated durable entry = %+v", entry)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerRenameConcurrentUseConsumesOneAvailableCardExactlyOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
seedRenameCreature(t, ctx, repos, "77")
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, "77")
|
||||
card := inventory.Slots["0:5"]
|
||||
card.Count = 1
|
||||
inventory.Slots["0:5"] = card
|
||||
if err := repos.Inventory.Save(ctx, inventory); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repos)
|
||||
command := RenameCommand{SelectedCharacterID: 77, ListType: listTypePet, SlotIndex: 5, NameRaw: []byte("Mina")}
|
||||
|
||||
const workers = 32
|
||||
results := make(chan RenameResult, workers)
|
||||
errorsCh := make(chan error, workers)
|
||||
var wait sync.WaitGroup
|
||||
for range workers {
|
||||
wait.Add(1)
|
||||
go func() {
|
||||
defer wait.Done()
|
||||
result, err := owner.Rename(ctx, command)
|
||||
if err != nil {
|
||||
errorsCh <- err
|
||||
return
|
||||
}
|
||||
results <- result
|
||||
}()
|
||||
}
|
||||
wait.Wait()
|
||||
close(results)
|
||||
close(errorsCh)
|
||||
failures := 0
|
||||
for err := range errorsCh {
|
||||
if !errors.Is(err, ErrPetRenameCardInvalid) {
|
||||
t.Fatalf("concurrent Rename: %v", err)
|
||||
}
|
||||
failures++
|
||||
}
|
||||
changed := 0
|
||||
successes := 0
|
||||
for result := range results {
|
||||
successes++
|
||||
if result.Changed {
|
||||
changed++
|
||||
}
|
||||
}
|
||||
if successes != 1 || changed != 1 || failures != workers-1 {
|
||||
t.Fatalf("successes=%d changed=%d failures=%d, want 1/1/%d", successes, changed, failures, workers-1)
|
||||
}
|
||||
record, _, _ := repos.Pet.Load(ctx, "77")
|
||||
if entry := record.Entries["37"]; entry.Name != "Mina" || !bytes.Equal(entry.NameRaw, []byte("Mina")) {
|
||||
t.Fatalf("final entry = %+v", entry)
|
||||
}
|
||||
inventory, _, _ = repos.Inventory.Load(ctx, "77")
|
||||
if _, found := inventory.Slots["0:5"]; found {
|
||||
t.Fatalf("consumed card row still exists: %+v", inventory.Slots["0:5"])
|
||||
}
|
||||
}
|
||||
|
||||
type renameCommitFailureUOW struct {
|
||||
base dnfrepo.Group
|
||||
err error
|
||||
}
|
||||
|
||||
func (u renameCommitFailureUOW) WithinCharacterPets(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
apply func(dnfrepo.InventoryRepository, dnfrepo.EquipmentRepository, dnfrepo.PetRepository) error,
|
||||
) error {
|
||||
staged := dnfrepomemory.NewMemoryGroup()
|
||||
if record, found, err := u.base.Inventory.Load(ctx, characterID); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
if err := staged.Inventory.Save(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if record, found, err := u.base.Equipment.Load(ctx, characterID); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
if err := staged.Equipment.Save(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if record, found, err := u.base.Pet.Load(ctx, characterID); err != nil {
|
||||
return err
|
||||
} else if found {
|
||||
if err := staged.Pet.Save(ctx, record); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := apply(staged.Inventory, staged.Equipment, staged.Pet); err != nil {
|
||||
return err
|
||||
}
|
||||
return u.err
|
||||
}
|
||||
|
||||
func seedRenameCreature(t *testing.T, ctx context.Context, repos dnfrepo.Group, characterID string) {
|
||||
t.Helper()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: characterID,
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"0:5": {
|
||||
ItemID: currentPetRenameCardItemID,
|
||||
Count: 2,
|
||||
Extra: map[string]string{
|
||||
"item_kind": "stackable",
|
||||
"pvf_path": currentPetRenameCardPVFPath,
|
||||
"stackable_type": "[creature]",
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
rawEquipment := make([]byte, 0x77)
|
||||
binary.LittleEndian.PutUint32(rawEquipment[24:28], 37)
|
||||
if err := repos.Equipment.Save(ctx, dnfrepo.EquipmentRecord{
|
||||
CharacterID: characterID,
|
||||
Entries: map[string]dnfrepo.EquipmentEntry{
|
||||
"26": {
|
||||
SlotIndex: currentEquippedCreatureSlot,
|
||||
ItemID: 63000,
|
||||
RawEntry: rawEquipment,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save equipment: %v", err)
|
||||
}
|
||||
if err := repos.Pet.Save(ctx, dnfrepo.PetRecord{
|
||||
CharacterID: characterID,
|
||||
Entries: map[string]dnfrepo.PetEntry{
|
||||
"37": {
|
||||
PetKey: "37",
|
||||
CreatureKey: 37,
|
||||
ItemID: 63000,
|
||||
SourceListType: listTypePet,
|
||||
SourceSlotIndex: 5,
|
||||
Name: "old",
|
||||
NameRaw: []byte("old"),
|
||||
Satiety: 80,
|
||||
Level: 4,
|
||||
Exp: 12,
|
||||
},
|
||||
},
|
||||
EquippedKey: "37",
|
||||
}); err != nil {
|
||||
t.Fatalf("save pet: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func renameCreatureRequestBody(slot int16, listType byte, name []byte) []byte {
|
||||
body := make([]byte, 7+len(name))
|
||||
binary.LittleEndian.PutUint16(body[0:2], uint16(slot))
|
||||
body[2] = listType
|
||||
binary.LittleEndian.PutUint32(body[3:7], uint32(len(name)))
|
||||
copy(body[7:], name)
|
||||
return body
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
// This file decodes current-EXE pet requests. Item identity is never accepted
|
||||
// from the request body; the owner resolves it from the selected durable slot.
|
||||
package pet
|
||||
|
||||
import (
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
const (
|
||||
listTypePet byte = 7
|
||||
maxCreatureRenameNameBytes = 12
|
||||
currentDstrLengthBytes = 4
|
||||
)
|
||||
|
||||
// RenameCreatureRequest is the exact current-EXE op100 body:
|
||||
// u16 slot + u8 list_type + DSTR raw_name. DSTR is decoded using the current
|
||||
// bridge's u32 byte-length convention; suffix bytes are never accepted.
|
||||
type RenameCreatureRequest struct {
|
||||
SlotIndex int16
|
||||
ListType byte
|
||||
NameRaw []byte
|
||||
}
|
||||
|
||||
// DecodeRenameCreatureRequest keeps the current wire layout separate from
|
||||
// the rename domain. The 12-byte encoded limit, non-empty rule, and literal
|
||||
// forbidden set are proved by current NoPack: the rename text control is
|
||||
// created with maximum 12 (sub_31F5CB0 -> sub_3539D80, read back by
|
||||
// sub_353C2E0), and the visible validator sub_31F57A0 rejects empty names and
|
||||
// the nine literal characters below. The legacy 86JP 13-byte rule is not
|
||||
// current-EXE proof and is not used.
|
||||
func DecodeRenameCreatureRequest(body []byte) (RenameCreatureRequest, error) {
|
||||
const fixedBytes = 2 + 1 + currentDstrLengthBytes
|
||||
if len(body) < fixedBytes {
|
||||
return RenameCreatureRequest{}, fmt.Errorf("invalid body length: got %d want at least %d", len(body), fixedBytes)
|
||||
}
|
||||
request := RenameCreatureRequest{
|
||||
SlotIndex: int16(binary.LittleEndian.Uint16(body[0:2])),
|
||||
ListType: body[2],
|
||||
}
|
||||
if request.ListType != listTypePet {
|
||||
return RenameCreatureRequest{}, fmt.Errorf("invalid list type: got %d want %d", request.ListType, listTypePet)
|
||||
}
|
||||
if request.SlotIndex < 0 ||
|
||||
(request.SlotIndex > 139 && (request.SlotIndex < 189 || request.SlotIndex > 238)) {
|
||||
return RenameCreatureRequest{}, fmt.Errorf("slot %d outside creature/consumable ranges 0..139 or 189..238", request.SlotIndex)
|
||||
}
|
||||
nameLength := uint64(binary.LittleEndian.Uint32(body[3:7]))
|
||||
if nameLength > maxCreatureRenameNameBytes {
|
||||
return RenameCreatureRequest{}, fmt.Errorf("creature name is %d bytes, maximum is %d", nameLength, maxCreatureRenameNameBytes)
|
||||
}
|
||||
if nameLength != uint64(len(body)-fixedBytes) {
|
||||
return RenameCreatureRequest{}, fmt.Errorf("DSTR length mismatch: declared %d actual %d", nameLength, len(body)-fixedBytes)
|
||||
}
|
||||
request.NameRaw = append([]byte(nil), body[fixedBytes:]...)
|
||||
if err := validateCreatureRenameName(request.NameRaw); err != nil {
|
||||
return RenameCreatureRequest{}, err
|
||||
}
|
||||
return request, nil
|
||||
}
|
||||
|
||||
// validateCreatureRenameName enforces the current-EXE-proved subset: encoded
|
||||
// length at most 12 bytes, non-empty, no NUL/C0/DEL control bytes, and none
|
||||
// of the nine literal characters rejected by sub_31F57A0 (apostrophe, space,
|
||||
// tab, backslash, percent, less-than, greater-than, double quote, vertical
|
||||
// bar). All checks are byte-level so GBK/codepage names are not mistaken for
|
||||
// control data; the dynamic blacklist, virtual filter, and single/double-byte
|
||||
// range tables are not yet exported and therefore not claimed.
|
||||
func validateCreatureRenameName(name []byte) error {
|
||||
if len(name) > maxCreatureRenameNameBytes {
|
||||
return fmt.Errorf("creature name is %d bytes, maximum is %d", len(name), maxCreatureRenameNameBytes)
|
||||
}
|
||||
if len(name) == 0 {
|
||||
return fmt.Errorf("creature name must not be empty")
|
||||
}
|
||||
for _, value := range name {
|
||||
switch value {
|
||||
case '\'', ' ', '\t', '\\', '%', '<', '>', '"', '|':
|
||||
return fmt.Errorf("creature name contains forbidden character %q", value)
|
||||
}
|
||||
if value < 0x20 || value == 0x7f {
|
||||
return fmt.Errorf("creature name contains a control character")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r RenameCreatureRequest) String() string {
|
||||
return fmt.Sprintf("list=%d slot=%d nameBytes=%d", r.ListType, r.SlotIndex, len(r.NameRaw))
|
||||
}
|
||||
|
||||
// HatchCreatureRequest is the exact current-EXE op102 body:
|
||||
// u8 list_type + u16 slot.
|
||||
type HatchCreatureRequest struct {
|
||||
ListType byte
|
||||
SlotIndex int16
|
||||
}
|
||||
|
||||
// DecodeHatchCreatureRequest rejects suffix guesses and non-pet lists.
|
||||
func DecodeHatchCreatureRequest(body []byte) (HatchCreatureRequest, error) {
|
||||
if len(body) != 3 {
|
||||
return HatchCreatureRequest{}, fmt.Errorf("invalid body length: got %d want 3", len(body))
|
||||
}
|
||||
if body[0] != listTypePet {
|
||||
return HatchCreatureRequest{}, fmt.Errorf("invalid list type: got %d want %d", body[0], listTypePet)
|
||||
}
|
||||
req := HatchCreatureRequest{ListType: body[0], SlotIndex: int16(binary.LittleEndian.Uint16(body[1:3]))}
|
||||
if req.SlotIndex < 0 || req.SlotIndex > 139 {
|
||||
return HatchCreatureRequest{}, fmt.Errorf("slot %d outside creature range 0..139", req.SlotIndex)
|
||||
}
|
||||
return req, nil
|
||||
}
|
||||
|
||||
func (r HatchCreatureRequest) String() string {
|
||||
return fmt.Sprintf("list=%d slot=%d", r.ListType, r.SlotIndex)
|
||||
}
|
||||
Reference in new issue
Block a user