Files
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

632 lines
26 KiB
Go

package dnfbridge
import (
"fmt"
"time"
"longheng.io/server/internal/modules/dnf/raid"
)
// The raid-team skill panel (攻坚队技能 / 小队技能) is one five-slot panel:
//
// slots 0/1/2 = "小队技能" -- they need a designated squad, PVF target [PARTY]
// slots 3/4 = "攻坚队" -- raid-wide, PVF target [RAID]
//
// The client hard-codes that split at 0x015E27CF (`cmp dword ptr [esi+0x1C8],3`
// then `jl`: below three it first makes the player pick a member target, at three
// and above it sends 0x02B6 straight away). The on-screen labels in the live
// client agree, and so does etc/raid/anton.etc.buff, which lists exactly three
// [PARTY] entries and two [RAID] ones.
//
// C2S 0x02B6 (CmdPacketRaidBuffSystem) is the activation request. Its only sender
// in the whole image is 0x010D7BB6 inside sub_10D7A00, and its body is a fixed
// five bytes: u8 category, u16 target, u16 extra. `选取技能(DEL)` is NOT that
// sender -- it is input action 0x12, which only switches the panel's state.
// See tools/_probe_694_report.md.
// Client raid-buff categories. These are the bytes the client sends in 0x02B6 and
// the key bytes it looks up in 0x0279. They are NOT the panel slot order: the
// client maps slot -> category through the jump table at 0x10D7C90, whose five
// targets yield exactly {2,3,4,1,0} -- the same array 86JP calls
// AntonClientBuffTypes (RaidHandler.Situation.cs:260) and the same one this server
// already uses for the 0x0279 group order (runtimeRaidClientBuffSlots).
const (
runtimeRaidBuffIncreaseTime byte = 0
runtimeRaidBuffAttackBonus byte = 1
runtimeRaidBuffInvincible byte = 2
runtimeRaidBuffRestore byte = 3
runtimeRaidBuffIncreaseCoin byte = 4
runtimeRaidBuffCategoryCount = 5
)
// runtimeRaidBuffDefinition is one [BUFF] entry of etc/raid/anton.etc.buff.
//
// The values are the shipped PVF's, read from
// D:\马维斯0705\etc\raid\anton.etc.buff (the same file this client's PVF holds), and
// they match 86JP's hardcoded fallback in AntonRaidRewardProvider.cs:290-312 entry
// for entry. They are constants rather than PVF reads because this server does not
// parse anton.etc.buff yet; wiring that up is a follow-up, and until it exists a
// constant table that says where it came from beats silently reporting zero
// cooldowns.
type runtimeRaidBuffDefinition struct {
Category byte
// RaidWide is the PVF target: [RAID] applies to every member, [PARTY] applies
// to the one designated squad.
RaidWide bool
CooldownSecs uint32
DurationSecs uint32
// EffectValue is the PVF's third number. Only the two skills that have a
// server-side meaning consume it -- INCREASE TIME adds it to the phase
// deadline, INCREASE COIN adds it to the squad's granted coins. The three
// remaining skills' values (15 / 0 / 7) are display-only: 86JP never applied
// them server-side either, and its client applied nothing itself
// (tools/_probe_279_report.md section 5), so the panel state is the whole
// effect there.
EffectValue uint32
}
var runtimeRaidBuffDefinitions = [runtimeRaidBuffCategoryCount]runtimeRaidBuffDefinition{
runtimeRaidBuffIncreaseTime: {Category: runtimeRaidBuffIncreaseTime, RaidWide: true, CooldownSecs: 7200, DurationSecs: 10, EffectValue: 300},
runtimeRaidBuffAttackBonus: {Category: runtimeRaidBuffAttackBonus, RaidWide: true, CooldownSecs: 420, DurationSecs: 30, EffectValue: 15},
runtimeRaidBuffInvincible: {Category: runtimeRaidBuffInvincible, RaidWide: false, CooldownSecs: 360, DurationSecs: 30, EffectValue: 0},
runtimeRaidBuffRestore: {Category: runtimeRaidBuffRestore, RaidWide: false, CooldownSecs: 240, DurationSecs: 60, EffectValue: 7},
runtimeRaidBuffIncreaseCoin: {Category: runtimeRaidBuffIncreaseCoin, RaidWide: false, CooldownSecs: 300, DurationSecs: 10, EffectValue: 4},
}
func runtimeRaidBuffDefinitionFor(category byte) (runtimeRaidBuffDefinition, bool) {
if category >= runtimeRaidBuffCategoryCount {
return runtimeRaidBuffDefinition{}, false
}
definition := runtimeRaidBuffDefinitions[category]
return definition, definition.CooldownSecs > 0
}
// runtimeRaidBuffRequest is a decoded C2S 0x02B6.
type runtimeRaidBuffRequest struct {
Category byte
// TargetKey is the roster member the panel had selected, as a character key.
// It is NOT a squad number: the client resolves it through the roster
// (sub_17B87F0) and a roster entry carries the member's name, and it writes it
// with WriteU16 over a value whose -1 sentinel is what blocks the send.
TargetKey uint16
// ExtraKey is the body's third field. It comes from the same roster entry as
// the send gate, and its meaning is still unproven, so it is carried and logged
// but never acted on.
ExtraKey uint16
}
// decodeRuntimeRaidBuffRequest reads one C2S 0x02B6.
//
// The shape checks are 86JP's (RaidHandler.Situation.cs:330-347) and they still
// describe this client, whose sender writes exactly one u16 target: length >= 5,
// an even (length-3), and a category inside 0..4. Unlike 86JP this does not reject
// a zero target key at decode time -- whether a target is required depends on the
// definition (raid-wide skills ignore it), so that decision belongs to the handler
// rather than to the shape.
func decodeRuntimeRaidBuffRequest(body []byte) (runtimeRaidBuffRequest, error) {
if len(body) < 5 {
return runtimeRaidBuffRequest{}, fmt.Errorf("raid buff request too short: len=%d", len(body))
}
if (len(body)-3)%2 != 0 {
return runtimeRaidBuffRequest{}, fmt.Errorf("raid buff request has a truncated target list: len=%d", len(body))
}
if body[0] >= runtimeRaidBuffCategoryCount {
return runtimeRaidBuffRequest{}, fmt.Errorf("raid buff category out of range: %d", body[0])
}
if targets := (len(body) - 3) / 2; targets > runtimeRaidMaxGroups*2 {
return runtimeRaidBuffRequest{}, fmt.Errorf("raid buff request names %d targets", targets)
}
return runtimeRaidBuffRequest{
Category: body[0],
TargetKey: uint16(body[1]) | uint16(body[2])<<8,
// The live body is exactly five bytes, so the third field sits at 3..4 and is
// always present on any shape this client produced. Anything past it would be
// a second target id, which 86JP accepted but this client never writes.
ExtraKey: uint16(body[3]) | uint16(body[4])<<8,
}, nil
}
// runtimeRaidBuffActivationKey names one activation slot. Keyed by the raid and
// the client category, which is 86JP's rule too (RaidHandler.Situation.cs:84) and
// means one activation per skill per raid, raid-wide: not per squad, and not per
// leader.
type runtimeRaidBuffActivationKey struct {
RaidKey uint32
Category byte
}
// runtimeRaidBuffActivation is one running skill. Both deadlines are absolute Unix
// seconds, because that is what 0x0279 carries and what the client compares against
// its own clock (0x017B5800 / 0x017B5812).
type runtimeRaidBuffActivation struct {
PartyIndex uint16
MemberKey uint16
ActiveUntil uint32
CooldownUntil uint32
}
// runtimeRaidBuffActivationFor returns one raid's live activation for a category.
// Expired cooldowns are pruned on read, the same lazy rule 86JP uses
// (RaidHandler.Situation.cs:293-296): there is no expiry timer anywhere, and a
// stale entry is indistinguishable from no entry once its cooldown has passed.
func (s *Service) runtimeRaidBuffActivationFor(raidKey uint32, category byte) (runtimeRaidBuffActivation, bool) {
if s == nil || raidKey == 0 {
return runtimeRaidBuffActivation{}, false
}
key := runtimeRaidBuffActivationKey{RaidKey: raidKey, Category: category}
now := uint32(time.Now().Unix())
s.raidMu.Lock()
defer s.raidMu.Unlock()
activation, ok := s.raidBuffActivations[key]
if !ok {
return runtimeRaidBuffActivation{}, false
}
if activation.CooldownUntil <= now {
delete(s.raidBuffActivations, key)
return runtimeRaidBuffActivation{}, false
}
return activation, true
}
// recordRuntimeRaidBuffActivation stores one activation. A category already inside
// its cooldown is refused, which is what makes the client's own 「技能冷却中」
// (string 30311) line truthful: the client blocks its send from the 0x0279 deadlines
// it was last given, and this is the same rule on the authoritative side.
//
// Cooldowns outlive a phase on purpose -- 86JP never clears them on a phase
// transition either (its ResetRaidPhasePresentationState omits _raidBuffActivations)
// -- but they cannot outlive the raid.
func (s *Service) recordRuntimeRaidBuffActivation(
raidKey uint32,
category byte,
definition runtimeRaidBuffDefinition,
partyIndex uint16,
memberKey uint16,
) (runtimeRaidBuffActivation, bool) {
if s == nil || raidKey == 0 {
return runtimeRaidBuffActivation{}, false
}
key := runtimeRaidBuffActivationKey{RaidKey: raidKey, Category: category}
now := uint32(time.Now().Unix())
s.raidMu.Lock()
defer s.raidMu.Unlock()
if current, ok := s.raidBuffActivations[key]; ok && current.CooldownUntil > now {
return runtimeRaidBuffActivation{}, false
}
activation := runtimeRaidBuffActivation{
PartyIndex: partyIndex,
MemberKey: memberKey,
ActiveUntil: now + definition.DurationSecs,
CooldownUntil: now + definition.CooldownSecs,
}
if s.raidBuffActivations == nil {
s.raidBuffActivations = make(map[runtimeRaidBuffActivationKey]runtimeRaidBuffActivation)
}
s.raidBuffActivations[key] = activation
return activation, true
}
// purgeRuntimeRaidBuffActivationsLocked drops every activation of one raid. Caller
// holds s.raidMu.
func (s *Service) purgeRuntimeRaidBuffActivationsLocked(raidKey uint32) {
if s == nil || raidKey == 0 {
return
}
for key := range s.raidBuffActivations {
if key.RaidKey == raidKey {
delete(s.raidBuffActivations, key)
}
}
}
// runtimeRaidBuffStatusBody builds the 0x0279 body: the five slots in the client's
// own slot order, each with the raid's live row (or no row at all).
//
// A slot with no rows is what an idle skill looks like, and a zero-row group is
// inert rather than harmful: the client's lookup skips the key entirely
// (0x017B57E2) and falls out of the clean false return at 0x017B5869. So this body
// is byte-identical to the all-empty registration body this server used before the
// feature existed, until something is actually activated.
func (s *Service) runtimeRaidBuffStatusBody(state runtimeRaidState) ([]byte, error) {
groups := make([]raid.RaidBuffGroup, 0, len(runtimeRaidClientBuffSlots))
now := uint32(time.Now().Unix())
for _, category := range runtimeRaidClientBuffSlots {
group := raid.RaidBuffGroup{Field0: category}
if s != nil && state.RaidKey != 0 {
key := runtimeRaidBuffActivationKey{RaidKey: state.RaidKey, Category: category}
s.raidMu.Lock()
activation, ok := s.raidBuffActivations[key]
if ok && activation.CooldownUntil <= now {
delete(s.raidBuffActivations, key)
ok = false
}
s.raidMu.Unlock()
if ok {
group.Entries = []raid.RaidBuffEntry{{
PartyIndex: activation.PartyIndex,
MemberKey: activation.MemberKey,
Value0: activation.ActiveUntil,
Value1: activation.CooldownUntil,
}}
}
}
groups = append(groups, group)
}
return raid.BuildRaidBuffBody(groups)
}
// broadcastRuntimeRaidBuffStatus publishes the 0x0279 status to the whole raid.
func (s *Service) broadcastRuntimeRaidBuffStatus(state runtimeRaidState) error {
body, err := s.runtimeRaidBuffStatusBody(state)
if err != nil {
return err
}
return s.fanOutRuntimeRaidWireSequence(state, runtimeRaidWireNotification{
msgID: raid.RaidBuffMsgID,
body: body,
})
}
// sendRuntimeRaidBuffStatus answers one session with the 0x0279 status. Every
// refused activation uses this, which is 86JP's shape
// (RaidHandler.Situation.cs:423-426): the panel is driven by that packet, so a
// rejection has to refresh it or the client keeps showing a cooldown the server no
// longer believes in.
func (s *Service) sendRuntimeRaidBuffStatus(session *gameSession, state runtimeRaidState) error {
if s == nil || session == nil {
return nil
}
body, err := s.runtimeRaidBuffStatusBody(state)
if err != nil {
return err
}
return s.sendGameUpperRawClassCodec(session, raid.RaidBuffMsgID, body, 0, true)
}
// runtimeRaidBuffLeaderAuthorized reports whether this member may use the panel.
//
// 86JP's CanUseAntonRaidBuff (RaidHandler.Situation.cs:170-178) is exactly this:
// the raid is running and the caller is its leader. Nothing else in the raid is
// leader-gated, so it is one small named rule rather than an inline check.
func runtimeRaidBuffLeaderAuthorized(state runtimeRaidState, memberKey uint16) bool {
return memberKey != 0 &&
state.LeaderID == memberKey &&
runtimeRaidBuffPhaseRunning(state)
}
// runtimeRaidBuffPhaseRunning reports "the raid's attack is in progress", which is
// 86JP's `raid.State == 2`.
func runtimeRaidBuffPhaseRunning(state runtimeRaidState) bool {
return state.Started && state.Lifecycle.Status == runtimeRaidLifecyclePhaseActive
}
// runtimeRaidBuffTargetParty resolves the activation row's target from the member
// the panel had selected.
//
// The client sends a MEMBER key, not a squad number, so unlike 86JP
// (Situation.cs:374-378, which matches `group.PartyIndex == requestedPartyIndex`)
// the squad has to be looked up FROM the member. A squad-designated skill also
// requires that member to be standing in a gate, which is the same rule the client
// states in string 30313 「只能指定已进入地下城的小队。」 and the same one 86JP
// enforces through `group.DungeonId != 0` (Situation.cs:388-393).
func (s *Service) runtimeRaidBuffTargetParty(
state runtimeRaidState,
targetKey uint16,
) (uint16, bool) {
if targetKey == 0 {
return 0, false
}
member, ok := runtimeRaidFrozenMember(state, targetKey)
if !ok {
return 0, false
}
placements := s.collectRuntimeRaidGatePlacements(state)
if _, placed := placements[targetKey]; !placed {
return 0, false
}
return uint16(runtimeRaidMemberSquad(member.GroupIndex)), true
}
// runtimeRaidBuffEffect is what an activated skill has to announce beyond its row.
type runtimeRaidBuffEffect struct {
// ExtendedTime is set by INCREASE TIME: the phase deadline moved, so the raid
// needs the new 0x0254/0x0255 pair.
ExtendedTime bool
Deadline time.Time
Remaining uint32
// CoinGranted is set by INCREASE COIN: the squad's reported spend changed, so
// the battle-situation rows need re-publishing.
CoinGranted bool
// State is the raid snapshot the effect was applied to, used to address the
// announcement.
State runtimeRaidState
}
// applyRuntimeRaidBuffEffect performs the server-side effect of one skill.
//
// Only two skills have one, and both are deliberately narrow, matching 86JP:
//
// - INCREASE TIME adds the PVF effect value (300s) to the phase deadline. It is
// CAPPED AT ONE FULL PHASE, refused outright once the phase has expired, and
// truncated when less room is left, which is 86JP's TryExtendPhaseTime
// (RaidManager.cs:1884-1936: `availableRoom = max - currentRemaining`, false
// when the remainder is 0, `applied = min(additional, availableRoom)`).
// - INCREASE COIN does not hand out coins: the client's denominator is ITS OWN
// per-gate allowance and never comes off the wire, so the only lever the server
// has is the numerator. 86JP does the same thing -- it raises the squad's
// `GrantedCoinCount` and reports `used - granted` -- which makes the client's
// own `allowance - reported` come out four higher. The grant is capped by the
// squad's ACTUAL over-spend, so a squad that has not spent beyond its grant gets
// nothing and the skill does not burn its cooldown.
//
// The three remaining skills return applied=true with no effect, because that is
// what they are: panel state.
func (s *Service) applyRuntimeRaidBuffEffect(
state runtimeRaidState,
definition runtimeRaidBuffDefinition,
targetKey uint16,
) (*runtimeRaidBuffEffect, bool, string) {
switch definition.Category {
case runtimeRaidBuffIncreaseTime:
return s.applyRuntimeRaidBuffIncreaseTime(state, definition)
case runtimeRaidBuffIncreaseCoin:
return s.applyRuntimeRaidBuffIncreaseCoin(state, definition, targetKey)
default:
return nil, true, "panel_state_only"
}
}
func (s *Service) applyRuntimeRaidBuffIncreaseTime(
state runtimeRaidState,
definition runtimeRaidBuffDefinition,
) (*runtimeRaidBuffEffect, bool, string) {
if definition.EffectValue == 0 {
return nil, false, "increase_time_effect_value_is_zero"
}
if !runtimeRaidBuffPhaseRunning(state) || state.Lifecycle.Phase == 0 {
return nil, false, "increase_time_needs_a_running_phase"
}
_, baseSeconds, ok := runtimeRaidPhaseConfig(state, state.Lifecycle.Phase)
if !ok || baseSeconds <= 0 {
return nil, false, "increase_time_has_no_phase_config"
}
// The phase math uses the same clock as the timer that will be re-armed below.
// Reading a different clock here would let the deadline move while the delay
// computed for the new job was based on another "now".
now := s.gameplayNow()
remaining := runtimeRaidPhaseRemaining(state, now)
if remaining <= 0 {
// An expired phase must not be revived: its timeout has already been
// scheduled and the raid is on its way to the failure result.
return nil, false, "increase_time_phase_already_expired"
}
maxTotal := time.Duration(baseSeconds) * time.Second
room := maxTotal - remaining
if room <= 0 {
return nil, false, "increase_time_phase_timer_is_already_full"
}
applied := time.Duration(definition.EffectValue) * time.Second
if applied > room {
applied = room
}
if applied <= 0 {
return nil, false, "increase_time_has_no_room"
}
deadline := state.Lifecycle.PhaseDeadline.Add(applied)
updated, err := s.setRuntimeRaidPhaseDeadline(state.RaidKey, state.Lifecycle.Phase, deadline)
if err != nil {
return nil, false, "increase_time_deadline_update_failed:" + err.Error()
}
// Re-arm the phase expiry under the SAME timer name, which is what replaces the
// old deadline: leaving the previous job armed would fail the phase at the old
// instant even though the deadline moved.
if err := s.scheduleRuntimeRaidPhaseExpiry(updated); err != nil {
return nil, false, "increase_time_reschedule_failed:" + err.Error()
}
return &runtimeRaidBuffEffect{
ExtendedTime: true,
Deadline: deadline,
Remaining: runtimeRaidDurationSeconds(runtimeRaidPhaseRemaining(updated, now)),
State: updated,
}, true, "phase_deadline_extended"
}
func (s *Service) applyRuntimeRaidBuffIncreaseCoin(
state runtimeRaidState,
definition runtimeRaidBuffDefinition,
targetKey uint16,
) (*runtimeRaidBuffEffect, bool, string) {
if definition.EffectValue == 0 {
return nil, false, "increase_coin_effect_value_is_zero"
}
placements := s.collectRuntimeRaidGatePlacements(state)
placement, placed := placements[targetKey]
if !placed {
return nil, false, "increase_coin_target_is_not_inside_a_gate"
}
identity, ok := runtimeRaidMonsterStateRowIdentityForMember(state, targetKey)
if !ok {
return nil, false, "increase_coin_target_has_no_row"
}
key := runtimeRaidReviveCoinKey{
RaidKey: state.RaidKey,
Phase: state.Lifecycle.Phase,
Group: identity.Group,
SoloCharID: identity.SoloCharID,
DungeonID: placement.DungeonID,
}
used := s.runtimeRaidReviveCoinUses(key)
granted := s.runtimeRaidReviveCoinGranted(key)
overUse := uint32(0)
if used > granted {
overUse = used - granted
}
if overUse == 0 {
// Nothing to cover: 86JP refuses here too (`appliedCount == 0` ->
// TryGrantAdditionalCoinUses returns false, RaidManager.cs:561-563), so the
// cooldown is not consumed for a squad that has spent nothing extra.
return nil, false, "increase_coin_squad_has_not_overspent"
}
applied := definition.EffectValue
if applied > overUse {
applied = overUse
}
s.grantRuntimeRaidReviveCoins(key, applied)
return &runtimeRaidBuffEffect{CoinGranted: true, State: state}, true, "revive_coin_grant_recorded"
}
// publishRuntimeRaidBuffEffect announces what an effect changed.
func (s *Service) publishRuntimeRaidBuffEffect(state runtimeRaidState, effect runtimeRaidBuffEffect) error {
if effect.ExtendedTime {
// 86JP's order for the time extension: 0x0254 then 0x0255
// (RaidHandler.Situation.cs:151-152).
deadline := effect.Deadline
duration := deadline.Sub(state.Lifecycle.PhaseStartedAt)
if duration < 0 {
duration = 0
}
return s.fanOutRuntimeRaidWireSequence(effect.State,
runtimeRaidWireNotification{
msgID: raid.RaidTimerMsgID,
body: raid.BuildRaidTimerBody(raid.RaidTimer{
TimerType: 0,
Value0: runtimeRaidTopTimerKey,
Value1: uint32(deadline.Unix()),
Value2: runtimeRaidDurationSeconds(duration),
}),
},
runtimeRaidWireNotification{
msgID: raid.RaidRemainTimeMsgID,
body: raid.BuildRaidRemainTimeBody(raid.RaidRemainTime{
TimerType: 0,
Seconds: effect.Remaining,
}),
})
}
if effect.CoinGranted {
// The squad's reported spend changed, so its battle-situation row has to be
// re-published (86JP broadcasts its 0x027B on the coin grant too,
// RaidHandler.Situation.cs:157).
return s.broadcastRuntimeRaidMonsterState(effect.State)
}
return nil
}
// handleOnlineRaidBuffSystem serves C2S 0x02B6.
//
// THERE IS DELIBERATELY NO REPLY ON THIS OPCODE, and that is not an oversight:
//
// - 86JP answered with `cmd=1, type=0x02B6, body={0|1}`
// (RaidHandler.cs:1935-1938), but its client had no inbound 0x02B6 handler at
// all, so a one-byte body was free.
// - THIS client does. It registers 0x02B6 inbound at 0x016281BA (handler
// 0x01623CD0), and that handler reads FOUR u32s off the inbound cursor
// (0x01623D03 `mov edi,4` + 0x01623D09 `call 0x34E3A50`). The read primitives
// perform no bounds check (tools/_probe_694_report.md section 5), so echoing
// 86JP's one-byte ack would make the client read fifteen bytes past the body --
// the shape of bug that surfaces as a random crash much later.
// - The panel does not need it. The five icons are driven entirely by 0x0279: the
// lookup sub_17B5730 reads the row deadlines and sub_15C6AD0 builds the icon
// from them. Publishing the new 0x0279 status IS the confirmation.
//
// So every path answers with 0x0279 and nothing else, including the refusal paths,
// whose resync is 86JP's own shape (Situation.cs:39-40, 59-60, 68-69, 79-80).
// Nothing here may fail the caller: the opcode sits on the client's input path.
func (s *Service) handleOnlineRaidBuffSystem(session *gameSession, typ uint16, body []byte) (bool, error) {
if s == nil || session == nil {
return false, nil
}
memberKey := session.selectedCharacterID
state, ok := s.runtimeRaidForMember(memberKey)
if !ok {
return true, nil
}
request, err := decodeRuntimeRaidBuffRequest(body)
if err != nil {
s.logGameEvent(session, "game-raid-buff-request-invalid",
"type", typ, "char_id", memberKey,
"body_len", len(body), "error", err)
_ = s.sendRuntimeRaidBuffStatus(session, state)
return true, nil
}
if !runtimeRaidBuffLeaderAuthorized(state, memberKey) {
s.logGameEvent(session, "game-raid-buff-rejected",
"type", typ, "char_id", memberKey, "raid_key", state.RaidKey,
"category", request.Category, "reason", "not_the_leader_or_phase_not_running")
_ = s.sendRuntimeRaidBuffStatus(session, state)
return true, nil
}
definition, ok := runtimeRaidBuffDefinitionFor(request.Category)
if !ok {
_ = s.sendRuntimeRaidBuffStatus(session, state)
return true, nil
}
partyIndex := uint16(0)
targetKey := memberKey
if !definition.RaidWide {
resolvedParty, resolved := s.runtimeRaidBuffTargetParty(state, request.TargetKey)
if !resolved {
s.logGameEvent(session, "game-raid-buff-rejected",
"type", typ, "char_id", memberKey, "raid_key", state.RaidKey,
"category", request.Category, "target", request.TargetKey,
"reason", "designated_squad_member_is_not_inside_a_gate")
_ = s.sendRuntimeRaidBuffStatus(session, state)
return true, nil
}
partyIndex = resolvedParty
targetKey = request.TargetKey
} else {
// A raid-wide skill ignores the wire target, exactly as 86JP does
// (Situation.cs:72-82): its row names the leader's own squad and the
// activating member.
if member, found := runtimeRaidFrozenMember(state, memberKey); found {
partyIndex = uint16(runtimeRaidMemberSquad(member.GroupIndex))
}
}
// The effect is applied before the activation is recorded, so a skill whose
// effect cannot be applied does not burn its cooldown. 86JP orders it the same
// way: the effect call gates the activation record (Situation.cs:107-128).
effect, effectApplied, effectNote := s.applyRuntimeRaidBuffEffect(state, definition, targetKey)
if !effectApplied {
s.logGameEvent(session, "game-raid-buff-rejected",
"type", typ, "char_id", memberKey, "raid_key", state.RaidKey,
"category", request.Category, "target", targetKey, "reason", effectNote)
_ = s.sendRuntimeRaidBuffStatus(session, state)
return true, nil
}
activation, activated := s.recordRuntimeRaidBuffActivation(
state.RaidKey, request.Category, definition, partyIndex, targetKey)
if !activated {
s.logGameEvent(session, "game-raid-buff-rejected",
"type", typ, "char_id", memberKey, "raid_key", state.RaidKey,
"category", request.Category, "reason", "still_on_cooldown")
_ = s.sendRuntimeRaidBuffStatus(session, state)
return true, nil
}
s.logGameEvent(session, "game-raid-buff-activated",
"type", typ, "char_id", memberKey, "raid_key", state.RaidKey,
"category", request.Category, "raid_wide", definition.RaidWide,
"target", targetKey, "party", partyIndex,
"active_until", activation.ActiveUntil,
"cooldown_until", activation.CooldownUntil,
"extra_key", request.ExtraKey,
"server_effect", effectNote)
// Order follows 86JP (Situation.cs:144 -> :151-152): the panel status first,
// then whatever the effect has to announce.
if err := s.broadcastRuntimeRaidBuffStatus(state); err != nil {
s.logGameEvent(session, "game-raid-buff-status-broadcast-failed",
"type", typ, "char_id", memberKey, "raid_key", state.RaidKey, "error", err)
}
if effect != nil {
if err := s.publishRuntimeRaidBuffEffect(state, *effect); err != nil {
s.logGameEvent(session, "game-raid-buff-effect-publish-failed",
"type", typ, "char_id", memberKey, "raid_key", state.RaidKey, "error", err)
}
}
return true, nil
}