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