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,102 @@
package sqlite
import (
"context"
"strconv"
"strings"
"longheng.io/server/internal/modules/dnf/repository"
)
// ReadCompanionSource 是支援兵(companion)需要的「一致只读快照」能力。
//
// 为什么必须是一个快照
// 支援兵要拿源角色的角色记录 + 装备 + 技能三份数据去构造一个 AIC 投影。
// 如果这三份各自读一次,中间被一次存档写入穿插,就会拼出"装备是新的、
// 技能是旧的"这种客户端读不了的状态。所以整组读取必须在同一个读锁里完成。
//
// 为什么用 component.read
// Component.read 持 mu.RLock 后在 runtime 的 Group 上调 apply,
// 期间所有写路径都被挡住 —— 这正是"一致快照"的实现方式,且不需要
// 另开一个只读事务(本仓储是内存态 + 异步落盘)。
//
// 为什么 rollback/只读语义没有照搬 MySQL 版
// 参考实现(mysql_companion_read.go)用 sql.TxOptions{ReadOnly} 开事务,
// 是因为 MySQL 版真的是逐行 SQL。本仓储的读取本身不产生写入,
// 而 Clone* 只负责把聚合从调用方手里摘开,所以这里不需要 ReadOnly 包装器。
func (s *characterStore) ReadCompanionSource(
ctx context.Context,
accountID, ownerID, sourceID string,
) (repository.CompanionSourceState, error) {
empty := repository.CompanionSourceState{}
if s == nil || s.component == nil {
return empty, repository.ErrCompanionReadUnavailable
}
if strings.TrimSpace(accountID) == "" || accountID != strings.TrimSpace(accountID) ||
!companionNumericID(ownerID) || !companionNumericID(sourceID) || ownerID == sourceID {
return empty, repository.ErrCompanionSelection
}
if ctx == nil {
ctx = context.Background()
}
if err := ctx.Err(); err != nil {
return empty, err
}
var result repository.CompanionSourceState
err := s.component.read(ctx, func(group repository.Group) error {
if group.Character == nil || group.Equipment == nil || group.Skill == nil {
return repository.ErrCompanionReadUnavailable
}
var found bool
var loadErr error
result.Owner, found, loadErr = group.Character.Load(ctx, ownerID)
if loadErr != nil {
return loadErr
}
if !found || result.Owner.Stats["delete_flag"] != 0 {
return repository.ErrCompanionCharacterUnavailable
}
result.Source, found, loadErr = group.Character.Load(ctx, sourceID)
if loadErr != nil {
return loadErr
}
if !found || result.Source.Stats["delete_flag"] != 0 {
return repository.ErrCompanionCharacterUnavailable
}
// 两个角色都必须在同一个账号名下:支援兵是"借用自己账号的另一个角色",
// 跨账号借用会让借用人的装备/技能快照泄漏给别人。
if result.Owner.AccountID != accountID || result.Source.AccountID != accountID {
return repository.ErrCompanionAccountMismatch
}
result.Equipment, result.HasEquipment, loadErr = group.Equipment.Load(ctx, sourceID)
if loadErr != nil {
return loadErr
}
result.Skill, result.HasSkill, loadErr = group.Skill.Load(ctx, sourceID)
if loadErr != nil {
return loadErr
}
return nil
})
if err != nil {
return empty, err
}
// 摘开聚合:调用方随后会在自己的 goroutine 里读这些记录,
// 不能让它和仓储内部的可变状态共享 map/切片。
result.Owner = repository.CloneCharacter(result.Owner)
result.Source = repository.CloneCharacter(result.Source)
result.Equipment = repository.CloneEquipment(result.Equipment)
result.Skill = repository.CloneSkill(result.Skill)
return result, nil
}
// companionNumericID 要求是规范的十进制 uint16(不含前导零/符号/空白),
// 与 MySQL 版保持同一套校验,避免 "007" 与 "7" 被当成两个不同角色。
func companionNumericID(value string) bool {
id, err := strconv.ParseUint(value, 10, 16)
return err == nil && id != 0 && strconv.FormatUint(id, 10) == value
}
var _ repository.CompanionSourceReader = (*characterStore)(nil)