DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

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

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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)
}
}
+192
View File
@@ -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)
}
+296
View File
@@ -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)
}
+2
View File
@@ -0,0 +1,2 @@
// Package pet 承接 DNF 宠物孵化、宠物状态请求和后续佩戴显示类协议。
package pet
+239
View File
@@ -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",
}
}
+297
View File
@@ -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())
}
}
+443
View File
@@ -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)
}
}
+262
View File
@@ -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
}
+116
View File
@@ -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)
}