DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
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, &currentDungeonAdmissionCounterCommit{
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, &currentDungeonAdmissionCounterCommit{
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 := &currentDungeonAdmissionCounterBatch{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
}