服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
555 lines
20 KiB
Go
555 lines
20 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/raid"
|
|
)
|
|
|
|
// S2C 0x027A (RaidMonsterStateMsgID) is the battle-situation board's row packet:
|
|
// one row per squad, carrying how many revive coins that squad has spent, the
|
|
// record-set key the squad itself reports under, and the per-object values the
|
|
// board draws its bars from.
|
|
//
|
|
// Everything below is shaped by tools/_probe_634_report.md (client disassembly of
|
|
// DNF.exe sha256 8A7AF614...AA4D) and by the 86JP reference
|
|
// (RaidHandler.Situation.cs:433-456, RaidPacketBuilder.cs:477-501). Three of those
|
|
// findings are hard constraints rather than preferences:
|
|
//
|
|
// 1. The client REPLACES its whole row table with this body. Its
|
|
// vector<Group>::operator= (0x17BF680) has no slot-preservation path, so a
|
|
// squad omitted from the body loses its row. Every squad that should be drawn
|
|
// must be in the same packet.
|
|
// 2. A body with GROUP COUNT 0 is not "nothing to say": it clears the table
|
|
// (0x01D504CC-0x01D50502 on the 587 side, 0x17BF6AC on this one). This file
|
|
// therefore never emits an empty body -- see runtimeRaidMonsterStateRows.
|
|
// 3. A group's dungeon is not a field in the packet. The client derives it by
|
|
// looking the group's FIRST member up in its 0x0248 participation rows
|
|
// (0x1D50BB6 -> 0x17B4280 -> [mgr+0xCC]) and skips the whole value phase when
|
|
// that lookup fails. The member list must therefore be the real squad, and the
|
|
// 0x0248 row for that squad must already have been sent.
|
|
|
|
// runtimeRaidMonsterRuntimeReport is one squad's stored 0x02B6 report for one gate.
|
|
//
|
|
// RecordSetKey is the value the reporting client itself used (the leading dword of
|
|
// its 0x02B6 body). It has to be echoed unchanged: the client keeps its records in
|
|
// a (dungeon -> key -> records) map and treats an unknown key by CREATING a record
|
|
// there, which then persists and re-enters the client's own later reports
|
|
// (_probe_634_report.md section 6c Q4).
|
|
type runtimeRaidMonsterRuntimeReport struct {
|
|
RecordSetKey uint32
|
|
Values []uint32
|
|
}
|
|
|
|
// runtimeRaidGatePlacement is where one online member currently stands.
|
|
//
|
|
// It deliberately carries the gate and nothing else. The board's V0 is a property
|
|
// of the GATE, not of the run a member happens to be in (see
|
|
// runtimeRaidReviveCoinKey), so it is read from the raid-scoped ledger instead of
|
|
// being scraped off whichever run is open.
|
|
type runtimeRaidGatePlacement struct {
|
|
DungeonID int
|
|
}
|
|
|
|
// collectRuntimeRaidGatePlacements reads every online member's current raid gate.
|
|
//
|
|
// The member session's own run binding is the only authority for "this member is
|
|
// inside this gate": freezeRuntimeRaidDungeonSelection is what writes it, and it
|
|
// is cleared with the run. A binding that names another raid or another phase is
|
|
// not this raid's gate and is skipped.
|
|
//
|
|
// Lock order: session.dungeon.mu is a leaf here -- the critical section reads two
|
|
// fields and takes no other lock, so it cannot invert the raid lifecycle's
|
|
// dungeon.mu -> raidMu order.
|
|
func (s *Service) collectRuntimeRaidGatePlacements(
|
|
state runtimeRaidState,
|
|
) map[uint16]runtimeRaidGatePlacement {
|
|
if s == nil || state.RaidKey == 0 {
|
|
return nil
|
|
}
|
|
placements := make(map[uint16]runtimeRaidGatePlacement, len(state.FrozenMembers))
|
|
for _, member := range state.FrozenMembers {
|
|
session, online := s.onlineGameSession(member.CharID)
|
|
if !online || session == nil {
|
|
continue
|
|
}
|
|
session.dungeon.mu.Lock()
|
|
runtime := session.dungeon.runtime
|
|
var binding runtimeRaidDungeonBinding
|
|
if runtime != nil {
|
|
binding = runtime.raidDungeon
|
|
}
|
|
session.dungeon.mu.Unlock()
|
|
if binding.RaidKey != state.RaidKey ||
|
|
binding.Phase != state.Lifecycle.Phase ||
|
|
binding.MapID == 0 {
|
|
continue
|
|
}
|
|
placements[member.CharID] = runtimeRaidGatePlacement{
|
|
DungeonID: binding.MapID,
|
|
}
|
|
}
|
|
return placements
|
|
}
|
|
|
|
// runtimeRaidMonsterStateRowIdentity names one board row. It is the same identity
|
|
// the 0x0248 participation rows and the stored 0x02B6 reports use: one row per
|
|
// non-zero squad, and one row per unassigned member (group 0).
|
|
type runtimeRaidMonsterStateRowIdentity struct {
|
|
Group byte
|
|
SoloCharID uint16
|
|
}
|
|
|
|
// runtimeRaidMonsterStateRows builds the 0x027A body rows for one raid.
|
|
//
|
|
// Only squads actually standing in a gate get a row: a squad with no gate has
|
|
// nothing for the board to draw, and because the client replaces its whole table,
|
|
// leaving such a squad out is exactly how its stale row is removed.
|
|
//
|
|
// ok is false when no squad is in a gate. Callers MUST NOT send that case: a
|
|
// group count of 0 clears the client's table rather than being a no-op
|
|
// (_probe_634_report.md section 6c Q1), so "no rows" has to mean "send nothing",
|
|
// not "send an empty body".
|
|
func (s *Service) runtimeRaidMonsterStateRows(
|
|
state runtimeRaidState,
|
|
) ([]raid.RaidMonsterStateGroup, bool) {
|
|
if s == nil || state.RaidKey == 0 {
|
|
return nil, false
|
|
}
|
|
placements := s.collectRuntimeRaidGatePlacements(state)
|
|
if len(placements) == 0 {
|
|
return nil, false
|
|
}
|
|
reports := s.runtimeRaidMonsterRuntimeValuesFor(state.RaidKey)
|
|
|
|
identities := runtimeRaidMonsterStateRowIdentities(state)
|
|
rows := make([]raid.RaidMonsterStateGroup, 0, len(identities))
|
|
for _, identity := range identities {
|
|
members := runtimeRaidMonsterStateRowMembers(state, identity)
|
|
if len(members) == 0 {
|
|
continue
|
|
}
|
|
// The row's gate is the gate its members stand in. Members of one squad
|
|
// share a run, so the first member that is placed decides it; a member who
|
|
// is between scenes simply contributes nothing.
|
|
dungeonID := 0
|
|
for _, member := range members {
|
|
if placement, placed := placements[member]; placed {
|
|
dungeonID = placement.DungeonID
|
|
break
|
|
}
|
|
}
|
|
if dungeonID == 0 {
|
|
continue
|
|
}
|
|
// V0 is the squad's spend in THIS gate after the coins INCREASE COIN has
|
|
// covered are taken off, which is 86JP's GetAntonReportedUsedCoinCount
|
|
// (Situation.cs:458-463). The client renders `its own gate allowance - V0`,
|
|
// so subtracting the grant is the ONLY way a granted coin can reach the
|
|
// panel: the allowance itself never travels on the wire.
|
|
//
|
|
// Both numbers come from the gate-scoped ledger rather than from the run the
|
|
// squad happens to be in: the client's allowance belongs to the GATE, so a
|
|
// per-run counter would zero itself every re-entry.
|
|
coinKey := runtimeRaidReviveCoinKey{
|
|
RaidKey: state.RaidKey,
|
|
Phase: state.Lifecycle.Phase,
|
|
Group: identity.Group,
|
|
SoloCharID: identity.SoloCharID,
|
|
DungeonID: dungeonID,
|
|
}
|
|
used := s.runtimeRaidReviveCoinUses(coinKey)
|
|
granted := s.runtimeRaidReviveCoinGranted(coinKey)
|
|
report, reported := runtimeRaidMonsterRuntimeReportFrom(reports, identity, dungeonID)
|
|
row := raid.RaidMonsterStateGroup{
|
|
PartyIndex: uint16(identity.Group),
|
|
Members: members,
|
|
Value0: runtimeRaidReportedUsedCoinCount(used, granted),
|
|
}
|
|
if reported {
|
|
row.Value1 = report.RecordSetKey
|
|
row.Values = report.Values
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
if len(rows) == 0 {
|
|
return nil, false
|
|
}
|
|
return rows, true
|
|
}
|
|
|
|
// runtimeRaidMonsterStateRowIdentities lists the board's rows in a stable order:
|
|
// squads ascending, then unassigned members by character id.
|
|
func runtimeRaidMonsterStateRowIdentities(
|
|
state runtimeRaidState,
|
|
) []runtimeRaidMonsterStateRowIdentity {
|
|
identities := make([]runtimeRaidMonsterStateRowIdentity, 0, len(state.FrozenMembers))
|
|
seenSquads := make(map[byte]bool, runtimeRaidMaxGroups)
|
|
for _, member := range state.FrozenMembers {
|
|
group := runtimeRaidMemberSquad(member.GroupIndex)
|
|
if group == 0 {
|
|
identities = append(identities, runtimeRaidMonsterStateRowIdentity{SoloCharID: member.CharID})
|
|
continue
|
|
}
|
|
if seenSquads[group] {
|
|
continue
|
|
}
|
|
seenSquads[group] = true
|
|
identities = append(identities, runtimeRaidMonsterStateRowIdentity{Group: group})
|
|
}
|
|
sort.Slice(identities, func(i, j int) bool {
|
|
if identities[i].Group != identities[j].Group {
|
|
return identities[i].Group < identities[j].Group
|
|
}
|
|
return identities[i].SoloCharID < identities[j].SoloCharID
|
|
})
|
|
return identities
|
|
}
|
|
|
|
// runtimeRaidMonsterStateRowMembers lists one row's member keys. An unassigned
|
|
// member is its own row so it never appears inside a real squad's row, which is
|
|
// the same rule the 0x0248 rows follow.
|
|
//
|
|
// The squad list comes from the LIVE labels for the reason
|
|
// runtimeRaidMembersInSquadLiveRows records: the client classifies this row by
|
|
// looking each listed member up in its own 0x024F roster, and that roster is built
|
|
// from the live rows -- a member listed here whose live label is 0 makes the whole
|
|
// row draw as 单人 with an unresolved portrait.
|
|
func runtimeRaidMonsterStateRowMembers(
|
|
state runtimeRaidState,
|
|
identity runtimeRaidMonsterStateRowIdentity,
|
|
) []uint16 {
|
|
if identity.Group == 0 {
|
|
return []uint16{identity.SoloCharID}
|
|
}
|
|
return runtimeRaidMembersInSquadLiveRows(state, identity.Group)
|
|
}
|
|
|
|
// runtimeRaidMonsterRuntimeReportFrom picks one row's report out of an already
|
|
// copied snapshot, so building rows never holds s.raidMu while it works.
|
|
func runtimeRaidMonsterRuntimeReportFrom(
|
|
reports map[runtimeRaidMonsterRuntimeKey][]uint32,
|
|
identity runtimeRaidMonsterStateRowIdentity,
|
|
dungeonID int,
|
|
) (runtimeRaidMonsterRuntimeReport, bool) {
|
|
var (
|
|
best runtimeRaidMonsterRuntimeReport
|
|
found bool
|
|
)
|
|
for key, values := range reports {
|
|
if key.Group != identity.Group ||
|
|
key.SoloCharID != identity.SoloCharID ||
|
|
key.DungeonID != dungeonID {
|
|
continue
|
|
}
|
|
if found && key.RecordSetKey >= best.RecordSetKey {
|
|
continue
|
|
}
|
|
best = runtimeRaidMonsterRuntimeReport{
|
|
RecordSetKey: key.RecordSetKey,
|
|
Values: append([]uint32(nil), values...),
|
|
}
|
|
found = true
|
|
}
|
|
return best, found
|
|
}
|
|
|
|
// runtimeRaidMonsterStateBody encodes the 0x027A body for one raid.
|
|
//
|
|
// ok is false when the raid has no squad in a gate, and that case must NOT be sent:
|
|
// see runtimeRaidMonsterStateRows.
|
|
func (s *Service) runtimeRaidMonsterStateBody(state runtimeRaidState) ([]byte, bool, error) {
|
|
rows, ok := s.runtimeRaidMonsterStateRows(state)
|
|
if !ok {
|
|
return nil, false, nil
|
|
}
|
|
body, err := raid.BuildRaidMonsterStateBody(rows)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
return body, true, nil
|
|
}
|
|
|
|
// sendRuntimeRaidMonsterState answers one session's board refresh.
|
|
//
|
|
// The 0x0248 participation rows come FIRST by contract: the client resolves each
|
|
// row's dungeon through them, and drops the row's values when the lookup fails.
|
|
// The client asks for this refresh on a 7-15s timer, so a raid with nobody in a
|
|
// gate must answer with nothing at all rather than an empty table.
|
|
func (s *Service) sendRuntimeRaidMonsterState(session *gameSession, state runtimeRaidState) error {
|
|
if s == nil || session == nil {
|
|
return nil
|
|
}
|
|
body, ok, err := s.runtimeRaidMonsterStateBody(state)
|
|
if err != nil || !ok {
|
|
return err
|
|
}
|
|
return s.sendGameUpperRawClassCodec(session, raid.RaidMonsterStateMsgID, body, 0, true)
|
|
}
|
|
|
|
// broadcastRuntimeRaidMonsterState pushes the board's rows to every member of the
|
|
// raid. Every member gets the same body, because the client's table is global to
|
|
// the raid, not per squad.
|
|
func (s *Service) broadcastRuntimeRaidMonsterState(state runtimeRaidState) error {
|
|
if s == nil {
|
|
return nil
|
|
}
|
|
body, ok, err := s.runtimeRaidMonsterStateBody(state)
|
|
if err != nil || !ok {
|
|
return err
|
|
}
|
|
return s.broadcastRuntimeRaidWireSequence(state, runtimeRaidWireNotification{
|
|
msgID: raid.RaidMonsterStateMsgID,
|
|
body: body,
|
|
})
|
|
}
|
|
|
|
// scheduleRuntimeRaidMonsterStateBroadcast republishes the board from the gameplay
|
|
// timer queue instead of from the caller's own goroutine.
|
|
//
|
|
// The caller is the op41 use-coin path, which holds the spending member's
|
|
// session.dungeon.mu. Republishing inline would broadcast to every member, and the
|
|
// body's build reads each member's session lock to find out which gate that member
|
|
// stands in (collectRuntimeRaidGatePlacements), so two members spending a coin at
|
|
// the same instant would each wait for the other while holding their own lock --
|
|
// the live 2026-09-20 08:32:02Z deadlock, and the same reason
|
|
// scheduleRuntimeRaidForcedGroupReturn exists. Running the task on the queue means
|
|
// it starts with no session lock held.
|
|
//
|
|
// The raid state is looked up fresh inside the task rather than passed in, so the
|
|
// body reflects the ledger at the moment it is actually built: a spend or a gate
|
|
// clear that lands in between is included instead of being missed, and a raid that
|
|
// has already ended simply publishes nothing.
|
|
//
|
|
// A bare goroutine is the fallback when the queue is unavailable or refuses the
|
|
// task. Dropping the publish would freeze the 战况表 at its previous number -- the
|
|
// 2026-09-23 report was exactly that ("一直是10/12") -- so losing the refresh is
|
|
// worse than running it off-queue.
|
|
//
|
|
// A second spend cancels the first pending task of the same name (the queue
|
|
// replaces by name) and publishes once instead, which is what we want: the
|
|
// surviving task rebuilds the body from the ledger, so it carries both spends.
|
|
func (s *Service) scheduleRuntimeRaidMonsterStateBroadcast(raidKey uint32, source string) {
|
|
if s == nil || raidKey == 0 {
|
|
return
|
|
}
|
|
run := func(time.Time) {
|
|
s.raidMu.Lock()
|
|
current := s.raids[raidKey]
|
|
if current == nil {
|
|
s.raidMu.Unlock()
|
|
return
|
|
}
|
|
state := cloneRuntimeRaidState(*current)
|
|
s.raidMu.Unlock()
|
|
if err := s.broadcastRuntimeRaidMonsterState(state); err != nil {
|
|
s.logPacketEvent("game-raid-monster-state-broadcast-failed",
|
|
"raid_key", raidKey, "source", source, "error", err)
|
|
}
|
|
}
|
|
queue := s.ensureGameplayTimeQueue()
|
|
if queue == nil {
|
|
go run(time.Time{})
|
|
return
|
|
}
|
|
name := fmt.Sprintf("raid-monster-state:%d:%s", raidKey, source)
|
|
if err := queue.ScheduleAfter(name, 0, run); err != nil {
|
|
s.logPacketEvent("game-raid-monster-state-schedule-failed",
|
|
"raid_key", raidKey, "source", source, "error", err)
|
|
go run(time.Time{})
|
|
}
|
|
}
|
|
|
|
// runtimeRaidMonsterStateRowIdentityForMember names the board row a member belongs
|
|
// to: its squad's row, or its own row when it is unassigned.
|
|
func runtimeRaidMonsterStateRowIdentityForMember(
|
|
state runtimeRaidState,
|
|
memberKey uint16,
|
|
) (runtimeRaidMonsterStateRowIdentity, bool) {
|
|
member, ok := runtimeRaidFrozenMember(state, memberKey)
|
|
if !ok {
|
|
return runtimeRaidMonsterStateRowIdentity{}, false
|
|
}
|
|
group := runtimeRaidMemberSquad(member.GroupIndex)
|
|
if group == 0 {
|
|
return runtimeRaidMonsterStateRowIdentity{SoloCharID: memberKey}, true
|
|
}
|
|
return runtimeRaidMonsterStateRowIdentity{Group: group}, true
|
|
}
|
|
|
|
// runtimeRaidReportedUsedCoinCount is the number 634 carries for a row: the spend
|
|
// that the squad's own grant does not cover.
|
|
//
|
|
// It is 86JP's GetAntonReportedUsedCoinCount (RaidHandler.Situation.cs:458-463)
|
|
// verbatim, clamp included: the client subtracts this from its own per-gate
|
|
// allowance, so a spend already covered by a granted coin must not be reported
|
|
// twice, and the value can never go negative.
|
|
func runtimeRaidReportedUsedCoinCount(used, granted uint32) uint32 {
|
|
if used > granted {
|
|
return used - granted
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// runtimeRaidReviveCoinKey names one row's revive-coin ledger entry: the 634 row
|
|
// identity, the phase, and the gate.
|
|
//
|
|
// Phase and gate are both in the key on purpose, and both follow 86JP's own
|
|
// lifetime for this bookkeeping rather than a reset hook we would have to remember
|
|
// to call:
|
|
//
|
|
// - The gate is in the key because the allowance the client subtracts from is the
|
|
// GATE's own. 86JP keeps UsedCoinCount/GrantedCoinCount on
|
|
// RaidDungeonParticipation, which is per (dungeon, party) and is NOT reset by
|
|
// re-entering the gate. The counter this replaced lived on runtimeDungeonState,
|
|
// which is allocated per RUN, so every re-entry silently zeroed it: live
|
|
// 2026-09-23, a squad spent two coins in 黑雾之源 (10/12), re-entered the same
|
|
// gate, and the board went back to 12/12 -- the operator's report was
|
|
// "当前可用复活币好像不会跌破10…一直是10/12".
|
|
// - The phase is in the key because 86JP starts every phase with fresh
|
|
// participation records, so a new phase starts with nothing spent.
|
|
type runtimeRaidReviveCoinKey struct {
|
|
RaidKey uint32
|
|
Phase byte
|
|
Group byte
|
|
SoloCharID uint16
|
|
DungeonID int
|
|
}
|
|
|
|
// runtimeRaidReviveCoinUses is how many coins this gate has actually consumed for
|
|
// this row. It accumulates across re-entries within one phase and is dropped when
|
|
// the gate's own clear quota is met (see clearRuntimeRaidReviveCoinLedgerForGate).
|
|
func (s *Service) runtimeRaidReviveCoinUses(key runtimeRaidReviveCoinKey) uint32 {
|
|
if s == nil || key.RaidKey == 0 {
|
|
return 0
|
|
}
|
|
s.raidMu.Lock()
|
|
defer s.raidMu.Unlock()
|
|
return s.raidReviveUses[key]
|
|
}
|
|
|
|
// runtimeRaidReviveCoinGranted is how many revive coins this row's INCREASE COIN
|
|
// grants currently cover.
|
|
func (s *Service) runtimeRaidReviveCoinGranted(key runtimeRaidReviveCoinKey) uint32 {
|
|
if s == nil || key.RaidKey == 0 {
|
|
return 0
|
|
}
|
|
s.raidMu.Lock()
|
|
defer s.raidMu.Unlock()
|
|
return s.raidReviveGrants[key]
|
|
}
|
|
|
|
// recordRuntimeRaidReviveCoinUseLocked adds one consumed coin to a gate's ledger
|
|
// and returns the new total.
|
|
//
|
|
// The caller holds session.dungeon.mu (the op41 commit path does) and this takes
|
|
// s.raidMu, which is the order the raid lifecycle already uses -- the inverse is
|
|
// what would deadlock.
|
|
func (s *Service) recordRuntimeRaidReviveCoinUseLocked(
|
|
session *gameSession,
|
|
runtime *runtimeDungeonState,
|
|
) (runtimeRaidReviveCoinKey, uint32, bool) {
|
|
if s == nil || session == nil || runtime == nil {
|
|
return runtimeRaidReviveCoinKey{}, 0, false
|
|
}
|
|
binding := runtime.raidDungeon
|
|
if binding.RaidKey == 0 || binding.MapID == 0 {
|
|
return runtimeRaidReviveCoinKey{}, 0, false
|
|
}
|
|
key := runtimeRaidReviveCoinKey{
|
|
RaidKey: binding.RaidKey,
|
|
Phase: binding.Phase,
|
|
DungeonID: binding.MapID,
|
|
}
|
|
s.raidMu.Lock()
|
|
defer s.raidMu.Unlock()
|
|
state := s.raids[binding.RaidKey]
|
|
if state == nil || state.Lifecycle.Phase != binding.Phase {
|
|
return runtimeRaidReviveCoinKey{}, 0, false
|
|
}
|
|
member, ok := runtimeRaidFrozenMember(*state, session.selectedCharacterID)
|
|
if !ok {
|
|
return runtimeRaidReviveCoinKey{}, 0, false
|
|
}
|
|
key.Group = runtimeRaidMemberSquad(member.GroupIndex)
|
|
if key.Group == 0 {
|
|
key.SoloCharID = session.selectedCharacterID
|
|
}
|
|
if s.raidReviveUses == nil {
|
|
s.raidReviveUses = make(map[runtimeRaidReviveCoinKey]uint32)
|
|
}
|
|
total := s.raidReviveUses[key] + 1
|
|
s.raidReviveUses[key] = total
|
|
return key, total, true
|
|
}
|
|
|
|
// grantRuntimeRaidReviveCoins adds to a row's grant ledger and returns the new
|
|
// total. Like 86JP's GrantedCoinCount it lives as long as the gate's own entry.
|
|
func (s *Service) grantRuntimeRaidReviveCoins(key runtimeRaidReviveCoinKey, added uint32) uint32 {
|
|
if s == nil || key.RaidKey == 0 || added == 0 {
|
|
return 0
|
|
}
|
|
s.raidMu.Lock()
|
|
defer s.raidMu.Unlock()
|
|
if s.raidReviveGrants == nil {
|
|
s.raidReviveGrants = make(map[runtimeRaidReviveCoinKey]uint32)
|
|
}
|
|
total := s.raidReviveGrants[key] + added
|
|
s.raidReviveGrants[key] = total
|
|
return total
|
|
}
|
|
|
|
// clearRuntimeRaidReviveCoinLedgerForGateLocked drops one gate's spent/granted
|
|
// counters for every squad.
|
|
//
|
|
// It is 86JP's participation lifetime: its UsedCoinCount/GrantedCoinCount live on
|
|
// the RaidDungeonParticipation for that (dungeon, party), and a cleared dungeon ends
|
|
// that record. Our "cleared" is the objective's own quota being met
|
|
// (`Objectives[mapID].RemainingHP == 0`), which is what the caller checks.
|
|
//
|
|
// Caller holds s.raidMu. It touches only Service maps, so it can run on the clear
|
|
// path -- which holds the completing member's session lock -- without taking any
|
|
// session lock, which is the rule that path must obey.
|
|
//
|
|
// It returns how many ledger entries it dropped, so the caller can log the reset
|
|
// only when there was something to drop.
|
|
func (s *Service) clearRuntimeRaidReviveCoinLedgerForGateLocked(raidKey uint32, phase byte, dungeonID int) int {
|
|
if s == nil || raidKey == 0 || dungeonID == 0 {
|
|
return 0
|
|
}
|
|
dropped := 0
|
|
for key := range s.raidReviveUses {
|
|
if key.RaidKey == raidKey && key.Phase == phase && key.DungeonID == dungeonID {
|
|
delete(s.raidReviveUses, key)
|
|
dropped++
|
|
}
|
|
}
|
|
for key := range s.raidReviveGrants {
|
|
if key.RaidKey == raidKey && key.Phase == phase && key.DungeonID == dungeonID {
|
|
delete(s.raidReviveGrants, key)
|
|
dropped++
|
|
}
|
|
}
|
|
return dropped
|
|
}
|
|
|
|
// purgeRuntimeRaidReviveCoinLedgerLocked drops every revive-coin counter of one
|
|
// raid. Caller holds s.raidMu.
|
|
func (s *Service) purgeRuntimeRaidReviveCoinLedgerLocked(raidKey uint32) {
|
|
if s == nil || raidKey == 0 {
|
|
return
|
|
}
|
|
for key := range s.raidReviveUses {
|
|
if key.RaidKey == raidKey {
|
|
delete(s.raidReviveUses, key)
|
|
}
|
|
}
|
|
for key := range s.raidReviveGrants {
|
|
if key.RaidKey == raidKey {
|
|
delete(s.raidReviveGrants, key)
|
|
}
|
|
}
|
|
}
|