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,227 @@
package wedding
import (
"context"
"errors"
"fmt"
"sort"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/pvf"
repo "longheng.io/server/internal/modules/dnf/repository"
)
const CapsulePVFPath = "etc/couplegrowthcapsule.etc"
const capsulePrefix = "wedding_capsule_"
type CapsuleCatalog struct {
MaxOwn, MaxTaken, Gauge, MaxGauge, ItemID int64
}
func (c *CapsuleCatalog) valid() bool {
return c != nil && c.MaxOwn > 0 && c.MaxOwn <= 255 && c.MaxTaken > 0 && c.MaxTaken <= 255 && c.Gauge > 0 && c.Gauge <= c.MaxGauge && c.MaxGauge <= 0x7fffffff && c.ItemID > 0 && c.ItemID <= 0x7fffffff
}
func LoadCapsuleCatalog(source pvf.Source) (*CapsuleCatalog, error) {
if source == nil {
return nil, Invalid
}
text, err := source.ReadText(CapsulePVFPath)
if err != nil {
return nil, err
}
doc, err := pvf.Parse(CapsulePVFPath, text)
if err != nil {
return nil, err
}
c := &CapsuleCatalog{}
for name, field := range map[string]*int64{"max own count": &c.MaxOwn, "max taken count": &c.MaxTaken, "gage": &c.Gauge, "max gage": &c.MaxGauge, "capsule id": &c.ItemID} {
tokens, ok := doc.Section(name)
if !ok || len(tokens) != 1 || tokens[0].Kind != pvf.TokenInt {
return nil, fmt.Errorf("invalid capsule PVF section %s", name)
}
*field = tokens[0].Int
}
if !c.valid() {
return nil, Invalid
}
return c, nil
}
// Own is crafted stock; Taken is unclaimed stock sent by the spouse. Neither
// is a daily counter. The current EXE's NPC prompt displays all Taken gifts.
type CapsuleState struct{ Progress, Own, Taken int64 }
func ReadCapsule(c repo.CharacterRecord, rules *CapsuleCatalog) (CapsuleState, error) {
if !rules.valid() {
return CapsuleState{}, Invalid
}
w, err := Read(c)
if err != nil {
return CapsuleState{}, err
}
if w.Status != 4 || c.Stats[capsulePrefix+"pair"] != int64(w.PairID) || c.Stats[capsulePrefix+"date"] != w.Date {
return CapsuleState{}, nil
}
s := CapsuleState{c.Stats[capsulePrefix+"progress"], c.Stats[capsulePrefix+"own"], c.Stats[capsulePrefix+"taken"]}
if s.Progress < 0 || s.Progress > rules.MaxGauge || s.Own < 0 || s.Own > rules.MaxOwn || s.Taken < 0 || s.Taken > rules.MaxTaken {
return CapsuleState{}, Invalid
}
return s, nil
}
func storeCapsule(c *repo.CharacterRecord, s CapsuleState) {
if c.Stats == nil {
c.Stats = make(map[string]int64)
}
c.Stats[capsulePrefix+"pair"] = c.Stats["wedding_id"]
c.Stats[capsulePrefix+"date"] = c.Stats["wedding_date"]
c.Stats[capsulePrefix+"progress"], c.Stats[capsulePrefix+"own"], c.Stats[capsulePrefix+"taken"] = s.Progress, s.Own, s.Taken
}
func clearCapsule(c *repo.CharacterRecord) {
for k := range c.Stats {
if strings.HasPrefix(k, capsulePrefix) {
delete(c.Stats, k)
}
}
}
// AccumulateCapsuleClear is called inside the receipted dungeon settlement,
// after the server has accepted a recommended clear. It never grants an item.
func AccumulateCapsuleClear(c *repo.CharacterRecord, rules *CapsuleCatalog) error {
if c == nil || !rules.valid() {
return Invalid
}
w, err := Read(*c)
if err != nil {
return err
}
if w.Status != 4 || c.Stats["delete_flag"] != 0 {
return nil
}
s, err := ReadCapsule(*c, rules)
if err != nil {
return err
}
s.Progress = min(rules.MaxGauge, s.Progress+rules.Gauge)
storeCapsule(c, s)
return nil
}
var (
ErrCapsuleEnergy = errors.New("wedding capsule energy insufficient")
ErrCapsuleLimit = errors.New("wedding capsule stock limit")
ErrCapsuleEmpty = errors.New("wedding capsule stock empty")
ErrCapsuleBag = errors.New("wedding capsule inventory grant failed")
)
type CapsuleCommand struct {
ActorID, PartnerID, AccountID string
Opcode uint16
Now time.Time
Grant func(*repo.InventoryRecord, Item) error
}
type CapsuleResult struct {
Result
States [2]CapsuleState
Granted int64
}
// Make converts one full gauge into one gift; Give transfers all crafted
// gifts; Take claims all received gifts. Each operation is one dual UOW, so
// offline spouses, concurrent requests and bag failures preserve both stocks.
func CommitCapsule(ctx context.Context, repos repo.Group, cat *Catalog, rules *CapsuleCatalog, cmd CapsuleCommand) (CapsuleResult, error) {
if cat == nil || !rules.valid() || repos.CharacterTrade == nil || cmd.ActorID == "" || cmd.PartnerID == "" || cmd.ActorID == cmd.PartnerID || cmd.AccountID == "" || cmd.Now.IsZero() || cmd.Opcode < 1052 || cmd.Opcode > 1054 {
return CapsuleResult{}, Invalid
}
ids := []string{cmd.ActorID, cmd.PartnerID}
sort.Strings(ids)
var result CapsuleResult
err := repos.CharacterTrade.WithinCharacterTrade(ctx, ids[0], ids[1], func(chars repo.CharacterRepository, bags repo.InventoryRepository) error {
for i, id := range []string{cmd.ActorID, cmd.PartnerID} {
c, found, err := chars.Load(ctx, id)
if err != nil {
return err
}
if !found {
return Missing
}
result.Characters[i] = repo.CloneCharacter(c)
}
a, b := &result.Characters[0], &result.Characters[1]
if a.AccountID != cmd.AccountID {
return Invalid
}
if err := Validate(*a, *b, cat, 9, cmd.Now); err != nil {
return err
}
for i, c := range result.Characters {
s, err := ReadCapsule(c, rules)
if err != nil {
return err
}
result.States[i] = s
}
x, y := &result.States[0], &result.States[1]
switch cmd.Opcode {
case 1052:
if x.Progress < rules.MaxGauge {
return ErrCapsuleEnergy
}
if x.Own >= rules.MaxOwn {
return ErrCapsuleLimit
}
x.Progress -= rules.MaxGauge
x.Own++
case 1053:
if x.Own == 0 {
return ErrCapsuleEmpty
}
if x.Own > rules.MaxTaken-y.Taken {
return ErrCapsuleLimit
}
y.Taken += x.Own
x.Own = 0
case 1054:
if x.Taken == 0 {
return ErrCapsuleEmpty
}
if cmd.Grant == nil {
return Invalid
}
bag, found, err := bags.Load(ctx, cmd.ActorID)
if err != nil {
return err
}
if !found || bag.CharacterID != cmd.ActorID {
return Invalid
}
bag = repo.CloneInventory(bag)
if err := cmd.Grant(&bag, Item{rules.ItemID, x.Taken}); err != nil {
return fmt.Errorf("%w: %w", ErrCapsuleBag, err)
}
bag.UpdatedAt = cmd.Now
if err := repo.SaveInventoryFields(ctx, bags, bag, repo.InventoryFieldSlots); err != nil {
return err
}
result.Granted = x.Taken
x.Taken = 0
result.Inventories[0], result.AssetsChanged = bag, true
}
for i := range result.Characters {
c := &result.Characters[i]
storeCapsule(c, result.States[i])
c.UpdatedAt = cmd.Now
if err := repo.SaveCharacterFields(ctx, chars, *c, repo.CharacterFieldStats); err != nil {
return err
}
}
return nil
})
if err != nil {
return CapsuleResult{}, err
}
return result, nil
}
@@ -0,0 +1,220 @@
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")
}
})
}
}
@@ -0,0 +1,149 @@
// 本文件从实际 PVF 加载婚前任务、费用、婚礼时间、戒指和心意物品规则。
package wedding
import (
"fmt"
"longheng.io/server/internal/modules/dnf/pvf"
)
const PVFPath = "etc/weddingsystem.etc"
type Item struct{ ID, Count int64 }
type Ceremony struct {
Gold int64
Ring byte
Invitations int64
Gift Item
}
type Catalog struct {
MinimumLevel int64
Alimony [2]int64
RemarryDays, WeddingDays, MaxLove int64
QuestIDs [2]int64
Ceremonies [3]Ceremony
Delays [3]int64
PaidType byte
PaidTicket Item
RingTickets [6]Item
LoveItems map[int64]int64
EndSeconds int64
Positions [2][3]int64
}
func LoadCatalog(source pvf.Source) (*Catalog, error) {
if source == nil {
return nil, fmt.Errorf("wedding PVF source is required")
}
text, err := source.ReadText(PVFPath)
if err != nil {
return nil, err
}
doc, err := pvf.Parse(PVFPath, text)
if err != nil {
return nil, err
}
c := &Catalog{LoveItems: make(map[int64]int64)}
for _, spec := range []struct {
Name string
N int
Apply func([]int64)
}{
{"minimum level", 1, func(v []int64) { c.MinimumLevel = v[0] }},
{"alimony", 2, func(v []int64) { copy(c.Alimony[:], v) }},
{"days after divorce", 1, func(v []int64) { c.RemarryDays = v[0] }},
{"once how many days", 1, func(v []int64) { c.WeddingDays = v[0] }},
{"max love point", 1, func(v []int64) { c.MaxLove = v[0] }},
{"quest", 2, func(v []int64) { copy(c.QuestIDs[:], v) }},
{"minute selection", 3, func(v []int64) { copy(c.Delays[:], v) }},
{"noti end", 1, func(v []int64) { c.EndSeconds = v[0] }},
{"force move", 6, func(v []int64) { copy(c.Positions[0][:], v[:3]); copy(c.Positions[1][:], v[3:]) }},
{"ceremony", 12, func(v []int64) {
for i := 0; i < 3; i++ {
c.Ceremonies[i] = Ceremony{Gold: v[4*i+1], Ring: byte(v[4*i+2]), Invitations: v[4*i+3]}
}
}},
{"wedding gift", 9, func(v []int64) {
for i := 0; i < 3; i++ {
c.Ceremonies[i].Gift = Item{v[3*i+1], v[3*i+2]}
}
}},
{"charged wedding ticket", 3, func(v []int64) { c.PaidType = byte(v[0]); c.PaidTicket = Item{v[1], v[2]} }},
{"upgrade ring ticket", 12, func(v []int64) {
for i := 0; i < 6; i++ {
c.RingTickets[i] = Item{v[2*i], v[2*i+1]}
}
}},
{"love point item", -1, func(v []int64) {
for i := 0; i < len(v); i += 2 {
c.LoveItems[v[i]] = v[i+1]
}
}},
} {
tokens, ok := doc.Section(spec.Name)
if !ok || (spec.N >= 0 && len(tokens) != spec.N) || (spec.N == -1 && len(tokens)%2 != 0) {
return nil, fmt.Errorf("wedding PVF section %s has invalid length", spec.Name)
}
values := make([]int64, len(tokens))
for i, t := range tokens {
if t.Kind != pvf.TokenInt || t.Int < 0 || t.Int > 0x7fffffff {
return nil, fmt.Errorf("invalid wedding PVF %s[%d]", spec.Name, i)
}
values[i] = t.Int
}
if spec.Name == "charged wedding ticket" && values[0] > 2 {
return nil, fmt.Errorf("invalid paid wedding type")
}
if spec.Name == "ceremony" {
for i := 0; i < 3; i++ {
if values[4*i+2] > 5 {
return nil, fmt.Errorf("invalid wedding ring")
}
}
}
if spec.Name == "ceremony" || spec.Name == "wedding gift" {
stride := 4
if spec.Name == "wedding gift" {
stride = 3
}
for i := 0; i < 3; i++ {
if values[i*stride] != int64(i) {
return nil, fmt.Errorf("unordered wedding PVF %s", spec.Name)
}
}
}
spec.Apply(values)
}
if c.MinimumLevel < 30 || c.MinimumLevel > 127 || c.MaxLove <= 0 || c.PaidType > 2 || c.RemarryDays > 365 || c.WeddingDays > 365 {
return nil, fmt.Errorf("invalid wedding PVF rules")
}
for _, d := range c.Delays {
if d <= 0 || d > 86400 {
return nil, fmt.Errorf("invalid wedding ceremony delay")
}
}
if c.EndSeconds <= 0 || c.EndSeconds > 3600 {
return nil, fmt.Errorf("invalid wedding end interval")
}
if c.QuestIDs[0] <= 0 || c.QuestIDs[1] <= 0 || c.PaidTicket.ID <= 0 || c.PaidTicket.Count <= 0 {
return nil, fmt.Errorf("invalid wedding prerequisites")
}
for _, ceremony := range c.Ceremonies {
if ceremony.Gift.ID <= 0 || ceremony.Gift.Count <= 0 || ceremony.Invitations > 65535 {
return nil, fmt.Errorf("invalid wedding gifts")
}
}
for _, ticket := range c.RingTickets[1:] {
if ticket.ID <= 0 || ticket.Count <= 0 {
return nil, fmt.Errorf("invalid wedding ring ticket")
}
}
for _, pos := range c.Positions {
for _, v := range pos {
if v > 32767 {
return nil, fmt.Errorf("invalid wedding position")
}
}
}
return c, nil
}
@@ -0,0 +1,451 @@
// Package wedding owns reciprocal marriage state and the two-character asset transaction.
package wedding
import (
"context"
"encoding/binary"
"fmt"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/quest"
repo "longheng.io/server/internal/modules/dnf/repository"
)
type Error int32
func (e Error) Error() string { return fmt.Sprintf("wedding result %d", e) }
const (
Missing Error = -101
Invalid Error = -103
Busy Error = -102
GoldMissing Error = -7
Cooldown Error = -151
)
type State struct {
ChannelID uint16
PairID uint32
PartnerID uint16
Status, Ring, Ceremony byte
Love int64
Date, DivorcedAt, NextWeddingAt, StartAt int64
Room byte
}
func Read(c repo.CharacterRecord) (State, error) {
v := c.Stats
if v["wedding_channel"] < 0 || v["wedding_channel"] > 65535 {
return State{}, Invalid
}
s := State{PairID: uint32(v["wedding_id"]), PartnerID: uint16(v["wedding_partner_id"]), Status: byte(v["wedding_state"]), Ring: byte(v["wedding_ring"]), Ceremony: byte(v["wedding_ceremony"]), Love: v["wedding_love"], Date: v["wedding_date"], DivorcedAt: v["wedding_divorced_at"], NextWeddingAt: v["wedding_next_at"], StartAt: v["wedding_start_at"], Room: byte(v["wedding_room"])}
s.ChannelID = uint16(v["wedding_channel"])
for _, spec := range []struct {
key string
max int64
}{{"wedding_id", 0xffffffff}, {"wedding_partner_id", 65535}, {"wedding_state", 7}, {"wedding_ring", 5}, {"wedding_ceremony", 2}, {"wedding_room", 255}, {"wedding_love", 0x7fffffff}} {
if v[spec.key] < 0 || v[spec.key] > spec.max {
return State{}, Invalid
}
}
for _, stamp := range []int64{s.Date, s.DivorcedAt, s.NextWeddingAt, s.StartAt} {
if stamp < 0 || stamp > 0xffffffff {
return State{}, Invalid
}
}
if s.Status > 4 || ((s.Status > 0) != (s.PartnerID > 0 && s.PairID > 0)) || (s.Status == 0 && (s.PartnerID != 0 || s.PairID != 0)) {
return State{}, Invalid
}
return s, nil
}
func store(c *repo.CharacterRecord, s State, now time.Time) {
if c.Stats == nil {
c.Stats = make(map[string]int64)
}
for k, v := range map[string]int64{"id": int64(s.PairID), "partner_id": int64(s.PartnerID), "state": int64(s.Status), "ring": int64(s.Ring), "ceremony": int64(s.Ceremony), "love": s.Love, "date": s.Date, "divorced_at": s.DivorcedAt, "next_at": s.NextWeddingAt, "start_at": s.StartAt, "room": int64(s.Room)} {
c.Stats["wedding_"+k] = v
}
c.UpdatedAt = now
c.Stats["wedding_channel"] = int64(s.ChannelID)
}
// Sex follows the current EXE sub_1830F40, including the later job IDs.
func Sex(job string) (byte, error) {
n, err := strconv.Atoi(job)
if err != nil || n < 0 || n > 15 {
return 0, Invalid
}
switch n {
case 0, 2, 4, 7, 8, 9, 13, 15:
return 1, nil
default:
return 0, nil
}
}
func Validate(a, b repo.CharacterRecord, c *Catalog, operation byte, now time.Time) error {
if c == nil {
return Invalid
}
if a.CharacterID == "" || a.CharacterID == b.CharacterID || a.Stats["delete_flag"] != 0 || b.Stats["delete_flag"] != 0 {
return Missing
}
for _, r := range []repo.CharacterRecord{a, b} {
id, err := strconv.ParseUint(r.CharacterID, 10, 16)
if err != nil || id == 0 || strconv.FormatUint(id, 10) != r.CharacterID {
return Invalid
}
}
x, err := Read(a)
if err != nil {
return err
}
y, err := Read(b)
if err != nil {
return err
}
if operation == 1 {
if x.Status != 0 || y.Status != 0 {
return Error(-2)
}
if int64(a.Level) < c.MinimumLevel || int64(b.Level) < c.MinimumLevel {
return Error(-3)
}
sx, e1 := Sex(a.Job)
sy, e2 := Sex(b.Job)
if e1 != nil || e2 != nil || sx == sy {
return Error(-3)
}
for _, s := range []State{x, y} {
if s.DivorcedAt > 0 && now.Unix() < s.DivorcedAt+c.RemarryDays*86400 {
return Cooldown
}
}
return nil
}
if x.PairID == 0 || x.PairID != y.PairID || strconv.Itoa(int(x.PartnerID)) != b.CharacterID || strconv.Itoa(int(y.PartnerID)) != a.CharacterID || x.Status != y.Status {
return Invalid
}
if (x.Status == 2 || x.Status == 3) && (x.StartAt <= 0 || x.StartAt != y.StartAt || x.Ceremony != y.Ceremony || x.Room < 8 || x.Room > 38 || x.Room != y.Room) {
return Invalid
}
if x.ChannelID != y.ChannelID {
return Invalid
}
switch operation {
case 2:
if x.Status != 1 {
return Invalid
}
if now.Unix() < x.NextWeddingAt || now.Unix() < y.NextWeddingAt {
return Error(-199)
}
case 3:
if x.Status != 2 || now.Unix() < x.StartAt {
return Invalid
}
case 4:
if x.Status != 3 || now.Unix() < x.StartAt+c.EndSeconds {
return Invalid
}
case 5:
if x.Status != 1 {
return Invalid
}
case 6, 7:
if x.Status != 4 {
return Invalid
}
case 8, 9:
if x.Status != 4 {
return Invalid
}
default:
return Invalid
}
return nil
}
type Command struct {
ChannelID uint16
ActorID, PartnerID, AccountID string
PartnerAccountID string
Operation, Kind, Delay, Room, Ring byte
Slot int32
Now time.Time
// The bridge supplies PVF-backed item placement inside this transaction.
Grant func(*repo.InventoryRecord, Item) error
}
type Result struct {
Characters [2]repo.CharacterRecord
Inventories [2]repo.InventoryRecord
AssetsChanged bool
}
func Commit(ctx context.Context, repos repo.Group, c *Catalog, cmd Command) (Result, error) {
if c == nil || repos.CharacterTrade == nil || cmd.ActorID == cmd.PartnerID || cmd.AccountID == "" || cmd.Now.IsZero() {
return Result{}, Invalid
}
ids := []string{cmd.ActorID, cmd.PartnerID}
sort.Strings(ids)
if cmd.Operation == 2 {
// Completed quests are prerequisites, not wedding writes. Load before the two-character UOW.
if repos.Quest == nil {
return Result{}, Invalid
}
for _, id := range ids {
quests, found, err := repos.Quest.Load(ctx, id)
if err != nil {
return Result{}, err
}
if !found || !quest.IsCompleted(quests, c.QuestIDs[0]) || !quest.IsCompleted(quests, c.QuestIDs[1]) {
return Result{}, Error(-9)
}
}
}
var result Result
err := repos.CharacterTrade.WithinCharacterTrade(ctx, ids[0], ids[1], func(chars repo.CharacterRepository, bags repo.InventoryRepository) error {
loaded := map[string]repo.CharacterRecord{}
for _, id := range ids {
v, found, e := chars.Load(ctx, id)
if e != nil {
return e
}
if !found {
return Missing
}
loaded[id] = repo.CloneCharacter(v)
}
a, b := loaded[cmd.ActorID], loaded[cmd.PartnerID]
if a.AccountID != cmd.AccountID || (cmd.PartnerAccountID != "" && b.AccountID != cmd.PartnerAccountID) {
return Invalid
}
if err := Validate(a, b, c, cmd.Operation, cmd.Now); err != nil {
return err
}
x, _ := Read(a)
y, _ := Read(b)
var ia, ib repo.InventoryRecord
if cmd.Operation == 2 || cmd.Operation == 4 || cmd.Operation == 8 || cmd.Operation == 9 {
for _, id := range ids {
bag, found, e := bags.Load(ctx, id)
if e != nil {
return e
}
if !found {
return Invalid
}
if bag.CharacterID != id {
return Invalid
}
if id == cmd.ActorID {
ia = repo.CloneInventory(bag)
} else {
ib = repo.CloneInventory(bag)
}
}
}
switch cmd.Operation {
case 1:
clearCapsule(&a)
clearCapsule(&b)
clearRoom(&a)
clearRoom(&b)
ai, _ := strconv.ParseUint(a.CharacterID, 10, 16)
bi, _ := strconv.ParseUint(b.CharacterID, 10, 16)
// Both IDs are validated u16; this is an injective unordered-pair key, not a hash.
pair := uint32(min(ai, bi)<<16 | max(ai, bi))
x.PairID, x.PartnerID, x.Status = pair, uint16(bi), 1
y.PairID, y.PartnerID, y.Status = pair, uint16(ai), 1
case 2:
if cmd.Kind > 2 || cmd.Delay > 2 || cmd.Room < 8 || cmd.Room > 38 {
return Invalid
}
ceremony := c.Ceremonies[cmd.Kind]
if cmd.Grant == nil {
return Invalid
}
// Validate the real reward and both bag capacities before accepting payment.
for _, bag := range []repo.InventoryRecord{ia, ib} {
preview := repo.CloneInventory(bag)
if err := cmd.Grant(&preview, ceremony.Gift); err != nil {
return err
}
}
if a.Stats["gold"] < ceremony.Gold {
return Error(-16)
}
if cmd.Kind == c.PaidType {
if err := consume(&ia, -1, c.PaidTicket, cmd.Now); err != nil {
return Error(-14)
}
}
if a.Stats == nil {
a.Stats = make(map[string]int64)
}
a.Stats["gold"] -= ceremony.Gold
for _, s := range []*State{&x, &y} {
s.Status = 2
s.Ceremony = cmd.Kind
s.Room = cmd.Room
if cmd.Operation == 2 {
s.ChannelID = cmd.ChannelID
}
s.StartAt = cmd.Now.Unix() + c.Delays[cmd.Delay]
s.NextWeddingAt = cmd.Now.Unix() + c.WeddingDays*86400
}
result.AssetsChanged = true
case 3:
x.Status, y.Status = 3, 3
case 4:
if cmd.Grant == nil {
return Invalid
}
gift := c.Ceremonies[x.Ceremony].Gift
if err := cmd.Grant(&ia, gift); err != nil {
return err
}
if err := cmd.Grant(&ib, gift); err != nil {
return err
}
for _, s := range []*State{&x, &y} {
s.Status = 4
s.Ring = c.Ceremonies[s.Ceremony].Ring
s.Date = cmd.Now.Unix()
s.StartAt = 0
s.Room = 0
}
result.AssetsChanged = true
case 5, 6, 7:
clearCapsule(&a)
clearCapsule(&b)
clearRoom(&a)
clearRoom(&b)
for _, c := range []*repo.CharacterRecord{&a, &b} {
if c.Stats["mood_value"] == 1001 {
c.Stats["mood_value"] = 0
}
}
if cmd.Operation == 5 {
x = State{DivorcedAt: x.DivorcedAt, NextWeddingAt: x.NextWeddingAt}
y = State{DivorcedAt: y.DivorcedAt, NextWeddingAt: y.NextWeddingAt}
break
}
cost := c.Alimony[0]
if cmd.Operation == 6 {
cost = c.Alimony[1]
}
if a.Stats["gold"] < cost || (cmd.Operation == 7 && b.Stats["gold"] < cost) {
return GoldMissing
}
a.Stats["gold"] -= cost
if cmd.Operation == 7 {
b.Stats["gold"] -= cost
}
x = State{DivorcedAt: cmd.Now.Unix(), NextWeddingAt: x.NextWeddingAt}
y = State{DivorcedAt: cmd.Now.Unix(), NextWeddingAt: y.NextWeddingAt}
result.AssetsChanged = cost != 0
case 8:
if cmd.Slot < 3 || cmd.Slot > 353 {
return Invalid
}
if cmd.Ring < 1 || cmd.Ring > 5 || x.Ring+1 != cmd.Ring || y.Ring != x.Ring {
return Error(-2)
}
if err := consume(&ia, cmd.Slot, c.RingTickets[cmd.Ring], cmd.Now); err != nil {
return Error(-4)
}
x.Ring, y.Ring = cmd.Ring, cmd.Ring
result.AssetsChanged = true
case 9:
if cmd.Slot < 3 || cmd.Slot > 353 {
return Invalid
}
stack := ia.Slots[fmt.Sprintf("0:%d", cmd.Slot)]
points := c.LoveItems[stack.ItemID]
if points <= 0 || x.Love < 0 || x.Love > c.MaxLove || points > c.MaxLove-x.Love || y.Love != x.Love {
return Error(-2)
}
if err := consume(&ia, cmd.Slot, Item{stack.ItemID, 1}, cmd.Now); err != nil {
return Error(-4)
}
x.Love += points
y.Love = x.Love
result.AssetsChanged = true
}
store(&a, x, cmd.Now)
store(&b, y, cmd.Now)
for _, character := range []repo.CharacterRecord{a, b} {
if err := repo.SaveCharacterFields(ctx, chars, character, repo.CharacterFieldStats); err != nil {
return err
}
}
if ia.CharacterID != "" {
ia.UpdatedAt, ib.UpdatedAt = cmd.Now, cmd.Now
for _, bag := range []repo.InventoryRecord{ia, ib} {
if err := repo.SaveInventoryFields(ctx, bags, bag, repo.InventoryFieldSlots); err != nil {
return err
}
}
}
result.Characters = [2]repo.CharacterRecord{a, b}
result.Inventories = [2]repo.InventoryRecord{ia, ib}
return nil
})
if err != nil {
return Result{}, err
}
return result, nil
}
func consume(bag *repo.InventoryRecord, slot int32, item Item, now time.Time) error {
if item.ID <= 0 || item.Count <= 0 {
return Invalid
}
keys := make([]string, 0, len(bag.Slots))
for key := range bag.Slots {
keys = append(keys, key)
}
sort.Strings(keys)
remaining := item.Count
for _, key := range keys {
list, raw, valid := strings.Cut(key, ":")
index, err := strconv.Atoi(raw)
if !valid || err != nil || list != "0" || raw != strconv.Itoa(index) || index < 3 || index > 353 || (slot >= 0 && index != int(slot)) {
continue
}
stack := bag.Slots[key]
if stack.ItemID != item.ID || stack.Count <= 0 || (!stack.ExpireAt.IsZero() && !now.Before(stack.ExpireAt)) {
continue
}
switch strings.ToLower(stack.Extra["equipment_lock_state"]) {
case "1", "2", "active", "locked", "unlocking", "pending_unlock":
continue
}
take := min(remaining, stack.Count)
remaining -= take
stack.Count -= take
if stack.Count == 0 {
delete(bag.Slots, key)
} else {
if stack.Extra == nil {
stack.Extra = make(map[string]string)
}
for _, field := range []string{"amount_or_count", "amount", "count", "stack", "quantity"} {
stack.Extra[field] = strconv.FormatInt(stack.Count, 10)
}
if len(stack.RawEntry) >= 10 {
binary.LittleEndian.PutUint32(stack.RawEntry[6:10], uint32(stack.Count))
}
bag.Slots[key] = stack
}
if remaining == 0 {
return nil
}
}
return Invalid
}
@@ -0,0 +1,284 @@
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)
}
}
+498
View File
@@ -0,0 +1,498 @@
package wedding
import (
"context"
"crypto/rand"
"fmt"
"math/big"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/pvf"
repo "longheng.io/server/internal/modules/dnf/repository"
)
type TreeReward struct {
Item
Weight int64
}
type Tree struct {
Days, DailyWater, MaxGrowth int64
Water map[int64]int64
Rewards []TreeReward
}
type RoomCatalog struct {
Seeds map[int64]int64
Trees map[int64]Tree
Positions map[int64]map[int]bool
}
type RoomState struct {
Installed [28]int64
Cargo [40]Item
Tree, Expires, Growth, WaterDay, WaterCount int64
}
type RoomCommand struct {
ActorID, PartnerID, AccountID string
Opcode uint16
Slot int32
ItemID, Count int64
From, To byte
FromSlot, ToSlot int
FromItem, ToItem int64
Gold int64
Materials []Item
Now time.Time
Grant func(*repo.InventoryRecord, Item) error
}
type RoomResult struct {
Result
Room RoomState
Reward Item
Slot int
}
func LoadRoomCatalog(source pvf.Source) (*RoomCatalog, error) {
c := &RoomCatalog{Seeds: map[int64]int64{}, Trees: map[int64]Tree{}, Positions: map[int64]map[int]bool{}}
for _, path := range []string{"decoration/coupletree.etc", "decoration/coupleroomiteminfo.etc"} {
text, err := source.ReadText(path)
if err != nil {
return nil, err
}
doc, err := pvf.Parse(path, text)
if err != nil {
return nil, err
}
var tree Tree
var index int64
for _, section := range doc.Sections {
tokens := doc.Tokens[section.Start:section.End]
var values []int64
for _, token := range tokens {
if token.Kind == pvf.TokenInt {
values = append(values, token.Int)
}
}
switch strings.ToLower(section.Name) {
case "seed":
if len(values)%2 != 0 {
return nil, Invalid
}
for i := 0; i < len(values); i += 2 {
c.Seeds[values[i]] = values[i+1]
}
case "tree":
tree = Tree{Water: map[int64]int64{}}
index = 0
case "index":
if len(values) == 1 {
index = values[0]
}
case "expire day":
if len(values) == 1 {
tree.Days = values[0]
}
case "day water count":
if len(values) == 1 {
tree.DailyWater = values[0]
}
case "grow point":
if len(values)%2 != 0 {
return nil, Invalid
}
for i := 0; i < len(values); i += 2 {
if values[i] <= 0 || values[i+1] <= 0 {
return nil, Invalid
}
tree.Water[values[i]] = values[i+1]
}
case "step":
for _, v := range values {
tree.MaxGrowth = max(tree.MaxGrowth, v)
}
case "reward":
if len(values)%3 != 0 {
return nil, Invalid
}
for i := 0; i < len(values); i += 3 {
if values[i] <= 0 || values[i+1] <= 0 || values[i+1] > 1000000 || values[i+2] <= 0 {
return nil, Invalid
}
tree.Rewards = append(tree.Rewards, TreeReward{Item{values[i], values[i+2]}, values[i+1]})
}
case "/tree":
if index <= 0 || tree.Days <= 0 || tree.Days > 365 || tree.DailyWater <= 0 || tree.DailyWater > 255 || tree.MaxGrowth <= 0 || len(tree.Water) == 0 || len(tree.Rewards) == 0 {
return nil, Invalid
}
c.Trees[index] = tree
case "coupleroom item":
if len(values) < 3 || values[1] < 1 || values[1] > 28 || len(values) != 3+5*int(values[1]) {
return nil, Invalid
}
positions := map[int]bool{}
for i := int64(0); i < values[1]; i++ {
p := values[2+5*i]
if p < 0 || p >= 28 {
return nil, Invalid
}
positions[int(p)] = true
}
c.Positions[values[0]] = positions
}
}
}
if len(c.Seeds) == 0 || len(c.Positions) == 0 {
return nil, Invalid
}
for item, index := range c.Seeds {
if item <= 0 || c.Trees[index].MaxGrowth <= 0 {
return nil, Invalid
}
}
return c, nil
}
func ReadRoom(character repo.CharacterRecord, pairID uint32, now time.Time) (RoomState, error) {
var room RoomState
v := character.Stats
if v["wedding_house_id"] != int64(pairID) {
return room, nil
}
for i := range room.Installed {
room.Installed[i] = v[fmt.Sprintf("wedding_house_installed_%d", i)]
if room.Installed[i] < 0 || room.Installed[i] > 0xffffffff {
return RoomState{}, Invalid
}
}
for i := range room.Cargo {
room.Cargo[i] = Item{v[fmt.Sprintf("wedding_house_cargo_%d", i)], v[fmt.Sprintf("wedding_house_count_%d", i)]}
if room.Cargo[i].ID < 0 || room.Cargo[i].ID > 0xffffffff || room.Cargo[i].Count < 0 || room.Cargo[i].Count > 65535 || (room.Cargo[i].ID == 0) != (room.Cargo[i].Count == 0) {
return RoomState{}, Invalid
}
}
room.Tree, room.Expires, room.Growth = v["wedding_house_tree"], v["wedding_house_expires"], v["wedding_house_growth"]
room.WaterDay, room.WaterCount = v["wedding_house_day"], v["wedding_house_water"]
if room.Tree < 0 || room.Tree > 0xffffffff || room.Expires < 0 || room.Expires > 0xffffffff || room.Growth < 0 || room.Growth > 0xffffffff || room.WaterCount < 0 || room.WaterCount > 255 {
return RoomState{}, Invalid
}
if room.Tree != 0 && room.Expires <= now.Unix() {
room.Tree, room.Expires, room.Growth = 0, 0, 0
}
day, _ := strconv.ParseInt(now.In(time.FixedZone("CST", 8*3600)).Format("20060102"), 10, 64)
if room.WaterDay != day {
room.WaterDay, room.WaterCount = day, 0
}
return room, nil
}
func clearRoom(c *repo.CharacterRecord) {
for key := range c.Stats {
if strings.HasPrefix(key, "wedding_house_") {
delete(c.Stats, key)
}
}
}
func storeRoom(c *repo.CharacterRecord, pairID uint32, r RoomState) {
clearRoom(c)
c.Stats["wedding_house_id"] = int64(pairID)
for i, id := range r.Installed {
if id != 0 {
c.Stats[fmt.Sprintf("wedding_house_installed_%d", i)] = id
}
}
for i, item := range r.Cargo {
if item.ID != 0 {
c.Stats[fmt.Sprintf("wedding_house_cargo_%d", i)] = item.ID
c.Stats[fmt.Sprintf("wedding_house_count_%d", i)] = item.Count
}
}
for key, value := range map[string]int64{"tree": r.Tree, "expires": r.Expires, "growth": r.Growth, "day": r.WaterDay, "water": r.WaterCount} {
c.Stats["wedding_house_"+key] = value
}
}
func CommitRoom(ctx context.Context, repos repo.Group, cat *Catalog, rules *RoomCatalog, cmd RoomCommand) (RoomResult, error) {
if repos.CharacterTrade == nil || rules == nil || cmd.ActorID == cmd.PartnerID || cmd.Now.IsZero() || cmd.AccountID == "" {
return RoomResult{}, Invalid
}
ids := []string{cmd.ActorID, cmd.PartnerID}
sort.Strings(ids)
var out RoomResult
err := repos.CharacterTrade.WithinCharacterTrade(ctx, ids[0], ids[1], func(chars repo.CharacterRepository, bags repo.InventoryRepository) error {
for i, id := range []string{cmd.ActorID, cmd.PartnerID} {
c, found, err := chars.Load(ctx, id)
if err != nil {
return err
}
if !found {
return Missing
}
out.Characters[i] = repo.CloneCharacter(c)
b, found, err := bags.Load(ctx, id)
if err != nil {
return err
}
if !found || b.CharacterID != id {
return Invalid
}
out.Inventories[i] = repo.CloneInventory(b)
}
a, b := &out.Characters[0], &out.Characters[1]
if a.AccountID != cmd.AccountID {
return Invalid
}
if err := Validate(*a, *b, cat, 9, cmd.Now); err != nil {
return err
}
s, _ := Read(*a)
canonical := a
if b.CharacterID < a.CharacterID {
canonical = b
}
r, err := ReadRoom(*canonical, s.PairID, cmd.Now)
if err != nil {
return err
}
bag := &out.Inventories[0]
switch cmd.Opcode {
case 1045: // Query the shared decoration storage.
case 21: // NPC pricing is resolved by the existing shop catalog.
if len(rules.Positions[cmd.ItemID]) == 0 || cmd.Count < 1 || cmd.Count > 65535 || cmd.Gold < 0 || a.Stats["gold"] < 0 || a.Stats["gold"] > 0x7fffffff {
return Invalid
}
if cmd.Gold > a.Stats["gold"] {
return GoldMissing
}
for _, cost := range cmd.Materials {
if cost.Count <= 0 {
return Invalid
}
if cost.ID == 10002606 {
if a.Stats["wedding_love"] != b.Stats["wedding_love"] || a.Stats["wedding_love"] < cost.Count {
return Error(-9)
}
a.Stats["wedding_love"] -= cost.Count
b.Stats["wedding_love"] -= cost.Count
} else if err := consume(bag, -1, cost, cmd.Now); err != nil {
return Error(-9)
}
}
a.Stats["gold"] -= cmd.Gold
slot := -1
for i, item := range r.Cargo {
if item.ID == cmd.ItemID && item.Count <= 65535-cmd.Count {
slot = i
break
}
}
if slot < 0 {
for i, item := range r.Cargo {
if item.ID == 0 {
slot = i
break
}
}
}
if slot < 0 {
return Error(-20)
}
r.Cargo[slot] = Item{cmd.ItemID, r.Cargo[slot].Count + cmd.Count}
out.Slot = slot
out.AssetsChanged = true
case 1046:
if err := moveRoomItem(&r, rules, cmd); err != nil {
return err
}
case 1047: // Delete the selected number of stored decorations.
if cmd.Slot < 0 || cmd.Slot >= 40 || cmd.Count < 1 {
return Invalid
}
item := &r.Cargo[cmd.Slot]
if item.ID != cmd.ItemID || item.Count < cmd.Count {
return Invalid
}
item.Count -= cmd.Count
if item.Count == 0 {
*item = Item{}
}
case 1048:
index := rules.Seeds[cmd.ItemID]
tree, ok := rules.Trees[index]
if r.Tree != 0 || !ok {
return Error(-4)
}
if err := consume(bag, cmd.Slot, Item{cmd.ItemID, 1}, cmd.Now); err != nil {
return Error(-4)
}
r.Tree, r.Expires, r.Growth = index, cmd.Now.Unix()+tree.Days*86400, 0
out.AssetsChanged = true
case 1049:
tree, ok := rules.Trees[r.Tree]
if !ok || cmd.Now.Unix() >= r.Expires {
return Error(-4)
}
points := tree.Water[cmd.ItemID]
if points <= 0 {
return Error(-9)
}
if r.Growth >= tree.MaxGrowth {
return Error(-9)
}
if r.WaterCount >= tree.DailyWater {
return Error(-6)
}
if err := consume(bag, cmd.Slot, Item{cmd.ItemID, 1}, cmd.Now); err != nil {
return Error(-9)
}
r.Growth = min(tree.MaxGrowth, r.Growth+points)
r.WaterCount++
out.AssetsChanged = true
case 1050:
tree, ok := rules.Trees[r.Tree]
if !ok || cmd.Now.Unix() >= r.Expires {
return Error(-4)
}
if r.Growth < tree.MaxGrowth {
return Error(-6)
}
if cmd.Grant == nil {
return Invalid
}
var total int64
for _, reward := range tree.Rewards {
total += reward.Weight
}
if total <= 0 {
return Invalid
}
roll, err := rand.Int(rand.Reader, big.NewInt(total))
if err != nil {
return err
}
n := roll.Int64()
for _, reward := range tree.Rewards {
n -= reward.Weight
if n < 0 {
out.Reward = reward.Item
break
}
}
for i := range out.Inventories {
if err := cmd.Grant(&out.Inventories[i], out.Reward); err != nil {
return err
}
}
r.Growth = 0
out.AssetsChanged = true
case 1051:
r.Tree, r.Expires, r.Growth = 0, 0, 0
default:
return Invalid
}
if cmd.Opcode != 1045 {
storeRoom(canonical, s.PairID, r)
for _, c := range out.Characters {
c.UpdatedAt = cmd.Now
if err := repo.SaveCharacterFields(ctx, chars, c, repo.CharacterFieldStats); err != nil {
return err
}
}
if out.AssetsChanged {
for _, bag := range out.Inventories {
bag.UpdatedAt = cmd.Now
if err := repo.SaveInventoryFields(ctx, bags, bag, repo.InventoryFieldSlots); err != nil {
return err
}
}
}
}
out.Room = r
return nil
})
if err != nil {
return RoomResult{}, err
}
return out, nil
}
func moveRoomItem(r *RoomState, c *RoomCatalog, cmd RoomCommand) error {
if cmd.From > 1 || cmd.To > 1 || cmd.FromSlot < 0 || cmd.ToSlot < 0 || (cmd.From == cmd.To && cmd.FromSlot == cmd.ToSlot) {
return Invalid
}
get := func(kind byte, slot int) (Item, bool) {
if kind == 0 {
if slot >= len(r.Installed) {
return Item{}, false
}
id := r.Installed[slot]
if id == 0 {
return Item{}, true
}
return Item{id, 1}, true
}
if slot >= len(r.Cargo) {
return Item{}, false
}
return r.Cargo[slot], true
}
from, ok := get(cmd.From, cmd.FromSlot)
if !ok || from.ID == 0 || from.ID != cmd.FromItem {
return Invalid
}
to, ok := get(cmd.To, cmd.ToSlot)
if !ok {
return Invalid
}
// Native empty destination sometimes carries the source ID.
if to.ID != 0 && to.ID != cmd.ToItem {
return Invalid
}
if cmd.From == 0 && cmd.To == 0 {
return Invalid
}
if cmd.To == 0 && !c.Positions[from.ID][cmd.ToSlot] {
return Error(-20)
}
if cmd.From == 1 && cmd.To == 1 {
r.Cargo[cmd.FromSlot], r.Cargo[cmd.ToSlot] = to, from
return nil
}
if cmd.From == 0 {
if to.ID != 0 && to.ID != from.ID {
return Error(-20)
}
if to.Count >= 65535 {
return Invalid
}
r.Cargo[cmd.ToSlot] = Item{from.ID, to.Count + 1}
r.Installed[cmd.FromSlot] = 0
return nil
}
if to.ID != 0 {
if from.Count == 1 {
r.Cargo[cmd.FromSlot] = to
} else {
empty := -1
for i, item := range r.Cargo {
if item.ID == 0 {
empty = i
break
}
}
if empty < 0 {
return Error(-20)
}
r.Cargo[empty] = to
r.Cargo[cmd.FromSlot].Count--
}
} else {
r.Cargo[cmd.FromSlot].Count--
if r.Cargo[cmd.FromSlot].Count == 0 {
r.Cargo[cmd.FromSlot] = Item{}
}
}
r.Installed[cmd.ToSlot] = from.ID
return nil
}
@@ -0,0 +1,160 @@
package wedding
import (
"context"
"errors"
"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 TestWeddingRoomSharedAssetsAndRollback(t *testing.T) {
ctx := context.Background()
now := time.Date(2026, 9, 8, 2, 0, 0, 0, time.UTC)
repos := memory.NewMemoryGroup()
for _, id := range []string{"1", "2"} {
partner := int64(2)
if id == "2" {
partner = 1
}
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: id, AccountID: "a" + id, Stats: map[string]int64{"wedding_state": 4, "wedding_id": 65538, "wedding_partner_id": partner, "wedding_love": 300, "gold": 1000}}); err != nil {
t.Fatal(err)
}
if err := repos.Inventory.Save(ctx, repo.InventoryRecord{CharacterID: id, Slots: map[string]repo.ItemStack{"0:3": {ItemID: 10002766, Count: 2}, "0:4": {ItemID: 10002765, Count: 10}}}); err != nil {
t.Fatal(err)
}
}
rules := &RoomCatalog{Seeds: map[int64]int64{10002766: 1}, Trees: map[int64]Tree{1: {Days: 30, DailyWater: 2, MaxGrowth: 30, Water: map[int64]int64{10002765: 10}, Rewards: []TreeReward{{Item{10002901, 2}, 1}}}}, Positions: map[int64]map[int]bool{10002694: {0: true}}}
cat := &Catalog{}
cmd := RoomCommand{ActorID: "1", PartnerID: "2", AccountID: "a1", Now: now}
apply := func() RoomResult {
t.Helper()
result, err := CommitRoom(ctx, repos, cat, rules, cmd)
if err != nil {
t.Fatal(err)
}
return result
}
cmd.Opcode, cmd.ItemID, cmd.Count, cmd.Materials = 21, 10002694, 1, []Item{{10002606, 100}}
r := apply()
if r.Room.Cargo[0] != (Item{10002694, 1}) || r.Characters[0].Stats["wedding_love"] != 200 || r.Characters[1].Stats["wedding_love"] != 200 {
t.Fatalf("purchase: %+v", r)
}
cmd.Opcode, cmd.From, cmd.FromSlot, cmd.FromItem, cmd.To, cmd.ToSlot = 1046, 1, 0, 10002694, 0, 0
r = apply()
if r.Room.Installed[0] != 10002694 || r.Room.Cargo[0] != (Item{}) {
t.Fatalf("install: %+v", r.Room)
}
cmd.ActorID, cmd.PartnerID, cmd.AccountID = "2", "1", "a2"
cmd.From, cmd.To, cmd.ToSlot, cmd.ToItem = 0, 1, 3, 10002694
r = apply()
if r.Room.Installed[0] != 0 || r.Room.Cargo[3] != (Item{10002694, 1}) {
t.Fatalf("partner retrieve: %+v", r.Room)
}
cmd.Opcode, cmd.Slot, cmd.ItemID = 1048, 3, 10002766
r = apply()
if r.Room.Tree != 1 || r.Room.Expires != now.Unix()+30*86400 || r.Inventories[0].Slots["0:3"].Count != 1 {
t.Fatalf("plant: %+v", r.Room)
}
cmd.Opcode, cmd.Slot, cmd.ItemID = 1049, 4, 10002765
apply()
r = apply()
if r.Room.Growth != 20 || r.Room.WaterCount != 2 {
t.Fatalf("water: %+v", r.Room)
}
if _, err := CommitRoom(ctx, repos, cat, rules, cmd); err != Error(-6) {
t.Fatalf("daily water limit must use native daily-limit message, got %v", err)
}
stored, _, _ := repos.Character.Load(ctx, "1")
waterBag, _, _ := repos.Inventory.Load(ctx, cmd.ActorID)
if stored.Stats["wedding_house_growth"] != 20 || stored.Stats["wedding_house_water"] != 2 || waterBag.Slots["0:4"].Count != 8 {
t.Fatal("rejected watering changed growth, daily count or water items")
}
cmd.Now = now.Add(24 * time.Hour)
r = apply()
if r.Room.Growth != 30 || r.Room.WaterCount != 1 {
t.Fatalf("next day: %+v", r.Room)
}
if _, err := CommitRoom(ctx, repos, cat, rules, cmd); err != Error(-9) {
t.Fatalf("mature tree must use native sufficient-water message, got %v", err)
}
cmd.Opcode = 1050
cmd.Grant = func(b *repo.InventoryRecord, item Item) error {
b.Slots["0:5"] = repo.ItemStack{ItemID: item.ID, Count: item.Count}
if b.CharacterID == "1" {
return errors.New("partner bag full")
}
return nil
}
before, _, _ := repos.Inventory.Load(ctx, "2")
if _, err := CommitRoom(ctx, repos, cat, rules, cmd); err == nil {
t.Fatal("partial reward committed")
}
after, _, _ := repos.Inventory.Load(ctx, "2")
if !reflect.DeepEqual(before, after) {
t.Fatal("failed harvest changed actor inventory")
}
cmd.Grant = func(b *repo.InventoryRecord, item Item) error {
b.Slots["0:5"] = repo.ItemStack{ItemID: item.ID, Count: item.Count}
return nil
}
r = apply()
if r.Room.Growth != 0 || r.Room.Tree != 1 {
t.Fatalf("harvest: %+v", r.Room)
}
for _, b := range r.Inventories {
if b.Slots["0:5"].ItemID != 10002901 || b.Slots["0:5"].Count != 2 {
t.Fatalf("reward: %+v", b)
}
}
if _, err := CommitRoom(ctx, repos, cat, rules, cmd); err == nil {
t.Fatal("repeated harvest granted rewards")
}
cmd.Opcode = 1045
cmd.AccountID = "a1"
if _, err := CommitRoom(ctx, repos, cat, rules, cmd); err == nil {
t.Fatal("foreign account admitted")
}
cmd.AccountID = "a2"
cmd.Now = now.Add(31 * 24 * time.Hour)
if expired := apply(); expired.Room.Tree != 0 {
t.Fatal("expired tree blocks new planting")
}
cmd.Opcode, cmd.Slot, cmd.ItemID = 1048, 3, 10002766
repos.CharacterTrade = failingWeddingTrade{repos.CharacterTrade}
if _, err := CommitRoom(ctx, repos, cat, rules, cmd); err == nil {
t.Fatal("save failure accepted")
}
after, _, _ = repos.Inventory.Load(ctx, "2")
if after.Slots["0:3"].Count != 1 {
t.Fatal("save failure consumed seed")
}
}
func TestWeddingRoomRuntimePVF(t *testing.T) {
path := os.Getenv("DNF90_PVF_PATH")
if path == "" {
t.Skip("set DNF90_PVF_PATH for room catalog validation")
}
a, err := platformpvf.Open(path)
if err != nil {
t.Fatal(err)
}
c, err := LoadRoomCatalog(a)
if err != nil {
t.Fatal(err)
}
if len(c.Trees) != 3 || c.Seeds[10002766] != 1 || c.Seeds[10002768] != 2 || c.Seeds[10002767] != 3 || len(c.Positions[10002694]) == 0 {
t.Fatalf("catalog: %+v", c)
}
for _, tree := range c.Trees {
if tree.Days != 30 || tree.DailyWater != 2 || tree.MaxGrowth != 30 || tree.Water[10002765] != 10 || tree.Water[10002905] != 20 || len(tree.Rewards) != 6 {
t.Fatalf("tree: %+v", tree)
}
}
}
@@ -0,0 +1,99 @@
package wedding
import (
"context"
"errors"
"sort"
"time"
repo "longheng.io/server/internal/modules/dnf/repository"
)
// LovePointMaterial is the PVF shop currency, not a physical inventory stack.
const LovePointMaterial = 10002606
var ErrShopLoveMissing = errors.New("wedding shop love points are insufficient")
type RingPurchase struct {
ActorID, PartnerID, AccountID string
Item Item
Gold, Love int64
Now time.Time
Grant func(*repo.InventoryRecord, Item) error
}
// BuyRingTicket debits the buyer's gold and the reciprocal couple currency
// together with the real inventory grant. It does not upgrade either ring.
func BuyRingTicket(ctx context.Context, repos repo.Group, cat *Catalog, cmd RingPurchase) (Result, error) {
if cat == nil || repos.CharacterTrade == nil || cmd.ActorID == "" || cmd.ActorID == cmd.PartnerID || cmd.AccountID == "" || cmd.Now.IsZero() || cmd.Item.Count != 1 || cmd.Gold < 0 || cmd.Gold > 0x7fffffff || cmd.Love <= 0 || cmd.Grant == nil {
return Result{}, Invalid
}
known := false
for _, ticket := range cat.RingTickets[1:] {
known = known || ticket.ID == cmd.Item.ID
}
if !known {
return Result{}, Invalid
}
ids := []string{cmd.ActorID, cmd.PartnerID}
sort.Strings(ids)
var result Result
err := repos.CharacterTrade.WithinCharacterTrade(ctx, ids[0], ids[1], func(chars repo.CharacterRepository, bags repo.InventoryRepository) error {
for i, id := range []string{cmd.ActorID, cmd.PartnerID} {
c, found, err := chars.Load(ctx, id)
if err != nil {
return err
}
if !found {
return Missing
}
result.Characters[i] = repo.CloneCharacter(c)
}
a, b := &result.Characters[0], &result.Characters[1]
if a.AccountID != cmd.AccountID {
return Invalid
}
if err := Validate(*a, *b, cat, 9, cmd.Now); err != nil {
return err
}
if a.Stats["gold"] < cmd.Gold {
return GoldMissing
}
if a.Stats["gold"] > 0x7fffffff {
return Invalid
}
if a.Stats["wedding_love"] != b.Stats["wedding_love"] || a.Stats["wedding_love"] < cmd.Love || a.Stats["wedding_love"] > cat.MaxLove {
return ErrShopLoveMissing
}
bag, found, err := bags.Load(ctx, cmd.ActorID)
if err != nil {
return err
}
if !found || bag.CharacterID != cmd.ActorID {
return Invalid
}
bag = repo.CloneInventory(bag)
if err := cmd.Grant(&bag, cmd.Item); err != nil {
return err
}
a.Stats["gold"] -= cmd.Gold
a.Stats["wedding_love"] -= cmd.Love
b.Stats["wedding_love"] -= cmd.Love
for _, c := range []*repo.CharacterRecord{a, b} {
c.UpdatedAt = cmd.Now
if err := repo.SaveCharacterFields(ctx, chars, *c, repo.CharacterFieldStats); err != nil {
return err
}
}
bag.UpdatedAt = cmd.Now
if err := repo.SaveInventoryFields(ctx, bags, bag, repo.InventoryFieldSlots); err != nil {
return err
}
result.Inventories[0], result.AssetsChanged = bag, true
return nil
})
if err != nil {
return Result{}, err
}
return result, nil
}