服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
342 lines
14 KiB
Go
342 lines
14 KiB
Go
package dnfbridge
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"time"
|
|
|
|
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
// raidEntryRefundReceiptValueKey is the settings key under which the entry-cost
|
|
// refund records that it already returned this attempt's material ticket.
|
|
const raidEntryRefundReceiptValueKey = "raid_entry_refund_v1"
|
|
|
|
// runtimeRaidEntryRefundScope names ONE failed attempt for the durable material
|
|
// receipt, the way grantRuntimeFiendWarRetry's scope names one run for the retry
|
|
// ticket (raid_fiendwar_retry.go:50): the raid key, the start attempt and the
|
|
// lifecycle start instant are each immutable for the attempt, so a replayed
|
|
// timeout callback is read-only and a later run can never reuse an earlier
|
|
// receipt. Built from state rather than from a counter for that reason.
|
|
func runtimeRaidEntryRefundScope(state runtimeRaidState) string {
|
|
return fmt.Sprintf("raid_entry_refund:v1:%08x:%x:%x",
|
|
state.RaidKey, state.StartAttempt, uint64(state.Lifecycle.StartedAt.UnixNano()))
|
|
}
|
|
|
|
// runtimeRaidEntryMaterialRefundOwnedByRetry reports whether this run's entry
|
|
// material is already returned by the native retry-ticket path.
|
|
//
|
|
// 超时空之战 authors the entry ticket and the failed-run retry ticket as the SAME
|
|
// item (fiendwar.etc: EntryCost.ItemID == RetryItem.ItemID == 490007844, see
|
|
// fiendwar_pvf.go:19-20 and fiendwar_pvf_test.go:63), and
|
|
// grantRuntimeFiendWarRetry already returns it, once, under its own durable
|
|
// receipt -- on BOTH of this raid's failure paths (raid_phase_runtime.go:1310 and
|
|
// raid_fiendwar_battle.go:829). Refunding it again from here would hand out a
|
|
// second ticket, so that path owns the material and this one steps aside. With
|
|
// [RETRY] turned off in the PVF nothing owns it and the generic refund takes over;
|
|
// an owner whose retry grant itself failed keeps owing it through
|
|
// scheduleRuntimeFiendWarRetry, which is receipt-guarded too.
|
|
func runtimeRaidEntryMaterialRefundOwnedByRetry(state runtimeRaidState) bool {
|
|
return state.RaidMode == runtimeRaidOwnerFiendWar &&
|
|
state.FiendWarConfig != nil && state.FiendWarConfig.Retry
|
|
}
|
|
|
|
// refundRuntimeRaidEntryOnFailure gives back everything one FAILED run charged at
|
|
// its start: the three admission counters (帐号日入场场数 / 帐号周入场场数 /
|
|
// 角色周入场次数, charged by reserveRuntimeRaidAdmissionCounters) and the entry
|
|
// material ticket (consumed by consumeRuntimeRaidEntryCostBatch).
|
|
//
|
|
// WHY this exists, and why it is a deliberate divergence from the official
|
|
// server: the entry is charged at raid start -- charge_point=raid_start_entry_not_clear
|
|
// (raid_admission_counter.go:115) -- and 86JP returns none of it, 「回城/失败/放弃都不
|
|
// 返还」 (86攻坚流程_对照与下一步_20260916.md:79). The operator's rule for this server
|
|
// is narrower and friendlier: a run that FAILS in combat refunds, one the party
|
|
// deliberately abandons does not.
|
|
//
|
|
// ARMED ONLY FOR A GENUINE COMBAT FAILURE, i.e. exactly where the client paints
|
|
// the 攻坚失败 banner:
|
|
//
|
|
// - 安徒恩 / 卢克 -- the attack-window countdown running out, which is
|
|
// scheduleRuntimeRaidPhaseExpiry's expireRuntimeRaidPhase branch. It is the
|
|
// only failure these two owners have.
|
|
// - 超时空之战 -- that same countdown, plus the battle tick's own wipe-out
|
|
// (battle.Failed in scheduleRuntimeFiendWarTick).
|
|
//
|
|
// Deliberately NOT armed for abandonRuntimeRaid (中途放弃/解散): the party chose to
|
|
// stop and the charge stands. Nor for a raid that never started or never paid --
|
|
// the guard below is the whole of that rule, and it is also what keeps this a
|
|
// no-op for the many synthetic states in tests.
|
|
//
|
|
// CALL IT AFTER the failure presentation has been published, never before: the
|
|
// counter half ends by re-pushing op1303/op1206 (see the tail of this function),
|
|
// and those two packets belong after the banner sequence rather than inside it.
|
|
//
|
|
// Idempotent twice over, which matters because all three call sites sit behind a
|
|
// timer that can fire twice: the counter half addresses durable
|
|
// dungeonadmission receipts and Owner.Restore leaves a restored tombstone behind
|
|
// (owner.go:408-412), while the material half carries its own per-attempt receipt.
|
|
func (s *Service) refundRuntimeRaidEntryOnFailure(state runtimeRaidState, now time.Time) error {
|
|
if s == nil || state.RaidKey == 0 || !state.Started || !state.EntryCostPaid ||
|
|
state.StartAttempt == 0 || len(state.ChargedMembers) == 0 {
|
|
return nil
|
|
}
|
|
if now.IsZero() {
|
|
now = time.Now().UTC()
|
|
} else {
|
|
now = now.UTC()
|
|
}
|
|
materialOwnedByRetry := runtimeRaidEntryMaterialRefundOwnedByRetry(state)
|
|
countersRestored, counterErr := s.refundRuntimeRaidAdmissionCounters(state, now)
|
|
var materialErr error
|
|
if !materialOwnedByRetry {
|
|
materialErr = s.refundRuntimeRaidEntryMaterial(state, now)
|
|
}
|
|
// The counters are the number the player reads back, and the client only
|
|
// re-renders them when a packet arrives -- it never queries them
|
|
// (raid_daily_availability.go:477-500) -- so a refund that does not re-push
|
|
// stays invisible until the next relog. Display only and non-fatal by
|
|
// construction, exactly like the push at the tail of a successful start.
|
|
s.broadcastCurrentRaidEntryCounters(state, "raid_failure_entry_refund")
|
|
err := errors.Join(counterErr, materialErr)
|
|
s.logPacketEvent("game-raid-entry-refunded-on-failure",
|
|
"raid_key", state.RaidKey,
|
|
"raid_mode", state.RaidMode,
|
|
"start_attempt", state.StartAttempt,
|
|
"charged_members", len(state.ChargedMembers),
|
|
"counters_restored", countersRestored,
|
|
"material_refunded", !materialOwnedByRetry,
|
|
"material_owned_by_retry_ticket", materialOwnedByRetry,
|
|
"error", errorText(err))
|
|
return err
|
|
}
|
|
|
|
// refundRuntimeRaidAdmissionCounters decrements the three admission counters of
|
|
// every member this run charged, and reports how many members it actually moved
|
|
// (as opposed to how many it replayed).
|
|
//
|
|
// It rebuilds the policies from the same single source of truth the charge used
|
|
// (currentRaidAdmissionPolicyKeys), so 「扣什么」 and 「退什么」 cannot drift, and it
|
|
// rebuilds each receipt with the same helper the charge committed under
|
|
// (runtimeRaidAdmissionReceipt) for the same reason.
|
|
//
|
|
// Owner.Restore is receipt-addressed and idempotent, and it is the ONLY correct
|
|
// primitive here: unlike restoreCurrentDungeonAdmissionCounterBatch it needs no
|
|
// in-memory batch, which is exactly what makes a refund possible at all -- the
|
|
// batch the start path held went out of scope when the start returned.
|
|
//
|
|
// One deliberate limitation, inherited from the counter storage rather than
|
|
// chosen here: a counter belongs to the service window it was charged in, so a
|
|
// run that fails after the 06:00 rollover refunds nothing into the new window.
|
|
// That is the right outcome -- the new window never paid.
|
|
func (s *Service) refundRuntimeRaidAdmissionCounters(state runtimeRaidState, now time.Time) (int, error) {
|
|
policyKeys := currentRaidAdmissionPolicyKeys(state.RaidMode)
|
|
if len(policyKeys) == 0 {
|
|
return 0, nil
|
|
}
|
|
policies := make([]dnfdungeonadmission.Policy, 0, len(policyKeys))
|
|
for _, policyKey := range policyKeys {
|
|
policy, found, err := s.currentDungeonAdmissionPolicy(policyKey)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if !found {
|
|
// Same defensive branch as the charge: a catalog without the section
|
|
// was never able to charge this member, so there is nothing to give back.
|
|
continue
|
|
}
|
|
policies = append(policies, policy)
|
|
}
|
|
if len(policies) == 0 {
|
|
return 0, nil
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok {
|
|
return 0, dnfdungeonadmission.ErrOwnerUnavailable
|
|
}
|
|
owner, err := dnfdungeonadmission.NewOwner(repositories)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
bootNonce, err := s.currentDungeonAdmissionReceiptNonce()
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
restored := 0
|
|
var refundErr error
|
|
for _, characterID := range state.ChargedMembers {
|
|
if characterID == 0 {
|
|
continue
|
|
}
|
|
receipt := runtimeRaidAdmissionReceipt(bootNonce, state, state.RaidMode, characterID)
|
|
restoreCtx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
snapshots, restoreErr := owner.Restore(restoreCtx, fmt.Sprint(characterID), policies, receipt, now)
|
|
cancel()
|
|
if restoreErr != nil {
|
|
refundErr = errors.Join(refundErr, fmt.Errorf("refund character %d raid admission counter: %w", characterID, restoreErr))
|
|
continue
|
|
}
|
|
// Restored && !Replayed is the only combination that means this call moved a
|
|
// counter: a replay reports Restored as well, because the tombstone it found
|
|
// is itself the proof the counter was given back (owner.go:550-567).
|
|
moved := false
|
|
for _, snapshot := range snapshots {
|
|
if snapshot.Restored && !snapshot.Replayed {
|
|
moved = true
|
|
break
|
|
}
|
|
}
|
|
if moved {
|
|
restored++
|
|
}
|
|
session, _ := s.onlineGameSession(characterID)
|
|
s.logGameEvent(session, "game-raid-admission-counter-refunded",
|
|
"raid_key", state.RaidKey,
|
|
"raid_mode", state.RaidMode,
|
|
"start_attempt", state.StartAttempt,
|
|
"char_id", characterID,
|
|
"policy_count", len(policies),
|
|
"result", map[bool]string{true: "restored", false: "replayed"}[moved],
|
|
"reason", "the_run_failed_in_combat_and_the_entry_count_is_given_back")
|
|
}
|
|
return restored, refundErr
|
|
}
|
|
|
|
// refundRuntimeRaidEntryMaterial hands every charged member the material ticket
|
|
// this run took from them, once per attempt.
|
|
//
|
|
// It is grantRuntimeFiendWarRetry's shape deliberately -- same unit of work, same
|
|
// per-member placement bound, same post-commit inventory refresh -- because that
|
|
// path is the established way this service returns an item to a whole raid
|
|
// roster, and the two must stay interchangeable for 超时空 where they meet.
|
|
func (s *Service) refundRuntimeRaidEntryMaterial(state runtimeRaidState, now time.Time) error {
|
|
itemID, count := runtimeRaidEntryCostForState(state)
|
|
if itemID == 0 || count == 0 {
|
|
return nil
|
|
}
|
|
items, err := s.currentPVFItemCatalog()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
definition, err := items.ResolveItem(itemID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
definition, err = currentPVFItemDefinitionForGrantAt(definition, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
repositories, ok := s.repositoryGroup()
|
|
if !ok || repositories.RaidRewards == nil {
|
|
return errRuntimeRaidRewardRepository
|
|
}
|
|
ids := make([]string, 0, len(state.ChargedMembers))
|
|
refreshes := make([]runtimeRaidRewardInventoryRefresh, 0, len(state.ChargedMembers))
|
|
for _, id := range state.ChargedMembers {
|
|
if id == 0 {
|
|
continue
|
|
}
|
|
ids = append(ids, strconv.Itoa(int(id)))
|
|
}
|
|
if len(ids) == 0 {
|
|
return nil
|
|
}
|
|
scope := runtimeRaidEntryRefundScope(state)
|
|
ctx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
|
defer cancel()
|
|
err = repositories.RaidRewards.WithinRaidRewards(ctx, ids, scope, func(inventories dnfrepo.InventoryRepository, receipts dnfrepo.SettingsRepository) error {
|
|
record, found, err := receipts.Load(ctx, scope)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if found && record.Values[raidEntryRefundReceiptValueKey] != "" {
|
|
return nil
|
|
}
|
|
for index, id := range ids {
|
|
inventory, found, err := inventories.Load(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return fmt.Errorf("%w: character=%s inventory missing", errRuntimeRaidRewardGrant, id)
|
|
}
|
|
// 本服务端把"主背包已解锁到第几格"做成了必填参数,漏传会把道具放进客户端
|
|
// 不渲染的扩展格 —— 与 raid_fiendwar_retry.go:73-78、raid_reward_runtime.go
|
|
// 的既有做法一致,按当前角色的放置上限传。
|
|
slots, err := grantCurrentCeraShopProduct(
|
|
&inventory,
|
|
definition,
|
|
count,
|
|
s.currentInventoryPlacementMainSlotCount(ctx, &inventory, id),
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
refresh := make([]int16, 0, len(slots))
|
|
for _, slot := range slots {
|
|
if slot <= math.MaxInt16 {
|
|
refresh = append(refresh, int16(slot))
|
|
}
|
|
}
|
|
refreshes = append(refreshes, runtimeRaidRewardInventoryRefresh{CharacterID: state.ChargedMembers[index], Slots: refresh})
|
|
inventory.UpdatedAt = now
|
|
if err := dnfrepo.SaveInventoryFields(ctx, inventories, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if record.Values == nil {
|
|
record.Values = map[string]string{}
|
|
}
|
|
record.Scope = scope
|
|
record.Values[raidEntryRefundReceiptValueKey] = fmt.Sprintf(
|
|
"raid=%d;attempt=%d;started=%d;item=%d;count=%d",
|
|
state.RaidKey, state.StartAttempt, state.Lifecycle.StartedAt.UnixNano(), itemID, count)
|
|
record.UpdatedAt = now
|
|
return receipts.Save(ctx, record)
|
|
})
|
|
if err == nil && len(refreshes) != 0 {
|
|
err = s.sendRuntimeRaidSettlementInventoryRefreshes(refreshes)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// refundFailedRuntimeRaidEntry is the timer-side entry point used by every
|
|
// failure site: run the refund, and when the durable work failed, log it and arm
|
|
// the single retry. Kept separate from the refund itself so a call site reads as
|
|
// one line and the retry policy lives in exactly one place.
|
|
func (s *Service) refundFailedRuntimeRaidEntry(state runtimeRaidState, now time.Time) {
|
|
if err := s.refundRuntimeRaidEntryOnFailure(state, now); err != nil {
|
|
s.logPacketEvent("game-raid-entry-refund-failed",
|
|
"raid_key", state.RaidKey, "start_attempt", state.StartAttempt, "error", err)
|
|
s.scheduleRuntimeRaidEntryRefundRetry(state, now)
|
|
}
|
|
}
|
|
|
|
// scheduleRuntimeRaidEntryRefundRetry retries the whole refund once after a
|
|
// second, the way scheduleRuntimeFiendWarRetry retries the retry ticket: the
|
|
// callers reach it when the durable work failed (typically a repository that was
|
|
// momentarily unavailable), so this is a single retry and not a loop. A second
|
|
// failure is logged and the attempt keeps its unspent receipts -- both halves are
|
|
// idempotent and addressable by (raid key, attempt, started-at), so a later
|
|
// callback can still claim them.
|
|
//
|
|
// It works from the SNAPSHOT rather than from the live raid row on purpose: the
|
|
// FiendWar failure release resets that row to its lobby five seconds later, which
|
|
// clears ChargedMembers, and the refund must still be able to pay out.
|
|
func (s *Service) scheduleRuntimeRaidEntryRefundRetry(state runtimeRaidState, now time.Time) {
|
|
queue := s.ensureGameplayTimeQueue()
|
|
if queue == nil {
|
|
return
|
|
}
|
|
name := fmt.Sprintf("raid-entry-refund:%d:%d", state.RaidKey, state.StartAttempt)
|
|
_ = queue.ScheduleAfter(name, time.Second, func(due time.Time) {
|
|
if err := s.refundRuntimeRaidEntryOnFailure(state, due); err != nil {
|
|
s.logPacketEvent("game-raid-entry-refund-retry-failed",
|
|
"raid_key", state.RaidKey, "start_attempt", state.StartAttempt, "error", err)
|
|
}
|
|
})
|
|
}
|