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

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

221 lines
7.9 KiB
Go

package wedding
import (
"context"
"errors"
"reflect"
"strconv"
"sync"
"testing"
"time"
repo "longheng.io/server/internal/modules/dnf/repository"
"longheng.io/server/internal/modules/dnf/repository/memory"
)
func capsuleTestPair(t *testing.T) (repo.Group, *Catalog, *CapsuleCatalog, CapsuleCommand) {
t.Helper()
repos := memory.NewMemoryGroup()
cat := &Catalog{MaxLove: 999, MinimumLevel: 30}
cat.RingTickets[3] = Item{10002613, 1}
rules := &CapsuleCatalog{MaxOwn: 10, MaxTaken: 10, Gauge: 2, MaxGauge: 10, ItemID: 10002910}
now := time.Unix(1789200000, 0)
for i := 1; i <= 2; i++ {
id := strconv.Itoa(i)
c := repo.CharacterRecord{CharacterID: id, AccountID: "a" + id, Level: 30, Stats: map[string]int64{"gold": 9000000}}
store(&c, State{PairID: 65538, PartnerID: uint16(3 - i), Status: 4, Ring: 2, Love: 946, Date: now.Unix() - 86400}, now)
if err := repos.Character.Save(context.Background(), c); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(context.Background(), repo.InventoryRecord{CharacterID: id, Slots: map[string]repo.ItemStack{}}); err != nil {
t.Fatal(err)
}
}
cmd := CapsuleCommand{ActorID: "1", PartnerID: "2", AccountID: "a1", Opcode: 1052, Now: now, Grant: func(b *repo.InventoryRecord, item Item) error {
if b.Slots == nil {
b.Slots = make(map[string]repo.ItemStack)
}
stack := b.Slots["0:57"]
stack.ItemID = item.ID
stack.Count += item.Count
b.Slots["0:57"] = stack
return nil
}}
return repos, cat, rules, cmd
}
func TestWeddingCapsuleWorkflowAndRepeatedClaims(t *testing.T) {
ctx := context.Background()
repos, cat, rules, cmd := capsuleTestPair(t)
for made := 0; made < 2; made++ {
for clear := 0; clear < 5; clear++ {
c, _, _ := repos.Character.Load(ctx, "1")
if err := AccumulateCapsuleClear(&c, rules); err != nil {
t.Fatal(err)
}
if err := repos.Character.Save(ctx, c); err != nil {
t.Fatal(err)
}
if clear < 4 {
if _, err := CommitCapsule(ctx, repos, cat, rules, cmd); !errors.Is(err, ErrCapsuleEnergy) {
t.Fatalf("early make: %v", err)
}
}
}
res, err := CommitCapsule(ctx, repos, cat, rules, cmd)
if err != nil || res.States[0].Progress != 0 || res.States[0].Own != int64(made+1) {
t.Fatalf("make=%+v %v", res.States, err)
}
}
cmd.Opcode = 1053
res, err := CommitCapsule(ctx, repos, cat, rules, cmd)
if err != nil || res.States[0].Own != 0 || res.States[1].Taken != 2 {
t.Fatalf("give=%+v %v", res.States, err)
}
if _, err := CommitCapsule(ctx, repos, cat, rules, cmd); !errors.Is(err, ErrCapsuleEmpty) {
t.Fatalf("repeat give=%v", err)
}
// The persisted pending stock survives the next day and has no online-session dependency.
cmd.ActorID, cmd.PartnerID, cmd.AccountID, cmd.Opcode, cmd.Now = "2", "1", "a2", 1054, cmd.Now.Add(48*time.Hour)
var wg sync.WaitGroup
errs := make(chan error, 2)
for i := 0; i < 2; i++ {
wg.Add(1)
go func() { defer wg.Done(); _, err := CommitCapsule(ctx, repos, cat, rules, cmd); errs <- err }()
}
wg.Wait()
close(errs)
success := 0
for err := range errs {
if err == nil {
success++
} else if !errors.Is(err, ErrCapsuleEmpty) {
t.Fatal(err)
}
}
bag, _, _ := repos.Inventory.Load(ctx, "2")
c, _, _ := repos.Character.Load(ctx, "2")
state, _ := ReadCapsule(c, rules)
if success != 1 || bag.Slots["0:57"].Count != 2 || state.Taken != 0 {
t.Fatalf("claims success=%d bag=%+v state=%+v", success, bag, state)
}
}
func TestWeddingCapsuleFailureRollbackAndRelationshipReset(t *testing.T) {
ctx := context.Background()
for _, mode := range []string{"make_limit", "give_limit", "full_bag", "rollback", "bad_pair", "deleted", "wrong_account"} {
t.Run(mode, func(t *testing.T) {
repos, cat, rules, cmd := capsuleTestPair(t)
a, _, _ := repos.Character.Load(ctx, "1")
b, _, _ := repos.Character.Load(ctx, "2")
storeCapsule(&a, CapsuleState{Progress: 10, Own: 2, Taken: 2})
switch mode {
case "make_limit":
storeCapsule(&a, CapsuleState{Progress: 10, Own: 10})
case "give_limit":
cmd.Opcode = 1053
storeCapsule(&b, CapsuleState{Taken: 9})
case "full_bag":
cmd.Opcode = 1054
cmd.Grant = func(*repo.InventoryRecord, Item) error { return errors.New("full") }
case "rollback":
cmd.Opcode = 1054
repos.CharacterTrade = failingWeddingTrade{repos.CharacterTrade}
case "bad_pair":
b.Stats["wedding_partner_id"] = 3
case "deleted":
b.Stats["delete_flag"] = 1
case "wrong_account":
cmd.AccountID = "wrong"
}
if err := repos.Character.Save(ctx, a); err != nil {
t.Fatal(err)
}
if err := repos.Character.Save(ctx, b); err != nil {
t.Fatal(err)
}
bag, _, _ := repos.Inventory.Load(ctx, "1")
if _, err := CommitCapsule(ctx, repos, cat, rules, cmd); err == nil {
t.Fatal("invalid operation succeeded")
}
afterA, _, _ := repos.Character.Load(ctx, "1")
afterB, _, _ := repos.Character.Load(ctx, "2")
afterBag, _, _ := repos.Inventory.Load(ctx, "1")
if !reflect.DeepEqual(a, afterA) || !reflect.DeepEqual(b, afterB) || !reflect.DeepEqual(bag, afterBag) {
t.Fatal("failure changed assets")
}
})
}
repos, cat, rules, cmd := capsuleTestPair(t)
a, _, _ := repos.Character.Load(ctx, "1")
storeCapsule(&a, CapsuleState{10, 2, 3})
if err := repos.Character.Save(ctx, a); err != nil {
t.Fatal(err)
}
res, err := Commit(ctx, repos, cat, Command{ActorID: "1", PartnerID: "2", AccountID: "a1", Operation: 7, Now: cmd.Now})
if err != nil {
t.Fatal(err)
}
for _, c := range res.Characters {
if state, err := ReadCapsule(c, rules); err != nil || state != (CapsuleState{}) || c.Stats[capsulePrefix+"pair"] != 0 {
t.Fatalf("divorce retained capsule: %+v %v", state, err)
}
}
// Even an imported stale row is scoped to its marriage date and pair.
a.Stats["wedding_date"]++
if state, err := ReadCapsule(a, rules); err != nil || state != (CapsuleState{}) {
t.Fatalf("old marriage reused %+v %v", state, err)
}
}
func TestWeddingRingPurchaseAtomicCurrencies(t *testing.T) {
ctx := context.Background()
for _, mode := range []string{"success", "gold", "love", "mismatch", "full_bag", "rollback", "not_married"} {
t.Run(mode, func(t *testing.T) {
repos, cat, _, capsule := capsuleTestPair(t)
cmd := RingPurchase{ActorID: "1", PartnerID: "2", AccountID: "a1", Item: Item{10002613, 1}, Gold: 3500000, Love: 500, Now: capsule.Now, Grant: capsule.Grant}
a, _, _ := repos.Character.Load(ctx, "1")
b, _, _ := repos.Character.Load(ctx, "2")
switch mode {
case "gold":
a.Stats["gold"] = 3499999
case "love":
a.Stats["wedding_love"] = 499
b.Stats["wedding_love"] = 499
case "mismatch":
b.Stats["wedding_love"] = 945
case "full_bag":
cmd.Grant = func(*repo.InventoryRecord, Item) error { return errors.New("full") }
case "rollback":
repos.CharacterTrade = failingWeddingTrade{repos.CharacterTrade}
case "not_married":
a.Stats["wedding_state"] = 1
b.Stats["wedding_state"] = 1
}
if err := repos.Character.Save(ctx, a); err != nil {
t.Fatal(err)
}
if err := repos.Character.Save(ctx, b); err != nil {
t.Fatal(err)
}
bag, _, _ := repos.Inventory.Load(ctx, "1")
res, err := BuyRingTicket(ctx, repos, cat, cmd)
if mode == "success" {
if err != nil || res.Characters[0].Stats["gold"] != 5500000 || res.Characters[1].Stats["gold"] != 9000000 || res.Characters[0].Stats["wedding_love"] != 446 || res.Characters[1].Stats["wedding_love"] != 446 || res.Inventories[0].Slots["0:57"].Count != 1 || res.Characters[0].Stats["wedding_ring"] != 2 || res.Characters[1].Stats["wedding_ring"] != 2 {
t.Fatalf("purchase=%+v %v", res, err)
}
return
}
if err == nil {
t.Fatal("invalid purchase succeeded")
}
afterA, _, _ := repos.Character.Load(ctx, "1")
afterB, _, _ := repos.Character.Load(ctx, "2")
afterBag, _, _ := repos.Inventory.Load(ctx, "1")
if !reflect.DeepEqual(a, afterA) || !reflect.DeepEqual(b, afterB) || !reflect.DeepEqual(bag, afterBag) {
t.Fatal("failed purchase changed assets")
}
})
}
}