服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
285 lines
10 KiB
Go
285 lines
10 KiB
Go
package wedding
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
modulepvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
"longheng.io/server/internal/modules/dnf/quest"
|
|
"os"
|
|
"reflect"
|
|
"testing"
|
|
"time"
|
|
|
|
repo "longheng.io/server/internal/modules/dnf/repository"
|
|
"longheng.io/server/internal/modules/dnf/repository/memory"
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
func TestWeddingCeremonyAssetsAndReplay(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Unix(1788854400, 0)
|
|
cat := &Catalog{MinimumLevel: 30, RemarryDays: 1, WeddingDays: 1, Alimony: [2]int64{500000, 500000}, MaxLove: 999, LoveItems: map[int64]int64{10002901: 1}, QuestIDs: [2]int64{20737, 20738}, Delays: [3]int64{300, 600, 900}, EndSeconds: 60, PaidType: 2, PaidTicket: Item{2683401, 1}}
|
|
cat.Ceremonies[0] = Ceremony{Gold: 500000, Ring: 0, Gift: Item{10002629, 1}}
|
|
cat.Ceremonies[2] = Ceremony{Ring: 2, Gift: Item{10002631, 1}}
|
|
cat.RingTickets[1] = Item{10002611, 1}
|
|
for _, mode := range []string{"complete", "missing_quest", "gold", "full_bag", "paid_ticket_missing", "paid", "rollback"} {
|
|
t.Run(mode, func(t *testing.T) {
|
|
repos := memory.NewMemoryGroup()
|
|
for _, id := range []string{"1", "2"} {
|
|
job := "0"
|
|
if id == "2" {
|
|
job = "1"
|
|
}
|
|
gold := int64(600000)
|
|
if mode == "gold" {
|
|
gold = 499999
|
|
}
|
|
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: id, AccountID: "a" + id, Job: job, Level: 30, Stats: map[string]int64{"gold": gold}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := repos.Inventory.Save(ctx, repo.InventoryRecord{CharacterID: id, Slots: map[string]repo.ItemStack{"0:3": {ItemID: 2683401, Count: 1}, "0:4": {ItemID: 10002611, Count: 1}, "0:5": {ItemID: 10002901, Count: 2, RawEntry: make([]byte, 119), Extra: map[string]string{"quantity": "2"}}}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
q := repo.QuestRecord{CharacterID: id, States: map[int64]repo.QuestState{20737: {Status: "completed"}, 20738: {Status: "completed"}}}
|
|
if mode == "missing_quest" && id == "2" {
|
|
delete(q.States, 20738)
|
|
}
|
|
if err := repos.Quest.Save(ctx, q); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
cmd := Command{ActorID: "1", PartnerID: "2", AccountID: "a1", PartnerAccountID: "a2", Operation: 1, Now: now}
|
|
if _, err := Commit(ctx, repos, cat, cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd.Operation, cmd.Room = 2, 8
|
|
cmd.Grant = func(bag *repo.InventoryRecord, item Item) error {
|
|
if mode == "full_bag" && bag.CharacterID == "2" {
|
|
return errors.New("bag full")
|
|
}
|
|
bag.Slots["0:6"] = repo.ItemStack{ItemID: item.ID, Count: item.Count}
|
|
return nil
|
|
}
|
|
if mode == "paid" || mode == "paid_ticket_missing" {
|
|
cmd.Kind = 2
|
|
}
|
|
if mode == "paid_ticket_missing" {
|
|
bag, _, _ := repos.Inventory.Load(ctx, "1")
|
|
delete(bag.Slots, "0:3")
|
|
if err := repos.Inventory.Save(ctx, bag); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
beforeC, _, _ := repos.Character.Load(ctx, "1")
|
|
beforeB, _, _ := repos.Inventory.Load(ctx, "1")
|
|
if mode == "rollback" {
|
|
repos.CharacterTrade = failingWeddingTrade{repos.CharacterTrade}
|
|
}
|
|
result, err := Commit(ctx, repos, cat, cmd)
|
|
if mode != "complete" && mode != "paid" {
|
|
if err == nil {
|
|
t.Fatal("invalid reservation committed")
|
|
}
|
|
a, _, _ := repos.Character.Load(ctx, "1")
|
|
bag, _, _ := repos.Inventory.Load(ctx, "1")
|
|
if !reflect.DeepEqual(a, beforeC) || !reflect.DeepEqual(bag, beforeB) {
|
|
t.Fatal("failed reservation charged assets")
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
wantGold := int64(100000)
|
|
if mode == "paid" {
|
|
wantGold = 600000
|
|
if _, ok := result.Inventories[0].Slots["0:3"]; ok {
|
|
t.Fatal("ticket not consumed")
|
|
}
|
|
}
|
|
if result.Characters[0].Stats["gold"] != wantGold || result.Characters[1].Stats["gold"] != 600000 {
|
|
t.Fatal("wrong payment")
|
|
}
|
|
if _, err := Commit(ctx, repos, cat, cmd); err == nil {
|
|
t.Fatal("reservation replay charged again")
|
|
}
|
|
cmd.Operation = 3
|
|
if _, err := Commit(ctx, repos, cat, cmd); err == nil {
|
|
t.Fatal("early ceremony")
|
|
}
|
|
cmd.Now = now.Add(300 * time.Second)
|
|
if _, err := Commit(ctx, repos, cat, cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
cmd.Operation = 4
|
|
if _, err := Commit(ctx, repos, cat, cmd); err == nil {
|
|
t.Fatal("early completion")
|
|
}
|
|
cmd.Now = now.Add(360 * time.Second)
|
|
result, err = Commit(ctx, repos, cat, cmd)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for i, c := range result.Characters {
|
|
state, e := Read(c)
|
|
if e != nil || state.Status != 4 || state.Room != 0 || state.Date != cmd.Now.Unix() || result.Inventories[i].Slots["0:6"].ItemID != cat.Ceremonies[cmd.Kind].Gift.ID {
|
|
t.Fatalf("completion: %+v %v", result, e)
|
|
}
|
|
}
|
|
if _, err := Commit(ctx, repos, cat, cmd); err == nil {
|
|
t.Fatal("completion replay granted again")
|
|
}
|
|
if mode == "paid" {
|
|
return
|
|
}
|
|
cmd.Operation, cmd.Slot, cmd.Ring = 8, 4, 1
|
|
result, err = Commit(ctx, repos, cat, cmd)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if result.Characters[1].Stats["wedding_ring"] != 1 {
|
|
t.Fatal("partner ring stale")
|
|
}
|
|
cmd.Operation, cmd.Slot = 9, 5
|
|
result, err = Commit(ctx, repos, cat, cmd)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
stack := result.Inventories[0].Slots["0:5"]
|
|
if stack.Count != 1 || stack.Extra["quantity"] != "1" || binary.LittleEndian.Uint32(stack.RawEntry[6:10]) != 1 || result.Characters[1].Stats["wedding_love"] != 1 {
|
|
t.Fatal("love item projection mismatch")
|
|
}
|
|
cat.MaxLove = 1
|
|
if _, err := Commit(ctx, repos, cat, cmd); err == nil {
|
|
t.Fatal("overflow consumed love item")
|
|
}
|
|
cat.MaxLove = 999
|
|
cmd.Operation = 7
|
|
if _, err := Commit(ctx, repos, cat, cmd); err == nil {
|
|
t.Fatal("divorce without sufficient gold")
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
type failingWeddingTrade struct{ repo.CharacterTradeUnitOfWork }
|
|
|
|
func (u failingWeddingTrade) WithinCharacterTrade(ctx context.Context, a, b string, apply func(repo.CharacterRepository, repo.InventoryRepository) error) error {
|
|
return u.CharacterTradeUnitOfWork.WithinCharacterTrade(ctx, a, b, func(chars repo.CharacterRepository, bags repo.InventoryRepository) error {
|
|
if err := apply(chars, bags); err != nil {
|
|
return err
|
|
}
|
|
return errors.New("injected commit failure")
|
|
})
|
|
}
|
|
|
|
func TestWeddingReciprocalTransaction(t *testing.T) {
|
|
ctx := context.Background()
|
|
now := time.Unix(1788854400, 0)
|
|
cat := &Catalog{MinimumLevel: 30, RemarryDays: 1, Alimony: [2]int64{500000, 500000}, MaxLove: 999, LoveItems: map[int64]int64{10002901: 1}}
|
|
cat.RingTickets[1] = Item{10002611, 1}
|
|
for _, failure := range []bool{false, true} {
|
|
t.Run(map[bool]string{false: "commit", true: "rollback"}[failure], func(t *testing.T) {
|
|
repos := memory.NewMemoryGroup()
|
|
initial := []repo.CharacterRecord{
|
|
{CharacterID: "1", AccountID: "dnf:1", Job: "0", Level: 30, Stats: map[string]int64{"gold": 600000}},
|
|
{CharacterID: "2", AccountID: "dnf:2", Job: "1", Level: 30, Stats: map[string]int64{"gold": 600000}},
|
|
}
|
|
for _, c := range initial {
|
|
if err := repos.Character.Save(ctx, c); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if failure {
|
|
repos.CharacterTrade = failingWeddingTrade{repos.CharacterTrade}
|
|
}
|
|
cmd := Command{ActorID: "1", PartnerID: "2", AccountID: "dnf:1", PartnerAccountID: "dnf:2", Operation: 1, Now: now}
|
|
result, err := Commit(ctx, repos, cat, cmd)
|
|
if failure {
|
|
if err == nil {
|
|
t.Fatal("injected failure accepted")
|
|
}
|
|
for _, want := range initial {
|
|
got, _, err := repos.Character.Load(ctx, want.CharacterID)
|
|
if err != nil || !reflect.DeepEqual(got, want) {
|
|
t.Fatalf("partial write: %+v %v", got, err)
|
|
}
|
|
}
|
|
return
|
|
}
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
a, _ := Read(result.Characters[0])
|
|
b, _ := Read(result.Characters[1])
|
|
if a.Status != 1 || b.Status != 1 || a.PartnerID != 2 || b.PartnerID != 1 || a.PairID != b.PairID {
|
|
t.Fatalf("inconsistent pair: %+v %+v", a, b)
|
|
}
|
|
if _, err := Commit(ctx, repos, cat, cmd); err == nil {
|
|
t.Fatal("replayed engagement committed")
|
|
}
|
|
cmd.Operation = 5
|
|
if _, err := Commit(ctx, repos, cat, cmd); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, c := range initial {
|
|
got, _, _ := repos.Character.Load(ctx, c.CharacterID)
|
|
state, e := Read(got)
|
|
if e != nil || state.Status != 0 || state.PartnerID != 0 || got.Stats["gold"] != 600000 {
|
|
t.Fatalf("breakup: %+v %v", got, e)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
for _, alter := range []func(*repo.CharacterRecord){
|
|
func(c *repo.CharacterRecord) { c.Level = 29 },
|
|
func(c *repo.CharacterRecord) { c.Job = "0" },
|
|
func(c *repo.CharacterRecord) { c.CharacterID = "1" },
|
|
func(c *repo.CharacterRecord) { c.Stats = map[string]int64{"delete_flag": 1} },
|
|
func(c *repo.CharacterRecord) { c.Stats = map[string]int64{"wedding_divorced_at": now.Unix() - 60} },
|
|
} {
|
|
a := repo.CharacterRecord{CharacterID: "1", Job: "0", Level: 30}
|
|
b := repo.CharacterRecord{CharacterID: "2", Job: "1", Level: 30}
|
|
alter(&b)
|
|
if err := Validate(a, b, cat, 1, now); err == nil {
|
|
t.Fatalf("invalid proposal accepted: %+v", b)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestWeddingRuntimePVF(t *testing.T) {
|
|
path := os.Getenv("DNF90_PVF_PATH")
|
|
if path == "" {
|
|
t.Skip("set DNF90_PVF_PATH for runtime wedding catalog validation")
|
|
}
|
|
archive, err := platformpvf.Open(path)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
c, err := LoadCatalog(archive)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if c.MinimumLevel != 30 || c.MaxLove != 999 || c.Alimony != [2]int64{500000, 500000} || c.Delays != [3]int64{300, 600, 900} || c.PaidTicket != (Item{2683401, 1}) || c.Ceremonies[0].Gold != 500000 || c.Ceremonies[1].Gold != 2000000 || c.RingTickets[5].ID != 10002615 || c.LoveItems[10002901] != 1 {
|
|
t.Fatalf("catalog mismatch: %+v", c)
|
|
}
|
|
index, err := modulepvf.Build(context.Background(), archive, modulepvf.BuildOptions{Lists: []string{quest.DefaultList}})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
quests, err := quest.Load(context.Background(), index)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
eligibility := quest.CharacterEligibility{Level: 30, Job: 0, Wedding: true}
|
|
first, err := quests.PlanAccept(eligibility, repo.QuestRecord{CharacterID: "1"}, 20737)
|
|
if err != nil || first.InitTrigger != 5 {
|
|
t.Fatalf("real wedding quest 1: %+v %v", first, err)
|
|
}
|
|
second, err := quests.PlanAccept(eligibility, repo.QuestRecord{CharacterID: "1", States: map[int64]repo.QuestState{20737: {Status: "completed"}}}, 20738)
|
|
if err != nil || second.QuestID != 20738 {
|
|
t.Fatalf("real wedding quest 2: %+v %v", second, err)
|
|
}
|
|
}
|