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,251 @@
|
||||
package dnfbridge
|
||||
|
||||
import (
|
||||
"context"
|
||||
cryptorand "crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
dnfdungeonadmission "longheng.io/server/internal/modules/dnf/dungeonadmission"
|
||||
)
|
||||
|
||||
type currentDungeonAdmissionCounterCommit struct {
|
||||
session *gameSession
|
||||
runtime *runtimeDungeonState
|
||||
characterID uint16
|
||||
dungeonID int64
|
||||
ownerKind string
|
||||
policies []dnfdungeonadmission.Policy
|
||||
receipt string
|
||||
restored bool
|
||||
}
|
||||
|
||||
// currentDungeonAdmissionCounterBatch is reserved before inventory and
|
||||
// runtime commits. Every later failure restores it after runtime and inventory
|
||||
// compensation, preserving the reverse order of the admission transaction.
|
||||
type currentDungeonAdmissionCounterBatch struct {
|
||||
owner *dnfdungeonadmission.Owner
|
||||
reservedAt time.Time
|
||||
commits []*currentDungeonAdmissionCounterCommit
|
||||
}
|
||||
|
||||
func (s *Service) reserveCurrentDungeonAdmissionCounters(
|
||||
ctx context.Context,
|
||||
leader *gameSession,
|
||||
leaderRuntime *runtimeDungeonState,
|
||||
preparedFollowers []*runtimePartyPreparedDungeonEntry,
|
||||
) (*currentDungeonAdmissionCounterBatch, error) {
|
||||
if leader == nil || leaderRuntime == nil || currentDungeonRuntimeIsPractice(leaderRuntime) {
|
||||
return nil, nil
|
||||
}
|
||||
// 周常五图的账号额度上限要加上冒险团分档加成;这里(有 session)刷新缓存,
|
||||
// 供下面的 currentDungeonAdmissionPolicies 读取。
|
||||
s.refreshAbisAdventureGroupLevel(leader)
|
||||
policies, err := s.currentDungeonAdmissionPolicies(uint32(leaderRuntime.Dungeon.ID))
|
||||
if err != nil || len(policies) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
repositories, ok := s.repositoryGroup()
|
||||
if !ok {
|
||||
return nil, dnfdungeonadmission.ErrOwnerUnavailable
|
||||
}
|
||||
owner, err := dnfdungeonadmission.NewOwner(repositories)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
members := make([]*currentDungeonAdmissionCounterCommit, 0, len(preparedFollowers)+1)
|
||||
members = append(members, ¤tDungeonAdmissionCounterCommit{
|
||||
session: leader, runtime: leaderRuntime, characterID: leader.selectedCharacterID,
|
||||
dungeonID: leaderRuntime.Dungeon.ID, ownerKind: "dungeon", policies: policies,
|
||||
})
|
||||
for _, prepared := range preparedFollowers {
|
||||
if prepared == nil || prepared.session == nil || prepared.runtime == nil {
|
||||
return nil, errors.New("prepared party member admission counter is incomplete")
|
||||
}
|
||||
if currentDungeonRuntimeIsPractice(prepared.runtime) {
|
||||
return nil, errors.New("party admission mixes practice and ordinary runtimes")
|
||||
}
|
||||
memberPolicies, policyErr := s.currentDungeonAdmissionPolicies(uint32(prepared.runtime.Dungeon.ID))
|
||||
if policyErr != nil {
|
||||
return nil, policyErr
|
||||
}
|
||||
if !sameCurrentDungeonAdmissionPolicies(policies, memberPolicies) {
|
||||
return nil, fmt.Errorf("party member %d admission policies differ from leader", prepared.session.selectedCharacterID)
|
||||
}
|
||||
members = append(members, ¤tDungeonAdmissionCounterCommit{
|
||||
session: prepared.session, runtime: prepared.runtime, characterID: prepared.session.selectedCharacterID,
|
||||
dungeonID: prepared.runtime.Dungeon.ID, ownerKind: "dungeon", policies: memberPolicies,
|
||||
})
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
bootNonce, err := s.currentDungeonAdmissionReceiptNonce()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The leader token identifies this complete party attempt; each receipt also
|
||||
// contains a boot-unique nonce and member ID. The nonce prevents a service
|
||||
// restart in the same service window from replaying an old durable receipt.
|
||||
attemptToken := s.allocateDungeonLifecycleToken()
|
||||
if attemptToken == 0 {
|
||||
return nil, errors.New("dungeon admission attempt token is unavailable")
|
||||
}
|
||||
for _, member := range members {
|
||||
if member.runtime.lifecycleToken != 0 {
|
||||
return nil, fmt.Errorf("character %d prepared runtime already has lifecycle token", member.session.selectedCharacterID)
|
||||
}
|
||||
member.receipt = fmt.Sprintf(
|
||||
"dungeon-admission-v2:boot:%s:attempt:%d:character:%d:dungeon:%d",
|
||||
bootNonce,
|
||||
attemptToken,
|
||||
member.session.selectedCharacterID,
|
||||
leaderRuntime.Dungeon.ID,
|
||||
)
|
||||
if _, err := owner.Preflight(ctx, fmt.Sprint(member.session.selectedCharacterID), member.policies, now); err != nil {
|
||||
return nil, fmt.Errorf("preflight character %d dungeon admission counter: %w", member.session.selectedCharacterID, err)
|
||||
}
|
||||
}
|
||||
|
||||
batch := ¤tDungeonAdmissionCounterBatch{owner: owner, reservedAt: now, commits: make([]*currentDungeonAdmissionCounterCommit, 0, len(members))}
|
||||
for _, member := range members {
|
||||
// Register the exact receipt before Commit. SQL commit errors can be
|
||||
// outcome-ambiguous; Restore is receipt-aware and therefore compensates
|
||||
// a commit that actually landed while remaining a no-op when it did not.
|
||||
batch.commits = append(batch.commits, member)
|
||||
if _, err := owner.Commit(ctx, fmt.Sprint(member.session.selectedCharacterID), member.policies, member.receipt, now); err != nil {
|
||||
if restoreErr := s.restoreCurrentDungeonAdmissionCounterBatch(batch); restoreErr != nil {
|
||||
err = errors.Join(err, restoreErr)
|
||||
}
|
||||
return nil, fmt.Errorf("reserve character %d dungeon admission counter: %w", member.session.selectedCharacterID, err)
|
||||
}
|
||||
member.runtime.lifecycleToken = s.allocateDungeonLifecycleToken()
|
||||
if member.runtime.lifecycleToken == 0 {
|
||||
err := errors.New("dungeon runtime lifecycle token is unavailable")
|
||||
if restoreErr := s.restoreCurrentDungeonAdmissionCounterBatch(batch); restoreErr != nil {
|
||||
err = errors.Join(err, restoreErr)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
s.logGameEvent(member.session, "game-dungeon-admission-counter-reserved",
|
||||
"char_id", member.session.selectedCharacterID,
|
||||
"dungeon_id", leaderRuntime.Dungeon.ID,
|
||||
"policy_count", len(member.policies),
|
||||
"attempt_token", attemptToken,
|
||||
"runtime_token", member.runtime.lifecycleToken)
|
||||
}
|
||||
return batch, nil
|
||||
}
|
||||
|
||||
func (s *Service) restoreCurrentDungeonAdmissionCounterBatch(batch *currentDungeonAdmissionCounterBatch) error {
|
||||
if batch == nil || batch.owner == nil {
|
||||
return nil
|
||||
}
|
||||
var restoreErr error
|
||||
for index := len(batch.commits) - 1; index >= 0; index-- {
|
||||
commit := batch.commits[index]
|
||||
if commit == nil || commit.characterID == 0 || commit.restored {
|
||||
continue
|
||||
}
|
||||
restoreCtx, cancel := context.WithTimeout(context.Background(), createWriteTimeout)
|
||||
_, err := batch.owner.Restore(
|
||||
restoreCtx,
|
||||
fmt.Sprint(commit.characterID),
|
||||
commit.policies,
|
||||
commit.receipt,
|
||||
batch.reservedAt,
|
||||
)
|
||||
cancel()
|
||||
if err != nil {
|
||||
if commit.session != nil {
|
||||
s.logGameEvent(commit.session, "game-admission-counter-restore-failed",
|
||||
"owner_kind", commit.ownerKind,
|
||||
"char_id", commit.characterID,
|
||||
"error", err)
|
||||
}
|
||||
restoreErr = errors.Join(restoreErr, fmt.Errorf("restore character %d %s admission counter: %w", commit.characterID, commit.ownerKind, err))
|
||||
continue
|
||||
}
|
||||
commit.restored = true
|
||||
if commit.session != nil {
|
||||
s.logGameEvent(commit.session, "game-admission-counter-restored",
|
||||
"owner_kind", commit.ownerKind,
|
||||
"char_id", commit.characterID,
|
||||
"dungeon_id", commit.dungeonID,
|
||||
"policy_count", len(commit.policies))
|
||||
}
|
||||
}
|
||||
return restoreErr
|
||||
}
|
||||
|
||||
func (s *Service) currentDungeonAdmissionReceiptNonce() (string, error) {
|
||||
if s == nil {
|
||||
return "", errors.New("dungeon admission receipt owner is unavailable")
|
||||
}
|
||||
s.dungeonAdmissionReceiptOnce.Do(func() {
|
||||
if s.dungeonAdmissionReceiptNonce != "" {
|
||||
return
|
||||
}
|
||||
var raw [16]byte
|
||||
if _, err := cryptorand.Read(raw[:]); err != nil {
|
||||
s.dungeonAdmissionReceiptErr = fmt.Errorf("generate cryptographic process nonce: %w", err)
|
||||
return
|
||||
}
|
||||
s.dungeonAdmissionReceiptNonce = hex.EncodeToString(raw[:])
|
||||
})
|
||||
if s.dungeonAdmissionReceiptErr != nil {
|
||||
return "", s.dungeonAdmissionReceiptErr
|
||||
}
|
||||
if s.dungeonAdmissionReceiptNonce == "" {
|
||||
return "", errors.New("dungeon admission receipt process nonce is unavailable")
|
||||
}
|
||||
return s.dungeonAdmissionReceiptNonce, nil
|
||||
}
|
||||
|
||||
func (s *Service) currentDungeonAdmissionPolicies(dungeonID uint32) ([]dnfdungeonadmission.Policy, error) {
|
||||
if s == nil || dungeonID == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
// 【2026-10-12 接通】神秘异次元遗迹五图(4906~4910)不进 catalog:
|
||||
// etc/accountcharacdungeonincountchn.etc 只声明周周期,而实际准入是日额度
|
||||
// (账号 5 次 + 冒险团分档,角色各图 2/1/1/1/1)。catalog.loadExplicitWeekly
|
||||
// 对这份 PVF 只产出 Evidence、不产出 Policy,所以 PoliciesForDungeon 对它们
|
||||
// 返回空。这里补上按天生成的策略,让它们复用既有的
|
||||
// 预留 → 失败回滚 → 提交 全套语义(见 reserveCurrentDungeonAdmissionCounters)。
|
||||
if spec, found := abisDungeonSpecFor(dungeonID); found {
|
||||
// 冒险团等级取自角色 Stats["manage_level"];缺省 0 时 [adventure group
|
||||
// add enterable count chn] 的加成是 0,等于纯基础 5 次。
|
||||
return abisEntryPolicies(spec, s.currentAbisAdventureGroupLevel())
|
||||
}
|
||||
s.worldMapMu.RLock()
|
||||
catalog := s.dungeonAdmissionCatalog
|
||||
s.worldMapMu.RUnlock()
|
||||
if catalog == nil {
|
||||
// Synthetic bridge fixtures install their own worldmap without a complete
|
||||
// active-PVF archive. Production preload always installs the catalog and
|
||||
// fails startup if its structured sources cannot be audited.
|
||||
s.initialEquipmentMu.Lock()
|
||||
hasRuntimePVF := s.initialEquipmentArchive != nil
|
||||
s.initialEquipmentMu.Unlock()
|
||||
if hasRuntimePVF {
|
||||
return nil, errors.New("dungeon admission counter catalog is unavailable")
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
return catalog.PoliciesForDungeon(dungeonID), nil
|
||||
}
|
||||
|
||||
func sameCurrentDungeonAdmissionPolicies(left, right []dnfdungeonadmission.Policy) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index].Key != right[index].Key || left[index].Scope != right[index].Scope ||
|
||||
left[index].Period != right[index].Period || left[index].Limit != right[index].Limit ||
|
||||
left[index].ResetWeekday != right[index].ResetWeekday {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
Reference in new issue
Block a user