服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
4524 lines
180 KiB
Go
4524 lines
180 KiB
Go
// raid_online.go 负责当前 game 连接内的攻坚队运行态流程。
|
||
// 这里只保存在线会话可见的短生命周期攻坚队,不落 DB;跨服持久 owner 后续再替换该边界。
|
||
package dnfbridge
|
||
|
||
import (
|
||
"bytes"
|
||
"context"
|
||
"encoding/binary"
|
||
"errors"
|
||
"fmt"
|
||
"hash/fnv"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||
"longheng.io/server/internal/modules/dnf/dnfenum"
|
||
"longheng.io/server/internal/modules/dnf/party"
|
||
dnfproto "longheng.io/server/internal/modules/dnf/protocol"
|
||
"longheng.io/server/internal/modules/dnf/raid"
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
)
|
||
|
||
const (
|
||
runtimeRaidDefaultGroup = 1
|
||
// runtimeRaidMaxGroups bounds the attack-squad index a leader may address.
|
||
// Five is NOT the client's limit: 86JP pages the raid situation/manager list
|
||
// five groups at a time (RaidManager.cs:837-842 `groupsPerPage = 5`), and the
|
||
// current client's op669 sender has been observed emitting targets 2..10
|
||
// (2026-09-15 16:02: 2/3/4 then 10/9/8/7 then 5/6; 2026-09-17 10:23: 2/3/4/5/6),
|
||
// i.e. the second page is a real addressable range. Every value above the old
|
||
// cap of 5 was dropped with `game-raid-manager-rejected`, which is
|
||
// indistinguishable in game from "改编制没反应".
|
||
runtimeRaidMaxGroups = 10
|
||
runtimeRaidGroupSize = 4
|
||
runtimeRaidFailureCode = 3
|
||
runtimeRaidEntryItemID = 10096296
|
||
runtimeRaidEntryCount = 1
|
||
// runtimeRaidBrowseTTL bounds how long an op1359 browse target stays live
|
||
// after the request that named it. The client polls while its attack-party
|
||
// window is open, so a target that has gone unrefreshed for this long belongs
|
||
// to a window the player already closed and must stop receiving roster pushes.
|
||
runtimeRaidBrowseTTL = 30 * time.Second
|
||
// runtimeRaidJoinReasonGeneric is reason 0x13 of the class-0 op821 notice
|
||
// handler sub_1D0F150, which renders string id 704 "对方无法加入队伍". The
|
||
// handler's 174-entry reason table gives a message to only four codes
|
||
// (0x13 / 0x3C / 0xBF / 0xC0) and stays silent for every other value, so a
|
||
// refused admission has to use this one or report nothing at all.
|
||
runtimeRaidJoinReasonGeneric byte = 0x13
|
||
)
|
||
|
||
var errRuntimeRaidEntryCost = errors.New("raid entry cost is insufficient")
|
||
|
||
type runtimeRaidState struct {
|
||
RaidKey uint32
|
||
LeaderID uint16
|
||
RaidMode byte
|
||
RouteOrRaidType byte
|
||
NameBytes []byte
|
||
TailFlag byte
|
||
SingleRaidType byte
|
||
AntonConfig *raid.AntonConfig
|
||
LukeConfig *raid.LukeConfig
|
||
// FiendWarConfig 只在 RaidMode == runtimeRaidOwnerFiendWar 时非 nil。它的
|
||
// Battle.Raid 是一个合成的 *raid.AntonConfig(PhaseMax=1),所以
|
||
// runtimeRaidBaseConfig 对超时空返回 config.Battle.Raid,走的是与安徒恩/卢克
|
||
// 完全相同的阶段/名额/入场费骨架。
|
||
FiendWarConfig *raid.FiendWarConfig
|
||
Members []runtimeRaidMemberState
|
||
Started bool
|
||
StartPending bool
|
||
StartAttempt uint64
|
||
EntryCostPaid bool
|
||
ChargedMembers []uint16
|
||
FrozenMembers []runtimeRaidMemberState
|
||
PendingCostSync []runtimeRaidEntryCostUpdate
|
||
Lifecycle runtimeRaidLifecycleState
|
||
// SquadParties is the squad -> managed party projection table: it records
|
||
// which ordinary party this service created for each non-zero attack squad.
|
||
// It is the bookkeeping behind the raid's "one non-zero squad <-> one real
|
||
// ordinary party" invariant, and it is what makes it safe to refresh or
|
||
// dissolve that party when a squad changes without ever touching a party the
|
||
// player formed themselves. A squad that is absent here owns no projection.
|
||
SquadParties map[byte]uint16
|
||
// SquadPartyLeaders is the squad -> leader of the ordinary party that squad
|
||
// fights in. It is what the client's per-member 队长标志 is filled from once the
|
||
// raid is running (see runtimeRaidMemberLeadsSquad): the record's leader bool is
|
||
// a boolean to the client -- it stores "field == 1" at member+0x42 and every
|
||
// leader-gated raid control tests its OWN record -- so it has to name the leader
|
||
// the player can actually act as, which is the squad's, not the 团长's.
|
||
// Written wherever an ordinary party is published or projected, dropped when
|
||
// that leader leaves its squad.
|
||
SquadPartyLeaders map[byte]uint16
|
||
}
|
||
|
||
type runtimeRaidMemberState struct {
|
||
CharID uint16
|
||
Name string
|
||
Level byte
|
||
Job byte
|
||
Grow byte
|
||
GroupIndex byte
|
||
SlotOrder byte
|
||
UserState byte
|
||
HPPercent byte
|
||
MPPercent byte
|
||
Waiting byte
|
||
// RejoinPosition 是这个成员上一次在超时空城镇里被确认过的坐标
|
||
// (captureRuntimeFiendWarRejoinPosition)。它只作为诊断留存:进程重连一律回
|
||
// PVF 等待室起点,不回这个坐标 —— 见 runtimeFiendWarRejoinLocation 的说明。
|
||
RejoinPosition currentTownPositionSnapshot
|
||
}
|
||
|
||
// runtimeRaidDirectorySubscription remembers the last native op831 query for
|
||
// an open Find Raid window. Access is guarded by Service.mu.
|
||
type runtimeRaidDirectorySubscription struct {
|
||
Active bool
|
||
Request raid.OtherChannelListRequest
|
||
}
|
||
|
||
type runtimeRaidDirectorySubscriber struct {
|
||
Session *gameSession
|
||
Request raid.OtherChannelListRequest
|
||
}
|
||
|
||
func (s *Service) handleOnlineRaidCommand(session *gameSession, typ uint16, body []byte) (bool, error) {
|
||
if session == nil {
|
||
return false, nil
|
||
}
|
||
// The public raid page can be queried before the client has completed its
|
||
// raid-channel transition. Keep that read-only directory request available,
|
||
// while preserving the current session/channel gate for every mutation.
|
||
if dnfenum.CmdPacket(typ) == dnfenum.CmdPacketRaidOtherChannelList {
|
||
return true, s.handleOnlineOtherChannelList(session, typ, body)
|
||
}
|
||
// C2S 820 comes from the "寻找攻坚队" window rather than from a clicked actor,
|
||
// so it is reachable on a channel the applicant has not moved into yet. It is
|
||
// admitted through the same pending-prompt machinery as the actor-click path,
|
||
// which is what keeps the team-channel gate out of this branch.
|
||
if dnfenum.CmdPacket(typ) == dnfenum.CmdPacketRaidOtherChannelRequestJoin {
|
||
return true, s.handleOnlineRaidOtherChannelRequestJoin(session, typ, body)
|
||
}
|
||
if runtimeRaidModeForSession(session) == 0 {
|
||
return false, nil
|
||
}
|
||
switch typ {
|
||
case raid.RaidResultStageRequestMsgID:
|
||
return true, s.handleRuntimeRaidResultStage(session, body)
|
||
case raid.RaidRewardCardRequestMsgID:
|
||
return true, s.handleRuntimeRaidRewardCard(session, body)
|
||
case raid.RaidDoBehaviorRequestMsgID:
|
||
return true, s.handleRuntimeRaidDoBehavior(session, body)
|
||
case raid.RaidMessageRequestMsgID:
|
||
return true, s.handleRuntimeRaidMessage(session, body)
|
||
}
|
||
switch dnfenum.CmdPacket(typ) {
|
||
// 超时空的 BOSS 机制上报走自己的 opcode (1511)。它是 C2S,不是面板包;
|
||
// 不接这条,双 BOSS 的四机制永远推不完,两个 BOSS 区域就永远开不了门。
|
||
case dnfenum.CmdPacketFiendWarBossProc:
|
||
return true, s.handleRuntimeFiendWarBoss(session, body)
|
||
case dnfenum.CmdPacketCreateRaid:
|
||
return true, s.handleOnlineCreateRaid(session, typ, body)
|
||
case dnfenum.CmdPacketRequestRaidInfo:
|
||
return true, s.handleOnlineRequestRaidInfo(session, typ, body)
|
||
case dnfenum.CmdPacketModifyRaidInfo:
|
||
return true, s.handleOnlineModifyRaidInfo(session, typ, body)
|
||
case dnfenum.CmdPacketLeaveRaid:
|
||
return true, s.handleOnlineLeaveRaid(session, typ, body)
|
||
case dnfenum.CmdPacketStartRaid:
|
||
return true, s.handleOnlineStartRaid(session, typ, body)
|
||
case dnfenum.CmdPacketRaidEntryCostInfo:
|
||
return true, s.handleOnlineRaidEntryCostInfo(session, typ, body)
|
||
case dnfenum.CmdPacketRaidSetSymbol:
|
||
return true, s.handleOnlineRaidSetSymbol(session, typ, body)
|
||
case dnfenum.CmdPacketSetRaidWaiting:
|
||
return true, s.handleOnlineSetRaidWaiting(session, typ, body)
|
||
case dnfenum.CmdPacketRejoinRaid:
|
||
return true, s.handleOnlineRejoinRaid(session, typ, body)
|
||
case dnfenum.CmdPacketRaidManagerWork:
|
||
return true, s.handleOnlineRaidManagerWork(session, typ, body)
|
||
case dnfenum.CmdPacketRaidMemberChangeState:
|
||
return true, s.handleOnlineRaidMemberChangeState(session, typ, body)
|
||
case dnfenum.CmdPacketRaidMonsterHp:
|
||
return s.handleOnlineRaidSituationMonsterHp(session, typ, body)
|
||
case dnfenum.CmdPacketRaidBuffSystem:
|
||
return s.handleOnlineRaidBuffSystem(session, typ, body)
|
||
default:
|
||
return false, nil
|
||
}
|
||
}
|
||
|
||
// handleOnlineOtherChannelList serves the native public-raid object page.
|
||
// Current EXE sub_1D7AA10 registers class-0/op753 for this window and consumes
|
||
// total count, returned offset, and complete sub_17B8910 raid objects. op884 is
|
||
// a different cross-channel page container and cannot populate the attack-raid
|
||
// manager vector used by the “寻找攻坚队” rows.
|
||
func (s *Service) handleOnlineOtherChannelList(session *gameSession, typ uint16, body []byte) error {
|
||
req, err := raid.DecodeOtherChannelListRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-other-channel-list-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
s.storeRuntimeRaidDirectorySubscription(session, req)
|
||
return s.sendRuntimePublicRaidDirectoryPage(session, req, "client_query")
|
||
}
|
||
|
||
// handleOnlineRaidOtherChannelRequestJoin serves C2S 820, the application the
|
||
// "寻找攻坚队" window sends when the applicant cannot simply click the leader's
|
||
// actor -- a different town area on the same channel, or another channel
|
||
// entirely. Before this handler existed the opcode reached only the module-level
|
||
// stub, which answers nothing, so the leader was never told.
|
||
//
|
||
// The wire layout is read from the client's own sender sub_17B1530, which uses
|
||
// the generic body writers: sub_3454FC0 advances the cursor by one byte,
|
||
// sub_3454FF0 by two, sub_3455020 by four. The body is therefore
|
||
// u8 + u16 + u32 + u16, which is exactly what
|
||
// raid.DecodeOtherChannelRequestJoinRequest already parses.
|
||
//
|
||
// The first two fields are the raid key, not "a channel plus a mode": the sender
|
||
// passes byte[raidObj+0x2E] and word[raidObj+0x2C], i.e. key bytes 2 and 0..1.
|
||
// A live sample of mode=23 target=1 belongs to raid_key 0x00170001, which is the
|
||
// key the same connection was browsing at that moment.
|
||
func (s *Service) handleOnlineRaidOtherChannelRequestJoin(session *gameSession, typ uint16, body []byte) error {
|
||
req, err := raid.DecodeOtherChannelRequestJoinRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-request-join-body-invalid",
|
||
"type", typ, "body_len", len(body), "error", err)
|
||
return s.sendRuntimeRaidJoinRejected(session, runtimeRaidJoinReasonGeneric)
|
||
}
|
||
raidKey := uint32(req.Mode)<<16 | uint32(req.TargetKey)
|
||
state, ok := s.runtimeOpenRaidByKey(raidKey)
|
||
if !ok || !runtimeRaidOpenForPeerAdmission(state) {
|
||
s.logGameEvent(session, "game-raid-request-join-rejected",
|
||
"type", typ,
|
||
"raid_key", raidKey,
|
||
"request_mode", req.Mode,
|
||
"request_target", req.TargetKey,
|
||
"has_raid", ok,
|
||
"reason", "raid_not_open_for_admission")
|
||
return s.sendRuntimeRaidJoinRejected(session, runtimeRaidJoinReasonGeneric)
|
||
}
|
||
// The applicant is always the outsider in this path, so the admission target
|
||
// is the leader rather than whoever the request named. From here the flow is
|
||
// the already-proven one: op7 prompt, op11 response, admitRuntimeRaidPeer,
|
||
// and the 664 binding that brings the applicant's own raid object up.
|
||
handled, err := s.recordRuntimeRaidPeerAdmission(session, party.RequestPeerRequest{
|
||
TargetID: state.LeaderID,
|
||
Mode: runtimeRaidPeerRequestMode,
|
||
Value0: req.ClientValue,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-request-join-served",
|
||
"type", typ,
|
||
"raid_key", raidKey,
|
||
"leader_id", state.LeaderID,
|
||
"handled", handled,
|
||
"member_count", len(state.Members),
|
||
"source", "op820_browse_window_application")
|
||
return nil
|
||
}
|
||
|
||
// sendRuntimeRaidJoinRejected answers a refused C2S 820.
|
||
//
|
||
// The only class-0 handler registered for op820 and op821 is sub_1D0F150, a
|
||
// "u8 result + u8 reason" notice: a non-zero result returns immediately and a
|
||
// zero result indexes a 174-entry table by (reason - 0x13). Only four reasons
|
||
// carry a message -- 0x13 对方无法加入队伍, 0x3C 个人商店开设中,无法组队,
|
||
// 0xBF 对方正作为佣兵出战中, 0xC0 商店开设中,无法组队。 -- and every other code
|
||
// renders nothing, which is why the generic one is the only safe choice here.
|
||
func (s *Service) sendRuntimeRaidJoinRejected(session *gameSession, reason byte) error {
|
||
body := raid.BuildOtherChannelResponseJoinBody(0x00, reason)
|
||
return s.sendGameUpperRawClassCodec(session, raid.RaidOtherChannelResponseJoinMsgID, body, 0, true)
|
||
}
|
||
|
||
func (s *Service) sendRuntimePublicRaidDirectoryPage(session *gameSession, req raid.OtherChannelListRequest, source string) error {
|
||
typ := uint16(dnfenum.CmdPacketRaidOtherChannelList)
|
||
states, totalCount, offset := s.runtimePublicRaidDirectoryPage(session, req)
|
||
objects := make([]raid.RaidObjectMode0, 0, len(states))
|
||
for _, state := range states {
|
||
objects = append(objects, runtimeRaidObjectMode0(state))
|
||
}
|
||
pageBody, err := raid.BuildPublicRaidListBody(raid.PublicRaidListPage{
|
||
TotalCount: totalCount,
|
||
Offset: offset,
|
||
Objects: objects,
|
||
})
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-public-list-build-failed", "type", typ, "row_count", len(objects), "error", err)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidPublicListMsgID, pageBody, 0, true); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-other-channel-list-served",
|
||
"type", typ,
|
||
"source", source,
|
||
"mode", req.Mode,
|
||
"has_context", req.HasContext,
|
||
"name_len", len(req.NameBytes),
|
||
"response_type", raid.RaidPublicListMsgID,
|
||
"response_body_len", len(pageBody),
|
||
"total_count", totalCount,
|
||
"offset", offset,
|
||
"row_count", len(objects))
|
||
return nil
|
||
}
|
||
|
||
func cloneRuntimeRaidDirectoryRequest(req raid.OtherChannelListRequest) raid.OtherChannelListRequest {
|
||
req.NameBytes = append([]byte(nil), req.NameBytes...)
|
||
return req
|
||
}
|
||
|
||
func (s *Service) storeRuntimeRaidDirectorySubscription(session *gameSession, req raid.OtherChannelListRequest) {
|
||
if s == nil || session == nil {
|
||
return
|
||
}
|
||
s.mu.Lock()
|
||
session.raidDirectory = runtimeRaidDirectorySubscription{
|
||
Active: true,
|
||
Request: cloneRuntimeRaidDirectoryRequest(req),
|
||
}
|
||
s.mu.Unlock()
|
||
}
|
||
|
||
func (s *Service) runtimeRaidDirectorySubscribers() []runtimeRaidDirectorySubscriber {
|
||
if s == nil {
|
||
return nil
|
||
}
|
||
s.mu.Lock()
|
||
rows := make([]runtimeRaidDirectorySubscriber, 0, len(s.gameSessions))
|
||
seen := make(map[*gameSession]struct{}, len(s.gameSessions))
|
||
for _, session := range s.gameSessions {
|
||
if session == nil || !session.raidDirectory.Active || session.conn == nil || session.selectedCharacterID == 0 {
|
||
continue
|
||
}
|
||
if _, duplicate := seen[session]; duplicate {
|
||
continue
|
||
}
|
||
seen[session] = struct{}{}
|
||
rows = append(rows, runtimeRaidDirectorySubscriber{
|
||
Session: session,
|
||
Request: cloneRuntimeRaidDirectoryRequest(session.raidDirectory.Request),
|
||
})
|
||
}
|
||
s.mu.Unlock()
|
||
sort.Slice(rows, func(i, j int) bool {
|
||
return rows[i].Session.selectedCharacterID < rows[j].Session.selectedCharacterID
|
||
})
|
||
return rows
|
||
}
|
||
|
||
// refreshRuntimePublicRaidDirectories pushes a fresh op753 snapshot to every
|
||
// currently open native directory window. A committed raid mutation must not
|
||
// be reported as failed merely because one observer disconnected mid-push.
|
||
func (s *Service) refreshRuntimePublicRaidDirectories(source string) {
|
||
for _, subscriber := range s.runtimeRaidDirectorySubscribers() {
|
||
if err := s.sendRuntimePublicRaidDirectoryPage(subscriber.Session, subscriber.Request, source); err != nil {
|
||
s.logGameEvent(subscriber.Session, "game-raid-public-list-refresh-failed", "source", source, "error", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Service) runtimePublicRaidDirectoryPage(session *gameSession, req raid.OtherChannelListRequest) ([]runtimeRaidState, uint32, uint32) {
|
||
var requestedOffset uint32
|
||
var myChannelOnly bool
|
||
var recruitingOnly bool
|
||
if req.HasContext {
|
||
requestedOffset = binary.LittleEndian.Uint32(req.Context[0:4])
|
||
// sub_1B4B520 writes the two checkbox controls into the last two
|
||
// context bytes: “我的频道” then “只查看招募中的攻坚队”.
|
||
myChannelOnly = req.Context[6] != 0
|
||
recruitingOnly = req.Context[7] != 0
|
||
}
|
||
|
||
s.raidMu.Lock()
|
||
states := make([]runtimeRaidState, 0, len(s.raids))
|
||
for _, state := range s.raids {
|
||
if state == nil || state.TailFlag != 0 {
|
||
continue
|
||
}
|
||
if recruitingOnly && (state.StartPending || state.Started) {
|
||
continue
|
||
}
|
||
if myChannelOnly && session != nil && uint16(state.RaidKey>>16) != uint16(session.channel.ID) {
|
||
continue
|
||
}
|
||
if len(req.NameBytes) != 0 && !bytes.Contains(state.NameBytes, req.NameBytes) {
|
||
continue
|
||
}
|
||
states = append(states, cloneRuntimeRaidState(*state))
|
||
}
|
||
s.raidMu.Unlock()
|
||
|
||
sort.Slice(states, func(i, j int) bool { return states[i].RaidKey < states[j].RaidKey })
|
||
totalCount := uint32(len(states))
|
||
if requestedOffset >= totalCount {
|
||
requestedOffset = 0
|
||
}
|
||
end := requestedOffset + raid.RaidPublicListPageSize
|
||
if end > totalCount {
|
||
end = totalCount
|
||
}
|
||
return states[int(requestedOffset):int(end)], totalCount, requestedOffset
|
||
}
|
||
|
||
func (s *Service) handleOnlineRequestRaidInfo(session *gameSession, typ uint16, body []byte) error {
|
||
req, err := raid.DecodeRequestRaidInfoRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-info-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
state, hasRuntimeRaid := s.runtimeRaidForMember(selectedCharacterID(session))
|
||
raidKey := uint32(0)
|
||
if hasRuntimeRaid {
|
||
raidKey = state.RaidKey
|
||
}
|
||
// The current class-0/op1359 handler sub_1D3A150 -> sub_A42AB0 does not read a
|
||
// body; it only marks the resolved local raid object as present. Directory
|
||
// cooldown initialization is the distinct class-0/op1228 notification.
|
||
//
|
||
// Measured: withholding this acknowledgement from a browsing non-member does
|
||
// NOT stop the client adopting the raid, so it is always sent. Observed op1359
|
||
// traffic uses mode 1; mode 2 is what the client requester sub_17B1A90 builds,
|
||
// and the browsing flow never emitted it.
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidInfoMsgID, raid.BuildRaidInfoResponseBody(), 0, true); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-info-served",
|
||
"type", typ,
|
||
"mode", req.Mode,
|
||
"requested_raid_key", req.RaidKey,
|
||
"raid_key", raidKey,
|
||
"has_runtime_raid", hasRuntimeRaid,
|
||
"body_len", len(req.Raw))
|
||
if hasRuntimeRaid {
|
||
return nil
|
||
}
|
||
// The requester owns no raid, so the attack-party window is open on a raid picked
|
||
// from the public directory. A member receives the waiting list and the material
|
||
// snapshot from the entry-cost path, which is member-gated and answers a browsing
|
||
// outsider with a failure, so nothing ever populated that window for them.
|
||
return s.sendRuntimeRaidBrowseList(session, req)
|
||
}
|
||
|
||
// sendRuntimeRaidObjectRemoved tells one session that a raid object it holds is
|
||
// gone. See raid.BuildRaidObjectRemoveBody for why an explicit removal is needed
|
||
// at all: every roster push in this service targets surviving members, so neither
|
||
// a leaving member nor a browsing outsider is ever told to drop the object.
|
||
func (s *Service) sendRuntimeRaidObjectRemoved(session *gameSession, raidKey uint32) error {
|
||
if s == nil || session == nil || raidKey == 0 {
|
||
return nil
|
||
}
|
||
return s.sendGameUpperRawClassCodec(session, raid.RaidObjectMsgID, raid.BuildRaidObjectRemoveBody(raidKey), 0, true)
|
||
}
|
||
|
||
// broadcastRuntimeRaidObjectRemoved retires the object for every member of a
|
||
// disbanded raid. The leaver is excluded because the leave path already sent it
|
||
// directly. 86JP reaches the same set on its disbanded branch by broadcasting its
|
||
// remove packet to every previous member.
|
||
func (s *Service) broadcastRuntimeRaidObjectRemoved(state runtimeRaidState, excludedCharacterID uint16) error {
|
||
if s == nil || state.RaidKey == 0 {
|
||
return nil
|
||
}
|
||
body := raid.BuildRaidObjectRemoveBody(state.RaidKey)
|
||
var firstErr error
|
||
for _, member := range state.Members {
|
||
if member.CharID == excludedCharacterID {
|
||
continue
|
||
}
|
||
session, ok := s.onlineGameSession(member.CharID)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidObjectMsgID, body, 0, true); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
return firstErr
|
||
}
|
||
|
||
// runtimeOpenRaidOnChannel returns the recruiting team raid scoped to one
|
||
// channel. RaidKey carries the catalog channel id in its high word, which is the
|
||
// same split the public directory filters on, so a key built for another channel
|
||
// can never be served to this requester.
|
||
func (s *Service) runtimeOpenRaidOnChannel(channelID uint16) (runtimeRaidState, bool) {
|
||
if s == nil || channelID == 0 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
var found *runtimeRaidState
|
||
for _, state := range s.raids {
|
||
if state == nil || state.TailFlag != 0 || state.Started || state.StartPending {
|
||
continue
|
||
}
|
||
if uint16(state.RaidKey>>16) != channelID {
|
||
continue
|
||
}
|
||
if found == nil || state.RaidKey < found.RaidKey {
|
||
found = state
|
||
}
|
||
}
|
||
if found == nil {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
return cloneRuntimeRaidState(*found), true
|
||
}
|
||
|
||
// sendRuntimeRaidBrowseList answers a non-member's op1359 with the complete
|
||
// payload the browsing attack-party window needs. A member obtains the roster
|
||
// from the entry-cost path, which is member-gated and answers an outsider with a
|
||
// failure, so nothing ever populated that window for an outsider.
|
||
//
|
||
// The payload has two halves, split here because the live-refresh path replays
|
||
// them at different rates:
|
||
//
|
||
// - identity: the member actor records plus 0x0250, 819 and 0x0256. The roster
|
||
// level and portrait are not carried by any raid packet: the client resolves
|
||
// the 0x0250/0x0333 member key through sub_1A089E0 -> sub_1C5B070, which looks
|
||
// it up in the container at [this+0x278] and does nothing when the lookup
|
||
// misses. A browsing outsider has never met those characters, so every row
|
||
// renders with level 0 and the default portrait. Only a changed member id set
|
||
// needs this half replayed.
|
||
// - roster: 0x024F mode 3. This is the packet that carries each row's subgroup
|
||
// and slot, so it is pushed on every roster mutation.
|
||
//
|
||
// 0x024F is safe to send to a browsing outsider only because RaidProbe.dll patches
|
||
// out the single call inside that handler which made the client adopt the raid:
|
||
// the mode 3 branch ends with
|
||
//
|
||
// cmp ecx, <own channel> / jne skip / call sub_17C3470
|
||
//
|
||
// and with that call NOPed the roster still renders and the claim no longer
|
||
// happens. The path that legitimately sets the field for real members is the 664
|
||
// response handler sub_1D04340, which is not touched.
|
||
func (s *Service) sendRuntimeRaidBrowseList(session *gameSession, req raid.RequestRaidInfoRequest) error {
|
||
if s == nil || session == nil {
|
||
return nil
|
||
}
|
||
state, source, ok := s.runtimeRaidBrowseTarget(req.RaidKey, townPresenceChannelID(session))
|
||
// The roster order is shared by the key list and the actor pass below.
|
||
sortedMembers := runtimeRaidSortedMembers(state)
|
||
|
||
if !ok {
|
||
s.clearRuntimeRaidBrowseTarget(session)
|
||
return nil
|
||
}
|
||
if err := s.sendRuntimeRaidBrowseMemberActors(session, sortedMembers); err != nil {
|
||
return err
|
||
}
|
||
// No suppression here. A member-id fingerprint used to swallow this whole
|
||
// payload when the roster had not changed, and it was only cleared on a
|
||
// character change, so switching channels and coming back still got nothing.
|
||
// The load is a few hundred bytes and the client only asks while its window is
|
||
// open. The same fingerprint is still used on the refresh path, but there it
|
||
// only decides whether the identity half is replayed -- never whether the
|
||
// mode=3 roster is.
|
||
rosterBody, err := raid.BuildMemberRefreshMode3Body(runtimeRaidMemberRefresh(state))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidMemberRefreshMsgID, rosterBody, 0, true); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendRuntimeRaidBrowseKeyLists(session, state, sortedMembers); err != nil {
|
||
return err
|
||
}
|
||
s.markRuntimeRaidBrowseTarget(session, state.RaidKey, runtimeRaidMemberFingerprint(state))
|
||
s.logGameEvent(session, "game-raid-browse-list-sent",
|
||
"raid_key", state.RaidKey,
|
||
"channel_id", uint16(state.RaidKey>>16),
|
||
"channel_source", source,
|
||
"request_mode", req.Mode,
|
||
"leader_id", state.LeaderID,
|
||
"member_count", len(state.Members),
|
||
"waiting_body_len", len(runtimeRaidWaitingListBody(state)),
|
||
"userinfo_keys", len(sortedMembers),
|
||
"source", "op1359_non_member_browse_list")
|
||
return nil
|
||
}
|
||
|
||
// sendRuntimeRaidBrowseMemberActors installs each member as a remote actor in the
|
||
// browsing client's town scene, which is what fills the container at [this+0x278]
|
||
// that the roster renderer resolves its rows against. The roster packet itself
|
||
// carries name and job, but the level and portrait are looked up from that
|
||
// container, so a member with no actor record renders as an empty Lv cell.
|
||
//
|
||
// The lookup goes through peerActorInfoForCharacter, not the presence map
|
||
// directly: a member standing in a Seria room is deliberately absent from
|
||
// presence, and reading the presence map here meant the browsing window showed a
|
||
// blank Lv for exactly that member until it walked out into the public town.
|
||
//
|
||
// Restricted to the requester's own channel: the coordinates only make sense
|
||
// inside one town scene, and cross-channel browsing is already accepted as
|
||
// incomplete.
|
||
func (s *Service) sendRuntimeRaidBrowseMemberActors(session *gameSession, sortedMembers []runtimeRaidMemberState) error {
|
||
ownChannel := townPresenceChannelID(session)
|
||
// 批次缓存(2026-09-26)。改前这里逐成员调 sendTownRemoteActorState,它一路走到
|
||
// sendTownRemoteActorProjection 并自己造一个 context.Background(),于是两层缓存
|
||
// 全都没有 —— 与 sendChannelWidePeerActors 改前完全相同的毛病(见
|
||
// online_player_manager.go 里那段说明)。读缓存能把这一批里共享的 PVF/配置读取去重。
|
||
ctx, _ := withProjectionReadCache(context.Background())
|
||
ctx = withActorProjectionBodiesCache(ctx)
|
||
for _, member := range sortedMembers {
|
||
actor, ok := s.peerActorInfoForCharacter(member.CharID)
|
||
if !ok || actor.Session == nil {
|
||
s.logGameEvent(session, "game-raid-browse-member-actor-unavailable",
|
||
"char_id", member.CharID,
|
||
"source", "raid_browse_member_actor")
|
||
continue
|
||
}
|
||
if ownChannel == 0 || townPresenceChannelID(actor.Session) != ownChannel {
|
||
continue
|
||
}
|
||
// 只发 mode0(2026-09-26)。本函数的文档写明:roster 自带 name/job,而 level 与
|
||
// portrait 是从这份 actor 记录里查的 —— 那正是 mode0 的职责
|
||
// (见 sendTownRemoteActorRecord 的说明)。mode1 是绑对方装备与技能页的那一半,
|
||
// 名单不需要;而且它在副本里会把对方的技能页绑到本地受控角色上
|
||
// (见 runtimeRaidPeerActorInstallModeFor 的注释)。
|
||
if err := s.sendTownRemoteActorRecordWithContext(ctx, session, actor, "raid_browse_member_actor"); err != nil {
|
||
s.logGameEvent(session, "game-raid-browse-member-actor-failed",
|
||
"char_id", member.CharID,
|
||
"error", err)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// sendRuntimeRaidBrowseKeyLists sends the two member-key lists the browsing window
|
||
// needs to resolve names, jobs and levels, followed by the material snapshot.
|
||
//
|
||
// - 0x0250 waiting list: u16 count + rows{u16 memberKey, u8 state}.
|
||
// - 819 carries the raid user key list. The 0x0250 rows hold only a member key and
|
||
// a state byte, so without this the client has no character record to resolve
|
||
// and every roster row renders with level 0 and no portrait. Keys are the member
|
||
// character ids, sorted exactly like the roster.
|
||
// - 0x0256 entry-cost snapshot: the 入场材料 column.
|
||
func (s *Service) sendRuntimeRaidBrowseKeyLists(session *gameSession, state runtimeRaidState, sortedMembers []runtimeRaidMemberState) error {
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidWaitingListMsgID, runtimeRaidWaitingListBody(state), 0, true); err != nil {
|
||
return err
|
||
}
|
||
keys := make([]uint32, 0, len(state.Members))
|
||
for _, member := range sortedMembers {
|
||
keys = append(keys, uint32(member.CharID))
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidOtherChannelUserinfoMsgID, raid.BuildOtherChannelUserinfoBody(keys), 0, true); err != nil {
|
||
return err
|
||
}
|
||
return s.sendRuntimeRaidEntryCostSnapshot(session, state)
|
||
}
|
||
|
||
// runtimeRaidSortedMembers returns the roster order shared by every browse packet.
|
||
func runtimeRaidSortedMembers(state runtimeRaidState) []runtimeRaidMemberState {
|
||
sortedMembers := append([]runtimeRaidMemberState(nil), state.Members...)
|
||
runtimeRaidSortMembers(sortedMembers)
|
||
return sortedMembers
|
||
}
|
||
|
||
// runtimeRaidMemberFingerprint hashes the member id set alone. A subgroup move
|
||
// leaves it unchanged; any add, remove or replacement changes it.
|
||
func runtimeRaidMemberFingerprint(state runtimeRaidState) uint64 {
|
||
ids := make([]uint16, 0, len(state.Members))
|
||
for _, member := range state.Members {
|
||
ids = append(ids, member.CharID)
|
||
}
|
||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||
hash := fnv.New64a()
|
||
var raw [2]byte
|
||
for _, id := range ids {
|
||
binary.LittleEndian.PutUint16(raw[:], id)
|
||
_, _ = hash.Write(raw[:])
|
||
}
|
||
return hash.Sum64()
|
||
}
|
||
|
||
// markRuntimeRaidBrowseTarget records that this session's attack-party window is
|
||
// open on raidKey, together with the identity fingerprint it was just sent.
|
||
func (s *Service) markRuntimeRaidBrowseTarget(session *gameSession, raidKey uint32, memberFingerprint uint64) {
|
||
if s == nil || session == nil {
|
||
return
|
||
}
|
||
// gameplayNow() takes Service.mu internally, so it must be sampled first.
|
||
now := s.gameplayNow()
|
||
s.mu.Lock()
|
||
session.raidBrowseTarget = raidKey
|
||
session.raidBrowseAt = now
|
||
session.raidBrowseMembers = memberFingerprint
|
||
s.mu.Unlock()
|
||
}
|
||
|
||
// clearRuntimeRaidBrowseTarget drops the browse subscription. Called when op1359
|
||
// no longer resolves to an open raid, so a stale window stops receiving pushes.
|
||
func (s *Service) clearRuntimeRaidBrowseTarget(session *gameSession) {
|
||
if s == nil || session == nil {
|
||
return
|
||
}
|
||
s.mu.Lock()
|
||
session.raidBrowseTarget = 0
|
||
session.raidBrowseAt = time.Time{}
|
||
session.raidBrowseMembers = 0
|
||
s.mu.Unlock()
|
||
}
|
||
|
||
// runtimeRaidBrowseSubscribers lists the live sessions whose attack-party window
|
||
// is open on raidKey and which are not themselves members of it. Members are
|
||
// excluded because the caller already pushes to them, and a duplicate mode=3
|
||
// rebuild would only make the client re-render the roster twice.
|
||
func (s *Service) runtimeRaidBrowseSubscribers(raidKey uint32, excluded uint16, members map[uint16]struct{}) []*gameSession {
|
||
if s == nil || raidKey == 0 {
|
||
return nil
|
||
}
|
||
now := s.gameplayNow()
|
||
expiry := now.Add(-runtimeRaidBrowseTTL)
|
||
s.mu.Lock()
|
||
rows := make([]*gameSession, 0, len(s.gameSessions))
|
||
seen := make(map[*gameSession]struct{}, len(s.gameSessions))
|
||
for _, session := range s.gameSessions {
|
||
if session == nil || session.conn == nil || session.selectedCharacterID == 0 {
|
||
continue
|
||
}
|
||
if session.raidBrowseTarget != raidKey || session.raidBrowseAt.Before(expiry) {
|
||
continue
|
||
}
|
||
if _, duplicate := seen[session]; duplicate {
|
||
continue
|
||
}
|
||
if session.selectedCharacterID == excluded {
|
||
continue
|
||
}
|
||
seen[session] = struct{}{}
|
||
if _, isMember := members[session.selectedCharacterID]; isMember {
|
||
continue
|
||
}
|
||
rows = append(rows, session)
|
||
}
|
||
s.mu.Unlock()
|
||
sort.Slice(rows, func(i, j int) bool { return rows[i].selectedCharacterID < rows[j].selectedCharacterID })
|
||
return rows
|
||
}
|
||
|
||
// broadcastRuntimeRaidBrowseRefresh pushes the current roster to every outsider
|
||
// currently browsing this raid. The member loop in broadcastRuntimeRaidRefresh
|
||
// only walks state.Members, so a leader regrouping its squads produced nothing at
|
||
// all for an outsider: the open window kept the old layout until it was closed and
|
||
// reopened.
|
||
//
|
||
// rosterBody is reused from the caller so both paths stay byte-identical.
|
||
func (s *Service) broadcastRuntimeRaidBrowseRefresh(state runtimeRaidState, rosterBody []byte, excluded uint16) {
|
||
if s == nil || state.RaidKey == 0 {
|
||
return
|
||
}
|
||
members := make(map[uint16]struct{}, len(state.Members))
|
||
for _, member := range state.Members {
|
||
members[member.CharID] = struct{}{}
|
||
}
|
||
subscribers := s.runtimeRaidBrowseSubscribers(state.RaidKey, excluded, members)
|
||
if len(subscribers) == 0 {
|
||
return
|
||
}
|
||
fingerprint := runtimeRaidMemberFingerprint(state)
|
||
sortedMembers := runtimeRaidSortedMembers(state)
|
||
for _, session := range subscribers {
|
||
if err := s.sendRuntimeRaidBrowseRoster(session, state, sortedMembers, rosterBody, fingerprint); err != nil {
|
||
s.logGameEvent(session, "game-raid-browse-refresh-failed",
|
||
"raid_key", state.RaidKey,
|
||
"error", err)
|
||
}
|
||
}
|
||
}
|
||
|
||
// sendRuntimeRaidBrowseRoster replays the browsed raid into one outsider window.
|
||
// The mode=3 roster always goes out; the identity half only when the member id set
|
||
// changed, because a brand new member has no key list entry and would otherwise
|
||
// render as a nameless level-0 row.
|
||
func (s *Service) sendRuntimeRaidBrowseRoster(
|
||
session *gameSession,
|
||
state runtimeRaidState,
|
||
sortedMembers []runtimeRaidMemberState,
|
||
rosterBody []byte,
|
||
fingerprint uint64,
|
||
) error {
|
||
now := s.gameplayNow()
|
||
s.mu.Lock()
|
||
identityStale := session.raidBrowseMembers != fingerprint
|
||
session.raidBrowseMembers = fingerprint
|
||
session.raidBrowseAt = now
|
||
s.mu.Unlock()
|
||
|
||
if identityStale {
|
||
if err := s.sendRuntimeRaidBrowseMemberActors(session, sortedMembers); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidMemberRefreshMsgID, rosterBody, 0, true); err != nil {
|
||
return err
|
||
}
|
||
if !identityStale {
|
||
return nil
|
||
}
|
||
if err := s.sendRuntimeRaidBrowseKeyLists(session, state, sortedMembers); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-browse-identity-refreshed",
|
||
"raid_key", state.RaidKey,
|
||
"member_count", len(state.Members),
|
||
"source", "roster_mutation_member_set_changed")
|
||
return nil
|
||
}
|
||
|
||
// runtimeRaidBrowseTarget resolves which raid an op1359 request names.
|
||
//
|
||
// The body is u8 mode + u32 raid_key (see raid.DecodeRequestRaidInfoRequest), so the
|
||
// key is read directly instead of being guessed from a channel. The requester's own
|
||
// channel is only a fallback for a body that carries no key, so a shape change
|
||
// degrades to the old behaviour instead of hiding the raid.
|
||
func (s *Service) runtimeRaidBrowseTarget(raidKey uint32, sessionChannel uint16) (runtimeRaidState, string, bool) {
|
||
if state, ok := s.runtimeOpenRaidByKey(raidKey); ok {
|
||
return state, "op1359_raid_key", true
|
||
}
|
||
if state, ok := s.runtimeOpenRaidOnChannel(sessionChannel); ok {
|
||
return state, "session_channel_fallback", true
|
||
}
|
||
return runtimeRaidState{}, "", false
|
||
}
|
||
|
||
// runtimeOpenRaidByKey returns the recruiting team raid with this exact key.
|
||
func (s *Service) runtimeOpenRaidByKey(raidKey uint32) (runtimeRaidState, bool) {
|
||
if s == nil || raidKey == 0 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.raids[raidKey]
|
||
if state == nil || state.TailFlag != 0 || state.Started || state.StartPending {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
return cloneRuntimeRaidState(*state), true
|
||
}
|
||
|
||
func (s *Service) handleOnlineRaidEntryCostInfo(session *gameSession, typ uint16, body []byte) error {
|
||
if _, err := raid.DecodeEntryCostInfoRequest(body); err != nil {
|
||
s.logGameEvent(session, "game-raid-entry-cost-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
state, ok := s.runtimeRaidForMember(selectedCharacterID(session))
|
||
if !ok {
|
||
return s.handleOnlineRaidBrowseEntryCostInfo(session, typ)
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, typ, raid.BuildRaidCommandSuccessBody(), dnfproto.DefaultChannelClassification, true); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidWaitingListMsgID, runtimeRaidWaitingListBody(state), 0, true); err != nil {
|
||
return err
|
||
}
|
||
// Broadcast rather than answer only the requester. A member sends this request when
|
||
// its own material state changes, and answering only that one session left every
|
||
// other member on a stale snapshot: the item showed up in the requester window and
|
||
// nowhere else.
|
||
return s.broadcastRuntimeRaidEntryCost(state)
|
||
}
|
||
|
||
// handleOnlineRaidBrowseEntryCostInfo answers the same request from a session that
|
||
// is only browsing a raid it has not joined.
|
||
//
|
||
// The member gate alone used to refuse it outright. That refusal is measurable on
|
||
// the wire: the same connection received a 598 (0x0256) snapshot whose every row
|
||
// already read field=1 value=1, while the 入场材料 column still rendered "-",
|
||
// because the client learns *which* item the raid charges from this ACK and 0x0256
|
||
// only reports who has it.
|
||
//
|
||
// Only the read-only half of the member answer is reproduced -- the success byte,
|
||
// the waiting list and the material snapshot. Nothing here claims the raid: the
|
||
// 0x024F object and the 664 binding stay where they are, which is what keeps a
|
||
// browsing outsider from adopting the raid.
|
||
func (s *Service) handleOnlineRaidBrowseEntryCostInfo(session *gameSession, typ uint16) error {
|
||
state, ok := s.runtimeRaidBrowseSubscription(session)
|
||
if !ok {
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, typ, raid.BuildRaidCommandSuccessBody(), dnfproto.DefaultChannelClassification, true); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidWaitingListMsgID, runtimeRaidWaitingListBody(state), 0, true); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendRuntimeRaidEntryCostSnapshot(session, state); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-browse-entry-cost-served",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"member_count", len(state.Members),
|
||
"source", "op658_browsing_outsider_read_only_ack")
|
||
return nil
|
||
}
|
||
|
||
// runtimeRaidBrowseSubscription resolves the raid a session's attack-party window
|
||
// is currently open on, reusing the same open-raid rule as the op1359 browse list.
|
||
func (s *Service) runtimeRaidBrowseSubscription(session *gameSession) (runtimeRaidState, bool) {
|
||
if s == nil || session == nil {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
s.mu.Lock()
|
||
raidKey := session.raidBrowseTarget
|
||
s.mu.Unlock()
|
||
if raidKey == 0 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
return s.runtimeOpenRaidByKey(raidKey)
|
||
}
|
||
|
||
// broadcastRuntimeRaidEntryCost sends the 0x0256 material snapshot to every online
|
||
// member of the raid.
|
||
func (s *Service) broadcastRuntimeRaidEntryCost(state runtimeRaidState) error {
|
||
var firstErr error
|
||
for _, member := range state.Members {
|
||
memberSession, online := s.onlineGameSession(member.CharID)
|
||
if !online {
|
||
continue
|
||
}
|
||
if err := s.sendRuntimeRaidEntryCostSnapshot(memberSession, state); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
return firstErr
|
||
}
|
||
|
||
// handleOnlineRaidSetSymbol stores the native single-raid route selector.
|
||
// The current client sends symbol values 0, 1, or 2; the PVF trigger graph
|
||
// names the corresponding branches 0, 3, and 6. This command is one-way in
|
||
// the current EXE, so no synthetic ACK is emitted.
|
||
func (s *Service) handleOnlineRaidSetSymbol(session *gameSession, typ uint16, body []byte) error {
|
||
req, err := raid.DecodeSetSymbolRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-set-symbol-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
return nil
|
||
}
|
||
characterID := selectedCharacterID(session)
|
||
if characterID == 0 {
|
||
return nil
|
||
}
|
||
// Inside a started attack party the same body is a dungeon script's
|
||
// [RAID SET SYMBOL] (symbol, operand, operation): Luke's phase-one generator
|
||
// report or Anton's phase-two shield report.
|
||
if current, ok := s.runtimeRaidForMember(characterID); ok && current.TailFlag != 1 && current.Started {
|
||
// 卢克一阶段的发电机上报(op662)先认领;认领不到才落回安徒恩的护盾上报。
|
||
// 安徒恩状态永远认领不到(owner != Luke),路径逐字不变。
|
||
if s.applyRuntimeRaidLukeGeneratorReport(session, req) {
|
||
return s.sendGameUpperRawClassCodec(session, typ, raid.BuildRaidCommandSuccessBody(), dnfproto.DefaultChannelClassification, true)
|
||
}
|
||
handled, err := s.applyRuntimeRaidBarrierRequest(session, req)
|
||
if err != nil || !handled {
|
||
return err
|
||
}
|
||
return s.sendGameUpperRawClassCodec(session, typ, raid.BuildRaidCommandSuccessBody(), dnfproto.DefaultChannelClassification, true)
|
||
}
|
||
s.raidMu.Lock()
|
||
state := s.runtimeRaidByMemberLocked(characterID)
|
||
if state == nil || state.TailFlag != 1 || state.Started || state.StartPending {
|
||
s.raidMu.Unlock()
|
||
return nil
|
||
}
|
||
if state.RaidMode == runtimeRaidOwnerLuke {
|
||
if req.Symbol == 0 {
|
||
state.SingleRaidType = runtimeRaidLukeLightRoute
|
||
} else {
|
||
state.SingleRaidType = runtimeRaidLukeDarkRoute
|
||
}
|
||
} else {
|
||
state.SingleRaidType = req.Symbol * 3
|
||
}
|
||
updated := cloneRuntimeRaidState(*state)
|
||
s.raidMu.Unlock()
|
||
s.logGameEvent(session, "game-raid-single-route-selected",
|
||
"type", typ,
|
||
"raid_key", updated.RaidKey,
|
||
"source_value", req.SourceValue,
|
||
"target_value", req.TargetValue,
|
||
"symbol", req.Symbol,
|
||
"single_raid_type", updated.SingleRaidType)
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) handleOnlineSetRaidWaiting(session *gameSession, typ uint16, body []byte) error {
|
||
req, err := raid.DecodeSetWaitingRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-waiting-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
state, row, ok := s.setRuntimeRaidWaiting(selectedCharacterID(session), req.RouteRaidType)
|
||
if !ok {
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, typ, raid.BuildRaidCommandSuccessBody(), dnfproto.DefaultChannelClassification, true); err != nil {
|
||
return err
|
||
}
|
||
body = raid.BuildWaitingChangeBody(0, row)
|
||
for _, member := range state.Members {
|
||
memberSession, online := s.onlineGameSession(member.CharID)
|
||
if !online {
|
||
continue
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(memberSession, raid.RaidWaitingChangeMsgID, body, 0, true); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) sendRuntimeRaidEntryCostSnapshot(session *gameSession, state runtimeRaidState) error {
|
||
return s.sendGameUpperRawClassCodec(session, raid.RaidEntryCostMsgID, s.runtimeRaidEntryCostSnapshotBody(state), 0, true)
|
||
}
|
||
|
||
func (s *Service) runtimeRaidEntryCostSnapshotBody(state runtimeRaidState) []byte {
|
||
entryItemID, entryCount := runtimeRaidEntryCostForState(state)
|
||
rows := make([]raid.EntryCostRow, 0, len(state.Members))
|
||
repositories, repositoriesOK := s.repositoryGroup()
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
members := append([]runtimeRaidMemberState(nil), state.Members...)
|
||
runtimeRaidSortMembers(members)
|
||
for _, member := range members {
|
||
row := raid.EntryCostRow{MemberKey: member.CharID}
|
||
if state.EntryCostPaid || runtimeRaidMemberHasEntryCost(ctx, repositories, repositoriesOK, member.CharID, entryItemID, entryCount) {
|
||
row.Field = 1
|
||
row.Value = 1
|
||
}
|
||
rows = append(rows, row)
|
||
}
|
||
return raid.BuildEntryCostBody(rows)
|
||
}
|
||
|
||
func runtimeRaidMemberHasEntryCost(ctx context.Context, repositories dnfrepo.Group, repositoriesOK bool, characterID uint16, itemID uint32, count uint32) bool {
|
||
if !repositoriesOK || repositories.Inventory == nil || characterID == 0 {
|
||
return false
|
||
}
|
||
record, found, err := repositories.Inventory.Load(ctx, strconv.Itoa(int(characterID)))
|
||
if err != nil || !found {
|
||
return false
|
||
}
|
||
_, ok := currentEpicProductionFindMaterialSlot(record, itemID, count)
|
||
return ok
|
||
}
|
||
|
||
func runtimeRaidEntryCostForState(state runtimeRaidState) (uint32, uint32) {
|
||
config := runtimeRaidBaseConfig(state)
|
||
if config != nil && config.EntryCost.ItemID != 0 && config.EntryCost.Count != 0 {
|
||
return config.EntryCost.ItemID, config.EntryCost.Count
|
||
}
|
||
return runtimeRaidEntryItemID, runtimeRaidEntryCount
|
||
}
|
||
|
||
func runtimeRaidWaitingListBody(state runtimeRaidState) []byte {
|
||
members := append([]runtimeRaidMemberState(nil), state.Members...)
|
||
runtimeRaidSortMembers(members)
|
||
rows := make([]raid.WaitingRow, 0, len(members))
|
||
for _, member := range members {
|
||
rows = append(rows, raid.WaitingRow{MemberKey: member.CharID, State: member.Waiting})
|
||
}
|
||
return raid.BuildWaitingListBody(rows)
|
||
}
|
||
|
||
type runtimeRaidEntryCostUpdate struct {
|
||
CharacterID uint16
|
||
Entries []currentItemListEntry
|
||
}
|
||
|
||
func (s *Service) consumeRuntimeRaidEntryCost(state runtimeRaidState) ([]runtimeRaidEntryCostUpdate, error) {
|
||
// 超时空的入场费走 runtimeRaidBaseConfig -> Battle.Raid.EntryCost,与安徒恩/
|
||
// 卢克同一条路径;账号日/周次数由 reserveRuntimeRaidAdmissionCounters 统一扣
|
||
// (currentRaidAdmissionPolicyKeys 现在含 FiendWar 三个 key),不再另起一套。
|
||
batch, err := s.consumeRuntimeRaidEntryCostBatch(state)
|
||
if err != nil || batch == nil {
|
||
return nil, err
|
||
}
|
||
return cloneRuntimeRaidEntryCostUpdates(batch.updates), nil
|
||
}
|
||
|
||
type runtimeRaidEntryCostBatch struct {
|
||
characterIDs []string
|
||
before []dnfrepo.InventoryRecord
|
||
updates []runtimeRaidEntryCostUpdate
|
||
restored bool
|
||
}
|
||
|
||
func (s *Service) consumeRuntimeRaidEntryCostBatch(state runtimeRaidState) (*runtimeRaidEntryCostBatch, error) {
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.RaidEntryCost == nil {
|
||
return nil, dnfrepo.ErrRaidEntryCostTransactionUnavailable
|
||
}
|
||
members := append([]runtimeRaidMemberState(nil), state.Members...)
|
||
runtimeRaidSortMembers(members)
|
||
entryItemID, entryCount := runtimeRaidEntryCostForState(state)
|
||
characterIDs := make([]string, 0, len(members))
|
||
for _, member := range members {
|
||
characterIDs = append(characterIDs, strconv.Itoa(int(member.CharID)))
|
||
}
|
||
batch := &runtimeRaidEntryCostBatch{
|
||
characterIDs: append([]string(nil), characterIDs...),
|
||
before: make([]dnfrepo.InventoryRecord, 0, len(members)),
|
||
updates: make([]runtimeRaidEntryCostUpdate, 0, len(members)),
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
err := repositories.RaidEntryCost.WithinRaidEntryCost(ctx, characterIDs, func(inventories dnfrepo.InventoryRepository) error {
|
||
for _, member := range members {
|
||
characterID := strconv.Itoa(int(member.CharID))
|
||
record, found, loadErr := inventories.Load(ctx, characterID)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
if !found {
|
||
return fmt.Errorf("%w: character=%d inventory missing", errRuntimeRaidEntryCost, member.CharID)
|
||
}
|
||
batch.before = append(batch.before, dnfrepo.CloneInventory(record))
|
||
slot, found := currentEpicProductionFindMaterialSlot(record, entryItemID, entryCount)
|
||
if !found {
|
||
return fmt.Errorf("%w: character=%d item=%d count=%d", errRuntimeRaidEntryCost, member.CharID, entryItemID, entryCount)
|
||
}
|
||
entry, consumeErr := currentEpicProductionConsumeStack(&record, slot, entryItemID, entryCount)
|
||
if consumeErr != nil {
|
||
return fmt.Errorf("%w: character=%d: %v", errRuntimeRaidEntryCost, member.CharID, consumeErr)
|
||
}
|
||
if saveErr := inventories.Save(ctx, record); saveErr != nil {
|
||
return saveErr
|
||
}
|
||
batch.updates = append(batch.updates, runtimeRaidEntryCostUpdate{CharacterID: member.CharID, Entries: []currentItemListEntry{entry}})
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
return batch, nil
|
||
}
|
||
|
||
func (s *Service) restoreRuntimeRaidEntryCostBatch(batch *runtimeRaidEntryCostBatch) error {
|
||
if batch == nil || batch.restored {
|
||
return nil
|
||
}
|
||
repositories, ok := s.repositoryGroup()
|
||
if !ok || repositories.RaidEntryCost == nil || len(batch.before) != len(batch.characterIDs) {
|
||
return dnfrepo.ErrRaidEntryCostTransactionUnavailable
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
err := repositories.RaidEntryCost.WithinRaidEntryCost(ctx, batch.characterIDs, func(inventories dnfrepo.InventoryRepository) error {
|
||
for _, record := range batch.before {
|
||
if err := inventories.Save(ctx, dnfrepo.CloneInventory(record)); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
batch.restored = true
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) sendRuntimeRaidEntryCostUpdates(updates []runtimeRaidEntryCostUpdate) (bool, error) {
|
||
allDelivered := true
|
||
for _, update := range updates {
|
||
session, ok := s.onlineGameSession(update.CharacterID)
|
||
if !ok || len(update.Entries) == 0 {
|
||
allDelivered = false
|
||
continue
|
||
}
|
||
body := buildCurrentItemUpdateBody(dnfrepo.MainInventoryListType, update.Entries)
|
||
if err := s.sendGameUpperRawClass(session, uint16(dnfenum.CmdPacketWalkoutPartyMember), body, 0); err != nil {
|
||
return false, err
|
||
}
|
||
}
|
||
return allDelivered, nil
|
||
}
|
||
|
||
func (s *Service) flushRuntimeRaidEntryCostUpdates(raidKey uint32) error {
|
||
s.raidMu.Lock()
|
||
state := s.raids[raidKey]
|
||
if state == nil || len(state.PendingCostSync) == 0 {
|
||
s.raidMu.Unlock()
|
||
return nil
|
||
}
|
||
attempt := state.StartAttempt
|
||
updates := cloneRuntimeRaidEntryCostUpdates(state.PendingCostSync)
|
||
s.raidMu.Unlock()
|
||
|
||
allDelivered, err := s.sendRuntimeRaidEntryCostUpdates(updates)
|
||
if err != nil || !allDelivered {
|
||
return err
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state = s.raids[raidKey]
|
||
if state != nil && state.StartAttempt == attempt {
|
||
state.PendingCostSync = nil
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) handleOnlineCreateRaid(session *gameSession, typ uint16, body []byte) error {
|
||
req, err := raid.DecodeCreateRaidRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-create-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
s.logWarn("dnfbridge raid create rejected: invalid body",
|
||
"conn_id", session.connID,
|
||
"channel_id", session.channel.ID,
|
||
"client_pid", sessionClientPID(session),
|
||
"type", typ,
|
||
"body_len", len(body),
|
||
"error", err)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
sourceID := selectedCharacterID(session)
|
||
if sourceID == 0 {
|
||
s.logWarn("dnfbridge raid create rejected: session has no selected character",
|
||
"conn_id", session.connID,
|
||
"channel_id", session.channel.ID,
|
||
"client_pid", sessionClientPID(session),
|
||
"account_id", s.accountIDForSession(session),
|
||
"type", typ,
|
||
"body_len", len(body))
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
// 普通(队伍)卢克以前在这里被拒,因为两阶段触发图当时还没有 owner。
|
||
// 现在有了(raid_luke_phase_runtime.go),所以卢克的两种模式都跟安徒恩走同一条创建路径。
|
||
// 那道拒绝连提示都是错的:失败码落到客户端 op664 的分支表就是 dstr 32059
|
||
// 「抱歉, 不存在该玩家。」—— 也就是玩家给队伍起完名之后看到的弹窗。
|
||
// (上游 bb52082「实现卢克团本两阶段触发图并放开普通攻坚队门禁」)
|
||
ownerMode := runtimeRaidModeForSession(session)
|
||
// Refuse a single (单人) raid for an owner that has no single mode BEFORE the
|
||
// owner config is loaded. Fiend War never declares one: letting the request
|
||
// through created a one-member raid object that could never start --
|
||
// commitRuntimeRaidStartWithOwner returns false for it on every attempt and
|
||
// cancelRuntimeRaidStart leaves the object alive, so the leader stayed
|
||
// "already in a raid" and every later join was refused with the client-side
|
||
// 「抱歉, 不存在该玩家。」 notice (live 2026-10-07, 5 attempts across 2 raids).
|
||
//
|
||
// This check deliberately does not load the owner config: Anton's and Fiend
|
||
// War's configs are resolved lazily, so loading them here would turn a
|
||
// not-yet-configured PVF source into a hard create failure. Ownership is
|
||
// decided by the channel type alone.
|
||
if !runtimeRaidOwnerSupportsSingleRaid(ownerMode) && runtimeRaidSingleRequested(req.TailFlag) {
|
||
s.logGameEvent(session, "game-raid-create-single-unsupported",
|
||
"type", typ,
|
||
"source_char_id", sourceID,
|
||
"owner_mode", ownerMode,
|
||
"route_or_raid_type", req.RouteOrRaidType,
|
||
"tail_flag", req.TailFlag,
|
||
"reason", "owner_has_no_single_raid_mode")
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
ownerConfig, configErr := s.runtimeRaidOwnerConfigForSession(session)
|
||
if ownerMode == runtimeRaidOwnerLuke && (configErr != nil || ownerConfig.Raid == nil) {
|
||
s.logGameEvent(session, "game-raid-create-pvf-rejected",
|
||
"type", typ,
|
||
"source_char_id", sourceID,
|
||
"error", configErr)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
state, created := s.createRuntimeRaid(session, sourceID, req)
|
||
if !created {
|
||
// A reconnecting client can submit CREATE_RAID before it has consumed the
|
||
// post-select rejoin snapshot. Preserve the unfinished server-owned raid
|
||
// (including its single/normal mode) and bind that object back to the EXE.
|
||
// Returning the generic reason=3 here is incorrect: this client renders it
|
||
// as "player does not exist", using the requested raid name as the title.
|
||
if existing, ok := s.runtimeRaidForMember(sourceID); ok &&
|
||
existing.LeaderID == sourceID &&
|
||
(existing.RaidMode == 0 || existing.RaidMode == ownerMode) &&
|
||
!existing.StartPending &&
|
||
!existing.Started && !existing.EntryCostPaid {
|
||
if existing.TailFlag == 1 {
|
||
if err := s.clearSingleRaidOrdinaryPartyProjection(session, "unstarted_single_raid_create_recovery"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.sendRuntimeRaidCreateBinding(session, typ, existing); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-create-recovered-existing",
|
||
"type", typ,
|
||
"raid_key", existing.RaidKey,
|
||
"source_char_id", sourceID,
|
||
"existing_route_or_raid_type", existing.RouteOrRaidType,
|
||
"requested_route_or_raid_type", req.RouteOrRaidType,
|
||
"existing_tail_flag", existing.TailFlag,
|
||
"requested_tail_flag", req.TailFlag)
|
||
return nil
|
||
}
|
||
s.logWarn("dnfbridge raid create rejected: character already has an incompatible raid state",
|
||
"conn_id", session.connID,
|
||
"channel_id", session.channel.ID,
|
||
"client_pid", sessionClientPID(session),
|
||
"account_id", s.accountIDForSession(session),
|
||
"type", typ,
|
||
"source_char_id", sourceID,
|
||
"body_len", len(body))
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
if ownerConfig.Raid != nil {
|
||
state, created = s.attachRuntimeRaidOwnerConfig(state.RaidKey, ownerConfig)
|
||
if !created {
|
||
s.logGameEvent(session, "game-raid-create-owner-bind-rejected",
|
||
"type", typ,
|
||
"source_char_id", sourceID,
|
||
"raid_key", state.RaidKey,
|
||
"owner_mode", ownerConfig.Mode)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
}
|
||
if state.TailFlag == 1 {
|
||
if err := s.clearSingleRaidOrdinaryPartyProjection(session, "single_raid_created"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
if err := s.sendRuntimeRaidCreateBinding(session, typ, state); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-created",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"leader_id", state.LeaderID,
|
||
"member_count", len(state.Members),
|
||
"route_or_raid_type", state.RouteOrRaidType,
|
||
"tail_flag", state.TailFlag,
|
||
"single_raid_type", state.SingleRaidType)
|
||
s.logInfo("dnfbridge raid created",
|
||
"conn_id", session.connID,
|
||
"channel_id", session.channel.ID,
|
||
"client_pid", sessionClientPID(session),
|
||
"account_id", s.accountIDForSession(session),
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"leader_id", state.LeaderID,
|
||
"member_count", len(state.Members),
|
||
"route_or_raid_type", state.RouteOrRaidType,
|
||
"tail_flag", state.TailFlag,
|
||
"single_raid_type", state.SingleRaidType)
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) sendRuntimeRaidCreateBinding(session *gameSession, typ uint16, state runtimeRaidState) error {
|
||
// Current EXE selects the raid key immediately while consuming the 664
|
||
// success ACK. The complete mode=0 object must already exist by then.
|
||
if err := s.broadcastRuntimeRaidCreate(state); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, typ, raid.BuildCreateRaidResultBody(state.RaidKey), dnfproto.DefaultChannelClassification, true); err != nil {
|
||
return err
|
||
}
|
||
// The success ACK selects the created or restored raid. Send the canonical
|
||
// mode=3 roster immediately afterwards so the ordinary raid member table
|
||
// and party-allocation window materialize the leader row.
|
||
return s.broadcastRuntimeRaidRefresh(state)
|
||
}
|
||
|
||
func (s *Service) broadcastRuntimeRaidCreate(state runtimeRaidState) error {
|
||
body, err := raid.BuildRaidObjectMode0Body(runtimeRaidObjectMode0(state))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
buffBody, err := s.runtimeRaidBuffStatusBody(state)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
for _, member := range state.Members {
|
||
session, ok := s.onlineGameSession(member.CharID)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidObjectMsgID, body, 0, true); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidBuffMsgID, buffBody, 0, true); err != nil {
|
||
return err
|
||
}
|
||
// Do not synthesize an empty op634 snapshot here. Current EXE
|
||
// sub_1D50990 replaces the complete native objective/monster-state
|
||
// container with the received rows. A zero-row packet therefore erases
|
||
// the locally materialized Anton graph; the next scene reports op695
|
||
// {dungeon=0,count=0} and its actors retire without combat. Until a
|
||
// dedicated raid service owns real rows, leaving this container under
|
||
// the EXE is the only evidence-backed behavior.
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) handleOnlineModifyRaidInfo(session *gameSession, typ uint16, body []byte) error {
|
||
req, err := raid.DecodeModifyRaidInfoRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-modify-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
sourceID := selectedCharacterID(session)
|
||
state, ok := s.modifyRuntimeRaidInfo(sourceID, req)
|
||
if !ok {
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, typ, []byte{1}, dnfproto.DefaultChannelClassification, true); err != nil {
|
||
return err
|
||
}
|
||
return s.broadcastRuntimeRaidRefresh(state)
|
||
}
|
||
|
||
func (s *Service) handleOnlineLeaveRaid(session *gameSession, typ uint16, body []byte) error {
|
||
request, err := raid.DecodeLeaveRaidRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-leave-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
sourceID := selectedCharacterID(session)
|
||
// op665 carries a target key and the client uses the one opcode for two
|
||
// gestures: leaving names the sender, and the leader kicking a member names
|
||
// that member. Measured: the leader's own connection sent 665 with body u16=2
|
||
// while member 2 was kicked, and the old code removed the sender, so the
|
||
// leader kicked itself out and the raid passed to the member.
|
||
//
|
||
// A self-leave stays unambiguous under either reading of the field, because
|
||
// the raid key's low u16 is always the creator's character id
|
||
// (nextRuntimeRaidKeyLocked builds channel<<16|sourceID). A key that names a
|
||
// CURRENT member of the raid can therefore only be a kick.
|
||
//
|
||
// ⚠ 真机 2026-10-01 20:50 补的一条:超时空名册只剩一个人之后,客户端按"退出攻坚队"
|
||
// 发出来的 key 是 **3**(既不是自己 22、也不是攻坚队 key 的低 16 位)。以前这里
|
||
// 只看"是队长且 key 不是我",于是这一脚被当成踢人 → 3 不是成员 → 拒绝 → 玩家
|
||
// 看到的就是"点了没反应"。
|
||
//
|
||
// 所以超时空多一条:key 认不出名册里的人时**不算踢人**,回落到"自己退出"。
|
||
// 安东/卢克逐字不变 —— 那边"队长的 stale key 一律按踢人处理、认不出就拒"是
|
||
// 既有测试钉住的行为(raid_kick_member_test.go:129),不动它。
|
||
if targetID := request.RaidOrMemberKey; targetID != 0 && targetID != sourceID {
|
||
if state, ok := s.runtimeRaidForMember(sourceID); ok &&
|
||
state.TailFlag != 1 && state.LeaderID == sourceID {
|
||
fiendWarUnknownKey := state.RaidMode == runtimeRaidOwnerFiendWar &&
|
||
runtimeRaidMemberIndex(state, targetID) < 0
|
||
if !fiendWarUnknownKey {
|
||
return s.handleOnlineKickRaidMember(session, typ, targetID)
|
||
}
|
||
}
|
||
}
|
||
// 超时空的离队语义与另外两个 owner 都不同:一个人离开只是把从名册里摘掉、把
|
||
// 场景送回公开城镇,其余人继续打;只有名册空了才解散。它也因此不能塞进下面那条
|
||
// 以 TailFlag(单人) 为条件的队伍分支 —— TailFlag 对超时空是 0。
|
||
if state, ok := s.runtimeRaidForMember(sourceID); ok && state.Started &&
|
||
state.RaidMode == runtimeRaidOwnerFiendWar && isCurrentFiendWarRaidChannel(session) {
|
||
return s.handleRuntimeFiendWarLeave(session, typ, request.RaidOrMemberKey, state)
|
||
}
|
||
if state, ok := s.runtimeRaidForMember(sourceID); ok && state.Started && state.TailFlag == 1 {
|
||
// A started SINGLE raid has one member and an unfinished run, so leaving
|
||
// it abandons the run instead of just dropping a roster row. A started
|
||
// TEAM raid falls through to the ordinary leave below -- the 86JP
|
||
// reference lets a member (or the leader) leave at any point, and refusing
|
||
// it left the player stuck in a raid they could not create another one
|
||
// beside.
|
||
_, frozenMember := runtimeRaidFrozenMember(state, sourceID)
|
||
if state.StartPending || state.LeaderID != sourceID || !frozenMember ||
|
||
uint16(state.RaidKey) != request.RaidOrMemberKey {
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
abandoned, abandonErr := s.abandonRuntimeRaid(state.RaidKey, sourceID, s.gameplayNow())
|
||
if abandonErr != nil {
|
||
s.logGameEvent(session, "game-raid-active-single-leave-rejected",
|
||
"type", typ,
|
||
"char_id", sourceID,
|
||
"request_key", request.RaidOrMemberKey,
|
||
"raid_key", state.RaidKey,
|
||
"error", abandonErr)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
storeRuntimeRaidEndedProjection(session, abandoned.RaidKey)
|
||
if err := s.sendRuntimeSingleRaidEnded(session, typ, "active_single_raid_leave"); err != nil {
|
||
return err
|
||
}
|
||
s.refreshRuntimePublicRaidDirectories("active_single_raid_left")
|
||
s.logGameEvent(session, "game-raid-active-single-left",
|
||
"type", typ,
|
||
"char_id", sourceID,
|
||
"request_key", request.RaidOrMemberKey,
|
||
"raid_key", abandoned.RaidKey,
|
||
"raid_phase", abandoned.Lifecycle.Phase,
|
||
"lifecycle_status", abandoned.Lifecycle.Status)
|
||
return nil
|
||
}
|
||
// Capture the roster before the removal consumes it. A disband leaves no
|
||
// surviving member list to address, and every character that was in the raid
|
||
// still has to be told the object is gone.
|
||
previousRaid, hadPreviousRaid := s.runtimeRaidForMember(sourceID)
|
||
state, removed, disbanded := s.removeRuntimeRaidMember(sourceID)
|
||
if !removed {
|
||
if state.RaidKey == 0 {
|
||
if runtimeRaidEndedProjectionMatches(session, request.RaidOrMemberKey) {
|
||
// 超时空的收尾不是"单人已结束",而是"离队的那次重试":把本地面板
|
||
// 基线清掉再回公开城镇。
|
||
if isCurrentFiendWarRaidChannel(session) {
|
||
return s.finishRuntimeFiendWarLeave(session, typ)
|
||
}
|
||
return s.sendRuntimeSingleRaidEnded(session, typ, "ended_single_raid_leave_retry")
|
||
}
|
||
if completed, ok := s.completedSingleRuntimeRaidForLeave(sourceID, request.RaidOrMemberKey); ok {
|
||
storeRuntimeRaidEndedProjection(session, completed.RaidKey)
|
||
if err := s.sendRuntimeSingleRaidEnded(session, typ, "completed_single_raid_leave"); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-completed-single-leave-acknowledged",
|
||
"type", typ,
|
||
"char_id", sourceID,
|
||
"request_key", request.RaidOrMemberKey,
|
||
"raid_key", completed.RaidKey,
|
||
"raid_phase", completed.Lifecycle.Phase,
|
||
"reason", "completed_reward_record_matches_released_runtime_raid")
|
||
return nil
|
||
}
|
||
// A finished TEAM raid is reached the same way: finalizeRuntimeRaid
|
||
// deleted the live row (raid_lifecycle.go:2097) while the member's client
|
||
// still held the raid object, so the request must be answered from the
|
||
// committed completion record instead of the live roster.
|
||
if finished, ok := s.completedTeamRuntimeRaidForLeave(sourceID); ok {
|
||
return s.leaveFinishedTeamRuntimeRaid(session, typ, finished, "completed_team_raid_leave_retry")
|
||
}
|
||
}
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
// A player who leaves on their own was never forcibly removed, so the op665
|
||
// flag stays clear even when the departure empties the raid and disbands it.
|
||
// The flag is what makes the client print 46791 "已经强制退出攻坚队。", and passing
|
||
// the disband result there made an ordinary leave show that message.
|
||
if err := s.sendGameUpperRawClassCodec(session, typ, raid.BuildLeaveRaidResultBody(false), dnfproto.DefaultChannelClassification, true); err != nil {
|
||
return err
|
||
}
|
||
// The leaver always drops the object it materialized. No roster push in this
|
||
// service targets a character that has just left, so without an explicit
|
||
// removal the window keeps listing the raid and the client stays stuck on a
|
||
// raid it is no longer in. A borrowed browse object is dropped with it, or a
|
||
// later browse would treat the object as still held and send a roster-only
|
||
// refresh for one the client no longer has.
|
||
removedRaidKey := previousRaid.RaidKey
|
||
if removedRaidKey == 0 {
|
||
removedRaidKey = state.RaidKey
|
||
}
|
||
if err := s.sendRuntimeRaidObjectRemoved(session, removedRaidKey); err != nil {
|
||
return err
|
||
}
|
||
// Leaving the attack party is ALSO leaving the squad party it projected.
|
||
//
|
||
// Operator rule, 2026-09-29: "退出攻坚队也视作自动退队并清理组队状态". The
|
||
// projection-only clear below was not enough: it rewrote this session's own party
|
||
// cache, but the central RuntimePartyManager kept the leaver in the squad party,
|
||
// so the survivors' clients kept drawing a member row whose peer no longer
|
||
// existed -- and every one of them, the leaver included, sat on 连接中 forever
|
||
// ("退队以后还显示连接中 其他还未退出攻坚队的小队员就完蛋了 一直显示连接中").
|
||
//
|
||
// leaveRuntimePartyOnAbandonedDungeonExit is the proven sequence for exactly this
|
||
// transition: manager leave + the leaver's own removal frame + relay re-sync or
|
||
// close + the survivor broadcast that drops the row. It is deliberately a no-op
|
||
// for a one-member party, where leaving is indistinguishable from staying.
|
||
if err := s.leaveRuntimePartyOnAbandonedDungeonExit(
|
||
session,
|
||
runtimePartyStateSnapshot(session),
|
||
"raid_leave_treated_as_automatic_party_leave",
|
||
); err != nil {
|
||
return err
|
||
}
|
||
previousParty := runtimePartyStateSnapshot(session)
|
||
storeRuntimePartyState(session, alignedcmd.PartyState{})
|
||
if err := s.sendRuntimePartyMemberRemoved(session, uint16(previousParty.PartyID), alignedcmd.PartyState{}); err != nil {
|
||
return err
|
||
}
|
||
if disbanded {
|
||
// Nobody survives to receive a roster refresh, so the removal also has to
|
||
// reach every character that was in the disbanded raid.
|
||
if hadPreviousRaid {
|
||
if err := s.broadcastRuntimeRaidObjectRemoved(previousRaid, sourceID); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
s.refreshRuntimePublicRaidDirectories("raid_disbanded")
|
||
s.logGameEvent(session, "game-raid-disbanded-on-leave",
|
||
"type", typ,
|
||
"raid_key", previousRaid.RaidKey,
|
||
"char_id", sourceID,
|
||
"was_leader", previousRaid.LeaderID == sourceID,
|
||
"member_count", len(previousRaid.Members))
|
||
// The raid row is gone; the cost summary's final window is published before
|
||
// the caller returns, so a disbanded raid still leaves one line behind.
|
||
s.publishRuntimeRaidCostSummary(previousRaid.RaidKey, "raid_disbanded_on_leave")
|
||
return nil
|
||
}
|
||
if state.LeaderID != previousRaid.LeaderID {
|
||
// The departing leader handed the raid to the next member in roster order;
|
||
// the survivors learn about it from the roster refresh below, and this line
|
||
// records who took over.
|
||
s.logGameEvent(session, "game-raid-leave-leadership-transferred",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"char_id", sourceID,
|
||
"previous_leader_id", previousRaid.LeaderID,
|
||
"new_leader_id", state.LeaderID,
|
||
"member_count", len(state.Members))
|
||
}
|
||
// The survivors' squad parties have to lose the leaver and renumber: the
|
||
// projection detaches the departed member from the party it created for their
|
||
// squad and rebuilds what is left.
|
||
if err := s.applyRuntimeRaidParties(state); err != nil {
|
||
s.logGameEvent(session, "game-raid-squad-party-projection-failed",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"stage", "leave_raid",
|
||
"error", err)
|
||
}
|
||
if err := s.broadcastRuntimeRaidRefresh(state); err != nil {
|
||
return err
|
||
}
|
||
return s.broadcastRuntimeRaidBuffRegistration(state, 0)
|
||
}
|
||
|
||
// handleOnlineKickRaidMember removes one member on the leader's behalf.
|
||
//
|
||
// Only the leader may name somebody else, and only a member that is still on the
|
||
// roster. The kicked character is released exactly like a leaver: it has to drop
|
||
// the raid object it materialized and its ordinary-party projection, or the raid
|
||
// window stays on screen listing a raid it is no longer part of. The leader keeps
|
||
// the raid and receives the op665 result for its own request.
|
||
func (s *Service) handleOnlineKickRaidMember(session *gameSession, typ uint16, targetID uint16) error {
|
||
leaderID := selectedCharacterID(session)
|
||
leaderRaid, inRaid := s.runtimeRaidForMember(leaderID)
|
||
if !inRaid || targetID == 0 || targetID == leaderID {
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
if leaderRaid.LeaderID != leaderID {
|
||
s.logGameEvent(session, "game-raid-kick-rejected",
|
||
"type", typ,
|
||
"raid_key", leaderRaid.RaidKey,
|
||
"leader_id", leaderID,
|
||
"target_id", targetID,
|
||
"reason", "requester_is_not_the_leader")
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
if runtimeRaidMemberIndex(leaderRaid, targetID) < 0 {
|
||
s.logGameEvent(session, "game-raid-kick-rejected",
|
||
"type", typ,
|
||
"raid_key", leaderRaid.RaidKey,
|
||
"leader_id", leaderID,
|
||
"target_id", targetID,
|
||
"reason", "target_is_not_a_member")
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
targetSession, targetOnline := s.onlineGameSession(targetID)
|
||
|
||
// The leader is still a member, so a kick can never disband the raid; the
|
||
// returned flag is deliberately not used to signal anything to the leader.
|
||
remaining, removed, _ := s.removeRuntimeRaidMember(targetID)
|
||
if !removed {
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
// The op665 result is receiver-relative: sub_1D04A00 closes the receiver's raid
|
||
// windows and tears down its raid projection whichever flag it carries. It is
|
||
// therefore the "you are out" notification and belongs to the KICKED member, not
|
||
// to the leader who asked. Sending it to the leader made the leader's own member
|
||
// list disappear and its browse window fall back to 参加攻坚队.
|
||
//
|
||
// The non-zero flag additionally pops string 46791 "已经强制退出攻坚队。" and
|
||
// runs the forced-leave cleanup, which is what distinguishes a kick from leaving.
|
||
//
|
||
// The leader learns about the removal from the roster refresh broadcast below.
|
||
if targetOnline && targetSession != nil {
|
||
if err := s.sendGameUpperRawClassCodec(targetSession, typ, raid.BuildForcedLeaveRaidResultBody(), dnfproto.DefaultChannelClassification, true); err != nil {
|
||
return err
|
||
}
|
||
if err := s.sendRuntimeRaidObjectRemoved(targetSession, leaderRaid.RaidKey); err != nil {
|
||
return err
|
||
}
|
||
previousParty := runtimePartyStateSnapshot(targetSession)
|
||
storeRuntimePartyState(targetSession, alignedcmd.PartyState{})
|
||
if err := s.sendRuntimePartyMemberRemoved(targetSession, uint16(previousParty.PartyID), alignedcmd.PartyState{}); err != nil {
|
||
return err
|
||
}
|
||
clearRuntimeRaidRejoinProjection(targetSession)
|
||
}
|
||
if err := s.applyRuntimeRaidParties(remaining); err != nil {
|
||
s.logGameEvent(session, "game-raid-squad-party-projection-failed",
|
||
"type", typ,
|
||
"raid_key", leaderRaid.RaidKey,
|
||
"stage", "kick_raid_member",
|
||
"error", err)
|
||
}
|
||
if err := s.broadcastRuntimeRaidRefresh(remaining); err != nil {
|
||
return err
|
||
}
|
||
if err := s.broadcastRuntimeRaidBuffRegistration(remaining, 0); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-member-kicked",
|
||
"type", typ,
|
||
"raid_key", leaderRaid.RaidKey,
|
||
"leader_id", leaderID,
|
||
"target_id", targetID,
|
||
"target_online", targetOnline,
|
||
"member_count", len(remaining.Members))
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) sendRuntimeSingleRaidEnded(session *gameSession, typ uint16, source string) error {
|
||
if err := s.sendGameUpperRawClassCodec(session, typ, raid.BuildLeaveRaidResultBody(true), dnfproto.DefaultChannelClassification, true); err != nil {
|
||
return err
|
||
}
|
||
clearRuntimeRaidRejoinProjection(session)
|
||
return s.clearSingleRaidOrdinaryPartyProjection(session, source)
|
||
}
|
||
|
||
// publishExpiredRuntimeRaid is the terminal notification for a phase that ran
|
||
// out of time. expireRuntimeRaidPhase has already removed the authoritative
|
||
// raid, so every frozen member must be released here: the attack party is gone
|
||
// server-side and a client that is never told keeps its raid window, its rejoin
|
||
// projection, and its ordinary-party projection forever.
|
||
//
|
||
// Attack parties are notified exactly like single raids. Both modes end on the
|
||
// same native op665 disband result, and a per-member socket failure must not
|
||
// stop the remaining members from being released - with a full twenty-member
|
||
// roster the old early return left most of the raid stranded.
|
||
// publishFailedRuntimeRaid announces the attack-window failure, in 86JP's order
|
||
// (RaidHandler.Runtime.cs RunAttackTimeoutAsync:646-711): the closed gate table,
|
||
// the phase-one failure symbol, a zeroed remaining time, the state-4 object, the
|
||
// per-member fail result, and finally the "you may leave the dungeon" op31 to every
|
||
// member still standing inside an Anton gate.
|
||
//
|
||
// This is not the release: the raid stays in the raid table for ten seconds so a
|
||
// client that reconnects can still read the result, and removeRuntimeRaidFailedPhase
|
||
// releases it afterwards.
|
||
func (s *Service) publishFailedRuntimeRaid(state runtimeRaidState) error {
|
||
if state.RaidKey == 0 || len(state.FrozenMembers) == 0 ||
|
||
state.Lifecycle.Status != runtimeRaidLifecycleFailed {
|
||
return errRuntimeRaidLifecycleState
|
||
}
|
||
phase := state.Lifecycle.Phase
|
||
wirePhase, err := runtimeRaidSettlementWirePhase(phase)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
_, dungeonBody, dungeonRows, err := runtimeRaidPhaseSnapshotBodies(state)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// The rows are already closed by expireRuntimeRaidPhase (state 2 everywhere);
|
||
// the full table travels once here so every client redraws the selector from the
|
||
// same closed state, and the local table is recorded so a later snapshot only
|
||
// sends what moves. The trailer is recorded with the rows because this table is
|
||
// the one that carries it: dungeonBody is the same runtimeRaidPhaseSnapshotBodies
|
||
// projection.
|
||
s.recordRuntimeRaidDungeonStateRows(state.RaidKey, phase, dungeonRows, runtimeRaidInfectionMapIDForWire(state))
|
||
notifications := []runtimeRaidWireNotification{
|
||
{msgID: raid.RaidDungeonStateMsgID, body: dungeonBody},
|
||
{msgID: raid.RaidRemainTimeMsgID, body: raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{TimerType: 0, Seconds: 0})},
|
||
{
|
||
msgID: raid.RaidStateMsgID,
|
||
body: raid.BuildRaidStateBody(raid.RaidState{
|
||
State: runtimeRaidFailedState,
|
||
Argument: runtimeRaidFailedStateArgument,
|
||
}),
|
||
},
|
||
}
|
||
if phase == runtimeRaidDungeonPhaseOne {
|
||
body, err := raid.BuildRaidSymbolBody([]raid.RaidSymbolRow{
|
||
{Key: antonSymbolPhaseOneFail, Value: 1},
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
notifications = append(notifications, runtimeRaidWireNotification{
|
||
msgID: raid.RaidSymbolMsgID,
|
||
body: body,
|
||
})
|
||
}
|
||
// 失败结算也带本阶段死亡次数,同样**所有团本都填**(服主 2026-09-29 拍板):
|
||
// 这个 u16 与阵营无关,依据见 raid_phase_death_count.go。
|
||
failureDeaths := s.runtimeRaidPhaseDeathCount(state.RaidKey, state.Lifecycle.Phase)
|
||
resultBody, err := raid.BuildRaidSettlementResultBody(raid.RaidSettlementResult{
|
||
ResultType: runtimeRaidFailResultType,
|
||
PhaseIndex: wirePhase,
|
||
ClearSeconds: runtimeRaidDurationSeconds(runtimeRaidPhaseElapsed(state, s.gameplayNow())),
|
||
DeathCount: failureDeaths,
|
||
// 86JP ships Score 0 and the trailing flag 1 with a failed result
|
||
// (RaidPacketBuilder.BuildRaidResult(1u, phaseIndex, seconds, deaths, 0u, 1)).
|
||
Score: 0,
|
||
Flags: 1,
|
||
})
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := s.broadcastRuntimeRaidWireSequence(state, notifications...); err != nil {
|
||
return err
|
||
}
|
||
source := "raid_phase_attack_timeout"
|
||
event := "game-raid-phase-timeout-published"
|
||
if state.TailFlag == 1 {
|
||
source = "single_raid_phase_attack_timeout"
|
||
event = "game-raid-single-phase-timeout-published"
|
||
}
|
||
var firstErr error
|
||
for _, member := range state.FrozenMembers {
|
||
session, online := s.onlineGameSession(member.CharID)
|
||
if !online {
|
||
continue
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidResultMsgID, resultBody, 0, true); err != nil {
|
||
if firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
s.logGameEvent(session, "game-raid-phase-timeout-result-failed",
|
||
"char_id", member.CharID, "raid_key", state.RaidKey, "raid_phase", phase, "error", err)
|
||
continue
|
||
}
|
||
s.logGameEvent(session, event,
|
||
"char_id", member.CharID,
|
||
"raid_key", state.RaidKey,
|
||
"raid_phase", phase,
|
||
"lifecycle_revision", state.Lifecycle.Revision,
|
||
"result_type", runtimeRaidFailResultType,
|
||
"source", source)
|
||
}
|
||
s.publishRuntimeRaidFailedGateReturn(state)
|
||
return firstErr
|
||
}
|
||
|
||
// publishRuntimeRaidFailedGateReturn tells every member still inside one of the
|
||
// failed phase's gates that it may leave it.
|
||
//
|
||
// It is 86JP's EnablePhaseOneDungeonReturnAsync (RaidHandler.Rewards.cs:1993-2013):
|
||
// class-0 op31 with the one-byte enable-clear body, sent only to members whose
|
||
// current run is an Anton dungeon. The current client registers exactly that pair
|
||
// as its settlement entry (see currentDungeonSettlementEntryMsgID), so without it a
|
||
// member that ignores the result screen has no way out of a dungeon the raid no
|
||
// longer owns.
|
||
func (s *Service) publishRuntimeRaidFailedGateReturn(state runtimeRaidState) {
|
||
for _, member := range state.FrozenMembers {
|
||
session, online := s.onlineGameSession(member.CharID)
|
||
if !online || session == nil {
|
||
continue
|
||
}
|
||
session.dungeon.mu.Lock()
|
||
runtime := session.dungeon.runtime
|
||
inside := runtime != nil && runtime.Session != nil &&
|
||
dungeonRuntimeOwnsCharacter(runtime, member.CharID) &&
|
||
isAntonRaidEntryDungeonID(runtime.Dungeon.ID)
|
||
session.dungeon.mu.Unlock()
|
||
if !inside {
|
||
continue
|
||
}
|
||
if err := s.sendGameUpperRawClass(session, currentDungeonSettlementEntryMsgID, []byte{0}, 0); err != nil {
|
||
s.logGameEvent(session, "game-raid-phase-timeout-gate-return-failed",
|
||
"char_id", member.CharID,
|
||
"raid_key", state.RaidKey,
|
||
"dungeon_id", runtime.Dungeon.ID,
|
||
"error", err)
|
||
continue
|
||
}
|
||
s.logGameEvent(session, "game-raid-phase-timeout-gate-return-enabled",
|
||
"char_id", member.CharID,
|
||
"raid_key", state.RaidKey,
|
||
"raid_phase", state.Lifecycle.Phase,
|
||
"dungeon_id", runtime.Dungeon.ID,
|
||
"msg_id", currentDungeonSettlementEntryMsgID)
|
||
}
|
||
}
|
||
|
||
// publishExpiredRuntimeRaid announces the release of a raid that is already gone
|
||
// from the raid table: it is the ten-second-later half of the timeout
|
||
// (removeRuntimeRaidFailedPhase) and the only packet a member still holding the
|
||
// terminal object needs.
|
||
func (s *Service) publishExpiredRuntimeRaid(state runtimeRaidState) error {
|
||
if state.RaidKey == 0 || len(state.FrozenMembers) == 0 ||
|
||
(state.Lifecycle.Status != runtimeRaidLifecycleFailed &&
|
||
state.Lifecycle.Status != runtimeRaidLifecycleAbandoned) {
|
||
return errRuntimeRaidLifecycleState
|
||
}
|
||
source := "raid_phase_expired"
|
||
event := "game-raid-phase-expired-published"
|
||
if state.TailFlag == 1 {
|
||
source = "single_raid_phase_expired"
|
||
event = "game-raid-single-phase-expired-published"
|
||
}
|
||
var firstErr error
|
||
for _, member := range state.FrozenMembers {
|
||
session, online := s.onlineGameSession(member.CharID)
|
||
if !online {
|
||
continue
|
||
}
|
||
storeRuntimeRaidEndedProjection(session, state.RaidKey)
|
||
if err := s.sendRuntimeSingleRaidEnded(session, uint16(dnfenum.CmdPacketLeaveRaid), source); err != nil {
|
||
if firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
s.logGameEvent(session, "game-raid-phase-expired-publish-failed",
|
||
"char_id", member.CharID,
|
||
"raid_key", state.RaidKey,
|
||
"raid_phase", state.Lifecycle.Phase,
|
||
"error", err)
|
||
continue
|
||
}
|
||
s.logGameEvent(session, event,
|
||
"char_id", member.CharID,
|
||
"raid_key", state.RaidKey,
|
||
"raid_phase", state.Lifecycle.Phase,
|
||
"lifecycle_revision", state.Lifecycle.Revision)
|
||
}
|
||
s.refreshRuntimePublicRaidDirectories(source)
|
||
return firstErr
|
||
}
|
||
|
||
// completedSingleRuntimeRaidForLeave is the post-settlement ownership proof
|
||
// for op665. Final reward commit deliberately removes the live Raid object, so
|
||
// a later client leave request must be checked against the committed completion
|
||
// record instead of a member index that no longer exists.
|
||
func (s *Service) completedSingleRuntimeRaidForLeave(characterID uint16, requestKey uint16) (runtimeRaidState, bool) {
|
||
if s == nil || characterID == 0 || requestKey == 0 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
s.raidRewardCommitMu.Lock()
|
||
defer s.raidRewardCommitMu.Unlock()
|
||
var matched runtimeRaidState
|
||
found := false
|
||
for _, completion := range s.raidRewardCompleted {
|
||
state := completion.Raid
|
||
if state.TailFlag != 1 || state.RaidKey == 0 || uint16(state.RaidKey) != requestKey ||
|
||
state.Lifecycle.Status != runtimeRaidLifecycleFinished {
|
||
continue
|
||
}
|
||
if _, member := runtimeRaidFrozenMember(state, characterID); !member {
|
||
continue
|
||
}
|
||
if found && matched.RaidKey != state.RaidKey {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
matched = cloneRuntimeRaidState(state)
|
||
found = true
|
||
}
|
||
return matched, found
|
||
}
|
||
|
||
// completedTeamRuntimeRaidForLeave is the team-raid half of the same proof.
|
||
//
|
||
// A team raid that finished phase two is released by finalizeRuntimeRaid
|
||
// (raid_lifecycle.go:2080-2099): the live raid row is deleted while every member's
|
||
// client still holds the raid object. The operator's rule (2026-09-20) is that
|
||
// clicking 退出攻坚队 there simply leaves the attack party -- "通关后点退出 就是直接
|
||
// 退出攻坚队了 不是什么已结束和静默消失" -- but before this the request matched no
|
||
// branch at all and was answered with the silent failure body {0,3}:
|
||
//
|
||
// 11:30:55 RECV type=665 body 0d 00 → SEND msg_id=665 hex …150000000003
|
||
// 11:31:00 / 11:31:02 / 11:31:52 / 11:32:17 / 11:35:11 / 11:35:13 / 11:47:03 / 11:47:06 …
|
||
//
|
||
// The request key is deliberately NOT used here: a self-leave carries the SENDER's
|
||
// own character id (measured above: char 13 sends 0d 00, char 10 sends 0a 00), while
|
||
// a team raid key's low u16 is the CREATOR's character id, so the key can only ever
|
||
// match for the creator. Membership in the finished raid's frozen roster is the proof.
|
||
func (s *Service) completedTeamRuntimeRaidForLeave(characterID uint16) (runtimeRaidState, bool) {
|
||
if s == nil || characterID == 0 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
s.raidRewardCommitMu.Lock()
|
||
defer s.raidRewardCommitMu.Unlock()
|
||
var matched runtimeRaidState
|
||
found := false
|
||
for _, completion := range s.raidRewardCompleted {
|
||
state := completion.Raid
|
||
if state.TailFlag == 1 || state.RaidKey == 0 ||
|
||
state.Lifecycle.Status != runtimeRaidLifecycleFinished {
|
||
continue
|
||
}
|
||
if _, member := runtimeRaidFrozenMember(state, characterID); !member {
|
||
continue
|
||
}
|
||
if found && matched.RaidKey != state.RaidKey {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
matched = cloneRuntimeRaidState(state)
|
||
found = true
|
||
}
|
||
return matched, found
|
||
}
|
||
|
||
// leaveFinishedTeamRuntimeRaid answers a leave request for a team raid the server has
|
||
// already released. It is the ordinary leave sequence of handleOnlineLeaveRaid
|
||
// (success ACK → drop the leaver's raid object → drop the party the raid projected),
|
||
// with no roster refresh: there is no live raid table left to refresh.
|
||
func (s *Service) leaveFinishedTeamRuntimeRaid(
|
||
session *gameSession,
|
||
typ uint16,
|
||
finished runtimeRaidState,
|
||
source string,
|
||
) error {
|
||
if session == nil || finished.RaidKey == 0 {
|
||
return nil
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(
|
||
session,
|
||
typ,
|
||
raid.BuildLeaveRaidResultBody(false),
|
||
dnfproto.DefaultChannelClassification,
|
||
true,
|
||
); err != nil {
|
||
return err
|
||
}
|
||
storeRuntimeRaidEndedProjection(session, finished.RaidKey)
|
||
// A stale rejoin projection would let a later op668 answer a rejoin window the
|
||
// player no longer has; the single-raid end path drops it for the same reason
|
||
// (sendRuntimeSingleRaidEnded).
|
||
clearRuntimeRaidRejoinProjection(session)
|
||
if err := s.sendRuntimeRaidObjectRemoved(session, finished.RaidKey); err != nil {
|
||
return err
|
||
}
|
||
previousParty := runtimePartyStateSnapshot(session)
|
||
storeRuntimePartyState(session, alignedcmd.PartyState{})
|
||
if err := s.sendRuntimePartyMemberRemoved(session, uint16(previousParty.PartyID), alignedcmd.PartyState{}); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-completed-team-leave-acknowledged",
|
||
"type", typ,
|
||
"char_id", session.selectedCharacterID,
|
||
"raid_key", finished.RaidKey,
|
||
"raid_phase", finished.Lifecycle.Phase,
|
||
"member_count", len(finished.FrozenMembers),
|
||
"party_id", previousParty.PartyID,
|
||
"source", source,
|
||
"reason", "finished_team_raid_released_the_live_row_but_the_client_still_held_its_object")
|
||
s.markFinishedRuntimeRaidLeaver(finished, session.selectedCharacterID)
|
||
return nil
|
||
}
|
||
|
||
// markFinishedRuntimeRaidLeaver records one departure from a raid the server already
|
||
// released and pushes the reduced roster to everyone still listed.
|
||
//
|
||
// Operator's report, 2026-09-20: "攻坚结束后 这个成员列表不实时刷新 1队的两个人已经退出
|
||
// 攻坚队了 还在攻坚队内的人看成员列表 显示这俩人还在攻坚队". Live raid 1507329:
|
||
//
|
||
// 13:21:29 game-raid-completed-team-leave-acknowledged char_id=1 member_count=4
|
||
// 13:21:36 game-raid-completed-team-leave-acknowledged char_id=21 member_count=4
|
||
//
|
||
// and no roster packet followed either time, because finalizeRuntimeRaid had already
|
||
// deleted the live row (raid_lifecycle.go:2097). The frozen completion record is the only
|
||
// roster left, so the reduced list is built from it minus everyone who has left so far.
|
||
func (s *Service) markFinishedRuntimeRaidLeaver(finished runtimeRaidState, leaverID uint16) {
|
||
if s == nil || finished.RaidKey == 0 || leaverID == 0 || len(finished.FrozenMembers) == 0 {
|
||
return
|
||
}
|
||
s.finishedRaidLeaverMu.Lock()
|
||
if s.finishedRaidLeavers == nil {
|
||
s.finishedRaidLeavers = make(map[uint32]map[uint16]bool)
|
||
}
|
||
gone := s.finishedRaidLeavers[finished.RaidKey]
|
||
if gone == nil {
|
||
gone = make(map[uint16]bool, len(finished.FrozenMembers))
|
||
s.finishedRaidLeavers[finished.RaidKey] = gone
|
||
}
|
||
gone[leaverID] = true
|
||
remaining := make([]runtimeRaidMemberState, 0, len(finished.FrozenMembers))
|
||
for _, member := range finished.FrozenMembers {
|
||
if !gone[member.CharID] {
|
||
remaining = append(remaining, member)
|
||
}
|
||
}
|
||
if len(remaining) == 0 {
|
||
delete(s.finishedRaidLeavers, finished.RaidKey)
|
||
}
|
||
s.finishedRaidLeaverMu.Unlock()
|
||
if len(remaining) == 0 {
|
||
// The last member left: nobody is left to draw a roster for.
|
||
return
|
||
}
|
||
state := cloneRuntimeRaidState(finished)
|
||
state.Members = remaining
|
||
state.FrozenMembers = append([]runtimeRaidMemberState(nil), remaining...)
|
||
if state.LeaderID == leaverID {
|
||
state.LeaderID = remaining[0].CharID
|
||
}
|
||
if err := s.broadcastRuntimeRaidRefresh(state); err != nil {
|
||
s.logPacketEvent("game-raid-finished-roster-refresh-failed",
|
||
"raid_key", finished.RaidKey,
|
||
"leaver_char_id", leaverID,
|
||
"error", err)
|
||
return
|
||
}
|
||
s.logPacketEvent("game-raid-finished-roster-refreshed",
|
||
"raid_key", finished.RaidKey,
|
||
"leaver_char_id", leaverID,
|
||
"remaining_members", len(remaining),
|
||
"reason", "a_member_left_a_released_raid_and_the_list_must_drop_him")
|
||
}
|
||
|
||
func (s *Service) handleOnlineStartRaid(session *gameSession, typ uint16, body []byte) error {
|
||
if err := raid.DecodeStartRaidRequest(body); err != nil {
|
||
s.logGameEvent(session, "game-raid-start-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
sourceID := selectedCharacterID(session)
|
||
ownerConfig, configErr := s.runtimeRaidOwnerConfigForSession(session)
|
||
config := ownerConfig.Raid
|
||
// Record the complete local state before any validation mutates a start
|
||
// reservation. This distinguishes a missing C2S 666 from a legitimate
|
||
// single/team roster rejection during client testing.
|
||
if current, ok := s.runtimeRaidForMember(sourceID); ok {
|
||
minimumMembers, maximumMembers := runtimeRaidStartRosterBounds(current, config)
|
||
entryItemID, entryCount := runtimeRaidEntryCostForState(current)
|
||
s.logGameEvent(session, "game-raid-start-received",
|
||
"type", typ,
|
||
"body_len", len(body),
|
||
"source_char_id", sourceID,
|
||
"raid_key", current.RaidKey,
|
||
"route_or_raid_type", current.RouteOrRaidType,
|
||
"tail_flag", current.TailFlag,
|
||
"single_raid_type", current.SingleRaidType,
|
||
"member_count", len(current.Members),
|
||
"minimum_members", minimumMembers,
|
||
"maximum_members", maximumMembers,
|
||
"entry_item_id", entryItemID,
|
||
"entry_count", entryCount)
|
||
} else {
|
||
s.logGameEvent(session, "game-raid-start-received-without-state",
|
||
"type", typ, "body_len", len(body), "source_char_id", sourceID)
|
||
}
|
||
if configErr != nil {
|
||
s.logGameEvent(session, "game-raid-start-pvf-rejected", "type", typ, "source_char_id", sourceID, "error", configErr)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
// 创建侧那道「卢克只允许单人」的子句跟着一起撤了:卢克两种模式现在都有自己的阶段图。
|
||
// (上游 bb52082)
|
||
if current, ok := s.runtimeRaidForMember(sourceID); !ok || (current.RaidMode != 0 && current.RaidMode != ownerConfig.Mode) {
|
||
s.logGameEvent(session, "game-raid-start-owner-rejected",
|
||
"type", typ,
|
||
"source_char_id", sourceID,
|
||
"channel_raid_mode", ownerConfig.Mode,
|
||
"state_found", ok,
|
||
"state_raid_mode", current.RaidMode,
|
||
"tail_flag", current.TailFlag)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
// Second gate for the same defect the create path now refuses: a Fiend War
|
||
// raid object that still carries TailFlag=1 (an older save, or one created
|
||
// before the create-side refusal landed) must not be allowed to reserve,
|
||
// debit and then fail to commit. Refuse it here, while the raid is untouched.
|
||
if existing, ok := s.runtimeRaidForMember(sourceID); ok &&
|
||
!runtimeRaidOwnerSupportsSingleRaid(existing.RaidMode) &&
|
||
runtimeRaidSingleRequested(existing.TailFlag) {
|
||
s.logGameEvent(session, "game-raid-start-single-unsupported",
|
||
"type", typ,
|
||
"source_char_id", sourceID,
|
||
"raid_key", existing.RaidKey,
|
||
"owner_mode", existing.RaidMode,
|
||
"tail_flag", existing.TailFlag,
|
||
"started", existing.Started,
|
||
"reason", "owner_has_no_single_raid_mode")
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
reservation, err := s.reserveRuntimeRaidStart(sourceID)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-start-rejected",
|
||
"type", typ,
|
||
"source_char_id", sourceID,
|
||
"error", err)
|
||
if sendErr := s.sendRaidCommandFailure(session, typ); sendErr != nil {
|
||
return sendErr
|
||
}
|
||
if reservation.State.RaidKey != 0 {
|
||
return s.sendRuntimeRaidEntryCostSnapshot(session, reservation.State)
|
||
}
|
||
return nil
|
||
}
|
||
state := reservation.State
|
||
// C2S 666 with an empty body is also the client's NEXT-PHASE start request:
|
||
// 86JP RaidHandler.HandleStartRaid dispatches an empty START_RAID whose raid
|
||
// sits in state 5 (the between-phase standby UI) to
|
||
// HandleStartNextRaidPhaseAsync -> TryPrepareNextPhase(leader) -> start phase
|
||
// two, and its break timer only auto-starts the phase when nobody asks first.
|
||
// Treating it as an idempotent re-start of phase one left the raid parked in
|
||
// standby: live report 2026-09-16 20:30Z, the leader pressed 开始 three times
|
||
// and every answer was a standby-timer replay with lifecycle_status=5.
|
||
if reservation.AlreadyStarted && len(body) == 0 &&
|
||
state.Lifecycle.Status == runtimeRaidLifecycleStandby &&
|
||
state.Lifecycle.Phase == runtimeRaidDungeonPhaseOne {
|
||
if state.LeaderID != sourceID {
|
||
s.logGameEvent(session, "game-raid-next-phase-rejected",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"source_char_id", sourceID,
|
||
"leader_id", state.LeaderID,
|
||
"reason", "only_the_raid_leader_can_start_the_next_phase")
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
// The next-phase start activates a phase on the same clients as the
|
||
// first one, so it carries the same waiting-area gate: a member pulled
|
||
// into the activation burst from outside the waiting area is the storm in
|
||
// raid_start_waiting_area.go, regardless of which phase is starting.
|
||
if spec, specOK := runtimeRaidTownSpecForOwnerMode(ownerConfig.Mode); specOK &&
|
||
isCurrentImplementedRaidChannel(session) {
|
||
if outside := s.runtimeRaidMembersOutsideWaitingArea(state, spec); len(outside) != 0 {
|
||
return s.rejectRuntimeRaidStartOutsideWaitingArea(
|
||
session, typ, reservation, state, spec, outside, "next_phase_start")
|
||
}
|
||
}
|
||
activated, activateErr := s.activateRuntimeRaidPhase(
|
||
state.RaidKey,
|
||
runtimeRaidDungeonPhaseTwo,
|
||
time.Now().UTC(),
|
||
true,
|
||
)
|
||
if activateErr != nil {
|
||
s.logGameEvent(session, "game-raid-next-phase-rejected",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"source_char_id", sourceID,
|
||
"lifecycle_status", byte(state.Lifecycle.Status),
|
||
"lifecycle_phase", state.Lifecycle.Phase,
|
||
"error", activateErr)
|
||
return s.sendRaidCommandFailure(session, typ)
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, typ, []byte{1}, dnfproto.DefaultChannelClassification, true); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-next-phase-started",
|
||
"type", typ,
|
||
"raid_key", activated.RaidKey,
|
||
"raid_phase", activated.Lifecycle.Phase,
|
||
"source_char_id", sourceID,
|
||
"reason", "leader_start_command_during_between_phase_standby")
|
||
return nil
|
||
}
|
||
if !reservation.AlreadyStarted {
|
||
minimumMembers, maximumMembers := runtimeRaidStartRosterBounds(state, config)
|
||
if len(state.FrozenMembers) < minimumMembers || len(state.FrozenMembers) > maximumMembers {
|
||
// This rejection used to be silent, which made a two-member roster look
|
||
// like the client had ignored the click.
|
||
s.logGameEvent(session, "game-raid-start-roster-rejected",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"member_count", len(state.FrozenMembers),
|
||
"minimum_members", minimumMembers,
|
||
"maximum_members", maximumMembers,
|
||
"reason", "roster_out_of_bounds")
|
||
s.cancelRuntimeRaidStart(reservation)
|
||
return s.sendRaidStartFailure(session, typ, runtimeRaidStartFailureMinimumRoster, nil)
|
||
}
|
||
if err := s.validateRuntimeRaidRosterReady(session, state.FrozenMembers); err != nil {
|
||
s.logGameEvent(session, "game-raid-start-roster-rejected",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"member_count", len(state.FrozenMembers),
|
||
"reason", "roster_not_standing_by",
|
||
"error", err)
|
||
s.cancelRuntimeRaidStart(reservation)
|
||
if sendErr := s.sendRaidCommandFailure(session, typ); sendErr != nil {
|
||
return sendErr
|
||
}
|
||
return nil
|
||
}
|
||
// Every member has to be standing in the raid's waiting area before the
|
||
// phase is activated. This is a deliberate extra gate with no 86JP
|
||
// counterpart; raid_start_waiting_area.go records the measured client
|
||
// storm that it exists to prevent and why the channel alone is not the
|
||
// test. It runs before the entry ticket is consumed, so a refused start
|
||
// leaves the raid exactly as it was.
|
||
if spec, specOK := runtimeRaidTownSpecForOwnerMode(ownerConfig.Mode); specOK &&
|
||
isCurrentImplementedRaidChannel(session) {
|
||
if outside := s.runtimeRaidMembersOutsideWaitingArea(state, spec); len(outside) != 0 {
|
||
return s.rejectRuntimeRaidStartOutsideWaitingArea(
|
||
session, typ, reservation, state, spec, outside, "initial_start")
|
||
}
|
||
}
|
||
state.AntonConfig = nil
|
||
state.LukeConfig = nil
|
||
state.FiendWarConfig = nil
|
||
if ownerConfig.Mode == runtimeRaidOwnerLuke {
|
||
state.LukeConfig = ownerConfig.Luke
|
||
} else {
|
||
state.AntonConfig = config
|
||
}
|
||
// 超时空额外的 owner 配置。安徒恩/卢克走到这里 FiendWarConfig 仍是 nil,
|
||
// 与改动前逐字节一致;它们的分支一个都没有变。
|
||
if ownerConfig.Mode == runtimeRaidOwnerFiendWar {
|
||
state.FiendWarConfig = ownerConfig.FiendWar
|
||
}
|
||
startTime := time.Now().UTC()
|
||
counterCtx, counterCancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
counterBatch, err := s.reserveRuntimeRaidAdmissionCounters(
|
||
counterCtx, state, ownerConfig.Mode, startTime)
|
||
counterCancel()
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-start-admission-counter-rejected",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"raid_mode", ownerConfig.Mode,
|
||
"error", err)
|
||
s.cancelRuntimeRaidStart(reservation)
|
||
if sendErr := s.sendRaidStartFailure(session, typ, runtimeRaidStartFailureDailyLimit, nil); sendErr != nil {
|
||
return sendErr
|
||
}
|
||
return s.sendRuntimeRaidEntryCostSnapshot(session, state)
|
||
}
|
||
entryCostBatch, err := s.consumeRuntimeRaidEntryCostBatch(state)
|
||
if err != nil {
|
||
if restoreErr := s.restoreCurrentDungeonAdmissionCounterBatch(counterBatch); restoreErr != nil {
|
||
err = errors.Join(err, restoreErr)
|
||
}
|
||
missing := s.runtimeRaidMembersMissingEntryCost(state)
|
||
s.logGameEvent(session, "game-raid-start-entry-cost-rejected",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"item_id", config.EntryCost.ItemID,
|
||
"count", config.EntryCost.Count,
|
||
"missing_members", formatRuntimeRaidMemberIDs(missing),
|
||
"error", err)
|
||
s.cancelRuntimeRaidStart(reservation)
|
||
if sendErr := s.sendRaidStartFailure(session, typ, runtimeRaidStartFailureEntryCost, missing); sendErr != nil {
|
||
return sendErr
|
||
}
|
||
return s.sendRuntimeRaidEntryCostSnapshot(session, state)
|
||
}
|
||
var committed bool
|
||
state, committed = s.commitRuntimeRaidStartWithOwner(reservation, entryCostBatch.updates, ownerConfig, startTime)
|
||
if !committed {
|
||
s.cancelRuntimeRaidStart(reservation)
|
||
commitErr := fmt.Errorf("commit runtime raid start after durable entry debit: raid=%d attempt=%d", reservation.State.RaidKey, reservation.Attempt)
|
||
if restoreErr := s.restoreRuntimeRaidEntryCostBatch(entryCostBatch); restoreErr != nil {
|
||
commitErr = errors.Join(commitErr, restoreErr)
|
||
}
|
||
if restoreErr := s.restoreCurrentDungeonAdmissionCounterBatch(counterBatch); restoreErr != nil {
|
||
commitErr = errors.Join(commitErr, restoreErr)
|
||
}
|
||
return commitErr
|
||
}
|
||
}
|
||
// The repository transaction above is the durable boundary. The current
|
||
// client must observe the successful command ACK before any op14 inventory
|
||
// projection of that committed debit.
|
||
if err := s.sendGameUpperRawClassCodec(session, typ, []byte{1}, dnfproto.DefaultChannelClassification, true); err != nil {
|
||
return err
|
||
}
|
||
if err := s.flushRuntimeRaidEntryCostUpdates(state.RaidKey); err != nil {
|
||
s.logGameEvent(session, "game-raid-start-post-ack-failed", "stage", "entry_cost_flush", "raid_key", state.RaidKey, "error", err)
|
||
return err
|
||
}
|
||
if err := s.broadcastRuntimeRaidRefresh(state); err != nil {
|
||
s.logGameEvent(session, "game-raid-start-post-ack-failed", "stage", "member_refresh", "raid_key", state.RaidKey, "error", err)
|
||
return err
|
||
}
|
||
if !reservation.AlreadyStarted {
|
||
// The current EXE materializes the native 3,2,1 preparation stage only
|
||
// after its Raid Buff registration model exists. Publish op633 before
|
||
// state=1/op596/op597 rather than waiting for the later client query.
|
||
if err := s.broadcastRuntimeRaidBuffRegistration(state, 0); err != nil {
|
||
s.logGameEvent(session, "game-raid-start-post-ack-failed", "stage", "buff_registration", "raid_key", state.RaidKey, "error", err)
|
||
return err
|
||
}
|
||
if err := s.applyRuntimeRaidParties(state); err != nil {
|
||
s.logGameEvent(session, "game-raid-start-post-ack-failed", "stage", "party_apply", "raid_key", state.RaidKey, "error", err)
|
||
return err
|
||
}
|
||
}
|
||
configSingleMode := false
|
||
configSinglePhaseDungeons := 0
|
||
configSingleTimeLimit := 0
|
||
configStartDelay := 0
|
||
if stateConfig := runtimeRaidBaseConfig(state); stateConfig != nil {
|
||
configStartDelay = stateConfig.StartDelaySeconds
|
||
if stateConfig.SingleMode != nil {
|
||
configSingleMode = true
|
||
configSinglePhaseDungeons = len(stateConfig.SingleMode.Phase.Dungeons)
|
||
configSingleTimeLimit = stateConfig.SingleMode.TimeLimitSeconds
|
||
}
|
||
}
|
||
s.logGameEvent(session, "game-raid-start-lifecycle",
|
||
"raid_key", state.RaidKey,
|
||
"raid_mode", state.RaidMode,
|
||
"tail_flag", state.TailFlag,
|
||
"single_raid_type", state.SingleRaidType,
|
||
"lifecycle_status", byte(state.Lifecycle.Status),
|
||
"lifecycle_phase", state.Lifecycle.Phase,
|
||
"started_at", state.Lifecycle.StartedAt,
|
||
"ready_deadline", state.Lifecycle.ReadyDeadline,
|
||
"phase_deadline", state.Lifecycle.PhaseDeadline,
|
||
"config_start_delay_seconds", configStartDelay,
|
||
"config_single_mode", configSingleMode,
|
||
"config_single_phase_dungeons", configSinglePhaseDungeons,
|
||
"config_single_time_limit_seconds", configSingleTimeLimit)
|
||
switch state.Lifecycle.Status {
|
||
case runtimeRaidLifecyclePreparing:
|
||
if err := s.publishRuntimeRaidReady(state); err != nil {
|
||
s.logGameEvent(session, "game-raid-start-post-ack-failed", "stage", "publish_ready", "raid_key", state.RaidKey, "error", err)
|
||
return err
|
||
}
|
||
case runtimeRaidLifecycleStandby:
|
||
// The break countdown is read from 0x0255 timerType 1 while the raid object
|
||
// state is 5, so a standby replay has to carry both packets (see
|
||
// publishRuntimeRaidStandbyTimerLocked).
|
||
standbyTimer := runtimeRaidStandbyTimer(state)
|
||
if err := s.broadcastRuntimeRaidWireSequence(state,
|
||
runtimeRaidWireNotification{
|
||
msgID: raid.RaidTimerMsgID,
|
||
body: raid.BuildRaidTimerBody(standbyTimer),
|
||
},
|
||
runtimeRaidWireNotification{
|
||
msgID: raid.RaidRemainTimeMsgID,
|
||
body: raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{
|
||
TimerType: 1,
|
||
Seconds: runtimeRaidStandbyRemainingSeconds(standbyTimer, s.gameplayNow()),
|
||
}),
|
||
},
|
||
); err != nil {
|
||
s.logGameEvent(session, "game-raid-start-post-ack-failed", "stage", "publish_standby", "raid_key", state.RaidKey, "error", err)
|
||
return err
|
||
}
|
||
case runtimeRaidLifecyclePhaseActive:
|
||
if err := s.broadcastRuntimeRaidPhaseSnapshot(state, false, time.Now().UTC()); err != nil {
|
||
s.logGameEvent(session, "game-raid-start-post-ack-failed", "stage", "publish_active", "raid_key", state.RaidKey, "error", err)
|
||
return err
|
||
}
|
||
}
|
||
// The admission counters charged above ARE what the entry window prints as
|
||
// 帐号日入场数 / 帐号周入场场数 / 角色周入场次数, and the client only re-renders them
|
||
// when a packet arrives -- it never queries them. Without this push every member
|
||
// keeps reading the pre-charge numbers until they relog (live 2026-09-30: start
|
||
// Anton, leave the party, reopen the entry window, still read 3/3 and 2/2).
|
||
//
|
||
// Deliberately LAST: the handshake above (ACK, op14, mode3 roster, op633, party
|
||
// frames, ready timer) is pinned packet-for-packet by
|
||
// TestRuntimeRaidStartFreezesOnlineRosterAndOrdersAckBeforeCostSync, so the
|
||
// counters ride the tail instead of joining it. Non-fatal by construction.
|
||
s.broadcastCurrentRaidEntryCounters(state, "raid_start_after_admission_charge")
|
||
s.logGameEvent(session, "game-raid-started",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"member_count", len(state.FrozenMembers),
|
||
"idempotent_replay", reservation.AlreadyStarted)
|
||
return nil
|
||
}
|
||
|
||
// runtimeRaidTeamStartMinimumMembers is the server-side floor for a team raid.
|
||
//
|
||
// etc/raid/anton.etc says 10 ("raid start minimum member"), but the current client
|
||
// enforces its own floor of 2 -- clicking start with a lone member pops "需要2人以上"
|
||
// and sends nothing -- so a roster the client happily submits (the live log shows
|
||
// C2S 666 arriving with member_count=2) was being rejected here and the client
|
||
// silently swallowed the failure. The floor matches the client's own number; the
|
||
// parsed PVF value stays 10 and TestRealScriptPVFAntonConfigMatchesCurrentTwoPhaseCatalog
|
||
// still asserts it.
|
||
const runtimeRaidTeamStartMinimumMembers = 2
|
||
|
||
func runtimeRaidStartRosterBounds(state runtimeRaidState, config *raid.AntonConfig) (int, int) {
|
||
if config == nil {
|
||
return 1, 0
|
||
}
|
||
if state.TailFlag == 1 {
|
||
return config.SingleStartMinimumMembers, 1
|
||
}
|
||
minimum := config.StartMinimumMembers
|
||
if minimum > runtimeRaidTeamStartMinimumMembers {
|
||
minimum = runtimeRaidTeamStartMinimumMembers
|
||
}
|
||
return minimum, config.MemberMax
|
||
}
|
||
|
||
// validateRuntimeRaidRosterReady is the "everybody is standing by, not off
|
||
// somewhere else" precondition of a start.
|
||
//
|
||
// Each frozen member's own session has to be on the raid channel and outside a
|
||
// dungeon run. A member still inside a sub-dungeon, or back on an ordinary
|
||
// channel, cannot reach the raid portal, and the roster must not start without
|
||
// them -- the official server refuses the start for exactly that reason.
|
||
//
|
||
// The town presence registry is deliberately not consulted here: the raid town
|
||
// suppresses presence for some transitions (the live log shows
|
||
// game-town-presence-suppressed reason=private_seria_room for a member standing in
|
||
// the raid town gate), so requiring a presence row would reject rosters that are
|
||
// perfectly fine. The member's town/area is reported in the diagnostic instead.
|
||
func (s *Service) validateRuntimeRaidRosterReady(session *gameSession, members []runtimeRaidMemberState) error {
|
||
if s == nil || len(members) == 0 {
|
||
return errRuntimeRaidNotMember
|
||
}
|
||
if !isCurrentImplementedRaidChannel(session) {
|
||
return nil
|
||
}
|
||
for _, member := range members {
|
||
identity, online := s.onlineGameSessionCharacterSnapshot(member.CharID)
|
||
if !online || identity.session == nil {
|
||
return fmt.Errorf("%w: frozen member %d is offline", errRuntimeRaidLifecycleState, member.CharID)
|
||
}
|
||
memberSession := identity.session
|
||
if !isCurrentImplementedRaidChannel(memberSession) {
|
||
return fmt.Errorf("%w: frozen member %d left the raid channel (channel=%d type=%d)",
|
||
errRuntimeRaidLifecycleState, member.CharID, memberSession.channel.ID, memberSession.channel.Type)
|
||
}
|
||
memberSession.dungeon.mu.Lock()
|
||
inDungeon := memberSession.dungeon.runtime != nil
|
||
memberSession.dungeon.mu.Unlock()
|
||
if inDungeon {
|
||
return fmt.Errorf("%w: frozen member %d is still inside a dungeon",
|
||
errRuntimeRaidLifecycleState, member.CharID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) handleOnlineRejoinRaid(session *gameSession, typ uint16, body []byte) error {
|
||
identity, identityOK := s.boundGameSessionCharacterSnapshot(session)
|
||
if !identityOK {
|
||
return nil
|
||
}
|
||
req, err := raid.DecodeRejoinRaidRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-rejoin-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
return normalizeRuntimeRaidRejoinError(s.sendRuntimeRaidRejoinPacket(
|
||
identity,
|
||
typ,
|
||
[]byte{0, runtimeRaidFailureCode},
|
||
dnfproto.DefaultChannelClassification,
|
||
))
|
||
}
|
||
state, added, ok := s.rejoinRuntimeRaidAdmission(req.RaidKey, identity.character)
|
||
if !ok {
|
||
return normalizeRuntimeRaidRejoinError(s.sendRuntimeRaidRejoinPacket(
|
||
identity,
|
||
typ,
|
||
[]byte{0, runtimeRaidFailureCode},
|
||
dnfproto.DefaultChannelClassification,
|
||
))
|
||
}
|
||
if err := s.sendRuntimeRaidRejoinPacket(
|
||
identity,
|
||
typ,
|
||
raid.BuildRaidCommandSuccessBody(),
|
||
dnfproto.DefaultChannelClassification,
|
||
); err != nil {
|
||
return normalizeRuntimeRaidRejoinError(err)
|
||
}
|
||
if added {
|
||
if err := s.broadcastRuntimeRaidRefreshExcept(state, identity.character); err != nil {
|
||
return err
|
||
}
|
||
if err := s.broadcastRuntimeRaidBuffRegistration(state, identity.character); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return normalizeRuntimeRaidRejoinError(s.sendRuntimeRaidRejoinRecovery(identity, state, s.runtimeRaidRejoinNow()))
|
||
}
|
||
|
||
func (s *Service) handleOnlineRaidManagerWork(session *gameSession, typ uint16, body []byte) error {
|
||
req, err := raid.DecodeManagerWorkRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-manager-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
return nil
|
||
}
|
||
sourceID := selectedCharacterID(session)
|
||
// The first u32 selects the leader operation: 0 moves a member between
|
||
// subgroups, 1 hands leadership to the named member. Treating every request
|
||
// as a subgroup move used to turn a leader transfer into a bogus regroup.
|
||
switch req.ActionOrMode {
|
||
case runtimeRaidManagerWorkAssignGroup:
|
||
state, ok := s.moveRuntimeRaidMember(sourceID, uint16(req.MemberCharKey), byte(req.TargetGroup))
|
||
if !ok {
|
||
// Log why the gate refused. "The leader drags a member and neither
|
||
// side reacts" has two very different causes -- the value was out of
|
||
// range, or the roster was frozen -- and only this line separates
|
||
// them without a debugger.
|
||
reason, editable, status, occupancy, leader := s.describeRuntimeRaidRegroupRefusal(sourceID)
|
||
s.logGameEvent(session, "game-raid-manager-rejected",
|
||
"type", typ,
|
||
"operation", req.ActionOrMode,
|
||
"source_char_id", sourceID,
|
||
"source_scene", s.runtimeRaidSceneLabel(sourceID),
|
||
"member_char_id", req.MemberCharKey,
|
||
"member_scene", s.runtimeRaidSceneLabel(uint16(req.MemberCharKey)),
|
||
"target_group", req.TargetGroup,
|
||
"max_groups", runtimeRaidMaxGroups,
|
||
"reason", reason,
|
||
"groups_editable", editable,
|
||
"lifecycle_status", status,
|
||
"group_occupancy", occupancy,
|
||
"sender_is_leader", leader)
|
||
return nil
|
||
}
|
||
s.logGameEvent(session, "game-raid-manager-regrouped",
|
||
"type", typ,
|
||
"operation", req.ActionOrMode,
|
||
"raid_key", state.RaidKey,
|
||
"source_char_id", sourceID,
|
||
"source_scene", s.runtimeRaidSceneLabel(sourceID),
|
||
"member_char_id", req.MemberCharKey,
|
||
"member_scene", s.runtimeRaidSceneLabel(uint16(req.MemberCharKey)),
|
||
"target_group", req.TargetGroup,
|
||
"groups_editable", runtimeRaidGroupsEditable(state),
|
||
"group_occupancy", len(state.Lifecycle.GroupOccupancy),
|
||
"started", state.Started)
|
||
// The regroup is authoritative for the ordinary parties too once the raid
|
||
// has started: 86JP drives a live reassignment through
|
||
// ReassignLiveRaidMember, which retires the old party and forms the new
|
||
// one (PartyManager.cs:210-326). Before the ready countdown this stays a
|
||
// no-op on purpose -- see the gate inside applyRuntimeRaidParties.
|
||
if err := s.applyRuntimeRaidParties(state); err != nil {
|
||
s.logGameEvent(session, "game-raid-squad-party-projection-failed",
|
||
"type", typ,
|
||
"raid_key", state.RaidKey,
|
||
"stage", "assign_group",
|
||
"error", err)
|
||
}
|
||
// MCP/IDA evidence says S2C 669 is DoNothing, and the current client
|
||
// materializes the attack-party panel from the mode=0 raid object
|
||
// (raid_phase_runtime.go:158-162), not from the mode=3 roster. Pushing
|
||
// only mode=3 left the leader's own panel on its previous snapshot --
|
||
// live 2026-09-17T09:40:22.878Z the leader's last mode=0 was 09:40:18.270
|
||
// with character 22 still in squad 1 (single/standby), while the member's
|
||
// window already read the new mode=3 row. Publish both, exactly like
|
||
// runtimeRaidManagerWorkTransferLeader just below.
|
||
if err := s.broadcastRuntimeRaidCreate(state); err != nil {
|
||
return err
|
||
}
|
||
if err := s.broadcastRuntimeRaidRefresh(state); err != nil {
|
||
return err
|
||
}
|
||
return s.broadcastRuntimeRaidBuffRegistration(state, 0)
|
||
case runtimeRaidManagerWorkTransferLeader:
|
||
// Validate the argument before touching state: the transfer commits
|
||
// under the raid lock and cannot be rolled back by a later check.
|
||
var state runtimeRaidState
|
||
ok := req.TargetGroup == 0
|
||
if ok {
|
||
state, ok = s.transferRuntimeRaidLeader(sourceID, uint16(req.MemberCharKey))
|
||
}
|
||
if !ok {
|
||
s.logGameEvent(session, "game-raid-manager-rejected",
|
||
"type", typ,
|
||
"operation", req.ActionOrMode,
|
||
"source_char_id", sourceID,
|
||
"member_char_id", req.MemberCharKey,
|
||
"argument", req.TargetGroup)
|
||
return nil
|
||
}
|
||
// The mode=0 object carries the leader record; the mode=3 roster carries
|
||
// the per-row leader flag. Publish both so every window agrees.
|
||
if err := s.broadcastRuntimeRaidCreate(state); err != nil {
|
||
return err
|
||
}
|
||
s.logGameEvent(session, "game-raid-leader-transferred",
|
||
"raid_key", state.RaidKey,
|
||
"old_leader_id", sourceID,
|
||
"new_leader_id", state.LeaderID)
|
||
return s.broadcastRuntimeRaidRefresh(state)
|
||
default:
|
||
s.logGameEvent(session, "game-raid-manager-rejected",
|
||
"type", typ,
|
||
"operation", req.ActionOrMode,
|
||
"source_char_id", sourceID,
|
||
"reason", "unknown_operation")
|
||
return nil
|
||
}
|
||
}
|
||
|
||
const (
|
||
runtimeRaidManagerWorkAssignGroup uint32 = 0
|
||
runtimeRaidManagerWorkTransferLeader uint32 = 1
|
||
)
|
||
|
||
// transferRuntimeRaidLeader hands leadership to a live member before the
|
||
// attack starts. A member inside its disconnect grace window cannot lead: the
|
||
// raid would have nobody able to start it.
|
||
func (s *Service) transferRuntimeRaidLeader(sourceID uint16, targetID uint16) (runtimeRaidState, bool) {
|
||
if sourceID == 0 || targetID == 0 || sourceID == targetID {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.runtimeRaidByMemberLocked(sourceID)
|
||
if state == nil || state.LeaderID != sourceID || state.StartPending || state.Started || state.TailFlag == 1 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
if runtimeRaidMemberIndex(*state, targetID) < 0 || s.runtimeRaidMemberDisconnectedLocked(*state, targetID) {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
if _, online := s.onlineGameSession(targetID); !online {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
state.LeaderID = targetID
|
||
return cloneRuntimeRaidState(*state), true
|
||
}
|
||
|
||
func (s *Service) handleOnlineRaidMemberChangeState(session *gameSession, typ uint16, body []byte) error {
|
||
req, err := raid.DecodeMemberChangeStateRequest(body)
|
||
if err != nil {
|
||
s.logGameEvent(session, "game-raid-member-state-body-invalid", "type", typ, "body_len", len(body), "error", err)
|
||
return nil
|
||
}
|
||
sourceID := selectedCharacterID(session)
|
||
state, ok := s.changeRuntimeRaidMemberState(sourceID, normalizeRuntimeRaidMemberState(req.State))
|
||
if !ok {
|
||
s.logGameEvent(session, "game-raid-member-state-ignored",
|
||
"type", typ,
|
||
"source_char_id", sourceID,
|
||
"state", req.State)
|
||
return nil
|
||
}
|
||
// MCP/IDA 证据显示 823 是客户端成员状态变化;可见同步走 0x24F 成员名单刷新。
|
||
return s.broadcastRuntimeRaidRefresh(state)
|
||
}
|
||
|
||
func normalizeRuntimeRaidMemberState(state byte) byte {
|
||
if state == 0 {
|
||
return 0
|
||
}
|
||
return 1
|
||
}
|
||
|
||
func (s *Service) createRuntimeRaid(session *gameSession, sourceID uint16, req raid.InfoRequest) (runtimeRaidState, bool) {
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
s.ensureRuntimeRaidMapsLocked()
|
||
if existing := s.runtimeRaidByMemberLocked(sourceID); existing != nil {
|
||
ownerMode := runtimeRaidModeForSession(session)
|
||
if existing.LeaderID != sourceID ||
|
||
(existing.RaidMode != 0 && existing.RaidMode != ownerMode) ||
|
||
existing.StartPending || existing.Started || existing.EntryCostPaid ||
|
||
existing.RouteOrRaidType != req.RouteOrRaidType || !bytes.Equal(existing.NameBytes, req.NameBytes) ||
|
||
existing.TailFlag != req.TailFlag {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
// Replayed CREATE_RAID is idempotent only when it describes the exact
|
||
// server-owned object. A reconnecting client can reopen the mode selector
|
||
// and submit a different single/normal choice before it has received the
|
||
// rejoin snapshot; never replace the unfinished raid or its progress.
|
||
return cloneRuntimeRaidState(*existing), true
|
||
}
|
||
members := s.initialRuntimeRaidMembersLocked(session, sourceID)
|
||
if req.TailFlag == 1 && len(members) > 1 {
|
||
members = members[:1]
|
||
}
|
||
// The leader opens the raid in squad 1; everybody else joins in standby.
|
||
//
|
||
// Live 2026-09-17 22:0x, the attack-party status window: the leader's own row is
|
||
// expected to carry 1队 while the members it invited are 单人 until it assigns
|
||
// them (the client reads the roster byte straight and renders 0 as 单人). The
|
||
// members the leader is already partied with are left in standby too: an
|
||
// ordinary party is projected into one squad by
|
||
// syncRuntimeRaidSquadsWithOrdinaryParty, and the 编制 is the leader's to hand
|
||
// out until that happens.
|
||
if len(members) > 0 {
|
||
members[0].GroupIndex = runtimeRaidDefaultGroup
|
||
members[0].SlotOrder = 0
|
||
}
|
||
for _, member := range members {
|
||
if s.runtimeRaidByMemberLocked(member.CharID) != nil {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
}
|
||
key := s.nextRuntimeRaidKeyLocked(session, sourceID)
|
||
state := runtimeRaidState{
|
||
RaidKey: key,
|
||
LeaderID: sourceID,
|
||
RaidMode: runtimeRaidModeForSession(session),
|
||
RouteOrRaidType: req.RouteOrRaidType,
|
||
NameBytes: append([]byte(nil), req.NameBytes...),
|
||
TailFlag: req.TailFlag,
|
||
SingleRaidType: 0,
|
||
}
|
||
state.Members = members
|
||
s.raids[key] = &state
|
||
s.logPacketEvent("game-raid-created-roster",
|
||
"raid_key", key,
|
||
"leader_id", sourceID,
|
||
"member_count", len(members),
|
||
"leader_group", runtimeRaidMemberSquad(members[0].GroupIndex),
|
||
"standby_members", countRuntimeRaidStandbyMembers(members),
|
||
"source_party_members", len(members)-1,
|
||
"tail_flag", req.TailFlag)
|
||
return cloneRuntimeRaidState(state), true
|
||
}
|
||
|
||
func (s *Service) attachRuntimeRaidOwnerConfig(raidKey uint32, ownerConfig runtimeRaidOwnerConfig) (runtimeRaidState, bool) {
|
||
if s == nil || raidKey == 0 || ownerConfig.Mode == 0 || ownerConfig.Raid == nil {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.raids[raidKey]
|
||
if state == nil || state.Started || state.StartPending || state.EntryCostPaid ||
|
||
(state.RaidMode != 0 && state.RaidMode != ownerConfig.Mode) {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
state.RaidMode = ownerConfig.Mode
|
||
state.AntonConfig = nil
|
||
state.LukeConfig = nil
|
||
state.FiendWarConfig = nil
|
||
if ownerConfig.Mode == runtimeRaidOwnerLuke {
|
||
if ownerConfig.Luke == nil || ownerConfig.Luke.Raid != ownerConfig.Raid {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
state.LukeConfig = ownerConfig.Luke
|
||
} else {
|
||
state.AntonConfig = ownerConfig.Raid
|
||
}
|
||
if ownerConfig.Mode == runtimeRaidOwnerFiendWar {
|
||
if ownerConfig.FiendWar == nil || ownerConfig.FiendWar.Battle == nil ||
|
||
ownerConfig.FiendWar.Battle.Raid != ownerConfig.Raid {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
state.FiendWarConfig = ownerConfig.FiendWar
|
||
}
|
||
return cloneRuntimeRaidState(*state), true
|
||
}
|
||
|
||
func (s *Service) modifyRuntimeRaidInfo(sourceID uint16, req raid.InfoRequest) (runtimeRaidState, bool) {
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.runtimeRaidByMemberLocked(sourceID)
|
||
if state == nil || state.LeaderID != sourceID || state.StartPending || state.Started {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
if state.TailFlag == 1 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
state.RouteOrRaidType = req.RouteOrRaidType
|
||
state.NameBytes = append(state.NameBytes[:0], req.NameBytes...)
|
||
state.TailFlag = req.TailFlag
|
||
return cloneRuntimeRaidState(*state), true
|
||
}
|
||
|
||
func (s *Service) runtimeRaidForMember(sourceID uint16) (runtimeRaidState, bool) {
|
||
if sourceID == 0 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.runtimeRaidByMemberLocked(sourceID)
|
||
if state == nil {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
return cloneRuntimeRaidState(*state), true
|
||
}
|
||
|
||
// runtimeRaidKeyForMember is the cheap form of runtimeRaidForMember: callers only
|
||
// need the key, and it runs on every town area enter, so it must not clone the
|
||
// whole roster.
|
||
func (s *Service) runtimeRaidKeyForMember(sourceID uint16) uint32 {
|
||
if s == nil || sourceID == 0 {
|
||
return 0
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.runtimeRaidByMemberLocked(sourceID)
|
||
if state == nil {
|
||
return 0
|
||
}
|
||
return state.RaidKey
|
||
}
|
||
|
||
type runtimeRaidStartReservation struct {
|
||
State runtimeRaidState
|
||
Attempt uint64
|
||
AlreadyStarted bool
|
||
}
|
||
|
||
func (s *Service) reserveRuntimeRaidStart(sourceID uint16) (runtimeRaidStartReservation, error) {
|
||
if sourceID == 0 {
|
||
return runtimeRaidStartReservation{}, errRuntimeRaidNotMember
|
||
}
|
||
s.raidMu.Lock()
|
||
state := s.runtimeRaidByMemberLocked(sourceID)
|
||
if state == nil || state.LeaderID != sourceID {
|
||
s.raidMu.Unlock()
|
||
return runtimeRaidStartReservation{}, errRuntimeRaidNotMember
|
||
}
|
||
if len(state.Members) == 0 || runtimeRaidHasOversizedGroup(*state) {
|
||
s.raidMu.Unlock()
|
||
return runtimeRaidStartReservation{}, errRuntimeRaidLifecycleState
|
||
}
|
||
if state.Started {
|
||
reservation := runtimeRaidStartReservation{
|
||
State: cloneRuntimeRaidState(*state),
|
||
Attempt: state.StartAttempt,
|
||
AlreadyStarted: true,
|
||
}
|
||
s.raidMu.Unlock()
|
||
return reservation, nil
|
||
}
|
||
if state.StartPending {
|
||
s.raidMu.Unlock()
|
||
return runtimeRaidStartReservation{}, errRuntimeRaidLifecycleState
|
||
}
|
||
state.StartAttempt++
|
||
if state.StartAttempt == 0 {
|
||
state.StartAttempt++
|
||
}
|
||
state.StartPending = true
|
||
state.FrozenMembers = append(state.FrozenMembers[:0], state.Members...)
|
||
runtimeRaidSortMembers(state.FrozenMembers)
|
||
reservation := runtimeRaidStartReservation{
|
||
State: cloneRuntimeRaidState(*state),
|
||
Attempt: state.StartAttempt,
|
||
}
|
||
s.raidMu.Unlock()
|
||
if err := s.validateRuntimeRaidRosterOnline(reservation.State.FrozenMembers); err != nil {
|
||
s.cancelRuntimeRaidStart(reservation)
|
||
return reservation, err
|
||
}
|
||
return reservation, nil
|
||
}
|
||
|
||
func (s *Service) validateRuntimeRaidRosterOnline(members []runtimeRaidMemberState) error {
|
||
if len(members) == 0 {
|
||
return errRuntimeRaidNotMember
|
||
}
|
||
for _, member := range members {
|
||
identity, ok := s.onlineGameSessionCharacterSnapshot(member.CharID)
|
||
if !ok || !s.boundGameSessionCharacterCurrent(identity) {
|
||
return fmt.Errorf("%w: frozen member %d is offline", errRuntimeRaidLifecycleState, member.CharID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func (s *Service) cancelRuntimeRaidStart(reservation runtimeRaidStartReservation) {
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.raids[reservation.State.RaidKey]
|
||
if state == nil || state.Started || state.LeaderID != reservation.State.LeaderID || state.StartAttempt != reservation.Attempt {
|
||
return
|
||
}
|
||
state.StartPending = false
|
||
state.FrozenMembers = nil
|
||
}
|
||
|
||
func (s *Service) setRuntimeRaidWaiting(sourceID uint16, waiting byte) (runtimeRaidState, raid.WaitingRow, bool) {
|
||
if sourceID == 0 {
|
||
return runtimeRaidState{}, raid.WaitingRow{}, false
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.runtimeRaidByMemberLocked(sourceID)
|
||
if state == nil || state.StartPending || state.Started {
|
||
return runtimeRaidState{}, raid.WaitingRow{}, false
|
||
}
|
||
index := runtimeRaidMemberIndex(*state, sourceID)
|
||
if index < 0 {
|
||
return runtimeRaidState{}, raid.WaitingRow{}, false
|
||
}
|
||
state.Members[index].Waiting = waiting
|
||
return cloneRuntimeRaidState(*state), raid.WaitingRow{MemberKey: sourceID, State: waiting}, true
|
||
}
|
||
|
||
// removeRuntimeRaidMember removes one character under the raid lock.
|
||
func (s *Service) removeRuntimeRaidMember(sourceID uint16) (runtimeRaidState, bool, bool) {
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state, removed, disbanded := s.removeRuntimeRaidMemberLocked(sourceID)
|
||
if state == nil {
|
||
return runtimeRaidState{}, removed, disbanded
|
||
}
|
||
return cloneRuntimeRaidState(*state), removed, disbanded
|
||
}
|
||
|
||
func (s *Service) commitRuntimeRaidStart(reservation runtimeRaidStartReservation, updates []runtimeRaidEntryCostUpdate, config *raid.AntonConfig, now time.Time) (runtimeRaidState, bool) {
|
||
return s.commitRuntimeRaidStartWithOwner(reservation, updates, runtimeRaidOwnerConfig{
|
||
Mode: runtimeRaidOwnerAnton,
|
||
Raid: config,
|
||
}, now)
|
||
}
|
||
|
||
func (s *Service) commitRuntimeRaidStartWithOwner(reservation runtimeRaidStartReservation, updates []runtimeRaidEntryCostUpdate, ownerConfig runtimeRaidOwnerConfig, now time.Time) (runtimeRaidState, bool) {
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.raids[reservation.State.RaidKey]
|
||
if state == nil || state.LeaderID != reservation.State.LeaderID || len(state.FrozenMembers) == 0 || !state.StartPending || state.StartAttempt != reservation.Attempt {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
if runtimeRaidHasOversizedGroup(*state) {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
chargedMembers := runtimeRaidMemberIDs(runtimeRaidState{Members: reservation.State.FrozenMembers})
|
||
if !sameRuntimeRaidMembers(state.FrozenMembers, chargedMembers) || !sameRuntimeRaidMembers(state.Members, chargedMembers) {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
if state.RaidMode == 0 {
|
||
state.RaidMode = ownerConfig.Mode
|
||
}
|
||
if ownerConfig.Raid == nil || state.RaidMode != ownerConfig.Mode {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
if state.TailFlag == 1 {
|
||
if state.RaidMode == runtimeRaidOwnerLuke {
|
||
state.SingleRaidType = runtimeRaidLukeSingleTypeForTime(now)
|
||
} else {
|
||
state.SingleRaidType = runtimeRaidSingleTypeForTime(now)
|
||
}
|
||
}
|
||
lifecycle, err := newRuntimeRaidLifecycleStateFromModeAndType(ownerConfig.Raid, state.RaidMode, now, state.TailFlag == 1, state.SingleRaidType)
|
||
if err != nil {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
if ownerConfig.Mode == runtimeRaidOwnerFiendWar {
|
||
if ownerConfig.FiendWar == nil || ownerConfig.FiendWar.Battle == nil {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
lifecycle.FiendWar, err = raid.NewFiendWarBattle(ownerConfig.FiendWar.Battle)
|
||
if err != nil {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
// 全员从 PVF 等待区起步,而且必须在开团这一刻就填好:
|
||
// moveRuntimeFiendWarGroup 与 tryRuntimeFiendWarEncounter 都靠
|
||
// GroupAreas/MemberAreas 判断"队伍现在在哪",空表会让第一次走位被判成
|
||
// "两个区域不相邻"。安东/卢克不建战场,这段整个不执行。
|
||
for _, member := range state.FrozenMembers {
|
||
lifecycle.FiendWar.GroupAreas[runtimeRaidNormalizedGroup(member.GroupIndex)] = ownerConfig.FiendWar.Battle.WaitingArea
|
||
lifecycle.FiendWar.MemberAreas[member.CharID] = ownerConfig.FiendWar.Battle.WaitingArea
|
||
}
|
||
}
|
||
state.Started = true
|
||
state.StartPending = false
|
||
state.EntryCostPaid = true
|
||
state.ChargedMembers = append(state.ChargedMembers[:0], chargedMembers...)
|
||
state.PendingCostSync = cloneRuntimeRaidEntryCostUpdates(updates)
|
||
state.AntonConfig = nil
|
||
state.LukeConfig = nil
|
||
state.FiendWarConfig = nil
|
||
if ownerConfig.Mode == runtimeRaidOwnerLuke {
|
||
state.LukeConfig = ownerConfig.Luke
|
||
} else {
|
||
state.AntonConfig = ownerConfig.Raid
|
||
}
|
||
if ownerConfig.Mode == runtimeRaidOwnerFiendWar {
|
||
state.FiendWarConfig = ownerConfig.FiendWar
|
||
}
|
||
state.Lifecycle = lifecycle
|
||
return cloneRuntimeRaidState(*state), true
|
||
}
|
||
|
||
func (s *Service) rejoinRuntimeRaid(raidKey uint32, sourceID uint16) (runtimeRaidState, bool) {
|
||
state, _, ok := s.rejoinRuntimeRaidAdmission(raidKey, sourceID)
|
||
return state, ok
|
||
}
|
||
|
||
func (s *Service) rejoinRuntimeRaidAdmission(raidKey uint32, sourceID uint16) (runtimeRaidState, bool, bool) {
|
||
if sourceID == 0 {
|
||
return runtimeRaidState{}, false, false
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
return s.rejoinRuntimeRaidAdmissionLocked(raidKey, sourceID)
|
||
}
|
||
|
||
// rejoinRuntimeRaidAdmissionLocked is the single roster-admission rule shared
|
||
// by the native op668 path and leader-approved peer admission. Caller holds
|
||
// raidMu. The second result reports whether a new member row was appended.
|
||
func (s *Service) rejoinRuntimeRaidAdmissionLocked(raidKey uint32, sourceID uint16) (runtimeRaidState, bool, bool) {
|
||
if sourceID == 0 {
|
||
return runtimeRaidState{}, false, false
|
||
}
|
||
state := s.raids[raidKey]
|
||
if state == nil {
|
||
return runtimeRaidState{}, false, false
|
||
}
|
||
for otherRaidKey, otherState := range s.raids {
|
||
if otherRaidKey != raidKey && otherState != nil && runtimeRaidMemberIndex(*otherState, sourceID) >= 0 {
|
||
return runtimeRaidState{}, false, false
|
||
}
|
||
}
|
||
added := false
|
||
if runtimeRaidMemberIndex(*state, sourceID) < 0 {
|
||
if state.StartPending || state.Started {
|
||
return runtimeRaidState{}, false, false
|
||
}
|
||
if state.TailFlag == 1 {
|
||
return runtimeRaidState{}, false, false
|
||
}
|
||
if len(state.Members) >= raid.MaxAttackPartyMembers {
|
||
return runtimeRaidState{}, false, false
|
||
}
|
||
// A joiner lands in standby, not in squad 1. The roster record carries the
|
||
// squad byte straight from the wire (sub_17B6EF0 stores it at +0x2D with no
|
||
// arithmetic), and every squad lookup in the client compares it against 1..
|
||
// so 0 matches no squad and the member shows as unassigned -- which is what
|
||
// the leader is then expected to fix by dragging them into a squad.
|
||
state.Members = append(state.Members, s.newRuntimeRaidMemberLocked(sourceID, 0, 0))
|
||
runtimeRaidSortMembers(state.Members)
|
||
// A seat reserved for an earlier raid is void once the character joins
|
||
// a roster through a live session.
|
||
delete(s.raidDisconnects, sourceID)
|
||
added = true
|
||
}
|
||
return cloneRuntimeRaidState(*state), added, true
|
||
}
|
||
|
||
func runtimeRaidFirstAvailablePosition(state runtimeRaidState) (byte, byte, bool) {
|
||
maxGroups := runtimeRaidGroupMaxFor(state)
|
||
size := runtimeRaidGroupSizeFor(state)
|
||
if maxGroups == 0 || size == 0 {
|
||
return 0, 0, false
|
||
}
|
||
occupied := make([][]bool, int(maxGroups)+1)
|
||
counts := make([]int, int(maxGroups)+1)
|
||
for index := range occupied {
|
||
occupied[index] = make([]bool, size)
|
||
}
|
||
for _, member := range state.Members {
|
||
group := member.GroupIndex
|
||
// Unassigned members occupy no squad slot. Folding them into squad 1, as
|
||
// this used to, made a squad read as full while the roster showed nobody
|
||
// in it.
|
||
if group < 1 || group > maxGroups {
|
||
continue
|
||
}
|
||
counts[group]++
|
||
if int(member.SlotOrder) < size {
|
||
occupied[group][member.SlotOrder] = true
|
||
}
|
||
}
|
||
for group := byte(1); group <= maxGroups; group++ {
|
||
if counts[group] >= size {
|
||
continue
|
||
}
|
||
for slot := 0; slot < size; slot++ {
|
||
if !occupied[group][slot] {
|
||
return group, byte(slot), true
|
||
}
|
||
}
|
||
}
|
||
return 0, 0, false
|
||
}
|
||
|
||
func (s *Service) changeRuntimeRaidMemberState(sourceID uint16, userState byte) (runtimeRaidState, bool) {
|
||
if sourceID == 0 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.runtimeRaidByMemberLocked(sourceID)
|
||
if state == nil {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
index := runtimeRaidMemberIndex(*state, sourceID)
|
||
if index < 0 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
state.Members[index].UserState = userState
|
||
return cloneRuntimeRaidState(*state), true
|
||
}
|
||
|
||
func (s *Service) moveRuntimeRaidMember(sourceID uint16, memberID uint16, targetGroup byte) (runtimeRaidState, bool) {
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.runtimeRaidByMemberLocked(sourceID)
|
||
if state == nil || state.LeaderID != sourceID || !runtimeRaidGroupsEditable(*state) {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
if state.TailFlag == 1 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
if targetGroup > runtimeRaidGroupMaxFor(*state) {
|
||
// 超时空只有 8 个编制;9/10 是给安东/卢克那 40 人格子留的。
|
||
return runtimeRaidState{}, false
|
||
}
|
||
index := runtimeRaidMemberIndex(*state, memberID)
|
||
if index < 0 {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
if targetGroup == 0 {
|
||
// Group 0 is "no squad", and the client uses it on the wire: the op669
|
||
// sender at 0x01B48292 walks the roster and emits {action 0, memberKey, 0}
|
||
// for each member it wants pulled out of its squad. Refusing it here left
|
||
// the leader unable to return anyone to standby.
|
||
state.Members[index].GroupIndex = 0
|
||
state.Members[index].SlotOrder = 0
|
||
} else {
|
||
// The native roster is a fixed runtimeRaidMaxGroups x runtimeRaidGroupSize
|
||
// grid addressed by (group, slot). A full target group has no free slot, so
|
||
// nextRuntimeRaidSlotExcluding would fall back to the last slot and place
|
||
// two members on the same grid cell.
|
||
//
|
||
// 超时空不是"拒绝",而是**挤人**:一个小队编制只能放 1 人(操作员
|
||
// 2026-10-02 的定案,客户端小队编制界面每队后面三格锁着),所以把 B 拖进
|
||
// 已经有 A 的队时,A 被退回未编队(单人),B 站上这个位置:
|
||
//
|
||
// 「就比如说 团长给A放在2队 然后再给B放在2队 A就应该变单人了
|
||
// 一个小队仅能有1人」
|
||
//
|
||
// 安东/卢克保持原样:满队仍是整脚拒绝,客户端的编制界面也不会因此变形。
|
||
size := runtimeRaidGroupSizeFor(*state)
|
||
if runtimeRaidGroupCountExcluding(*state, targetGroup, memberID) >= size {
|
||
if state.RaidMode != runtimeRaidOwnerFiendWar {
|
||
// 安东/卢克:满队仍然是整脚拒绝(原样,客户端编制界面不会因此变形)。
|
||
return runtimeRaidState{}, false
|
||
}
|
||
if !s.evictRuntimeRaidGroupLocked(state, targetGroup, memberID, "fiendwar_squad_holds_one_member") {
|
||
return runtimeRaidState{}, false
|
||
}
|
||
}
|
||
state.Members[index].GroupIndex = targetGroup
|
||
state.Members[index].SlotOrder = nextRuntimeRaidSlotExcluding(*state, targetGroup, memberID)
|
||
}
|
||
runtimeRaidSortMembers(state.Members)
|
||
if state.Started {
|
||
// Phase-two dungeon entry binds groups from the frozen roster, so a
|
||
// standby regroup has to land there as well or the next phase would
|
||
// still use the phase-one squads.
|
||
for i := range state.FrozenMembers {
|
||
if state.FrozenMembers[i].CharID != memberID {
|
||
continue
|
||
}
|
||
state.FrozenMembers[i].GroupIndex = state.Members[index].GroupIndex
|
||
state.FrozenMembers[i].SlotOrder = state.Members[index].SlotOrder
|
||
}
|
||
runtimeRaidSortMembers(state.FrozenMembers)
|
||
}
|
||
return cloneRuntimeRaidState(*state), true
|
||
}
|
||
|
||
// evictRuntimeRaidGroupLocked 把 targetGroup 里除 keepID 以外的人退回**未编队**
|
||
// (GroupIndex 0 = 客户端画的"单人"),返回是否真的挪走了人。
|
||
//
|
||
// 只在"一个小队只能放 1 人"的团(超时空)上调用,见 moveRuntimeRaidMember。
|
||
// 调用者持 raidMu。活名册与冻结名册一起改:进图名单、清数记账、战况表都读后者,
|
||
// 只改一边会让"编制里是谁"和"谁能进图"对不上。
|
||
func (s *Service) evictRuntimeRaidGroupLocked(
|
||
state *runtimeRaidState,
|
||
targetGroup byte,
|
||
keepID uint16,
|
||
reason string,
|
||
) bool {
|
||
if state == nil || targetGroup == 0 {
|
||
return false
|
||
}
|
||
evicted := make([]uint16, 0, 1)
|
||
for i := range state.Members {
|
||
if state.Members[i].CharID == keepID || runtimeRaidMemberSquad(state.Members[i].GroupIndex) != targetGroup {
|
||
continue
|
||
}
|
||
evicted = append(evicted, state.Members[i].CharID)
|
||
state.Members[i].GroupIndex = 0
|
||
state.Members[i].SlotOrder = 0
|
||
}
|
||
if len(evicted) == 0 {
|
||
return false
|
||
}
|
||
for _, charID := range evicted {
|
||
for i := range state.FrozenMembers {
|
||
if state.FrozenMembers[i].CharID != charID {
|
||
continue
|
||
}
|
||
state.FrozenMembers[i].GroupIndex = 0
|
||
state.FrozenMembers[i].SlotOrder = 0
|
||
}
|
||
}
|
||
runtimeRaidSortMembers(state.FrozenMembers)
|
||
s.logGameEvent(nil, "game-raid-manager-squad-evicted",
|
||
"raid_key", state.RaidKey,
|
||
"target_group", targetGroup,
|
||
"kept_char_id", keepID,
|
||
"evicted_char_ids", evicted,
|
||
"evicted_count", len(evicted),
|
||
"reason", reason)
|
||
return true
|
||
}
|
||
|
||
// runtimeRaidGroupsEditable reports when the leader may rearrange subgroups:
|
||
// before the ready countdown, and for the whole duration of a running attack --
|
||
// including while some squads are inside a dungeon.
|
||
//
|
||
// The old rule also required phase one, standby, and an empty GroupOccupancy, so
|
||
// a leader standing in the waiting area could not touch a squad whose members
|
||
// were inside a dungeon. That read in game as "团长和队员必须在同一区域才能改编制":
|
||
// with both players in the same area there is no occupancy and the regroup went
|
||
// through, and with one of them inside a dungeon the request was dropped
|
||
// silently (`game-raid-manager-rejected`, no packet on either side). The squad
|
||
// is roster bookkeeping, not dungeon ownership:
|
||
//
|
||
// - the squad -> ordinary-party projection is rebuilt on every regroup
|
||
// (applyRuntimeRaidParties -> materializeRuntimeRaidSquadParty), exactly like
|
||
// 86JP's ReassignLiveRaidMember (PartyManager.cs:210-326);
|
||
// - phase-two dungeon binding reads FrozenMembers, which
|
||
// moveRuntimeRaidMember keeps in step with the live roster;
|
||
// - a member's own dungeon run stays keyed by (map, group) in the lifecycle,
|
||
// and no path resolves a run from the current roster during the run.
|
||
//
|
||
// What still has to stay frozen is the two windows where the roster itself is
|
||
// being decided or paid out: the ready countdown (StartPending) and the
|
||
// movie/card presentation (AwaitingMovie / AwaitingRewards), which resolves the
|
||
// per-squad reward rows.
|
||
func runtimeRaidGroupsEditable(state runtimeRaidState) bool {
|
||
if state.StartPending || state.TailFlag == 1 {
|
||
return false
|
||
}
|
||
if !state.Started {
|
||
return true
|
||
}
|
||
switch state.Lifecycle.Status {
|
||
case runtimeRaidLifecyclePhaseActive, runtimeRaidLifecycleStandby:
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
// runtimeRaidSceneLabel renders one character's current scene for the
|
||
// raid manager-work log lines. The leader's regroup and the member's own window
|
||
// read the same roster, so when one of them reports "no reaction", the first
|
||
// thing worth ruling out is that the two were in different scenes (a member
|
||
// inside a dungeon run, a member in a private room) when the request arrived.
|
||
// The label deliberately names the scene, not the area, because a dungeon run
|
||
// and a private room both report area/instance values the town does not use.
|
||
func (s *Service) runtimeRaidSceneLabel(characterID uint16) string {
|
||
if characterID == 0 {
|
||
return "none"
|
||
}
|
||
info, ok := s.peerActorInfoForCharacter(characterID)
|
||
if !ok {
|
||
return "offline"
|
||
}
|
||
return fmt.Sprintf("ch=%d town=%d area=%d instance=%d",
|
||
info.ChannelID, info.TownID, info.AreaID, info.InstanceKey)
|
||
}
|
||
|
||
// describeRuntimeRaidRegroupRefusal mirrors the accept conditions of
|
||
// moveRuntimeRaidMember and reports which one refused, plus the state that made
|
||
// it refuse. Diagnostic only -- no caller branches on it.
|
||
func (s *Service) describeRuntimeRaidRegroupRefusal(sourceID uint16) (
|
||
reason string, editable bool, status uint16, occupancy int, leader bool) {
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.runtimeRaidByMemberLocked(sourceID)
|
||
if state == nil {
|
||
return "sender_not_in_raid", false, 0, 0, false
|
||
}
|
||
status = uint16(state.Lifecycle.Status)
|
||
occupancy = len(state.Lifecycle.GroupOccupancy)
|
||
editable = runtimeRaidGroupsEditable(*state)
|
||
leader = state.LeaderID == sourceID
|
||
if !leader {
|
||
return "sender_not_leader", editable, status, occupancy, leader
|
||
}
|
||
if !editable {
|
||
return "groups_frozen", editable, status, occupancy, leader
|
||
}
|
||
return "unknown", editable, status, occupancy, leader
|
||
}
|
||
|
||
// runtimeRaidGroupCountExcluding counts the members already occupying one
|
||
// subgroup, ignoring the member that is about to be moved. A member that is
|
||
// re-slotted inside its current subgroup therefore never counts against its
|
||
// own capacity. Unassigned members (group 0) belong to no subgroup and are not
|
||
// counted at all.
|
||
func runtimeRaidGroupCountExcluding(state runtimeRaidState, group byte, exclude uint16) int {
|
||
if group == 0 {
|
||
return 0
|
||
}
|
||
count := 0
|
||
for _, member := range state.Members {
|
||
if member.CharID == exclude || member.GroupIndex != group {
|
||
continue
|
||
}
|
||
count++
|
||
}
|
||
return count
|
||
}
|
||
|
||
// broadcastRuntimeRaidPhaseClearRegistration re-sends the mode=3 member list to
|
||
// every member of one raid. The per-member phase clear count travels inside the
|
||
// member record -- the current client has no 86JP-style column footer on this
|
||
// packet, its member-list reader ends right after the records -- so this push is
|
||
// the only way the 攻击队 window's 地下城通关 column can leave 0 before the next
|
||
// roster change. It deliberately skips the public-directory refresh that a full
|
||
// roster broadcast performs: clearing a gate changes no listing.
|
||
func (s *Service) broadcastRuntimeRaidPhaseClearRegistration(state runtimeRaidState) error {
|
||
body, err := raid.BuildMemberRefreshMode3Body(runtimeRaidMemberRefresh(state))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var firstErr error
|
||
for _, member := range state.Members {
|
||
session, ok := s.onlineGameSession(member.CharID)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(
|
||
session,
|
||
raid.RaidMemberRefreshMsgID,
|
||
body,
|
||
0,
|
||
true,
|
||
); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
return firstErr
|
||
}
|
||
|
||
func (s *Service) broadcastRuntimeRaidRefresh(state runtimeRaidState) error {
|
||
body, err := raid.BuildMemberRefreshMode3Body(runtimeRaidMemberRefresh(state))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
// The 0x024F mode-3 roster is the ONLY source of the squad byte the client's
|
||
// 战况表 draws each attack group from: the client stores the wire byte as-is and
|
||
// renders 0 as 单人 (see runtimeRaidMemberSquad). So a roster that carries a 0
|
||
// for a member who is meant to be in a squad is the board's "显示个单" -- and
|
||
// once it is drawn that way nothing re-sends the row unless a roster change
|
||
// happens. Log the bytes actually put on the wire, with the members that carry
|
||
// 0 called out, so that report is answerable from the log alone.
|
||
s.logRuntimeRaidSquadBytes(state, "game-raid-roster-refresh-squad-bytes", "roster_refresh_mode3")
|
||
var firstErr error
|
||
for _, member := range state.Members {
|
||
session, ok := s.onlineGameSession(member.CharID)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidMemberRefreshMsgID, body, 0, true); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
s.broadcastRuntimeRaidBrowseRefresh(state, body, 0)
|
||
s.refreshRuntimePublicRaidDirectories("raid_state_changed")
|
||
return firstErr
|
||
}
|
||
|
||
// logRuntimeRaidSquadBytes reports the squad label (and slot) each member carries
|
||
// in the roster the client draws its 战况表 from, plus the members whose label is
|
||
// 0 (unassigned), which the client renders as 单人.
|
||
func (s *Service) logRuntimeRaidSquadBytes(state runtimeRaidState, event string, source string) {
|
||
if s == nil || state.RaidKey == 0 {
|
||
return
|
||
}
|
||
unassigned := make([]uint16, 0, len(state.Members))
|
||
labels := make([]string, 0, len(state.Members))
|
||
for _, member := range state.Members {
|
||
squad := runtimeRaidMemberSquad(member.GroupIndex)
|
||
if squad == 0 {
|
||
unassigned = append(unassigned, member.CharID)
|
||
}
|
||
labels = append(labels, fmt.Sprintf("%d:%d:%d", member.CharID, squad, member.SlotOrder))
|
||
}
|
||
s.logPacketEvent(event,
|
||
"raid_key", state.RaidKey,
|
||
"raid_phase", int(state.Lifecycle.Phase),
|
||
"lifecycle_status", int(state.Lifecycle.Status),
|
||
"raid_started", state.Started,
|
||
"source", source,
|
||
"member_count", len(state.Members),
|
||
"frozen_count", len(state.FrozenMembers),
|
||
"squad_labels", strings.Join(labels, ","),
|
||
"unassigned_members", unassigned,
|
||
"unassigned_count", len(unassigned),
|
||
"client_visible", "a_0_squad_byte_is_drawn_as_单人_in_the_battle_status_table")
|
||
s.recordRaidStateEventLight(nil, "raid_squad_bytes", &state, map[string]any{
|
||
"source": source,
|
||
"squad_labels": strings.Join(labels, ","),
|
||
"unassigned_members": unassigned,
|
||
"unassigned_count": len(unassigned),
|
||
"result": "roster_published",
|
||
})
|
||
}
|
||
|
||
func (s *Service) broadcastRuntimeRaidRefreshExcept(state runtimeRaidState, excludedCharacterID uint16) error {
|
||
body, err := raid.BuildMemberRefreshMode3Body(runtimeRaidMemberRefresh(state))
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var firstErr error
|
||
for _, member := range state.Members {
|
||
if member.CharID == excludedCharacterID {
|
||
continue
|
||
}
|
||
session, ok := s.onlineGameSession(member.CharID)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidMemberRefreshMsgID, body, 0, true); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
s.broadcastRuntimeRaidBrowseRefresh(state, body, excludedCharacterID)
|
||
s.refreshRuntimePublicRaidDirectories("raid_state_changed")
|
||
return firstErr
|
||
}
|
||
|
||
func (s *Service) broadcastRuntimeRaidBuffRegistration(state runtimeRaidState, excludedCharacterID uint16) error {
|
||
body, err := s.runtimeRaidBuffStatusBody(state)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
var firstErr error
|
||
for _, member := range state.Members {
|
||
if member.CharID == excludedCharacterID {
|
||
continue
|
||
}
|
||
session, ok := s.onlineGameSession(member.CharID)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if err := s.sendGameUpperRawClassCodec(session, raid.RaidBuffMsgID, body, 0, true); err != nil && firstErr == nil {
|
||
firstErr = err
|
||
}
|
||
}
|
||
return firstErr
|
||
}
|
||
|
||
// applyRuntimeRaidParties reconciles the raid's attack squads with the ordinary
|
||
// party graph. It is the single owner of that projection and it is idempotent:
|
||
// after a call, every non-zero squad is exactly one real ordinary party whose
|
||
// members, order and slots match the roster, and no member outside every squad
|
||
// keeps a party this projection created.
|
||
//
|
||
// The invariant matters because the client draws its own party HUD -- and the
|
||
// party roster the raid windows feed it -- from the ordinary party table, not
|
||
// from the raid roster: a squad that exists only in the raid roster is invisible
|
||
// to its own members. 86JP enforces the same shape as a hard gate before a raid
|
||
// starts (PartyManager.ArePreparedRaidPartiesReady, PartyManager.cs:363-382) and
|
||
// rebuilds the parties on a live regroup (ReassignLiveRaidMember,
|
||
// PartyManager.cs:210-326). This service has no client-side gate, so it has to
|
||
// produce that state itself, at every moment a squad's membership can change.
|
||
func (s *Service) applyRuntimeRaidParties(state runtimeRaidState) error {
|
||
// A single Raid owns a one-character attack-group row, but it is not an
|
||
// ordinary party. Projecting that row through RuntimePartyManager renders
|
||
// the normal party HUD and leaves the client with ordinary-party authority
|
||
// after returning from a Raid dungeon.
|
||
if state.TailFlag == 1 {
|
||
for _, member := range state.Members {
|
||
session, online := s.onlineGameSession(member.CharID)
|
||
if !online {
|
||
continue
|
||
}
|
||
if err := s.clearSingleRaidOrdinaryPartyProjection(session, "single_raid_start"); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
if !state.Started || state.StartPending || len(state.Members) == 0 {
|
||
// Before the ready countdown an attack group is still the leader's plan
|
||
// and a member keeps whatever ordinary party they already had; taking it
|
||
// over there is what used to steal a town party at create time.
|
||
return nil
|
||
}
|
||
projectedBefore := s.runtimeRaidSquadPartyMembership(state)
|
||
assigned := make(map[uint16]bool, len(state.Members))
|
||
next := make(map[byte]uint16, runtimeRaidMaxGroups)
|
||
for _, group := range runtimeRaidPartyGroups(state) {
|
||
if len(group) == 0 {
|
||
continue
|
||
}
|
||
for _, member := range group {
|
||
assigned[member.CharID] = true
|
||
}
|
||
partyID, err := s.materializeRuntimeRaidSquadParty(state, group)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if partyID != 0 {
|
||
next[group[0].GroupIndex] = partyID
|
||
// The projection leads each squad with its first member
|
||
// (runtimeRaidGroupPartyState), which is also the answer the client's
|
||
// 队长标志 must give for that squad while no party publish has named one.
|
||
s.recordRuntimeRaidSquadPartyLeader(nil, group[0].CharID)
|
||
}
|
||
}
|
||
detached, err := s.detachDepartedRuntimeRaidSquadParties(projectedBefore, assigned)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
s.recordRuntimeRaidSquadParties(state.RaidKey, next)
|
||
// The "one non-zero squad <-> one real ordinary party" invariant is otherwise
|
||
// invisible on the wire: the projection speaks through ordinary party packets.
|
||
s.logGameEvent(nil, "game-raid-squad-parties-projected",
|
||
"raid_key", state.RaidKey,
|
||
"squad_count", len(next),
|
||
"squads", formatRuntimeRaidSquadPartyBindings(next),
|
||
"detached_members", detached)
|
||
return nil
|
||
}
|
||
|
||
// formatRuntimeRaidSquadPartyBindings renders the squad -> party projection table
|
||
// as "<squad>:<party_id>" pairs for the packet log.
|
||
func formatRuntimeRaidSquadPartyBindings(bindings map[byte]uint16) string {
|
||
if len(bindings) == 0 {
|
||
return ""
|
||
}
|
||
groups := make([]int, 0, len(bindings))
|
||
for group := range bindings {
|
||
groups = append(groups, int(group))
|
||
}
|
||
sort.Ints(groups)
|
||
out := make([]byte, 0, len(groups)*8)
|
||
for index, group := range groups {
|
||
if index > 0 {
|
||
out = append(out, ',')
|
||
}
|
||
out = strconv.AppendInt(out, int64(group), 10)
|
||
out = append(out, ':')
|
||
out = strconv.AppendInt(out, int64(bindings[byte(group)]), 10)
|
||
}
|
||
return string(out)
|
||
}
|
||
|
||
// runtimeRaidSquadPartyBindings copies the squad -> party projection table.
|
||
func runtimeRaidSquadPartyBindings(state runtimeRaidState) map[byte]uint16 {
|
||
if len(state.SquadParties) == 0 {
|
||
return nil
|
||
}
|
||
bindings := make(map[byte]uint16, len(state.SquadParties))
|
||
for group, partyID := range state.SquadParties {
|
||
bindings[group] = partyID
|
||
}
|
||
return bindings
|
||
}
|
||
|
||
// recordRuntimeRaidSquadParties stores the projection table on the authoritative
|
||
// raid state. The caller must not hold raidMu.
|
||
func (s *Service) recordRuntimeRaidSquadParties(raidKey uint32, bindings map[byte]uint16) {
|
||
if s == nil || raidKey == 0 {
|
||
return
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.raids[raidKey]
|
||
if state == nil {
|
||
return
|
||
}
|
||
if len(bindings) == 0 {
|
||
state.SquadParties = nil
|
||
return
|
||
}
|
||
state.SquadParties = bindings
|
||
}
|
||
|
||
// runtimeRaidSquadPartyMembership maps every member of a projected squad party to
|
||
// that party's id, as recorded before the current reconcile ran.
|
||
func (s *Service) runtimeRaidSquadPartyMembership(state runtimeRaidState) map[uint16]uint16 {
|
||
if len(state.SquadParties) == 0 {
|
||
return nil
|
||
}
|
||
manager := s.runtimePartyManagerForService()
|
||
if manager == nil {
|
||
return nil
|
||
}
|
||
membership := make(map[uint16]uint16)
|
||
for _, partyID := range state.SquadParties {
|
||
snapshot, found := manager.SnapshotByID(partyID)
|
||
if !found {
|
||
continue
|
||
}
|
||
for _, member := range snapshot.Members {
|
||
membership[member.UserID] = snapshot.ID
|
||
}
|
||
}
|
||
return membership
|
||
}
|
||
|
||
// materializeRuntimeRaidSquadParty makes one attack squad exist as exactly one
|
||
// real ordinary party.
|
||
//
|
||
// A squad whose recorded party already holds exactly these members, in the same
|
||
// slots and on the same live sessions, is only refreshed: op9 -> op153 without an
|
||
// op11 re-handshake, because op11 is a formation handshake and replaying it tears
|
||
// down a healthy peer link (see sendRuntimePartyRosterLocal). Every other case
|
||
// forms a new party, which is the one moment op11 may be sent again.
|
||
func (s *Service) materializeRuntimeRaidSquadParty(state runtimeRaidState, group []runtimeRaidMemberState) (uint16, error) {
|
||
if len(group) == 0 {
|
||
return 0, nil
|
||
}
|
||
if projected, current := s.runtimeRaidSquadPartyAssembly(state, group); current {
|
||
if err := s.publishRuntimeRaidSquadParty(state, group, projected, true); err != nil {
|
||
return 0, err
|
||
}
|
||
return uint16(projected.PartyID), nil
|
||
}
|
||
managed, ok := s.replaceManagedRuntimeParty(runtimeRaidGroupPartyState(state, group))
|
||
if !ok {
|
||
return 0, fmt.Errorf("replace managed runtime party for raid %d group %d", state.RaidKey, group[0].GroupIndex)
|
||
}
|
||
if err := s.publishRuntimeRaidSquadParty(state, group, managed, false); err != nil {
|
||
return 0, err
|
||
}
|
||
return uint16(managed.PartyID), nil
|
||
}
|
||
|
||
// publishRuntimeRaidSquadParty puts one squad's party frame on the wire, on that
|
||
// squad's OWN session loop.
|
||
//
|
||
// The formation used to be published inline, so the caller -- the raid leader's
|
||
// own session loop at 开始攻坚 -- wrote every squad's frames for every member, one
|
||
// after another. Live 2026-09-29 02:24 (16 members / 4 squads): op9 party frames
|
||
// left the server at 37, 42, 44, 45, 44, 47 then 102 and 151 per second,每条间隔
|
||
// ~20ms、一轮 17~23 个成员,三分钟 1614 条, and the operator's report is exactly
|
||
// that shape: "按队伍编制顺序自动组队 不是同时自动组队" plus "团长点开始以后会卡
|
||
// 很长一下".
|
||
//
|
||
// Posting each squad to its leader's loop makes the squads form in PARALLEL (each
|
||
// on its own loop) and returns the caller immediately. When the squad leader has
|
||
// no loop to post to, the work still runs inline, so nothing is ever dropped.
|
||
func (s *Service) publishRuntimeRaidSquadParty(
|
||
state runtimeRaidState,
|
||
group []runtimeRaidMemberState,
|
||
partyState alignedcmd.PartyState,
|
||
refreshOnly bool,
|
||
) error {
|
||
if s == nil || len(group) == 0 || partyState.PartyID <= 0 {
|
||
return nil
|
||
}
|
||
publish := func() error {
|
||
if refreshOnly {
|
||
return s.sendManagedRuntimePartyAggregateRefresh(partyState, "raid_squad_projection_refresh")
|
||
}
|
||
return s.sendManagedRuntimePartySnapshots(partyState)
|
||
}
|
||
leader, online := s.onlineGameSession(group[0].CharID)
|
||
if !online || leader == nil || leader.events == nil {
|
||
return publish()
|
||
}
|
||
if err := s.postGameSessionCharacterEvent(
|
||
leader,
|
||
"raid_squad_party_publish",
|
||
group[0].CharID,
|
||
leader.characterGeneration,
|
||
func() error {
|
||
started := time.Now()
|
||
err := publish()
|
||
s.logPacketEvent("game-raid-squad-party-publish",
|
||
"conn_id", leader.connID,
|
||
"raid_key", state.RaidKey,
|
||
"squad", group[0].GroupIndex,
|
||
"party_id", partyState.PartyID,
|
||
"squad_size", len(group),
|
||
"refresh_only", refreshOnly,
|
||
"wall_ms", raidMillis(time.Since(started)),
|
||
"reason", "published_on_the_squad_leaders_own_loop_so_every_squad_forms_in_parallel")
|
||
return err
|
||
},
|
||
); err != nil {
|
||
// The mailbox refused the hand-off: keep the old inline behaviour rather
|
||
// than leaving a squad without its party frame.
|
||
s.logPacketEvent("game-raid-squad-party-publish-inline-fallback",
|
||
"raid_key", state.RaidKey,
|
||
"squad", group[0].GroupIndex,
|
||
"party_id", partyState.PartyID,
|
||
"error", err)
|
||
return publish()
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// runtimeRaidSquadPartyAssembly reports whether the party recorded for this squad
|
||
// already is the squad's projection, in which case a refresh carries the roster
|
||
// and nothing about the party has to be rebuilt.
|
||
func (s *Service) runtimeRaidSquadPartyAssembly(state runtimeRaidState, group []runtimeRaidMemberState) (alignedcmd.PartyState, bool) {
|
||
if len(group) == 0 {
|
||
return alignedcmd.PartyState{}, false
|
||
}
|
||
partyID := state.SquadParties[group[0].GroupIndex]
|
||
if partyID == 0 {
|
||
return alignedcmd.PartyState{}, false
|
||
}
|
||
manager := s.runtimePartyManagerForService()
|
||
if manager == nil {
|
||
return alignedcmd.PartyState{}, false
|
||
}
|
||
snapshot, found := manager.SnapshotByID(partyID)
|
||
if !found || snapshot.Leader != group[0].CharID || len(snapshot.Members) != len(group) {
|
||
return alignedcmd.PartyState{}, false
|
||
}
|
||
for index, squadMember := range group {
|
||
identity, online := s.onlineGameSessionCharacterSnapshot(squadMember.CharID)
|
||
if !online {
|
||
return alignedcmd.PartyState{}, false
|
||
}
|
||
matched := false
|
||
for _, record := range snapshot.Members {
|
||
if record.UserID != squadMember.CharID {
|
||
continue
|
||
}
|
||
// A reconnected member carries a new session generation. The party's
|
||
// own rebind path owns that transition, so the projection is rebuilt
|
||
// rather than refreshed on top of a stale generation.
|
||
if record.SessionGeneration != identity.generation {
|
||
return alignedcmd.PartyState{}, false
|
||
}
|
||
if record.Slot != byte(index) {
|
||
return alignedcmd.PartyState{}, false
|
||
}
|
||
matched = true
|
||
break
|
||
}
|
||
if !matched {
|
||
return alignedcmd.PartyState{}, false
|
||
}
|
||
}
|
||
return snapshot.StateFor(group[0].CharID), true
|
||
}
|
||
|
||
// detachDepartedRuntimeRaidSquadParties removes the projection from every member
|
||
// that no longer fights in any squad (moved to standby, kicked, or gone) and was
|
||
// holding a party this projection created. A party the character formed itself is
|
||
// never touched: only the exact party id recorded for a squad is detached. It
|
||
// returns how many members were detached.
|
||
func (s *Service) detachDepartedRuntimeRaidSquadParties(projected map[uint16]uint16, assigned map[uint16]bool) (int, error) {
|
||
if len(projected) == 0 {
|
||
return 0, nil
|
||
}
|
||
manager := s.runtimePartyManagerForService()
|
||
if manager == nil {
|
||
return 0, nil
|
||
}
|
||
detached := 0
|
||
charIDs := make([]int, 0, len(projected))
|
||
for charID := range projected {
|
||
charIDs = append(charIDs, int(charID))
|
||
}
|
||
sort.Ints(charIDs)
|
||
for _, raw := range charIDs {
|
||
charID := uint16(raw)
|
||
if assigned[charID] {
|
||
continue
|
||
}
|
||
identity, online := s.onlineGameSessionCharacterSnapshot(charID)
|
||
if !online {
|
||
continue
|
||
}
|
||
snapshot, found := manager.SnapshotByUser(charID, identity.generation)
|
||
if !found || snapshot.ID != projected[charID] {
|
||
continue
|
||
}
|
||
if err := s.detachRuntimeRaidSquadProjection(identity.session, "raid_squad_projection_detach"); err != nil {
|
||
return detached, err
|
||
}
|
||
detached++
|
||
}
|
||
return detached, nil
|
||
}
|
||
|
||
// detachRuntimeRaidSquadProjection drops one character from the ordinary party
|
||
// this projection created and tells its client the party is gone, the same way
|
||
// clearSingleRaidOrdinaryPartyProjection does for a single raid.
|
||
func (s *Service) detachRuntimeRaidSquadProjection(session *gameSession, source string) error {
|
||
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
||
return nil
|
||
}
|
||
previous := runtimePartyStateSnapshot(session)
|
||
s.detachRuntimePartySession(session, source)
|
||
storeRuntimePartyState(session, alignedcmd.PartyState{})
|
||
if previous.PartyID <= 0 {
|
||
return nil
|
||
}
|
||
s.logGameEvent(session, "game-raid-squad-party-detached",
|
||
"source", source,
|
||
"char_id", session.selectedCharacterID,
|
||
"party_id", previous.PartyID,
|
||
"member_count", len(runtimePartyMembers(previous)))
|
||
return s.sendRuntimePartySnapshot(session, alignedcmd.PartyState{})
|
||
}
|
||
|
||
func (s *Service) clearSingleRaidOrdinaryPartyProjection(session *gameSession, source string) error {
|
||
if s == nil || session == nil || session.selectedCharacterID == 0 {
|
||
return nil
|
||
}
|
||
previous := runtimePartyStateSnapshot(session)
|
||
s.detachRuntimePartySession(session, source)
|
||
storeRuntimePartyState(session, alignedcmd.PartyState{})
|
||
if previous.PartyID <= 0 {
|
||
return nil
|
||
}
|
||
s.logGameEvent(session, "game-single-raid-ordinary-party-cleared",
|
||
"source", source,
|
||
"char_id", session.selectedCharacterID,
|
||
"party_id", previous.PartyID,
|
||
"member_count", len(runtimePartyMembers(previous)))
|
||
return s.sendRuntimePartySnapshot(session, alignedcmd.PartyState{})
|
||
}
|
||
|
||
// initialRuntimeRaidMembersLocked seeds a new raid with the creator and whoever is
|
||
// in the creator's ordinary party at that moment, all of them in standby.
|
||
//
|
||
// Nobody is placed in a squad here. A joiner already lands in standby for the same
|
||
// reason (see the join path above): the roster record carries the squad byte
|
||
// straight from the wire (sub_17B6EF0 stores it at +0x2D with no arithmetic) and
|
||
// every squad lookup in the client compares it against 1..N, so 0 matches no squad
|
||
// and the member shows as unassigned -- which is what the leader is then expected to
|
||
// fix by dragging them into a squad. The reference server draws the same line:
|
||
// RaidManager.Create assigns no PartyIndex at all and TryAssignParty is the only
|
||
// writer (ServerS4A21-master, RaidManager.cs:40-55 and :86-109), while
|
||
// BuildSituationGroups gives a PartyIndex-0 member its own singleton group (:339-344).
|
||
//
|
||
// Live report 2026-09-17 21:3x: a raid created by one character showed that
|
||
// character in squad 1 even though nobody had assigned it.
|
||
//
|
||
// The caller assigns squad 1 itself where that is what the gesture means: a raid
|
||
// created by a real ordinary party opens with that party already projected into one
|
||
// squad, and a single raid keeps its own seat.
|
||
func (s *Service) initialRuntimeRaidMembersLocked(session *gameSession, sourceID uint16) []runtimeRaidMemberState {
|
||
members := []runtimeRaidMemberState{s.newRuntimeRaidMemberLocked(sourceID, 0, 0)}
|
||
state := runtimePartyStateSnapshot(session)
|
||
for _, member := range runtimePartyMembers(state) {
|
||
if member.UserID == 0 || member.UserID == sourceID || len(members) >= raid.MaxAttackPartyMembers {
|
||
continue
|
||
}
|
||
members = append(members, s.newRuntimeRaidMemberLocked(member.UserID, 0, 0))
|
||
}
|
||
return members
|
||
}
|
||
|
||
// countRuntimeRaidStandbyMembers counts the roster members that belong to no attack
|
||
// group, for the creation log line.
|
||
func countRuntimeRaidStandbyMembers(members []runtimeRaidMemberState) int {
|
||
standby := 0
|
||
for _, member := range members {
|
||
if runtimeRaidMemberSquad(member.GroupIndex) == 0 {
|
||
standby++
|
||
}
|
||
}
|
||
return standby
|
||
}
|
||
|
||
func (s *Service) newRuntimeRaidMemberLocked(charID uint16, group byte, slot byte) runtimeRaidMemberState {
|
||
profile := s.runtimeRaidCharacterProfile(charID)
|
||
return runtimeRaidMemberState{
|
||
CharID: charID,
|
||
Name: profile.Name,
|
||
Level: profile.Level,
|
||
Job: profile.Job,
|
||
Grow: profile.Grow,
|
||
GroupIndex: group,
|
||
SlotOrder: slot,
|
||
UserState: 1,
|
||
HPPercent: 100,
|
||
MPPercent: 100,
|
||
}
|
||
}
|
||
|
||
type runtimeRaidCharacterProfile struct {
|
||
Name string
|
||
Level byte
|
||
Job byte
|
||
Grow byte
|
||
}
|
||
|
||
func (s *Service) runtimeRaidCharacterProfile(charID uint16) runtimeRaidCharacterProfile {
|
||
profile := runtimeRaidCharacterProfile{Name: csharpSelectCharacterName(charID)}
|
||
repos, ok := s.repositoryGroup()
|
||
if ok && repos.Character != nil {
|
||
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||
defer cancel()
|
||
if record, found, err := repos.Character.Load(ctx, strconv.Itoa(int(charID))); err == nil && found {
|
||
if record.Name != "" {
|
||
profile.Name = record.Name
|
||
}
|
||
if record.Level > 0 && record.Level <= 255 {
|
||
profile.Level = byte(record.Level)
|
||
}
|
||
if job, valid := characterJobByte(record); valid {
|
||
profile.Job = job
|
||
}
|
||
grow := numericCharacterStatValue(record, "grow_type")
|
||
// The raid record carries the character grow_type byte verbatim:
|
||
// low nibble is the class branch and bits 4..6 are awakening.
|
||
if grow >= 0 && grow <= 0x7f {
|
||
profile.Grow = byte(grow)
|
||
}
|
||
}
|
||
}
|
||
return profile
|
||
}
|
||
|
||
func (s *Service) sendRaidCommandFailure(session *gameSession, typ uint16) error {
|
||
return s.sendGameUpperRawClassCodec(session, typ, []byte{0, runtimeRaidFailureCode}, dnfproto.DefaultChannelClassification, true)
|
||
}
|
||
|
||
func (s *Service) ensureRuntimeRaidMapsLocked() {
|
||
if s.raids == nil {
|
||
s.raids = make(map[uint32]*runtimeRaidState)
|
||
}
|
||
}
|
||
|
||
func (s *Service) nextRuntimeRaidKeyLocked(session *gameSession, sourceID uint16) uint32 {
|
||
channelID := uint32(0)
|
||
if session != nil {
|
||
channelID = uint32(uint16(session.channel.ID))
|
||
}
|
||
base := channelID<<16 | uint32(sourceID)
|
||
if base != 0 {
|
||
if _, exists := s.raids[base]; !exists {
|
||
return base
|
||
}
|
||
}
|
||
for {
|
||
s.nextRaidSeq++
|
||
key := channelID<<16 | uint32(uint16(s.nextRaidSeq))
|
||
if key == 0 {
|
||
continue
|
||
}
|
||
if _, exists := s.raids[key]; !exists {
|
||
return key
|
||
}
|
||
}
|
||
}
|
||
|
||
func (s *Service) runtimeRaidByMemberLocked(charID uint16) *runtimeRaidState {
|
||
if charID == 0 {
|
||
return nil
|
||
}
|
||
for _, state := range s.raids {
|
||
if runtimeRaidMemberIndex(*state, charID) >= 0 {
|
||
return state
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
// removeRuntimeRaidMemberLocked removes one character from its raid. Caller holds
|
||
// raidMu.
|
||
//
|
||
// A leave always goes through, whatever the raid is doing: the 86JP reference
|
||
// removes the member without consulting the raid state at all
|
||
// (RaidManager.LeaveLocked, RaidManager.cs:1259). Refusing once
|
||
// StartPending/Started was set left a player stuck inside a raid they could no
|
||
// longer leave and unable to create another one, because the client keeps
|
||
// answering "you are already in a raid party" while the old object stays.
|
||
//
|
||
// Departing as the leader hands the raid to the next member in roster order --
|
||
// runtimeRaidSuccessorLocked skips anyone sitting in its own disconnect grace
|
||
// window, so the raid does not land on somebody who cannot start it. Only a leave
|
||
// that empties the roster disbands.
|
||
func (s *Service) removeRuntimeRaidMemberLocked(charID uint16) (*runtimeRaidState, bool, bool) {
|
||
state := s.runtimeRaidByMemberLocked(charID)
|
||
if state == nil {
|
||
return nil, false, false
|
||
}
|
||
next := state.Members[:0]
|
||
removed := false
|
||
for _, member := range state.Members {
|
||
if member.CharID == charID {
|
||
removed = true
|
||
continue
|
||
}
|
||
next = append(next, member)
|
||
}
|
||
if !removed {
|
||
return state, false, false
|
||
}
|
||
state.Members = next
|
||
delete(s.raidDisconnects, charID)
|
||
// A leaver cannot complete the run its session owned, so the gate it occupied
|
||
// has to be released with it or the rest of the squad can never re-enter.
|
||
s.releaseRuntimeRaidRunsOwnedByLocked(state, charID)
|
||
// The leaver also leaves the frozen roster: a phase settlement waits until
|
||
// every frozen member confirmed a card, and a member who is gone can never
|
||
// confirm it.
|
||
frozen := state.FrozenMembers[:0]
|
||
for _, member := range state.FrozenMembers {
|
||
if member.CharID == charID {
|
||
continue
|
||
}
|
||
frozen = append(frozen, member)
|
||
}
|
||
state.FrozenMembers = frozen
|
||
if len(state.Members) == 0 {
|
||
delete(s.raids, state.RaidKey)
|
||
// The raid is gone, so its gates' reported runtime values are meaningless:
|
||
// drop them here or every disbanded raid leaves one entry per gate per squad
|
||
// behind for the life of the process.
|
||
s.purgeRuntimeRaidMonsterRuntimeValuesLocked(state.RaidKey)
|
||
s.purgeRuntimeRaidBuffActivationsLocked(state.RaidKey)
|
||
s.purgeRuntimeRaidReviveCoinLedgerLocked(state.RaidKey)
|
||
s.purgeRuntimeRaidPhaseDeathsLocked(state.RaidKey)
|
||
// A disbanding raid leaves no seats behind: every survivor's disconnect
|
||
// reservation has to go too, or a later grace expiry would try to remove a
|
||
// member from a raid that no longer exists.
|
||
for member, reservation := range s.raidDisconnects {
|
||
if reservation.RaidKey == state.RaidKey {
|
||
delete(s.raidDisconnects, member)
|
||
}
|
||
}
|
||
return nil, true, true
|
||
}
|
||
if state.LeaderID == charID || runtimeRaidMemberIndex(*state, state.LeaderID) < 0 {
|
||
// Prefer a survivor whose transport is live; a leader inside its own
|
||
// grace window would leave the raid without anyone able to start it.
|
||
state.LeaderID = s.runtimeRaidSuccessorLocked(*state)
|
||
}
|
||
return state, true, false
|
||
}
|
||
|
||
func runtimeRaidMemberRefresh(state runtimeRaidState) raid.MemberRefresh {
|
||
members := append([]runtimeRaidMemberState(nil), state.Members...)
|
||
runtimeRaidSortMembers(members)
|
||
out := raid.MemberRefresh{RaidKey: state.RaidKey, Members: make([]raid.MemberRecord, 0, len(members))}
|
||
for _, member := range members {
|
||
out.Members = append(out.Members, runtimeRaidMemberRecord(state, member))
|
||
}
|
||
return out
|
||
}
|
||
|
||
func runtimeRaidObjectMode0(state runtimeRaidState) raid.RaidObjectMode0 {
|
||
members := append([]runtimeRaidMemberState(nil), state.Members...)
|
||
runtimeRaidSortMembers(members)
|
||
records := make([]raid.MemberRecord, 0, len(members))
|
||
var leader raid.MemberRecord
|
||
for _, member := range members {
|
||
record := runtimeRaidMemberRecord(state, member)
|
||
records = append(records, record)
|
||
if member.CharID == state.LeaderID {
|
||
leader = record
|
||
}
|
||
}
|
||
if leader.CharKey == 0 && len(records) > 0 {
|
||
leader = records[0]
|
||
}
|
||
objectState, objectPhase := runtimeRaidObjectLifecycleFields(state)
|
||
return raid.RaidObjectMode0{
|
||
RaidKey: state.RaidKey,
|
||
TitleBytes: runtimeRaidTitleBytes(state.NameBytes),
|
||
ObjectField24: state.RouteOrRaidType,
|
||
ObjectField28: objectState,
|
||
ObjectField30: objectPhase,
|
||
ObjectField34: 0,
|
||
ObjectField94: state.TailFlag,
|
||
Leader: leader,
|
||
Members: records,
|
||
}
|
||
}
|
||
|
||
// runtimeRaidObjectLifecycleFields projects the server lifecycle into the
|
||
// object fields read by current EXE sub_17B8910. In particular, selector
|
||
// refreshes must retain state=2 and the zero-based phase index; resetting an
|
||
// active object to state=0/phase=0xff makes sub_F176A0 clear every map row.
|
||
func runtimeRaidObjectLifecycleFields(state runtimeRaidState) (byte, byte) {
|
||
phase := byte(0xff)
|
||
if state.Lifecycle.Phase > 0 {
|
||
phase = state.Lifecycle.Phase - 1
|
||
}
|
||
switch state.Lifecycle.Status {
|
||
case runtimeRaidLifecyclePhaseActive:
|
||
return runtimeRaidPhaseStartState, phase
|
||
case runtimeRaidLifecycleAwaitingMovie:
|
||
return runtimeRaidSettlementStateResult, phase
|
||
case runtimeRaidLifecycleAwaitingRewards, runtimeRaidLifecycleStandby:
|
||
return runtimeRaidSettlementStateCardsOpened, phase
|
||
default:
|
||
return 0, 0xff
|
||
}
|
||
}
|
||
|
||
func runtimeRaidMemberRecord(state runtimeRaidState, member runtimeRaidMemberState) raid.MemberRecord {
|
||
return raid.MemberRecord{
|
||
CharKey: member.CharID,
|
||
Field4: member.UserState,
|
||
Name: member.Name,
|
||
RawNameBytes: rosterNameBytes(member.Name),
|
||
// Current EXE sub_1B4ED60 passes Field40 as the first (job) argument
|
||
// to sub_249D7F0 and splits Field44 into grow/awakening nibbles.
|
||
Field40: member.Job,
|
||
Field44: member.Grow,
|
||
GroupIndex: member.GroupIndex,
|
||
SlotOrder: member.SlotOrder,
|
||
// Wire field 56 is the current client's per-member phase clear count. The
|
||
// 攻击队 status window renders the 地下城通关 cell as
|
||
// "<this byte>/<phase requirement>" (sub_1B4FA0E reads the record's byte at
|
||
// +0x38, where sub_17B6EF0 stores this field), and the phase settlement
|
||
// only rewards members the client can prove cleared a gate. Sending 0 left
|
||
// the column at 0/1 forever and the phase end flipped no cards.
|
||
Field56: runtimeRaidPhaseClearCount(state, member.CharID),
|
||
// Wire field 66 is the client's answer to "is this member the leader?".
|
||
// sub_17B6EF0 reads it as a u32 and stores `value == 1` as a BOOL at
|
||
// member+0x42; the client then asks that byte about its OWN record
|
||
// (0x51b2748 -> my key -> 0x13b29c0 -> cmp byte ptr [record+42h], 0) for
|
||
// every leader-gated raid control, the 战况表 进入地下城/回到城镇 buttons
|
||
// included. It therefore names the leader the player can act as -- see
|
||
// runtimeRaidMemberLeadsSquad.
|
||
Field66BoolSource: boolUint32(runtimeRaidMemberLeadsSquad(state, member)),
|
||
}
|
||
}
|
||
|
||
// runtimeRaidMemberLeadsSquad answers the client's own question "am I the leader?"
|
||
// for one roster member.
|
||
//
|
||
// Operator report 2026-09-26 (raid 1507329, party 5 = [1 2 20 21], all in squad 1):
|
||
// char 1 -- the 团长 AND the squad's party leader -- pressed 返回城镇 alone, the party
|
||
// handed the leadership to char 20, and the client kept saying char 1 was the leader:
|
||
// the promoted member had no 队长标志 and no 战况表 buttons, while the member who had
|
||
// already left the party AND the dungeon still had both. The roster that caused it
|
||
// (decoded from packet_log 15:03:14.782) said char 1 group 0 / leader 1 and char 20
|
||
// group 1 slot 1 / leader 0, i.e. the flag was filled with "is the 团长", and the
|
||
// 团长 does not change hands when the squad's leadership does.
|
||
//
|
||
// So inside a running multi-member raid the flag is the SQUAD's leader:
|
||
//
|
||
// - the ordinary party the squad fights in, mirrored by
|
||
// recordRuntimeRaidSquadPartyLeader on every party publish (a leadership handover
|
||
// happens while both members are inside the gate, which is exactly where the
|
||
// squad-sync's dungeon-run guard would have skipped a relabel);
|
||
// - otherwise the squad's lowest occupied slot, which is the member
|
||
// runtimeRaidGroupPartyState would lead that squad's projected party with.
|
||
//
|
||
// Everything else keeps the old meaning on purpose: before the raid starts there are
|
||
// no squads and no ordinary parties to lead, and a single raid never owns one, so both
|
||
// keep answering exactly what they always did.
|
||
func runtimeRaidMemberLeadsSquad(state runtimeRaidState, member runtimeRaidMemberState) bool {
|
||
if !state.Started || state.TailFlag == 1 {
|
||
return member.CharID == state.LeaderID
|
||
}
|
||
squad := runtimeRaidMemberSquad(member.GroupIndex)
|
||
if squad == 0 {
|
||
return false
|
||
}
|
||
if leader := state.SquadPartyLeaders[squad]; leader != 0 {
|
||
if index := runtimeRaidMemberIndex(state, leader); index >= 0 &&
|
||
runtimeRaidMemberSquad(state.Members[index].GroupIndex) == squad {
|
||
return member.CharID == leader
|
||
}
|
||
}
|
||
return member.CharID == runtimeRaidLowestSlotMemberInSquad(state, squad)
|
||
}
|
||
|
||
// runtimeRaidLowestSlotMemberInSquad is the member a squad's projected ordinary party
|
||
// is led by when nothing else names one: runtimeRaidGroupPartyState takes the first
|
||
// member of the squad's slot order, and runtimeRaidPartyGroups hands it exactly that.
|
||
func runtimeRaidLowestSlotMemberInSquad(state runtimeRaidState, squad byte) uint16 {
|
||
leader := uint16(0)
|
||
slot := byte(0xff)
|
||
for _, candidate := range state.Members {
|
||
if candidate.CharID == 0 || runtimeRaidMemberSquad(candidate.GroupIndex) != squad {
|
||
continue
|
||
}
|
||
if leader == 0 || candidate.SlotOrder < slot {
|
||
leader, slot = candidate.CharID, candidate.SlotOrder
|
||
}
|
||
}
|
||
return leader
|
||
}
|
||
|
||
// recordRuntimeRaidSquadPartyLeader mirrors the ordinary party that was just published
|
||
// into the raid squad its leader currently occupies.
|
||
//
|
||
// Called for every party snapshot, deliberately BEFORE the dungeon-run guard of the
|
||
// squad sync below: that guard protects the squad RELABELING (an ordinary party is not
|
||
// a squad gesture while its members are inside a run), while this is bookkeeping about
|
||
// who leads the party they are already in -- and the handover it has to survive happens
|
||
// mid-run by definition.
|
||
func (s *Service) recordRuntimeRaidSquadPartyLeader(memberIDs []uint16, leaderID uint16) {
|
||
if s == nil || leaderID == 0 {
|
||
return
|
||
}
|
||
s.raidMu.Lock()
|
||
defer s.raidMu.Unlock()
|
||
state := s.runtimeRaidByMemberLocked(leaderID)
|
||
if state == nil {
|
||
return
|
||
}
|
||
index := runtimeRaidMemberIndex(*state, leaderID)
|
||
if index < 0 {
|
||
return
|
||
}
|
||
squad := runtimeRaidMemberSquad(state.Members[index].GroupIndex)
|
||
if squad == 0 {
|
||
return
|
||
}
|
||
if state.SquadPartyLeaders == nil {
|
||
state.SquadPartyLeaders = make(map[byte]uint16, runtimeRaidMaxGroups)
|
||
}
|
||
state.SquadPartyLeaders[squad] = leaderID
|
||
}
|
||
|
||
// runtimeRaidTitleBytes passes the raid name to the object builder exactly as the
|
||
// client sent it.
|
||
//
|
||
// This used to re-encode the name to UTF-16LE. That was wrong: the client reads
|
||
// the object title through the same length-prefixed narrow-string reader as every
|
||
// other dstr (sub_3457D20 -> 0x34D9CA0 widens it byte by byte from the local code
|
||
// page), so UTF-16LE bytes were rendered as local-code-page characters. The
|
||
// symptom is name-shaped, not random: a digits-only name stops at the first NUL,
|
||
// so "1111" showed as "1", and "礼白1" showed as "<y}v1" because
|
||
// 3C 79 7D 76 31 is the little-endian spelling of 礼白1.
|
||
//
|
||
// The 86 reference server does the same thing -- RaidManager.Create stores the
|
||
// request's own TitleBytes and RaidPacketBuilder.WriteRaidObject emits them with
|
||
// GamePacketWriter.WriteRawDstr. Its RaidHandler self-test pins a captured
|
||
// CREATE_RAID body to the narrow ASCII "123" (31 32 33), not 31 00 32 00 33 00,
|
||
// which is what settles that no conversion belongs here.
|
||
//
|
||
// The stored bytes are the client's own, so no migration is needed: 664/670
|
||
// bodies always carried GB18030, and only this outgoing hop ever re-encoded.
|
||
func runtimeRaidTitleBytes(raw []byte) []byte {
|
||
return append([]byte(nil), raw...)
|
||
}
|
||
|
||
// runtimeRaidPartyGroups groups the roster by squad for the ordinary-party
|
||
// projection, the Buff registration snapshot and the rejoin recovery.
|
||
//
|
||
// Unassigned members (group 0) are skipped rather than folded into squad 1. A
|
||
// standby member is in no attack group, so projecting one gives the client a
|
||
// squad party the raid roster simultaneously shows as empty. The dungeon-phase
|
||
// binding reads runtimeRaidNormalizedGroup instead and still treats an
|
||
// unassigned member as squad 1, so skipping them here does not make anyone
|
||
// ineligible to enter.
|
||
func runtimeRaidPartyGroups(state runtimeRaidState) [][]runtimeRaidMemberState {
|
||
members := append([]runtimeRaidMemberState(nil), state.Members...)
|
||
runtimeRaidSortMembers(members)
|
||
groups := make([][]runtimeRaidMemberState, 0, runtimeRaidMaxGroups)
|
||
byGroup := make(map[byte][]runtimeRaidMemberState)
|
||
for _, member := range members {
|
||
group := member.GroupIndex
|
||
if group < 1 || group > runtimeRaidMaxGroups {
|
||
continue
|
||
}
|
||
member.GroupIndex = group
|
||
byGroup[group] = append(byGroup[group], member)
|
||
}
|
||
keys := make([]int, 0, len(byGroup))
|
||
for group := range byGroup {
|
||
keys = append(keys, int(group))
|
||
}
|
||
sort.Ints(keys)
|
||
for _, key := range keys {
|
||
groups = append(groups, byGroup[byte(key)])
|
||
}
|
||
return groups
|
||
}
|
||
|
||
// runtimeRaidClientBuffSlots is the 90 client's raid-buff slot order.
|
||
//
|
||
// The reader sub_1D50660 (registration 0x01D517DC: push 0x1D50660 / push 0x279 /
|
||
// call 0x2262980) takes `u8 groupCount { u8 key, u8 rowCount, rows... }` where the
|
||
// key byte is used directly as the slot number: the icon refresh sub_15C6AD0
|
||
// walks a fixed five slots (`0x015C6C13 cmp esi,5 / 0x015C6C16 jl 0x15C6B50`) and
|
||
// looks each one up in the table the packet wrote (`0x017B57A3 cmp dword ptr
|
||
// [eax+0xC],edx` matches the key). Slot meanings come from the two dispatch
|
||
// tables at 0x160EDD0 / 0x160EBC0: 0 = 攻略时间增加 (+5 min), 1 = 攻击力增加 (30s),
|
||
// 2 = 无敌 (30s), 3 = 恢复 (60s), 4 = 攻击Buff.
|
||
//
|
||
// 86JP's AntonClientBuffTypes happens to be the same five values, but this list is
|
||
// the 90 client's own: see tools/_probe_279_report.md.
|
||
var runtimeRaidClientBuffSlots = [...]byte{2, 3, 4, 1, 0}
|
||
|
||
// runtimeRaidBuffIdleStatusBody is the 0x0279 body of a raid with no running
|
||
// skill: the five slots, each with no rows.
|
||
//
|
||
// Production sends the live status instead (Service.runtimeRaidBuffStatusBody),
|
||
// which is byte-identical to this whenever nothing is active. That is because an
|
||
// idle slot is a zero-row group, and a zero-row group is inert: the client's lookup
|
||
// skips the key entirely (`0x017B57E2 cmp esi,[ebp-0x28]`) and ends in the clean
|
||
// false return at 0x017B5869. So a row means "this buff is running" -- its third
|
||
// u32 is an absolute deadline that sub_17B5730 compares against the current seconds
|
||
// (`0x017B5800 mov ecx,[esi+8]` / `0x017B5806 sub ecx,eax` / `0x017B5812 jns ...`),
|
||
// and a value <= now is treated as expired.
|
||
//
|
||
// This entry point is kept as the idle reference the packet tests compare against,
|
||
// so those tests keep meaning "an untouched raid looks like this".
|
||
//
|
||
// The body this replaced attached one row per member with both deadlines zero,
|
||
// which is also inert by that same check -- but it used the group byte as a party
|
||
// index, so slot 1 (攻击力增加) was the only key present and the other four slots
|
||
// never existed for the client.
|
||
func runtimeRaidBuffIdleStatusBody() ([]byte, error) {
|
||
groups := make([]raid.RaidBuffGroup, 0, len(runtimeRaidClientBuffSlots))
|
||
for _, slot := range runtimeRaidClientBuffSlots {
|
||
groups = append(groups, raid.RaidBuffGroup{Field0: slot})
|
||
}
|
||
return raid.BuildRaidBuffBody(groups)
|
||
}
|
||
|
||
func runtimeRaidHasOversizedGroup(state runtimeRaidState) bool {
|
||
counts := make(map[byte]int)
|
||
for _, member := range state.Members {
|
||
group := member.GroupIndex
|
||
if group < 1 || group > runtimeRaidMaxGroups {
|
||
continue
|
||
}
|
||
counts[group]++
|
||
if counts[group] > runtimeRaidGroupSize {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// runtimeRaidGroupPartyState projects one attack squad onto a native party state.
|
||
//
|
||
// Slots are explicit: the group is already in roster order (runtimeRaidSortMembers
|
||
// orders by squad, then slot, then character), and both op9 and op153 carry that
|
||
// slot, so leaving it implicit let the party table's own fill-in order decide which
|
||
// body a member was painted into.
|
||
func runtimeRaidGroupPartyState(state runtimeRaidState, members []runtimeRaidMemberState) alignedcmd.PartyState {
|
||
leaderID := members[0].CharID
|
||
partyState := alignedcmd.PartyState{
|
||
PartyID: int(state.RaidKey&0xffff) + int(members[0].GroupIndex)*1000,
|
||
IsLeader: true,
|
||
UserID: leaderID,
|
||
UserState: 1,
|
||
NameBytes: append([]byte(nil), state.NameBytes...),
|
||
MaxMembers: runtimeRaidGroupSize,
|
||
Members: make([]alignedcmd.PartyMemberState, 0, len(members)),
|
||
}
|
||
if len(partyState.NameBytes) == 0 {
|
||
partyState.NameBytes = []byte("raid")
|
||
}
|
||
for index, member := range members {
|
||
partyState.Members = append(partyState.Members, alignedcmd.PartyMemberState{
|
||
UserID: member.CharID,
|
||
Slot: byte(index),
|
||
UserState: firstByte(member.UserState, 1),
|
||
HPPercent: firstByte(member.HPPercent, 100),
|
||
MPPercent: firstByte(member.MPPercent, 100),
|
||
})
|
||
}
|
||
return partyState
|
||
}
|
||
|
||
func runtimeRaidMemberIndex(state runtimeRaidState, charID uint16) int {
|
||
for i, member := range state.Members {
|
||
if member.CharID == charID {
|
||
return i
|
||
}
|
||
}
|
||
return -1
|
||
}
|
||
|
||
func runtimeRaidMemberIDs(state runtimeRaidState) []uint16 {
|
||
ids := make([]uint16, 0, len(state.Members))
|
||
for _, member := range state.Members {
|
||
ids = append(ids, member.CharID)
|
||
}
|
||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||
return ids
|
||
}
|
||
|
||
func sameRuntimeRaidMembers(members []runtimeRaidMemberState, ids []uint16) bool {
|
||
left := runtimeRaidMemberIDs(runtimeRaidState{Members: members})
|
||
right := append([]uint16(nil), ids...)
|
||
sort.Slice(right, func(i, j int) bool { return right[i] < right[j] })
|
||
if len(left) != len(right) {
|
||
return false
|
||
}
|
||
for index := range left {
|
||
if left[index] != right[index] {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func nextRuntimeRaidSlot(state runtimeRaidState, group byte) byte {
|
||
return nextRuntimeRaidSlotExcluding(state, group, 0)
|
||
}
|
||
|
||
func nextRuntimeRaidSlotExcluding(state runtimeRaidState, group byte, exclude uint16) byte {
|
||
used := make(map[byte]bool)
|
||
for _, member := range state.Members {
|
||
if member.CharID == exclude || member.GroupIndex != group {
|
||
continue
|
||
}
|
||
used[member.SlotOrder] = true
|
||
}
|
||
for i := byte(0); i < runtimeRaidGroupSize; i++ {
|
||
if !used[i] {
|
||
return i
|
||
}
|
||
}
|
||
return runtimeRaidGroupSize - 1
|
||
}
|
||
|
||
func runtimeRaidSortMembers(members []runtimeRaidMemberState) {
|
||
sort.SliceStable(members, func(i, j int) bool {
|
||
leftGroup := raidSortGroup(members[i].GroupIndex)
|
||
rightGroup := raidSortGroup(members[j].GroupIndex)
|
||
if leftGroup != rightGroup {
|
||
return leftGroup < rightGroup
|
||
}
|
||
if members[i].SlotOrder != members[j].SlotOrder {
|
||
return members[i].SlotOrder < members[j].SlotOrder
|
||
}
|
||
return members[i].CharID < members[j].CharID
|
||
})
|
||
}
|
||
|
||
func raidSortGroup(group byte) int {
|
||
if group == 0 {
|
||
return 1000
|
||
}
|
||
return int(group)
|
||
}
|
||
|
||
func cloneRuntimeRaidState(state runtimeRaidState) runtimeRaidState {
|
||
state.NameBytes = append([]byte(nil), state.NameBytes...)
|
||
state.Members = append([]runtimeRaidMemberState(nil), state.Members...)
|
||
state.ChargedMembers = append([]uint16(nil), state.ChargedMembers...)
|
||
state.FrozenMembers = append([]runtimeRaidMemberState(nil), state.FrozenMembers...)
|
||
state.PendingCostSync = cloneRuntimeRaidEntryCostUpdates(state.PendingCostSync)
|
||
state.SquadParties = runtimeRaidSquadPartyBindings(state)
|
||
state.Lifecycle = cloneRuntimeRaidLifecycleState(state.Lifecycle)
|
||
return state
|
||
}
|
||
|
||
func cloneRuntimeRaidEntryCostUpdates(source []runtimeRaidEntryCostUpdate) []runtimeRaidEntryCostUpdate {
|
||
out := make([]runtimeRaidEntryCostUpdate, len(source))
|
||
for updateIndex, update := range source {
|
||
out[updateIndex].CharacterID = update.CharacterID
|
||
out[updateIndex].Entries = make([]currentItemListEntry, len(update.Entries))
|
||
for entryIndex, entry := range update.Entries {
|
||
out[updateIndex].Entries[entryIndex] = entry
|
||
out[updateIndex].Entries[entryIndex].avatarSocketData = append([]byte(nil), entry.avatarSocketData...)
|
||
out[updateIndex].Entries[entryIndex].avatarColorData = append([]byte(nil), entry.avatarColorData...)
|
||
}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func boolUint32(value bool) uint32 {
|
||
if value {
|
||
return 1
|
||
}
|
||
return 0
|
||
}
|
||
|
||
func firstByte(value byte, fallback byte) byte {
|
||
if value != 0 {
|
||
return value
|
||
}
|
||
return fallback
|
||
}
|