服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
577 lines
21 KiB
Go
577 lines
21 KiB
Go
package dungeon
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"errors"
|
|
"fmt"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/adventuregroup"
|
|
"longheng.io/server/internal/modules/dnf/honor"
|
|
"longheng.io/server/internal/modules/dnf/onlineevent"
|
|
"longheng.io/server/internal/modules/dnf/progression"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
"longheng.io/server/internal/modules/dnf/wedding"
|
|
)
|
|
|
|
const (
|
|
settlementReceiptPrefix = "dungeon_settlement_receipt_"
|
|
settlementReceiptVersion = int64(1)
|
|
)
|
|
|
|
var ErrSettlementStateInvalid = errors.New("dungeon settlement persisted state is invalid")
|
|
|
|
type SettlementCommand struct {
|
|
AccountID string
|
|
CharacterID string
|
|
CompletionKey string
|
|
Tables *progression.Tables
|
|
Experience uint32
|
|
RecommendedDungeonClear bool
|
|
// WeddingCapsule 是婚姻成长胶囊的 PVF 规则;仅当"已婚角色通关推荐副本"时
|
|
// 由 bridge 解析并传入,其它情况为 nil,本 owner 会整段跳过。
|
|
WeddingCapsule *wedding.CapsuleCatalog
|
|
// 活动(onlineevent)进度闸门。这三项都必须在本事务内推进,否则"通关即签到"
|
|
// 这条链路就断了——签到类活动的进度只在副本结算里累加,没有第二个写入方。
|
|
//
|
|
// MonthlyAttendance 月度签到(活动 2370):推荐副本通关计数,每日 2 次
|
|
// WeeklyAttendance 周周签到(活动 2208):需要目录来判定当周与补签
|
|
// AttendanceMarble 签到弹珠:通关清次数决定额外骰子
|
|
//
|
|
// 三者都只在 RecommendedDungeonClear 为真时生效。
|
|
MonthlyAttendance bool
|
|
WeeklyAttendance *onlineevent.WeeklyCatalog
|
|
AttendanceMarble *onlineevent.MarbleCatalog
|
|
AdventureRuntime *adventuregroup.RuntimeConfig
|
|
HonorExpertTables *honor.Tables
|
|
MaximumCharacterLevel int
|
|
// RentalPointAward and RentalPointLimit are resolved from the current PVF
|
|
// rental catalog and committed with the dungeon completion receipt.
|
|
RentalPointAward uint32
|
|
RentalPointLimit uint32
|
|
// AllowZeroExperience opts a completion that legitimately pays no clear
|
|
// experience into the ledger. The zero-experience guard below exists to catch
|
|
// a caller whose clear-exp computation silently produced nothing; a raid gate
|
|
// is the one real case (its reward is the raid phase settlement, not character
|
|
// growth), so it has to ask explicitly instead of the guard being relaxed for
|
|
// everyone.
|
|
AllowZeroExperience bool
|
|
// Tower carries a Tower of Despair/Grief clear: its ten frozen reward slots
|
|
// and the entry pool it consumes. Nil for every ordinary dungeon.
|
|
Tower *TowerClear
|
|
// GuildContribution carries the 公会贡献度 events of this clear. Nil for
|
|
// every non-member and for clears that never took a roster snapshot.
|
|
GuildContribution *GuildContributionClear
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type SettlementResult struct {
|
|
Character dnfrepo.CharacterRecord
|
|
Skill dnfrepo.SkillRecord
|
|
Inventory dnfrepo.InventoryRecord
|
|
ExperienceGain uint32
|
|
HonorExpertGain uint32
|
|
SPGain int
|
|
TPGain int
|
|
LuckyStarBefore uint32
|
|
LuckyStarApplied uint32
|
|
LuckyStarAfter uint32
|
|
LuckyStarAttempted bool
|
|
Replayed bool
|
|
}
|
|
|
|
// CommitSettlement atomically advances experience and the SP/TP ledger and
|
|
// writes an idempotency receipt tied to the accepted dungeon completion.
|
|
func (o *Owner) CommitSettlement(ctx context.Context, cmd SettlementCommand) (SettlementResult, error) {
|
|
if o == nil || o.settlements == nil || strings.TrimSpace(cmd.CharacterID) == "" ||
|
|
strings.TrimSpace(cmd.AccountID) == "" || strings.TrimSpace(cmd.CompletionKey) == "" ||
|
|
cmd.Tables == nil {
|
|
return SettlementResult{}, ErrOwnerUnavailable
|
|
}
|
|
// A tower clear pays its reward through the tower window rather than clear
|
|
// experience, so a zero-experience tower settlement is legitimate.
|
|
// 同理:签到类活动即使这一笔不带经验(例如免经验的活动副本),只要它会推进
|
|
// 活动进度,也必须放行——否则玩家通关了却什么都没记上。
|
|
if cmd.Experience == 0 && !cmd.AllowZeroExperience && cmd.Tower == nil &&
|
|
!(cmd.RecommendedDungeonClear && (cmd.RentalPointAward > 0 || cmd.WeddingCapsule != nil ||
|
|
cmd.MonthlyAttendance || cmd.WeeklyAttendance != nil || cmd.AttendanceMarble != nil)) {
|
|
return SettlementResult{}, ErrSettlementStateInvalid
|
|
}
|
|
ctx = contextOrBackground(ctx)
|
|
wantHash := sha256.Sum256([]byte(cmd.CompletionKey))
|
|
now := updatedAtOrNow(cmd.UpdatedAt)
|
|
|
|
var result SettlementResult
|
|
err := o.settlements.WithinCharacterSettlement(ctx, cmd.CharacterID, func(tx dnfrepo.Group) error {
|
|
character, found, err := tx.Character.Load(ctx, cmd.CharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || character.CharacterID != cmd.CharacterID ||
|
|
strings.TrimSpace(character.AccountID) != strings.TrimSpace(cmd.AccountID) {
|
|
return ErrSettlementStateInvalid
|
|
}
|
|
skill, found, err := tx.Skill.Load(ctx, cmd.CharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || skill.CharacterID != cmd.CharacterID {
|
|
return ErrSettlementStateInvalid
|
|
}
|
|
inventory, found, err := tx.Inventory.Load(ctx, cmd.CharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || inventory.CharacterID != cmd.CharacterID {
|
|
return ErrSettlementStateInvalid
|
|
}
|
|
character = dnfrepo.CloneCharacter(character)
|
|
skill = dnfrepo.CloneSkill(skill)
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
if settlementReceiptMatches(character.Stats, wantHash) {
|
|
receipt, err := settlementReceiptValues(character, skill)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result = SettlementResult{
|
|
Character: character, Skill: skill, Inventory: inventory,
|
|
ExperienceGain: receipt.ExperienceGain,
|
|
SPGain: receipt.SPGain,
|
|
LuckyStarBefore: receipt.LuckyStarBefore,
|
|
LuckyStarApplied: receipt.LuckyStarApplied,
|
|
LuckyStarAfter: receipt.LuckyStarAfter,
|
|
LuckyStarAttempted: receipt.LuckyStarAttempted,
|
|
Replayed: true,
|
|
}
|
|
return nil
|
|
}
|
|
experience, present := character.Stats["exp"]
|
|
if !present || experience < 0 || uint64(experience) > math.MaxUint32 ||
|
|
character.Level <= 0 || skill.Points.SyncedLevel != character.Level {
|
|
return ErrSettlementStateInvalid
|
|
}
|
|
maximumLevel := cmd.MaximumCharacterLevel
|
|
if maximumLevel <= 0 {
|
|
maximumLevel = progression.MaximumCharacterLevel
|
|
}
|
|
planned, err := PlanSettlementProgressionAtCap(
|
|
cmd.Tables,
|
|
character.Level,
|
|
uint32(experience),
|
|
cmd.Experience,
|
|
skill.Points,
|
|
maximumLevel,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
maximumLevelExperience := maximumLevelExperienceGain(
|
|
character.Level,
|
|
uint32(experience),
|
|
cmd.Experience,
|
|
cmd.MaximumCharacterLevel,
|
|
cmd.Tables,
|
|
)
|
|
character.Level = planned.Experience.NewLevel
|
|
character.Stats["exp"] = int64(planned.Experience.NewExperience)
|
|
appliedHonorExpertGain := uint32(0)
|
|
if maximumLevelExperience > 0 && cmd.HonorExpertTables != nil {
|
|
currentExpert, err := honor.ExpertProgressFromStats(character.Stats)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
nextExpert, err := cmd.HonorExpertTables.AdvanceExpert(currentExpert, uint64(maximumLevelExperience))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
expertStats, err := honor.ExpertStats(nextExpert)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for key, value := range expertStats {
|
|
character.Stats[key] = value
|
|
}
|
|
appliedHonorExpertGain = maximumLevelExperience
|
|
}
|
|
if cmd.RecommendedDungeonClear {
|
|
// 签到弹珠:通关清次数决定额外骰子(4/8/12 次档)。必须在推荐次数
|
|
// 自增之前跑,它读的是"这一局之前"的累计清次数。
|
|
if err := onlineevent.RecordMarbleClear(&character, cmd.AttendanceMarble, now); err != nil {
|
|
return err
|
|
}
|
|
count := character.Stats[adventuregroup.RecommendedDungeonClearStatKey]
|
|
if count < 0 {
|
|
return ErrSettlementStateInvalid
|
|
}
|
|
if count < math.MaxUint16 {
|
|
character.Stats[adventuregroup.RecommendedDungeonClearStatKey] = count + 1
|
|
}
|
|
// 已婚角色的推荐副本通关同时累加婚姻成长胶囊进度。与推荐次数同事务,
|
|
// 所以重放同一 CompletionKey 不会重复计入(回执幂等门在上层)。
|
|
if cmd.WeddingCapsule != nil {
|
|
if err := wedding.AccumulateCapsuleClear(&character, cmd.WeddingCapsule); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
// 月度/周签到的进度落在**账号**上,所以有它们就必须把账号行读进同一事务。
|
|
monthlyClear := cmd.MonthlyAttendance && cmd.RecommendedDungeonClear
|
|
weeklyClear := cmd.WeeklyAttendance != nil && cmd.RecommendedDungeonClear
|
|
// 骁勇结晶从 2026-09-24 起对**任意等级**的经验记账(不再只收满级溢出),
|
|
// 所以只要这笔结算带了经验、又给了冒险团配置,就必须把账号记录读进来
|
|
// 同事务提交;否则未满级角色通关会被 ErrSettlementStateInvalid 卡住。
|
|
needsAccount := monthlyClear || weeklyClear || cmd.RentalPointAward > 0 || cmd.Tower != nil ||
|
|
(cmd.AdventureRuntime != nil && (cmd.Experience > 0 || maximumLevelExperience > 0))
|
|
var account dnfrepo.AccountRecord
|
|
accountLoaded := false
|
|
if needsAccount {
|
|
if tx.Account == nil {
|
|
return ErrSettlementStateInvalid
|
|
}
|
|
loaded, accountFound, err := tx.Account.Load(ctx, cmd.AccountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !accountFound || strings.TrimSpace(loaded.AccountID) != strings.TrimSpace(cmd.AccountID) {
|
|
// 账号行缺失时的取舍与怪物击杀一致:这笔结算里只要有**满级部分**
|
|
// (进胶囊槽/名誉经验)或者**租赁点券**要落,就整笔失败 —— 那两项
|
|
// 是原版规则,静默丢掉比报错更糟;只有"未满级经验镜像进骁勇结晶"
|
|
// 时跳过记账、继续照常发角色经验与奖励,不让一行账本缺失把玩家的
|
|
// 通关结算整个卡死("改了A把B弄坏"正是要避免的)。
|
|
//
|
|
// 签到同理:账号行不在就没法记签到进度,但也不能因此把整笔通关
|
|
// 卡死——角色经验/技能/背包的写入还在下面。
|
|
if cmd.RentalPointAward > 0 || maximumLevelExperience > 0 || cmd.Tower != nil {
|
|
return ErrSettlementStateInvalid
|
|
}
|
|
// 注意:这里不能 return —— 角色经验/技能/背包的写入还在下面。
|
|
needsAccount = false
|
|
} else {
|
|
account = dnfrepo.CloneAccount(loaded)
|
|
accountLoaded = true
|
|
}
|
|
}
|
|
if accountLoaded {
|
|
if monthlyClear {
|
|
if err := onlineevent.RecordMonthlyClear(&account, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if weeklyClear {
|
|
if err := onlineevent.RecordWeeklyClear(&account, cmd.WeeklyAttendance, now); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
if cmd.RentalPointAward > 0 {
|
|
if cmd.RentalPointLimit == 0 || cmd.RentalPointAward > cmd.RentalPointLimit {
|
|
return ErrSettlementStateInvalid
|
|
}
|
|
current, err := parseLuckyStars(account.Metadata[LuckyStarMetadataKey])
|
|
if err != nil {
|
|
return fmt.Errorf("%w: invalid %s metadata: %v", ErrSettlementStateInvalid, LuckyStarMetadataKey, err)
|
|
}
|
|
result.LuckyStarBefore = current
|
|
result.LuckyStarAfter = current
|
|
result.LuckyStarAttempted = true
|
|
if current < cmd.RentalPointLimit {
|
|
applied := cmd.RentalPointAward
|
|
available := cmd.RentalPointLimit - current
|
|
if applied > available {
|
|
applied = available
|
|
}
|
|
result.LuckyStarApplied = applied
|
|
result.LuckyStarAfter = current + applied
|
|
if applied > 0 {
|
|
if account.Metadata == nil {
|
|
account.Metadata = make(map[string]string)
|
|
}
|
|
account.Metadata[LuckyStarMetadataKey] = strconv.FormatUint(uint64(result.LuckyStarAfter), 10)
|
|
}
|
|
}
|
|
}
|
|
writeSettlementReceipt(
|
|
character.Stats,
|
|
wantHash,
|
|
cmd.Experience,
|
|
planned.SkillPoints.SPGain,
|
|
planned.Experience.NewLevel,
|
|
planned.Experience.NewExperience,
|
|
result.LuckyStarAttempted,
|
|
result.LuckyStarBefore,
|
|
result.LuckyStarApplied,
|
|
result.LuckyStarAfter,
|
|
)
|
|
character.UpdatedAt = now
|
|
skill.Points = planned.SkillPoints.New
|
|
skill.UpdatedAt = now
|
|
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldBase, dnfrepo.CharacterFieldStats); err != nil {
|
|
return err
|
|
}
|
|
if err := dnfrepo.SaveSkillFields(ctx, tx.Skill, skill, dnfrepo.SkillFieldPoints); err != nil {
|
|
return err
|
|
}
|
|
if accountLoaded && cmd.AdventureRuntime != nil && (cmd.Experience > 0 || maximumLevelExperience > 0) {
|
|
// 骁勇结晶 / 成长胶囊槽 / 账号名誉经验的口径只在 adventuregroup 里
|
|
// 定义一次:怪物击杀与任务完成两条路也用同一个函数记入,否则同一笔
|
|
// 经验会在不同路径上算出不同结果。
|
|
// AwardedExperience = 这次通关经验全额 → 骁勇结晶(任意等级)
|
|
// MaximumLevelExperience = 其中的满级溢出 → 胶囊槽 + 名誉经验
|
|
if _, err := adventuregroup.ApplyExperienceCredit(
|
|
&account,
|
|
*cmd.AdventureRuntime,
|
|
adventuregroup.ExperienceCredit{
|
|
AwardedExperience: uint64(cmd.Experience),
|
|
MaximumLevelExperience: uint64(maximumLevelExperience),
|
|
},
|
|
now,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if accountLoaded {
|
|
account.UpdatedAt = now
|
|
if err := tx.Account.Save(ctx, account); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if cmd.Tower != nil {
|
|
// Consume the entry pool (character entry first, then the account
|
|
// pool), then apply the frozen ten reward slots. Both live in this
|
|
// transaction so a failed grant leaves the floor and the pools
|
|
// untouched.
|
|
if !accountLoaded {
|
|
return ErrSettlementStateInvalid
|
|
}
|
|
if err := recordTowerEntryClear(&account, &character, cmd.Tower.Kind, now); err != nil {
|
|
return err
|
|
}
|
|
if err := applyTowerClear(&character, &inventory, cmd.Tower, now); err != nil {
|
|
return err
|
|
}
|
|
if err := RecordTower100FloorClear(&account, cmd.Tower.Kind, cmd.Tower.Floor); err != nil {
|
|
return err
|
|
}
|
|
character.UpdatedAt = now
|
|
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats); err != nil {
|
|
return err
|
|
}
|
|
account.UpdatedAt = now
|
|
if err := tx.Account.Save(ctx, account); err != nil {
|
|
return err
|
|
}
|
|
inventory.UpdatedAt = now
|
|
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result = SettlementResult{
|
|
Character: character, Skill: skill, Inventory: inventory,
|
|
ExperienceGain: cmd.Experience,
|
|
HonorExpertGain: appliedHonorExpertGain,
|
|
SPGain: planned.SkillPoints.SPGain,
|
|
TPGain: planned.SkillPoints.TPGain,
|
|
LuckyStarBefore: result.LuckyStarBefore,
|
|
LuckyStarApplied: result.LuckyStarApplied,
|
|
LuckyStarAfter: result.LuckyStarAfter,
|
|
LuckyStarAttempted: result.LuckyStarAttempted,
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return SettlementResult{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
func maximumLevelExperienceGain(
|
|
previousLevel int,
|
|
previousExperience uint32,
|
|
gain uint32,
|
|
maximumLevel int,
|
|
tables *progression.Tables,
|
|
) uint32 {
|
|
if maximumLevel <= 0 || previousLevel <= 0 || previousLevel > maximumLevel || gain == 0 || tables == nil {
|
|
return 0
|
|
}
|
|
if previousLevel >= maximumLevel {
|
|
return gain
|
|
}
|
|
entryExperience, err := tables.ThresholdToNext(maximumLevel - 1)
|
|
if err != nil {
|
|
return 0
|
|
}
|
|
total := uint64(previousExperience) + uint64(gain)
|
|
if total <= uint64(entryExperience) {
|
|
return 0
|
|
}
|
|
excess := total - uint64(entryExperience)
|
|
if excess > math.MaxUint32 {
|
|
return math.MaxUint32
|
|
}
|
|
return uint32(excess)
|
|
}
|
|
|
|
func PlanSettlementProgression(
|
|
tables *progression.Tables,
|
|
level int,
|
|
total uint32,
|
|
gain uint32,
|
|
points dnfrepo.SkillPointState,
|
|
) (progression.ExperienceSkillPointPlan, error) {
|
|
return PlanSettlementProgressionAtCap(tables, level, total, gain, points, progression.MaximumCharacterLevel)
|
|
}
|
|
|
|
func PlanSettlementProgressionAtCap(
|
|
tables *progression.Tables,
|
|
level int,
|
|
total uint32,
|
|
gain uint32,
|
|
points dnfrepo.SkillPointState,
|
|
maximumLevel int,
|
|
) (progression.ExperienceSkillPointPlan, error) {
|
|
if tables == nil || level <= 0 || points.SyncedLevel != level || maximumLevel <= 0 ||
|
|
maximumLevel > progression.MaximumCharacterLevel || level > maximumLevel {
|
|
return progression.ExperienceSkillPointPlan{}, ErrSettlementStateInvalid
|
|
}
|
|
newTotal := uint64(total) + uint64(gain)
|
|
if newTotal > math.MaxUint32 {
|
|
newTotal = math.MaxUint32
|
|
}
|
|
result := progression.ExperienceResult{
|
|
PreviousLevel: level,
|
|
PreviousExperience: total,
|
|
Gain: gain,
|
|
NewLevel: level,
|
|
NewExperience: uint32(newTotal),
|
|
}
|
|
for result.NewLevel < maximumLevel {
|
|
threshold, err := tables.ThresholdToNext(result.NewLevel)
|
|
if err != nil {
|
|
return progression.ExperienceSkillPointPlan{}, err
|
|
}
|
|
if result.NewExperience < threshold {
|
|
break
|
|
}
|
|
if _, ok := tables.SkillPointsAtLevel(result.NewLevel + 1); !ok {
|
|
return progression.ExperienceSkillPointPlan{}, ErrSettlementStateInvalid
|
|
}
|
|
result.NewLevel++
|
|
}
|
|
// Max-level ordinary EXP stays at the entry threshold. The caller still
|
|
// assigns the full post-cap award to HonorExpert.
|
|
if result.NewLevel == maximumLevel && maximumLevel > 1 {
|
|
entryThreshold, err := tables.ThresholdToNext(maximumLevel - 1)
|
|
if err != nil {
|
|
return progression.ExperienceSkillPointPlan{}, err
|
|
}
|
|
if result.NewExperience > entryThreshold {
|
|
result.NewExperience = entryThreshold
|
|
result.Saturated = true
|
|
}
|
|
}
|
|
result.LevelsGained = result.NewLevel - result.PreviousLevel
|
|
advance, err := tables.AdvanceSkillPoints(points, result.NewLevel)
|
|
if err != nil {
|
|
return progression.ExperienceSkillPointPlan{}, err
|
|
}
|
|
return progression.ExperienceSkillPointPlan{Experience: result, SkillPoints: advance}, nil
|
|
}
|
|
|
|
func settlementReceiptMatches(stats map[string]int64, want [sha256.Size]byte) bool {
|
|
if stats == nil || stats[settlementReceiptPrefix+"version"] != settlementReceiptVersion {
|
|
return false
|
|
}
|
|
for index := 0; index < 4; index++ {
|
|
if uint64(stats[fmt.Sprintf("%shash_%d", settlementReceiptPrefix, index)]) !=
|
|
binary.LittleEndian.Uint64(want[index*8:(index+1)*8]) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func writeSettlementReceipt(
|
|
stats map[string]int64,
|
|
hash [sha256.Size]byte,
|
|
gain uint32,
|
|
spGain int,
|
|
newLevel int,
|
|
newExperience uint32,
|
|
luckyStarAttempted bool,
|
|
luckyStarBefore uint32,
|
|
luckyStarApplied uint32,
|
|
luckyStarAfter uint32,
|
|
) {
|
|
stats[settlementReceiptPrefix+"version"] = settlementReceiptVersion
|
|
for index := 0; index < 4; index++ {
|
|
stats[fmt.Sprintf("%shash_%d", settlementReceiptPrefix, index)] =
|
|
int64(binary.LittleEndian.Uint64(hash[index*8 : (index+1)*8]))
|
|
}
|
|
stats[settlementReceiptPrefix+"experience_gain"] = int64(gain)
|
|
stats[settlementReceiptPrefix+"sp_gain"] = int64(spGain)
|
|
stats[settlementReceiptPrefix+"new_level"] = int64(newLevel)
|
|
stats[settlementReceiptPrefix+"new_experience"] = int64(newExperience)
|
|
stats[settlementReceiptPrefix+"lucky_star_attempted"] = 0
|
|
if luckyStarAttempted {
|
|
stats[settlementReceiptPrefix+"lucky_star_attempted"] = 1
|
|
}
|
|
stats[settlementReceiptPrefix+"lucky_star_before"] = int64(luckyStarBefore)
|
|
stats[settlementReceiptPrefix+"lucky_star_applied"] = int64(luckyStarApplied)
|
|
stats[settlementReceiptPrefix+"lucky_star_after"] = int64(luckyStarAfter)
|
|
}
|
|
|
|
type settlementReceipt struct {
|
|
ExperienceGain uint32
|
|
SPGain int
|
|
LuckyStarBefore uint32
|
|
LuckyStarApplied uint32
|
|
LuckyStarAfter uint32
|
|
LuckyStarAttempted bool
|
|
}
|
|
|
|
func settlementReceiptValues(
|
|
character dnfrepo.CharacterRecord,
|
|
skill dnfrepo.SkillRecord,
|
|
) (settlementReceipt, error) {
|
|
stats := character.Stats
|
|
gain, gainPresent := stats[settlementReceiptPrefix+"experience_gain"]
|
|
spGain := stats[settlementReceiptPrefix+"sp_gain"]
|
|
if !gainPresent || gain < 0 || gain > math.MaxUint32 || spGain < 0 || spGain > math.MaxInt ||
|
|
stats[settlementReceiptPrefix+"new_level"] != int64(character.Level) ||
|
|
stats[settlementReceiptPrefix+"new_experience"] != stats["exp"] ||
|
|
skill.Points.SyncedLevel != character.Level {
|
|
return settlementReceipt{}, ErrSettlementStateInvalid
|
|
}
|
|
receipt := settlementReceipt{ExperienceGain: uint32(gain), SPGain: int(spGain)}
|
|
attempted := stats[settlementReceiptPrefix+"lucky_star_attempted"]
|
|
if attempted == 0 {
|
|
if stats[settlementReceiptPrefix+"lucky_star_before"] != 0 ||
|
|
stats[settlementReceiptPrefix+"lucky_star_applied"] != 0 ||
|
|
stats[settlementReceiptPrefix+"lucky_star_after"] != 0 {
|
|
return settlementReceipt{}, ErrSettlementStateInvalid
|
|
}
|
|
return receipt, nil
|
|
}
|
|
if attempted != 1 {
|
|
return settlementReceipt{}, ErrSettlementStateInvalid
|
|
}
|
|
before := stats[settlementReceiptPrefix+"lucky_star_before"]
|
|
applied := stats[settlementReceiptPrefix+"lucky_star_applied"]
|
|
after := stats[settlementReceiptPrefix+"lucky_star_after"]
|
|
if before < 0 || before > math.MaxUint32 || applied < 0 || applied > math.MaxUint32 ||
|
|
after < 0 || after > math.MaxUint32 || before+applied != after {
|
|
return settlementReceipt{}, ErrSettlementStateInvalid
|
|
}
|
|
receipt.LuckyStarBefore = uint32(before)
|
|
receipt.LuckyStarApplied = uint32(applied)
|
|
receipt.LuckyStarAfter = uint32(after)
|
|
receipt.LuckyStarAttempted = true
|
|
return receipt, nil
|
|
}
|