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