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

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)
}
})
}