服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
388 lines
13 KiB
Go
388 lines
13 KiB
Go
// 本文件负责 PVF 教堂预约、入场和持久化仪式阶段的计时恢复。
|
|
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
"longheng.io/server/internal/modules/dnf/wedding"
|
|
)
|
|
|
|
func (s *Service) weddingRoom() (byte, error) {
|
|
for room := byte(8); room <= 38; room++ {
|
|
if area, found := s.townArea(39, int64(room)); found && strings.EqualFold(strings.ReplaceAll(area.MapPath, "\\", "/"), "Church/Church.map") {
|
|
return room, nil
|
|
}
|
|
}
|
|
return 0, fmt.Errorf("wedding church missing from runtime PVF")
|
|
}
|
|
|
|
func weddingTownInstance(c dnfrepo.CharacterRecord, room byte, channel uint16) (uint32, bool) {
|
|
state, err := wedding.Read(c)
|
|
if err != nil || (state.Status != 2 && state.Status != 3) || state.Room != room || state.ChannelID != channel {
|
|
return 0, false
|
|
}
|
|
return state.PairID, true
|
|
}
|
|
|
|
// currentWeddingGuildDungeon 对应上游的 runtime.guildDungeonID != 0。
|
|
//
|
|
// 工作区的 runtimeDungeonState 不记"这是不是公会地下城"这个标志:公会净化是通关后
|
|
// 由 creditCurrentGuildDungeonClear 用同一份目录反查入账的。所以这里复用同一个
|
|
// 判据(目录里能查到该副本 id 即为公会地下城),保持"公会本不算夫妻通关"的语义。
|
|
// 目录加载失败时返回 false —— 宁可漏排除,也不要因为读目录失败而吞掉正常通关。
|
|
//
|
|
// 判据本体已提到 currentGuildDungeonRun(公会贡献度那条规则也要用同一个判据),
|
|
// 这里只是保留夫妻系统原有的函数名与语义,行为逐字节不变。
|
|
func (s *Service) currentWeddingGuildDungeon(runtime *runtimeDungeonState) bool {
|
|
return s.currentGuildDungeonRun(runtime)
|
|
}
|
|
|
|
// The dungeon owner supplies the actual completion roster, not a town party list.
|
|
func (s *Service) currentWeddingDungeonCouple(ctx context.Context, runtime *runtimeDungeonState) (bool, error) {
|
|
if runtime == nil || runtime.ProtocolMode != currentDungeonProtocolModeNormal ||
|
|
runtime.raidDungeon.RaidKey != 0 || s.currentWeddingGuildDungeon(runtime) || runtime.altar != nil ||
|
|
runtime.tournament != nil || runtime.Dungeon.Metadata.NoFatigue ||
|
|
runtime.Dungeon.Metadata.TutorialDungeon.Value != 0 || runtime.Dungeon.Metadata.InfernoDungeon {
|
|
return false, nil
|
|
}
|
|
kind := normalizeDungeonPVFSymbol(runtime.Dungeon.Metadata.DungeonType)
|
|
if kind != "" && kind != "normal" {
|
|
return false, nil
|
|
}
|
|
owner := runtime.partyDungeonOwner
|
|
if owner == nil {
|
|
return false, nil
|
|
}
|
|
owner.mu.Lock()
|
|
defer owner.mu.Unlock()
|
|
// Freeze both recipients' eligibility at the first authoritative completion,
|
|
// so the first member returning to town does not invalidate the other's result.
|
|
if owner.weddingClearKnown {
|
|
return owner.weddingClearCouple, nil
|
|
}
|
|
if owner.retired || owner.entryMemberCount != 2 {
|
|
return false, nil
|
|
}
|
|
members := owner.members
|
|
if len(members) != 2 {
|
|
return false, nil
|
|
}
|
|
repos, ok := s.repositoryGroup()
|
|
if !ok || repos.Character == nil {
|
|
return false, dnfrepo.ErrRepoMissing
|
|
}
|
|
var states [2]wedding.State
|
|
containsActor := false
|
|
for i, member := range members {
|
|
identity, online := s.onlineGameSessionCharacterSnapshot(member.userID)
|
|
if !online || identity.generation != member.generation {
|
|
return false, nil
|
|
}
|
|
id := strconv.Itoa(int(member.userID))
|
|
containsActor = containsActor || id == runtime.Character.CharacterID
|
|
character, found, err := repos.Character.Load(ctx, id)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
if !found || character.AccountID != s.accountIDForSession(identity.session) || character.Stats["delete_flag"] != 0 {
|
|
return false, nil
|
|
}
|
|
states[i], err = wedding.Read(character)
|
|
if err != nil || states[i].Status != 1 {
|
|
return false, nil
|
|
}
|
|
}
|
|
owner.weddingClearCouple = containsActor && states[0].PairID == states[1].PairID &&
|
|
states[0].PartnerID == members[1].userID && states[1].PartnerID == members[0].userID
|
|
owner.weddingClearKnown = true
|
|
return owner.weddingClearCouple, nil
|
|
}
|
|
|
|
func (s *Service) weddingGiftAllocator(cat *wedding.Catalog, kind byte) (func(*dnfrepo.InventoryRecord, wedding.Item) error, error) {
|
|
if kind > 2 {
|
|
return nil, wedding.Invalid
|
|
}
|
|
items, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
gift := cat.Ceremonies[kind].Gift
|
|
definition, err := items.ResolveItem(uint32(gift.ID))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return func(bag *dnfrepo.InventoryRecord, item wedding.Item) error {
|
|
if item != gift {
|
|
return wedding.Invalid
|
|
}
|
|
_, err := s.grantCurrentWeddingMainProduct(bag, definition, uint32(item.Count))
|
|
return err
|
|
}, nil
|
|
}
|
|
|
|
func (s *Service) restoreWeddingCeremony(session *gameSession, c dnfrepo.CharacterRecord) error {
|
|
state, err := wedding.Read(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if state.Status != 2 && state.Status != 3 {
|
|
return nil
|
|
}
|
|
repos, ok := s.repositoryGroup()
|
|
if !ok || repos.Character == nil {
|
|
return dnfrepo.ErrRepoMissing
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
partner, found, err := repos.Character.Load(ctx, strconv.Itoa(int(state.PartnerID)))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return wedding.Missing
|
|
}
|
|
var publishErr error
|
|
if state.Status == 2 && s.gameplayNow().Before(time.Unix(state.StartAt, 0)) {
|
|
publishErr = s.publishWeddingReservation([2]dnfrepo.CharacterRecord{c, partner})
|
|
}
|
|
return errors.Join(publishErr, s.scheduleWeddingCeremony(c))
|
|
}
|
|
|
|
func weddingReservationBody(chars [2]dnfrepo.CharacterRecord, state wedding.State, channel uint32, invitations uint16) []byte {
|
|
var w packetWriter
|
|
w.writeUint32(state.PairID)
|
|
w.writeUint32(uint32(state.Room))
|
|
w.writeUint32(uint32(state.StartAt))
|
|
w.writeUint32(channel)
|
|
w.writeByte(state.Ceremony)
|
|
for _, c := range chars {
|
|
name := rosterNameBytes(c.Name)
|
|
w.writeBytes(name)
|
|
w.writeZero(30 - len(name))
|
|
}
|
|
w.writeUint16(invitations)
|
|
return w.bytes()
|
|
}
|
|
|
|
func (s *Service) publishWeddingReservation(chars [2]dnfrepo.CharacterRecord) error {
|
|
state, err := wedding.Read(chars[0])
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cat, err := s.currentWeddingCatalog()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, c := range chars {
|
|
if len(rosterNameBytes(c.Name)) > 29 {
|
|
return wedding.Invalid
|
|
}
|
|
}
|
|
for _, c := range chars {
|
|
id, _ := strconv.ParseUint(c.CharacterID, 10, 16)
|
|
identity, online := s.onlineGameSessionCharacterSnapshot(uint16(id))
|
|
if online {
|
|
err = errors.Join(err, s.sendGameUpperRawClass(identity.session, currentWeddingReservationMsgID, weddingReservationBody(chars, state, uint32(state.ChannelID), uint16(cat.Ceremonies[state.Ceremony].Invitations)), 0))
|
|
}
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *Service) sendWeddingInventorySnapshot(session *gameSession, bag dnfrepo.InventoryRecord) error {
|
|
if bag.CharacterID == "" {
|
|
return nil
|
|
}
|
|
// Only main-bag contents changed; retain all other native containers and equipped objects.
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
body, _, _, ok := s.buildCurrentItemListBodyForSession(ctx, session, 0)
|
|
if !ok {
|
|
return fmt.Errorf("wedding inventory snapshot missing")
|
|
}
|
|
return s.sendGameUpperRawClass(session, 13, body, 0)
|
|
}
|
|
|
|
func (s *Service) scheduleWeddingCeremony(c dnfrepo.CharacterRecord) error {
|
|
state, err := wedding.Read(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if state.Status != 2 && state.Status != 3 {
|
|
return nil
|
|
}
|
|
cat, err := s.currentWeddingCatalog()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
at := state.StartAt
|
|
if state.Status == 3 {
|
|
at += cat.EndSeconds
|
|
}
|
|
key := fmt.Sprintf("dnf-wedding:%d:%d", state.PairID, state.StartAt)
|
|
return s.ensureGameplayTimeQueue().ScheduleAfter(key, max(time.Duration(0), time.Unix(at, 0).Sub(s.gameplayNow())), func(time.Time) {
|
|
if err := s.advanceWeddingCeremony(c.CharacterID, state.PairID, state.StartAt); err != nil {
|
|
s.logWarn("wedding ceremony deferred", "character_id", c.CharacterID, "error", err)
|
|
// A full bag or transient DB failure leaves the stage intact; retry without charging again.
|
|
_ = s.ensureGameplayTimeQueue().ScheduleAfter(key, 30*time.Second, func(time.Time) {
|
|
repos, ok := s.repositoryGroup()
|
|
if !ok || repos.Character == nil {
|
|
return
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
fresh, found, e := repos.Character.Load(ctx, c.CharacterID)
|
|
if e == nil && found {
|
|
_ = s.scheduleWeddingCeremony(fresh)
|
|
}
|
|
})
|
|
}
|
|
})
|
|
}
|
|
|
|
func (s *Service) advanceWeddingCeremony(characterID string, pair uint32, start int64) error {
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
s.weddingMu.Lock()
|
|
defer s.weddingMu.Unlock()
|
|
repos, ok := s.repositoryGroup()
|
|
if !ok || repos.Character == nil {
|
|
return dnfrepo.ErrRepoMissing
|
|
}
|
|
c, found, err := repos.Character.Load(ctx, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return wedding.Missing
|
|
}
|
|
state, err := wedding.Read(c)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if state.PairID != pair || state.StartAt != start || (state.Status != 2 && state.Status != 3) {
|
|
return nil
|
|
}
|
|
cat, err := s.currentWeddingCatalog()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cmd := wedding.Command{ActorID: characterID, PartnerID: strconv.Itoa(int(state.PartnerID)), AccountID: c.AccountID, Operation: 3, Now: s.gameplayNow()}
|
|
if state.Status == 3 {
|
|
cmd.Operation = 4
|
|
cmd.Grant, err = s.weddingGiftAllocator(cat, state.Ceremony)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result, err := wedding.Commit(ctx, repos, cat, cmd)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var publishErr error
|
|
if cmd.Operation == 3 {
|
|
publishErr = s.publishWeddingCommit(result, 3)
|
|
for _, peer := range s.weddingCeremonyPeers(state) {
|
|
var w packetWriter
|
|
w.writeUint32(pair)
|
|
w.writeUint32(uint32(state.Room))
|
|
for _, character := range result.Characters {
|
|
name := rosterNameBytes(character.Name)
|
|
w.writeBytes(name)
|
|
w.writeZero(30 - len(name))
|
|
}
|
|
publishErr = errors.Join(publishErr, s.sendGameUpperRawClass(peer, currentWeddingStartMsgID, w.bytes(), 0))
|
|
}
|
|
return errors.Join(publishErr, s.scheduleWeddingCeremony(result.Characters[0]))
|
|
}
|
|
publishErr = s.publishWeddingCommit(result, 4)
|
|
for i, character := range result.Characters {
|
|
id, _ := strconv.ParseUint(character.CharacterID, 10, 16)
|
|
if identity, online := s.onlineGameSessionCharacterSnapshot(uint16(id)); online {
|
|
publishErr = errors.Join(publishErr, s.sendWeddingInventorySnapshot(identity.session, result.Inventories[i]))
|
|
}
|
|
}
|
|
// sub_1837CC0 end notification removes the ceremony object and invokes the native exit route.
|
|
for _, peer := range s.weddingCeremonyPeers(state) {
|
|
var w packetWriter
|
|
w.writeByte(1)
|
|
w.writeByte(state.Ceremony)
|
|
for _, character := range result.Characters {
|
|
name := rosterNameBytes(character.Name)
|
|
w.writeBytes(name)
|
|
w.writeZero(30 - len(name))
|
|
}
|
|
w.writeUint32(uint32(state.Room))
|
|
w.writeByte(state.Ceremony)
|
|
w.writeUint32(uint32(peer.channel.ID))
|
|
publishErr = errors.Join(publishErr, s.sendGameUpperRawClass(peer, currentWeddingEndMsgID, w.bytes(), 0))
|
|
}
|
|
return publishErr
|
|
}
|
|
|
|
func (s *Service) weddingCeremonyPeers(state wedding.State) []*gameSession {
|
|
if s.onlinePlayers == nil {
|
|
return nil
|
|
}
|
|
var peers []*gameSession
|
|
for _, p := range s.onlinePlayers.GetAreaInstancePlayers(state.ChannelID, 39, state.Room, state.PairID) {
|
|
if p.Session != nil {
|
|
peers = append(peers, p.Session)
|
|
}
|
|
}
|
|
return peers
|
|
}
|
|
|
|
func (s *Service) handleWeddingEnter(session *gameSession, body []byte) error {
|
|
if len(body) != 22 {
|
|
return nil
|
|
}
|
|
identity, current := s.boundGameSessionCharacterSnapshot(session)
|
|
if !current {
|
|
return nil
|
|
}
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
c, err := s.weddingCharacter(ctx, identity)
|
|
state, stateErr := wedding.Read(c)
|
|
if err == nil {
|
|
err = stateErr
|
|
}
|
|
if err == nil && (body[21] != 0 || state.Status != 2 || uint16(session.channel.ID) != state.ChannelID || binary.LittleEndian.Uint32(body[13:17]) != state.PairID || binary.LittleEndian.Uint32(body[17:21]) != uint32(state.Room) || !s.gameplayNow().Before(time.Unix(state.StartAt, 0))) {
|
|
err = wedding.Error(-6)
|
|
}
|
|
cat, catErr := s.currentWeddingCatalog()
|
|
if err == nil {
|
|
err = catErr
|
|
}
|
|
if err == nil {
|
|
index := 0
|
|
if c.CharacterID > strconv.Itoa(int(state.PartnerID)) {
|
|
index = 1
|
|
}
|
|
pos := cat.Positions[index]
|
|
err = s.handleTownSetUserArea(session, buildCurrentSoloTeleportMoveBody(currentSoloTeleportRequest{TownID: 39, AreaID: state.Room, PositionX: int16(pos[0]), PositionY: int16(pos[1]), Direction: 5}, uint16(c.Stats["town_id"])))
|
|
if err == nil {
|
|
session.townMu.Lock()
|
|
p := session.townPositionSnapshot
|
|
session.townMu.Unlock()
|
|
if p.TownID != 39 || p.AreaID != state.Room {
|
|
err = wedding.Error(-6)
|
|
}
|
|
}
|
|
}
|
|
var code wedding.Error
|
|
if err != nil && !errors.As(err, &code) {
|
|
code = wedding.Error(-6)
|
|
}
|
|
var w packetWriter
|
|
w.writeInt32(int(code))
|
|
return s.sendGameUpperSuccess(session, 1039, w.bytes())
|
|
}
|