服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
103 lines
3.9 KiB
Go
103 lines
3.9 KiB
Go
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)
|