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