服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
631 lines
26 KiB
Go
631 lines
26 KiB
Go
// 本文件负责原生婚姻协议、在线邀请和双方状态投影;持久化交给 wedding owner。
|
||
package dnfbridge
|
||
|
||
import (
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"slices"
|
||
"strconv"
|
||
"time"
|
||
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
"longheng.io/server/internal/modules/dnf/wedding"
|
||
)
|
||
|
||
// sub_182A570 starts the native invitation window's 6000 ms timer.
|
||
const weddingInvitationTimeout = 6 * time.Second
|
||
|
||
// currentWeddingPriestNPCID 是主持订婚/婚礼的神父 NPC(PVF npc/npc.lst 的
|
||
// chn_father.npc,摆在城镇 39 区域 0 的 (2365,156))。求婚要求双方都站在它旁边,
|
||
// 容差由 currentTownQuestMeetingRangeX/Y(±120/±80)决定。
|
||
const currentWeddingPriestNPCID int64 = 20013
|
||
|
||
// Full IDs from sub_18385E0's push imm32 instructions. The decompiler's
|
||
// incorrect char prototype for sub_225C940 truncates these to their low byte.
|
||
const (
|
||
currentWeddingInvitationMsgID uint16 = 997
|
||
currentWeddingResultMsgID uint16 = 998
|
||
currentWeddingEndMsgID uint16 = 999
|
||
currentWeddingStateMsgID uint16 = 1000
|
||
currentWeddingRingMsgID uint16 = 1001
|
||
currentWeddingBreakupMsgID uint16 = 1002
|
||
currentWeddingLoveMsgID uint16 = 1003
|
||
currentWeddingReservationMsgID uint16 = 1004
|
||
currentWeddingStartMsgID uint16 = 1007
|
||
currentWeddingActorMsgID uint16 = 1013
|
||
)
|
||
|
||
type currentWeddingInvitation struct {
|
||
from, to boundGameSessionCharacter
|
||
operation, kind, delay byte
|
||
expires time.Time
|
||
timer *time.Timer
|
||
}
|
||
|
||
func (s *Service) currentWeddingCatalog() (*wedding.Catalog, error) {
|
||
s.weddingCatalogMu.Lock()
|
||
defer s.weddingCatalogMu.Unlock()
|
||
if s.weddingCatalog != nil || s.weddingCatalogLoadErr != nil {
|
||
return s.weddingCatalog, s.weddingCatalogLoadErr
|
||
}
|
||
s.initialEquipmentMu.Lock()
|
||
archive, err := s.initialEquipmentArchiveLocked()
|
||
s.initialEquipmentMu.Unlock()
|
||
if err == nil {
|
||
s.weddingCatalog, err = wedding.LoadCatalog(archive)
|
||
}
|
||
s.weddingCatalogLoadErr = err
|
||
return s.weddingCatalog, err
|
||
}
|
||
|
||
func weddingGameplayModule() gameplayModuleDefinition {
|
||
m := gameplayModuleDefinition{Name: "wedding", LegacyHandlers: map[uint16]gameplayHandler{}, UpperHandlers: map[uint16]gameplayHandler{}, LegacyNormalizers: map[uint16]gameplayLegacyNormalizer{}}
|
||
for _, route := range []struct {
|
||
id uint16
|
||
size int
|
||
handle func(*Service, *gameSession, []byte) error
|
||
}{
|
||
{1032, 20, (*Service).handleWeddingRequest},
|
||
{1033, 21, (*Service).handleWeddingResponse},
|
||
{1035, 17, (*Service).handleWeddingPartner},
|
||
{1036, 18, (*Service).handleWeddingRing},
|
||
{1037, 17, (*Service).handleWeddingLove},
|
||
{1039, 22, (*Service).handleWeddingEnter},
|
||
} {
|
||
m.LegacyHandlers[route.id] = func(s *Service, session *gameSession, r gameplayRequest) error {
|
||
return route.handle(s, session, r.Body)
|
||
}
|
||
m.UpperHandlers[route.id] = defaultClassGameplayHandler("game-wedding-class-rejected", "current_exe_wedding_requires_default_classification", route.handle)
|
||
m.LegacyNormalizers[route.id] = func(body []byte) []byte { return stripLegacyTransportTrailer(body, route.size) }
|
||
}
|
||
for opcode, size := range map[uint16]int{1045: 14, 1046: 27, 1047: 23, 1048: 19, 1049: 19, 1050: 0, 1051: 0} {
|
||
handle := func(s *Service, session *gameSession, body []byte) error {
|
||
return s.weddingRoomCommand(session, opcode, body)
|
||
}
|
||
m.LegacyHandlers[opcode] = func(s *Service, session *gameSession, r gameplayRequest) error { return handle(s, session, r.Body) }
|
||
m.UpperHandlers[opcode] = defaultClassGameplayHandler("game-wedding-room-class-rejected", "current_exe_wedding_requires_default_classification", handle)
|
||
m.LegacyNormalizers[opcode] = func(body []byte) []byte { return stripLegacyTransportTrailer(body, size) }
|
||
}
|
||
for _, opcode := range []uint16{1052, 1053, 1054} {
|
||
handle := func(s *Service, session *gameSession, body []byte) error {
|
||
return s.weddingCapsuleCommand(session, opcode, body)
|
||
}
|
||
m.LegacyHandlers[opcode] = func(s *Service, session *gameSession, r gameplayRequest) error { return handle(s, session, r.Body) }
|
||
m.UpperHandlers[opcode] = defaultClassGameplayHandler("game-wedding-capsule-class-rejected", "current_exe_wedding_requires_default_classification", handle)
|
||
m.LegacyNormalizers[opcode] = func(body []byte) []byte {
|
||
if len(body) == 4 {
|
||
return nil
|
||
} // legacy transport trailer; native payload is empty
|
||
return body
|
||
}
|
||
}
|
||
return m
|
||
}
|
||
|
||
// sub_18347E0/sub_1831280 consume i32 result, u32 peer, u8 operation/kind/delay.
|
||
// sub_1831980/sub_1831310 consume the same fields plus u8 decision.
|
||
func weddingResultBody(code int32, target uint16, operation, kind, delay byte, decision ...byte) []byte {
|
||
var w packetWriter
|
||
w.writeInt32(int(code))
|
||
w.writeUint32(uint32(target))
|
||
w.writeByte(operation)
|
||
w.writeByte(kind)
|
||
w.writeByte(delay)
|
||
if len(decision) != 0 {
|
||
w.writeByte(decision[0])
|
||
}
|
||
return w.bytes()
|
||
}
|
||
|
||
func (s *Service) weddingReply(session *gameSession, opcode uint16, target uint16, operation, kind, delay byte, err error, decision ...byte) error {
|
||
var code wedding.Error
|
||
if err != nil && !errors.As(err, &code) {
|
||
code = wedding.Invalid
|
||
}
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-wedding-rejected", "opcode", opcode, "target_id", target, "operation", operation, "error", err)
|
||
}
|
||
// The command dispatcher success marker enables the handler; business errors are signed i32.
|
||
return s.sendGameUpperSuccess(session, opcode, weddingResultBody(int32(code), target, operation, kind, delay, decision...))
|
||
}
|
||
|
||
func (s *Service) weddingCharacter(ctx context.Context, identity boundGameSessionCharacter) (dnfrepo.CharacterRecord, error) {
|
||
if !s.boundGameSessionCharacterCurrent(identity) {
|
||
return dnfrepo.CharacterRecord{}, wedding.Missing
|
||
}
|
||
repos, ok := s.repositoryGroup()
|
||
if !ok || repos.Character == nil {
|
||
return dnfrepo.CharacterRecord{}, dnfrepo.ErrRepoMissing
|
||
}
|
||
c, found, err := repos.Character.Load(ctx, strconv.Itoa(int(identity.character)))
|
||
if err != nil {
|
||
return c, err
|
||
}
|
||
if !found || c.AccountID != s.accountIDForSession(identity.session) || c.Stats["delete_flag"] != 0 {
|
||
return c, wedding.Missing
|
||
}
|
||
return c, nil
|
||
}
|
||
|
||
// weddingAtPriest 只用于诊断日志:报告该角色离神父 NPC 的距离判定结果。
|
||
// currentWeddingRequiresPriest=false(当前运营口径)时它**不参与任何放行判定**,
|
||
// 仅供 game-wedding-propose-peers-missing 打印"离神父还有多远"。
|
||
func (s *Service) weddingAtPriest(identity boundGameSessionCharacter) bool {
|
||
if !s.boundGameSessionCharacterCurrent(identity) {
|
||
return false
|
||
}
|
||
session := identity.session
|
||
// Town publication promotes the session while holding townMu. A transition
|
||
// in progress is not an invitation boundary; do not invert that lock order.
|
||
if !session.townMu.TryLock() {
|
||
return false
|
||
}
|
||
pos := session.townPositionSnapshot
|
||
ready := session.townSceneReadyCharacterID == identity.character
|
||
session.townMu.Unlock()
|
||
return ready && pos.CharacterID == identity.character && slices.Contains(s.currentTownNearbyNPCIDs(pos), currentWeddingPriestNPCID)
|
||
}
|
||
|
||
// currentWeddingRequiresPriest 控制"求婚/应答是否必须双方都贴着神父"。
|
||
//
|
||
// 上游(以及本移植的第一版)要求双方都站在神父 NPC 20013 旁,容差沿用任务会面的
|
||
// ±120/±80。实测这个口径太苛刻:神父站在主街 y≈156,而角色自然站位在 y≈240
|
||
// (客户端视觉上已经是"站在神父面前"),实测**差 5 像素**就被拒,而客户端只会弹
|
||
// "未能搜索到该玩家",很容易被当成功能坏了。
|
||
//
|
||
// 运营口径(2026-10-04)放宽为:**只要同频道 + 同区域 + 双方场景就绪**。
|
||
// 想恢复上游的"必须在神父面前",把这里改成 true 即可 —— weddingAtPriest 仍然
|
||
// 保留着(并被诊断日志使用),不需要改任何其它代码。
|
||
const currentWeddingRequiresPriest = false
|
||
|
||
// weddingActorInTownScene 判断该角色是否已经在一个城镇场景里就绪。
|
||
//
|
||
// 它取代上游"必须在神父旁"的那道单人门(用于离婚/解除):只要求角色已进城、
|
||
// 场景就绪、坐标快照属于本人,不要求贴着任何 NPC。
|
||
func (s *Service) weddingActorInTownScene(identity boundGameSessionCharacter) bool {
|
||
if s == nil || identity.session == nil || !s.boundGameSessionCharacterCurrent(identity) {
|
||
return false
|
||
}
|
||
session := identity.session
|
||
// 与 weddingAtPriest 同款:拿不到 townMu 就当作"未就绪",绝不阻塞探测。
|
||
if !session.townMu.TryLock() {
|
||
return false
|
||
}
|
||
ready := session.townSceneReadyCharacterID == identity.character
|
||
pos := session.townPositionSnapshot
|
||
session.townMu.Unlock()
|
||
return ready && pos.CharacterID == identity.character
|
||
}
|
||
|
||
// weddingPeersPresent 是"双人婚姻手势"的准入判据(求婚 / 应答)。
|
||
//
|
||
// 默认口径(currentWeddingRequiresPriest=false):同频道 + 同区域 + 双方场景就绪。
|
||
// 注意它仍然要求双方注册在**同一个场景实例**里(PeerInSameArea),所以婚房/教会的
|
||
// 分实例场景不会串到别人身上。
|
||
func (s *Service) weddingPeersPresent(a, b boundGameSessionCharacter) bool {
|
||
if a.character == b.character || a.session == nil || b.session == nil {
|
||
return false
|
||
}
|
||
if a.session.channel.ID != b.session.channel.ID || s.onlinePlayers == nil {
|
||
return false
|
||
}
|
||
if s.onlinePlayers.SessionForCharacter(a.character) != a.session ||
|
||
s.onlinePlayers.SessionForCharacter(b.character) != b.session ||
|
||
!s.onlinePlayers.PeerInSameArea(a.character, b.character) {
|
||
return false
|
||
}
|
||
if currentWeddingRequiresPriest {
|
||
return s.weddingAtPriest(a) && s.weddingAtPriest(b)
|
||
}
|
||
return s.weddingActorInTownScene(a) && s.weddingActorInTownScene(b)
|
||
}
|
||
|
||
func (s *Service) handleWeddingRequest(session *gameSession, body []byte) error {
|
||
if len(body) != 20 {
|
||
return nil
|
||
}
|
||
identity, current := s.boundGameSessionCharacterSnapshot(session)
|
||
if !current {
|
||
return nil
|
||
}
|
||
target, op, kind, delay := binary.LittleEndian.Uint32(body[13:17]), body[17], body[18], body[19]
|
||
if target > 65535 || (op != 2 && (kind != 0 || delay != 0)) {
|
||
return s.weddingReply(session, 1032, 0, op, kind, delay, wedding.Invalid)
|
||
}
|
||
if op == 1 || op == 2 || op == 7 {
|
||
return s.weddingPropose(identity, uint16(target), op, kind, delay)
|
||
}
|
||
if op != 5 && op != 6 {
|
||
return s.weddingReply(session, 1032, uint16(target), op, kind, delay, wedding.Invalid)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
// ponytail: serialize wedding gestures across pairs; use per-pair locks if transaction latency becomes measurable.
|
||
s.weddingMu.Lock()
|
||
defer s.weddingMu.Unlock()
|
||
c, err := s.weddingCharacter(ctx, identity)
|
||
// 离婚/解除(op 5/6/7)原本要求"本人在神父旁"。按同一口径放宽为"本人已在
|
||
// 城镇场景里就绪",不再要求贴着神父 —— 与求婚那条保持一致。
|
||
if err == nil && !s.weddingActorInTownScene(identity) {
|
||
err = wedding.Missing
|
||
}
|
||
state, stateErr := wedding.Read(c)
|
||
if err == nil {
|
||
err = stateErr
|
||
}
|
||
if target == 0 {
|
||
target = uint32(state.PartnerID)
|
||
}
|
||
if err == nil && uint16(target) != state.PartnerID {
|
||
err = wedding.Invalid
|
||
}
|
||
cat, catErr := s.currentWeddingCatalog()
|
||
if err == nil {
|
||
err = catErr
|
||
}
|
||
var result wedding.Result
|
||
if err == nil {
|
||
repos, _ := s.repositoryGroup()
|
||
result, err = wedding.Commit(ctx, repos, cat, wedding.Command{ActorID: c.CharacterID, PartnerID: strconv.Itoa(int(target)), AccountID: s.accountIDForSession(session), Operation: op, Now: s.gameplayNow()})
|
||
}
|
||
replyErr := s.weddingReply(session, 1032, uint16(target), op, kind, delay, err)
|
||
if err != nil {
|
||
return replyErr
|
||
}
|
||
s.cancelWeddingForCharacterLocked(identity.character)
|
||
s.cancelWeddingForCharacterLocked(uint16(target))
|
||
return errors.Join(replyErr, s.publishWeddingCommit(result, op))
|
||
}
|
||
|
||
func (s *Service) weddingPropose(from boundGameSessionCharacter, target uint16, op, kind, delay byte) error {
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
s.weddingMu.Lock()
|
||
defer s.weddingMu.Unlock()
|
||
to, online := s.onlineGameSessionCharacterSnapshot(target)
|
||
var err error
|
||
if !online || !s.weddingPeersPresent(from, to) {
|
||
// 只看日志、不改判定:把 weddingPeersPresent 的每个子条件单独算出来。
|
||
// 没有它,"求婚被拒 -101" 只会告诉你"不在场",但不会说清是不同频道、
|
||
// 不在神父旁、不在同一场景实例,还是会话未就绪 —— 定位全靠翻 presence 日志。
|
||
s.logGameEvent(from.session, "game-wedding-propose-peers-missing",
|
||
s.weddingProposeDiagnostics(from, to, online)...)
|
||
err = wedding.Missing
|
||
}
|
||
if err == nil && (s.weddingInvitations[from.character] != nil || s.weddingInvitations[target] != nil) {
|
||
err = wedding.Busy
|
||
}
|
||
a, aErr := s.weddingCharacter(ctx, from)
|
||
b, bErr := s.weddingCharacter(ctx, to)
|
||
cat, catErr := s.currentWeddingCatalog()
|
||
if err == nil {
|
||
err = errors.Join(aErr, bErr, catErr)
|
||
}
|
||
if err == nil {
|
||
err = wedding.Validate(a, b, cat, op, s.gameplayNow())
|
||
}
|
||
if err == nil && (len(rosterNameBytes(a.Name)) == 0 || len(rosterNameBytes(a.Name)) > 29 || len(rosterNameBytes(b.Name)) == 0 || len(rosterNameBytes(b.Name)) > 29) {
|
||
err = wedding.Invalid
|
||
}
|
||
if err == nil && op == 2 && (kind > 2 || delay > 2) {
|
||
err = wedding.Invalid
|
||
}
|
||
if err != nil {
|
||
return s.weddingReply(from.session, 1032, target, op, kind, delay, err)
|
||
}
|
||
inv := ¤tWeddingInvitation{from: from, to: to, operation: op, kind: kind, delay: delay, expires: s.gameplayNow().Add(weddingInvitationTimeout)}
|
||
if s.weddingInvitations == nil {
|
||
s.weddingInvitations = make(map[uint16]*currentWeddingInvitation)
|
||
}
|
||
s.weddingInvitations[from.character], s.weddingInvitations[target] = inv, inv
|
||
if err = s.weddingReply(from.session, 1032, target, op, kind, delay, nil); err == nil {
|
||
err = s.sendGameUpperRawClass(to.session, currentWeddingInvitationMsgID, weddingResultBody(0, from.character, op, kind, delay), 0)
|
||
}
|
||
if err != nil {
|
||
s.removeWeddingInvitationLocked(inv)
|
||
return errors.Join(err, s.notifyWeddingResult(inv, wedding.Missing, 2))
|
||
}
|
||
inv.timer = time.AfterFunc(weddingInvitationTimeout, func() { s.expireWeddingInvitation(inv) })
|
||
s.logGameEvent(from.session, "game-wedding-invitation-sent", "from_char_id", from.character, "target_id", target, "target_conn_id", to.session.connID, "operation", op, "msg_id", currentWeddingInvitationMsgID)
|
||
return nil
|
||
}
|
||
|
||
// weddingProposeDiagnostics 只用于日志:把 weddingPeersPresent 的每个子条件单独算出来,
|
||
// 便于定位"求婚被拒 -101"到底卡在哪一条。它**不参与判定** —— 权威判据仍然只有
|
||
// weddingPeersPresent 一个,这里多算几遍只是为了写清楚原因。
|
||
//
|
||
// 关注的五条(与 weddingPeersPresent 一一对应):
|
||
// - target_online 对方是否在线(有绑定会话)
|
||
// - same_channel 是否同一频道
|
||
// - from/target_at_priest 两人是否都在神父 NPC 20013 旁(含场景就绪)
|
||
// - *_ready townSceneReadyCharacterID 是否等于本人
|
||
// - peer_same_area 是否注册在同一个场景实例
|
||
//
|
||
// 另外把坐标、所在城镇/区域和"身边识别到的 NPC 列表"一并打出来 —— 神父那条最容易
|
||
// 悄悄不成立:容差只有 ±120/±80,坐标差一点点就会被判成"不在旁边"。
|
||
func (s *Service) weddingProposeDiagnostics(
|
||
from boundGameSessionCharacter, to boundGameSessionCharacter, online bool,
|
||
) []any {
|
||
fields := []any{"target_online", online, "from_char", from.character, "target_char", to.character}
|
||
if !online {
|
||
return fields
|
||
}
|
||
fields = append(fields, "same_character", from.character == to.character)
|
||
fromChannel, targetChannel := 0, 0
|
||
if from.session != nil {
|
||
fromChannel = from.session.channel.ID
|
||
}
|
||
if to.session != nil {
|
||
targetChannel = to.session.channel.ID
|
||
}
|
||
fields = append(fields, "from_channel", fromChannel, "target_channel", targetChannel, "same_channel", fromChannel == targetChannel)
|
||
fields = append(fields,
|
||
"from_at_priest", s.weddingAtPriest(from),
|
||
"target_at_priest", s.weddingAtPriest(to))
|
||
fields = append(fields, s.weddingPriestPositionDiagnostics("from", from)...)
|
||
fields = append(fields, s.weddingPriestPositionDiagnostics("target", to)...)
|
||
if s.onlinePlayers != nil {
|
||
fields = append(fields,
|
||
"from_session_bound", s.onlinePlayers.SessionForCharacter(from.character) == from.session,
|
||
"target_session_bound", s.onlinePlayers.SessionForCharacter(to.character) == to.session,
|
||
"peer_same_area", s.onlinePlayers.PeerInSameArea(from.character, to.character))
|
||
} else {
|
||
fields = append(fields, "online_players_manager", "nil")
|
||
}
|
||
return fields
|
||
}
|
||
|
||
// weddingPriestPositionDiagnostics 报告一个会话"离神父还有多远"。
|
||
// prefix 用于区分发起方(from)与目标(target)。
|
||
func (s *Service) weddingPriestPositionDiagnostics(prefix string, identity boundGameSessionCharacter) []any {
|
||
session := identity.session
|
||
if session == nil {
|
||
return []any{prefix + "_session", "nil"}
|
||
}
|
||
// TryLock 与 weddingAtPriest 同款:拿不到就只记锁竞争,不去阻塞探测。
|
||
if !session.townMu.TryLock() {
|
||
return []any{prefix + "_town_mu_busy", true}
|
||
}
|
||
position := session.townPositionSnapshot
|
||
ready := session.townSceneReadyCharacterID == identity.character
|
||
session.townMu.Unlock()
|
||
nearby := s.currentTownNearbyNPCIDs(position)
|
||
nearPriest := slices.Contains(nearby, currentWeddingPriestNPCID)
|
||
return []any{
|
||
prefix + "_ready", ready,
|
||
prefix + "_snapshot_char", position.CharacterID,
|
||
// pos_valid 是最容易致命的一条:换场景(op36)会把快照置为无效,
|
||
// 而 currentTownNearbyNPCIDs 在无效时直接返回 nil,于是"在神父旁"
|
||
// 永远判 false —— 看起来就像"搜不到玩家"。
|
||
prefix + "_pos_valid", position.PositionValid,
|
||
prefix + "_town", position.TownID,
|
||
prefix + "_area", position.AreaID,
|
||
prefix + "_x", position.PositionX,
|
||
prefix + "_y", position.PositionY,
|
||
prefix + "_nearby_npcs", nearby,
|
||
prefix + "_near_priest", nearPriest,
|
||
}
|
||
}
|
||
|
||
func (s *Service) handleWeddingResponse(session *gameSession, body []byte) error {
|
||
if len(body) != 21 {
|
||
return nil
|
||
}
|
||
identity, current := s.boundGameSessionCharacterSnapshot(session)
|
||
if !current {
|
||
return nil
|
||
}
|
||
target, op, kind, delay, decision := binary.LittleEndian.Uint32(body[13:17]), body[17], body[18], body[19], body[20]
|
||
if target > 65535 || decision > 2 {
|
||
return s.weddingReply(session, 1033, 0, op, kind, delay, wedding.Invalid, decision)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
s.weddingMu.Lock()
|
||
defer s.weddingMu.Unlock()
|
||
inv := s.weddingInvitations[identity.character]
|
||
// sub_182A330 sends peer zero when declining or timing out; acceptance
|
||
// must still name the proposer, and only this recipient's invitation applies.
|
||
if target == 0 && decision != 0 && inv != nil && inv.to == identity {
|
||
target = uint32(inv.from.character)
|
||
}
|
||
// sub_1829F50 acknowledges a ceremony with zero kind/delay; retain the requester's selections.
|
||
if inv == nil || inv.to != identity || inv.from.character != uint16(target) || inv.operation != op || kind != 0 || delay != 0 {
|
||
return s.weddingReply(session, 1033, uint16(target), op, kind, delay, wedding.Missing, decision)
|
||
}
|
||
s.removeWeddingInvitationLocked(inv)
|
||
kind, delay = inv.kind, inv.delay
|
||
if !s.gameplayNow().Before(inv.expires) {
|
||
decision = 2
|
||
}
|
||
var result wedding.Result
|
||
var err error
|
||
if !s.weddingPeersPresent(inv.from, inv.to) {
|
||
err = wedding.Missing
|
||
}
|
||
if err == nil && decision == 0 {
|
||
cat, e := s.currentWeddingCatalog()
|
||
err = e
|
||
if err == nil {
|
||
repos, ok := s.repositoryGroup()
|
||
if !ok {
|
||
err = dnfrepo.ErrRepoMissing
|
||
} else {
|
||
room := byte(0)
|
||
if op == 2 {
|
||
room, err = s.weddingRoom()
|
||
}
|
||
cmd := wedding.Command{ActorID: strconv.Itoa(int(inv.from.character)), PartnerID: strconv.Itoa(int(inv.to.character)), AccountID: s.accountIDForSession(inv.from.session), PartnerAccountID: s.accountIDForSession(inv.to.session), Operation: op, Kind: kind, Delay: delay, Room: room, Now: s.gameplayNow()}
|
||
cmd.ChannelID = uint16(inv.from.session.channel.ID)
|
||
if err == nil && op == 2 {
|
||
cmd.Grant, err = s.weddingGiftAllocator(cat, kind)
|
||
}
|
||
if err == nil {
|
||
result, err = wedding.Commit(ctx, repos, cat, cmd)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
ackErr := s.weddingReply(session, 1033, uint16(target), op, kind, delay, err, decision)
|
||
var code wedding.Error
|
||
if err != nil && !errors.As(err, &code) {
|
||
code = wedding.Invalid
|
||
}
|
||
ackErr = errors.Join(ackErr, s.notifyWeddingResult(inv, code, decision))
|
||
s.logGameEvent(session, "game-wedding-decision", "from_char_id", target, "target_id", identity.character, "operation", op, "decision", decision, "result", int32(code))
|
||
if err == nil && decision == 0 {
|
||
ackErr = errors.Join(ackErr, s.publishWeddingCommit(result, op))
|
||
if op == 2 {
|
||
ackErr = errors.Join(ackErr, s.publishWeddingReservation(result.Characters), s.scheduleWeddingCeremony(result.Characters[0]))
|
||
ackErr = errors.Join(ackErr, s.sendWeddingInventorySnapshot(inv.from.session, result.Inventories[0]))
|
||
}
|
||
}
|
||
return ackErr
|
||
}
|
||
|
||
func (s *Service) notifyWeddingResult(inv *currentWeddingInvitation, code wedding.Error, decision byte) error {
|
||
if !s.boundGameSessionCharacterCurrent(inv.from) {
|
||
return nil
|
||
}
|
||
return s.sendGameUpperRawClass(inv.from.session, currentWeddingResultMsgID, weddingResultBody(int32(code), inv.to.character, inv.operation, inv.kind, inv.delay, decision), 0)
|
||
}
|
||
|
||
func (s *Service) removeWeddingInvitationLocked(inv *currentWeddingInvitation) {
|
||
for _, id := range []uint16{inv.from.character, inv.to.character} {
|
||
if s.weddingInvitations[id] == inv {
|
||
delete(s.weddingInvitations, id)
|
||
}
|
||
}
|
||
if inv.timer != nil {
|
||
inv.timer.Stop()
|
||
}
|
||
}
|
||
|
||
func (s *Service) expireWeddingInvitation(inv *currentWeddingInvitation) {
|
||
s.weddingMu.Lock()
|
||
defer s.weddingMu.Unlock()
|
||
if s.weddingInvitations[inv.to.character] != inv {
|
||
return
|
||
}
|
||
s.removeWeddingInvitationLocked(inv)
|
||
if err := s.notifyWeddingResult(inv, 0, 2); err != nil {
|
||
s.logGameEvent(inv.from.session, "game-wedding-timeout-send-failed", "error", err)
|
||
}
|
||
}
|
||
|
||
func (s *Service) cancelWeddingForCharacterLocked(id uint16) {
|
||
if inv := s.weddingInvitations[id]; inv != nil {
|
||
s.removeWeddingInvitationLocked(inv)
|
||
if err := s.notifyWeddingResult(inv, 0, 2); err != nil {
|
||
s.logGameEvent(inv.from.session, "game-wedding-cancel-send-failed", "error", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Service) clearWeddingInvitations(session *gameSession) {
|
||
if session == nil {
|
||
return
|
||
}
|
||
s.weddingMu.Lock()
|
||
defer s.weddingMu.Unlock()
|
||
for _, inv := range s.weddingInvitations {
|
||
if inv.from.session == session || inv.to.session == session {
|
||
s.cancelWeddingForCharacterLocked(inv.from.character)
|
||
}
|
||
}
|
||
}
|
||
|
||
// sub_18355B0: wedding ID, status, ceremony, date, love points, ring.
|
||
func weddingStateBody(state wedding.State) []byte {
|
||
if state.Status == 0 {
|
||
state.PairID, state.Ring = 0xffffffff, 0xff
|
||
}
|
||
var w packetWriter
|
||
w.writeUint32(state.PairID)
|
||
w.writeByte(state.Status)
|
||
w.writeByte(state.Ceremony)
|
||
w.writeUint32(uint32(state.Date))
|
||
w.writeUint32(uint32(state.Love))
|
||
w.writeByte(state.Ring)
|
||
return w.bytes()
|
||
}
|
||
|
||
func (s *Service) publishWeddingCommit(result wedding.Result, operation byte) error {
|
||
var err error
|
||
for _, c := range result.Characters {
|
||
id, _ := strconv.ParseUint(c.CharacterID, 10, 16)
|
||
identity, current := s.onlineGameSessionCharacterSnapshot(uint16(id))
|
||
if !current {
|
||
continue
|
||
}
|
||
// Full state (1000) destroys the native partner preview. Item changes
|
||
// have dedicated notifications which preserve that actor and window.
|
||
if operation == 8 {
|
||
err = errors.Join(err, s.sendGameUpperRawClass(identity.session, currentWeddingRingMsgID, []byte{byte(c.Stats["wedding_ring"])}, 0))
|
||
continue
|
||
}
|
||
if operation == 9 {
|
||
var w packetWriter
|
||
w.writeUint32(uint32(id))
|
||
w.writeUint32(uint32(c.Stats["wedding_love"]))
|
||
err = errors.Join(err, s.sendGameUpperRawClass(identity.session, currentWeddingLoveMsgID, w.bytes(), 0))
|
||
continue
|
||
}
|
||
if operation >= 5 && operation <= 7 {
|
||
err = errors.Join(err, s.sendGameUpperRawClass(identity.session, currentWeddingBreakupMsgID, []byte{operation}, 0))
|
||
}
|
||
err = errors.Join(err, s.sendWeddingCharacterState(identity.session, c))
|
||
if operation == 4 || (operation >= 5 && operation <= 7) {
|
||
err = errors.Join(err, s.sendWeddingCapsuleState(identity.session, c))
|
||
}
|
||
if operation == 1 || (operation >= 5 && operation <= 7) {
|
||
err = errors.Join(err, s.sendCurrentAcceptableQuestListOnlyForSession(identity.session, "wedding_relationship_changed"))
|
||
}
|
||
if result.AssetsChanged {
|
||
err = errors.Join(err, s.sendGameUpperRawClass(identity.session, 14, buildCurrentGoldStateBody(c.Stats["gold"]), 0))
|
||
}
|
||
state, stateErr := wedding.Read(c)
|
||
if stateErr == nil && s.onlinePlayers != nil {
|
||
for _, peer := range s.onlinePlayers.PeersInSameArea(identity.character) {
|
||
if peer.Session != nil {
|
||
err = errors.Join(err, s.sendGameUpperRawClass(peer.Session, currentWeddingActorMsgID, weddingActorStateBody(identity.character, state), 0))
|
||
}
|
||
}
|
||
}
|
||
}
|
||
return err
|
||
}
|
||
|
||
func (s *Service) sendWeddingCharacterState(session *gameSession, c dnfrepo.CharacterRecord) error {
|
||
state, err := wedding.Read(c)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendGameUpperRawClass(session, currentWeddingStateMsgID, weddingStateBody(state), 0); err != nil {
|
||
return err
|
||
}
|
||
session.weddingStateSent.Store(true)
|
||
// sub_1834DA0 clears the partner preview; sub_324E6C0 needs its name
|
||
// before the wedding dungeon menu can locate the spouse in the party.
|
||
if state.Status >= 1 && state.Status <= 4 {
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
return s.sendWeddingPartnerState(ctx, session, c)
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func weddingActorStateBody(id uint16, state wedding.State) []byte {
|
||
if state.Status == 0 {
|
||
state.PairID, state.Ring = 0xffffffff, 0xff
|
||
}
|
||
var w packetWriter
|
||
w.writeUint32(uint32(id))
|
||
w.writeUint32(state.PairID)
|
||
w.writeByte(state.Status)
|
||
w.writeByte(state.Ring)
|
||
w.writeUint32(uint32(state.Date))
|
||
return w.bytes()
|
||
}
|