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