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,2 @@
|
||||
// Package dungeonaltar owns durable Blood/Ultimate Altar admission and record state.
|
||||
package dungeonaltar
|
||||
@@ -0,0 +1,538 @@
|
||||
package dungeonaltar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/premium"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const settingsSuffix = ":dungeon_altar_v1"
|
||||
|
||||
const (
|
||||
receiptActive = "active"
|
||||
receiptRestored = "restored"
|
||||
)
|
||||
|
||||
type Owner struct {
|
||||
repositories dnfrepo.Group
|
||||
}
|
||||
|
||||
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
||||
if repositories.Character == nil || repositories.Settings == nil || repositories.CharacterSettlement == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{repositories: repositories}, nil
|
||||
}
|
||||
|
||||
func (o *Owner) ReserveEndless(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
rule AdmissionRule,
|
||||
receipt string,
|
||||
now time.Time,
|
||||
) (AdmissionSnapshot, error) {
|
||||
if o == nil || o.repositories.CharacterSettlement == nil {
|
||||
return AdmissionSnapshot{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
receipt = strings.TrimSpace(receipt)
|
||||
if characterID == "" || receipt == "" {
|
||||
return AdmissionSnapshot{}, fmt.Errorf("%w: character and receipt are required", ErrCommandInvalid)
|
||||
}
|
||||
if err := validateAdmissionRule(rule); err != nil {
|
||||
return AdmissionSnapshot{}, err
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
ctx = contextOrBackground(ctx)
|
||||
var result AdmissionSnapshot
|
||||
err := o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if tx.Character == nil || tx.Settings == nil {
|
||||
return ErrOwnerUnavailable
|
||||
}
|
||||
character, found, err := tx.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrCharacterMissing
|
||||
}
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
if character.Stats == nil {
|
||||
character.Stats = make(map[string]int64)
|
||||
}
|
||||
settings, found, err := tx.Settings.Load(ctx, SettingsScope(characterID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings = dnfrepo.CloneSettings(settings)
|
||||
if !found {
|
||||
settings.Scope = SettingsScope(characterID)
|
||||
}
|
||||
if settings.Values == nil {
|
||||
settings.Values = make(map[string]string)
|
||||
}
|
||||
resetAdmissionWindows(settings.Values, now)
|
||||
receiptKey := admissionReceiptKey(receipt)
|
||||
if stored := strings.TrimSpace(settings.Values[receiptKey]); stored != "" {
|
||||
replayed, state, parseErr := parseAdmissionReceipt(stored)
|
||||
if parseErr != nil {
|
||||
return parseErr
|
||||
}
|
||||
if state == receiptRestored {
|
||||
return ErrReceiptRestored
|
||||
}
|
||||
replayed.CharacterID = characterID
|
||||
replayed.Replayed = true
|
||||
result = replayed
|
||||
return nil
|
||||
}
|
||||
daily, err := storedUint32(settings.Values, "daily_count")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
weekly, err := storedUint32(settings.Values, "weekly_count")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rule.DailyLimit != 0 && daily >= rule.DailyLimit {
|
||||
return fmt.Errorf("%w: used=%d limit=%d", ErrDailyLimit, daily, rule.DailyLimit)
|
||||
}
|
||||
level := character.Level
|
||||
if level < 0 {
|
||||
level = 0
|
||||
}
|
||||
if level > math.MaxUint16 {
|
||||
level = math.MaxUint16
|
||||
}
|
||||
fee, err := admissionFee(rule, uint16(level), daily)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
basePrice, err := admissionBasePrice(rule, uint16(level))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lottoPoint := admissionLottoPoint(rule, daily)
|
||||
lottoGold := fee - basePrice
|
||||
totalLottoPoints, err := storedUint32(settings.Values, "lotto_points")
|
||||
if err != nil || totalLottoPoints > math.MaxUint32-lottoPoint {
|
||||
return ErrReceiptMismatch
|
||||
}
|
||||
totalLottoGold, err := storedInt64(settings.Values, "lotto_use_gold")
|
||||
if err != nil || lottoGold < 0 || totalLottoGold > math.MaxInt64-lottoGold {
|
||||
return ErrReceiptMismatch
|
||||
}
|
||||
gold := character.Stats["gold"]
|
||||
if gold < fee {
|
||||
return fmt.Errorf("%w: gold=%d fee=%d", ErrGoldInsufficient, gold, fee)
|
||||
}
|
||||
daily++
|
||||
weekly++
|
||||
totalLottoPoints += lottoPoint
|
||||
totalLottoGold += lottoGold
|
||||
gold -= fee
|
||||
character.Stats["gold"] = gold
|
||||
settings.Values["daily_count"] = strconv.FormatUint(uint64(daily), 10)
|
||||
settings.Values["weekly_count"] = strconv.FormatUint(uint64(weekly), 10)
|
||||
settings.Values["lotto_points"] = strconv.FormatUint(uint64(totalLottoPoints), 10)
|
||||
settings.Values["lotto_use_gold"] = strconv.FormatInt(totalLottoGold, 10)
|
||||
result = AdmissionSnapshot{
|
||||
CharacterID: characterID, Fee: fee, GoldAfter: gold,
|
||||
DailyEnterCount: daily, WeeklyEnterCount: weekly,
|
||||
LottoPointEarned: lottoPoint, LottoGoldSpent: lottoGold,
|
||||
TotalLottoPoints: totalLottoPoints, TotalLottoGold: totalLottoGold,
|
||||
}
|
||||
settings.Values[receiptKey] = formatAdmissionReceipt(result, receiptActive)
|
||||
settings.UpdatedAt = now
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
return dnfrepo.SaveSettingsFields(ctx, tx.Settings, settings, dnfrepo.SettingsFieldValues)
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (o *Owner) RestoreEndless(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
receipt string,
|
||||
now time.Time,
|
||||
) (AdmissionSnapshot, error) {
|
||||
if o == nil || o.repositories.CharacterSettlement == nil {
|
||||
return AdmissionSnapshot{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
receipt = strings.TrimSpace(receipt)
|
||||
if characterID == "" || receipt == "" {
|
||||
return AdmissionSnapshot{}, fmt.Errorf("%w: character and receipt are required", ErrCommandInvalid)
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
now = now.UTC()
|
||||
ctx = contextOrBackground(ctx)
|
||||
var result AdmissionSnapshot
|
||||
err := o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
character, found, err := tx.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return ErrCharacterMissing
|
||||
}
|
||||
settings, found, err := tx.Settings.Load(ctx, SettingsScope(characterID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || settings.Values == nil {
|
||||
return ErrReceiptMismatch
|
||||
}
|
||||
settings = dnfrepo.CloneSettings(settings)
|
||||
key := admissionReceiptKey(receipt)
|
||||
stored, state, err := parseAdmissionReceipt(settings.Values[key])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if state == receiptRestored {
|
||||
stored.CharacterID = characterID
|
||||
stored.Replayed = true
|
||||
result = stored
|
||||
return nil
|
||||
}
|
||||
daily, err := storedUint32(settings.Values, "daily_count")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
weekly, err := storedUint32(settings.Values, "weekly_count")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if premium.ServiceDayID(now) != parseInt64(settings.Values["daily_day"]) ||
|
||||
serviceWeekID(now) != parseInt64(settings.Values["weekly_week"]) || daily == 0 || weekly == 0 {
|
||||
return ErrReceiptMismatch
|
||||
}
|
||||
totalLottoPoints, err := storedUint32(settings.Values, "lotto_points")
|
||||
if err != nil || totalLottoPoints < stored.LottoPointEarned {
|
||||
return ErrReceiptMismatch
|
||||
}
|
||||
totalLottoGold, err := storedInt64(settings.Values, "lotto_use_gold")
|
||||
if err != nil || totalLottoGold < stored.LottoGoldSpent {
|
||||
return ErrReceiptMismatch
|
||||
}
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
if character.Stats == nil || stored.Fee < 0 || character.Stats["gold"] > math.MaxInt64-stored.Fee {
|
||||
return ErrReceiptMismatch
|
||||
}
|
||||
daily--
|
||||
weekly--
|
||||
totalLottoPoints -= stored.LottoPointEarned
|
||||
totalLottoGold -= stored.LottoGoldSpent
|
||||
character.Stats["gold"] += stored.Fee
|
||||
settings.Values["daily_count"] = strconv.FormatUint(uint64(daily), 10)
|
||||
settings.Values["weekly_count"] = strconv.FormatUint(uint64(weekly), 10)
|
||||
settings.Values["lotto_points"] = strconv.FormatUint(uint64(totalLottoPoints), 10)
|
||||
settings.Values["lotto_use_gold"] = strconv.FormatInt(totalLottoGold, 10)
|
||||
stored.GoldAfter = character.Stats["gold"]
|
||||
stored.DailyEnterCount = daily
|
||||
stored.WeeklyEnterCount = weekly
|
||||
stored.TotalLottoPoints = totalLottoPoints
|
||||
stored.TotalLottoGold = totalLottoGold
|
||||
settings.Values[key] = formatAdmissionReceipt(stored, receiptRestored)
|
||||
settings.UpdatedAt = now
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := dnfrepo.SaveSettingsFields(ctx, tx.Settings, settings, dnfrepo.SettingsFieldValues); err != nil {
|
||||
return err
|
||||
}
|
||||
stored.CharacterID = characterID
|
||||
result = stored
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func (o *Owner) UpdateBestRecord(
|
||||
ctx context.Context,
|
||||
characterID string,
|
||||
dungeonID uint32,
|
||||
round uint32,
|
||||
playTimeMillis uint32,
|
||||
receipt string,
|
||||
now time.Time,
|
||||
) (RecordSnapshot, error) {
|
||||
if o == nil || o.repositories.CharacterSettlement == nil {
|
||||
return RecordSnapshot{}, ErrOwnerUnavailable
|
||||
}
|
||||
characterID = strings.TrimSpace(characterID)
|
||||
receipt = strings.TrimSpace(receipt)
|
||||
if characterID == "" || dungeonID == 0 || round == 0 || round > 15 || receipt == "" {
|
||||
return RecordSnapshot{}, fmt.Errorf("%w: invalid record input", ErrCommandInvalid)
|
||||
}
|
||||
if now.IsZero() {
|
||||
now = time.Now()
|
||||
}
|
||||
ctx = contextOrBackground(ctx)
|
||||
var result RecordSnapshot
|
||||
err := o.repositories.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
if _, found, err := tx.Character.Load(ctx, characterID); err != nil {
|
||||
return err
|
||||
} else if !found {
|
||||
return ErrCharacterMissing
|
||||
}
|
||||
settings, found, err := tx.Settings.Load(ctx, SettingsScope(characterID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
settings = dnfrepo.CloneSettings(settings)
|
||||
if !found {
|
||||
settings.Scope = SettingsScope(characterID)
|
||||
}
|
||||
if settings.Values == nil {
|
||||
settings.Values = make(map[string]string)
|
||||
}
|
||||
receiptKey := recordReceiptKey(dungeonID, receipt)
|
||||
if stored := strings.TrimSpace(settings.Values[receiptKey]); stored != "" {
|
||||
parsed, err := parseRecord(stored, dungeonID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
parsed.Replayed = true
|
||||
result = parsed
|
||||
return nil
|
||||
}
|
||||
bestRound, err := storedUint32(settings.Values, recordRoundKey(dungeonID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
bestTime, err := storedUint32(settings.Values, recordTimeKey(dungeonID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if bestRound > 15 || (bestRound == 0 && bestTime != 0) {
|
||||
return ErrReceiptMismatch
|
||||
}
|
||||
changed := round > bestRound || (round == bestRound && (bestTime == 0 || playTimeMillis < bestTime))
|
||||
if changed {
|
||||
bestRound, bestTime = round, playTimeMillis
|
||||
settings.Values[recordRoundKey(dungeonID)] = strconv.FormatUint(uint64(bestRound), 10)
|
||||
settings.Values[recordTimeKey(dungeonID)] = strconv.FormatUint(uint64(bestTime), 10)
|
||||
}
|
||||
result = RecordSnapshot{DungeonID: dungeonID, BestRound: bestRound, BestTimeMillis: bestTime, Changed: changed}
|
||||
settings.Values[receiptKey] = formatRecord(result)
|
||||
settings.UpdatedAt = now.UTC()
|
||||
return dnfrepo.SaveSettingsFields(ctx, tx.Settings, settings, dnfrepo.SettingsFieldValues)
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
|
||||
func SettingsScope(characterID string) string {
|
||||
return "character:" + strings.TrimSpace(characterID) + settingsSuffix
|
||||
}
|
||||
|
||||
func admissionFee(rule AdmissionRule, level uint16, enterCount uint32) (int64, error) {
|
||||
price, err := admissionBasePrice(rule, level)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
percent := rule.AdmissionIncrease[len(rule.AdmissionIncrease)-1].Percent
|
||||
for _, row := range rule.AdmissionIncrease {
|
||||
if enterCount < row.UpperExclusiveEnterCount {
|
||||
percent = row.Percent
|
||||
break
|
||||
}
|
||||
}
|
||||
if price > math.MaxInt64/(100+percent) {
|
||||
return 0, ErrCommandInvalid
|
||||
}
|
||||
return price + price*percent/100 + rule.LottoCost, nil
|
||||
}
|
||||
|
||||
func admissionBasePrice(rule AdmissionRule, level uint16) (int64, error) {
|
||||
for _, band := range rule.PriceBands {
|
||||
if level >= band.MinimumLevel && level <= band.MaximumLevel {
|
||||
return band.Price, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("%w: level=%d has no price band", ErrCommandInvalid, level)
|
||||
}
|
||||
|
||||
func admissionLottoPoint(rule AdmissionRule, enterCount uint32) uint32 {
|
||||
points := rule.LottoPoints[len(rule.LottoPoints)-1].Points
|
||||
for _, row := range rule.LottoPoints {
|
||||
if enterCount < row.UpperExclusiveEnterCount {
|
||||
return row.Points
|
||||
}
|
||||
}
|
||||
return points
|
||||
}
|
||||
|
||||
func validateAdmissionRule(rule AdmissionRule) error {
|
||||
if len(rule.PriceBands) == 0 || len(rule.AdmissionIncrease) == 0 || len(rule.LottoPoints) == 0 || rule.LottoCost < 0 {
|
||||
return ErrCommandInvalid
|
||||
}
|
||||
var previousMaximum uint16
|
||||
for index, band := range rule.PriceBands {
|
||||
if band.MinimumLevel == 0 || band.MaximumLevel < band.MinimumLevel || band.Price < 0 ||
|
||||
(index > 0 && band.MinimumLevel != previousMaximum+1) {
|
||||
return ErrCommandInvalid
|
||||
}
|
||||
previousMaximum = band.MaximumLevel
|
||||
}
|
||||
var previousThreshold uint32
|
||||
for _, row := range rule.AdmissionIncrease {
|
||||
if row.UpperExclusiveEnterCount == 0 || row.UpperExclusiveEnterCount <= previousThreshold || row.Percent < 0 {
|
||||
return ErrCommandInvalid
|
||||
}
|
||||
previousThreshold = row.UpperExclusiveEnterCount
|
||||
}
|
||||
previousThreshold = 0
|
||||
for _, row := range rule.LottoPoints {
|
||||
if row.UpperExclusiveEnterCount == 0 || row.UpperExclusiveEnterCount <= previousThreshold {
|
||||
return ErrCommandInvalid
|
||||
}
|
||||
previousThreshold = row.UpperExclusiveEnterCount
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func resetAdmissionWindows(values map[string]string, now time.Time) {
|
||||
day := premium.ServiceDayID(now)
|
||||
if parseInt64(values["daily_day"]) != day {
|
||||
values["daily_day"] = strconv.FormatInt(day, 10)
|
||||
values["daily_count"] = "0"
|
||||
}
|
||||
week := serviceWeekID(now)
|
||||
if parseInt64(values["weekly_week"]) != week {
|
||||
values["weekly_week"] = strconv.FormatInt(week, 10)
|
||||
values["weekly_count"] = "0"
|
||||
values["lotto_points"] = "0"
|
||||
values["lotto_use_gold"] = "0"
|
||||
}
|
||||
}
|
||||
|
||||
func serviceWeekID(now time.Time) int64 {
|
||||
day := premium.ServiceDayID(now)
|
||||
// Unix service day 0 is Thursday. Shift by three so Monday 06:00 begins a week.
|
||||
return (day + 3) / 7
|
||||
}
|
||||
|
||||
func admissionReceiptKey(receipt string) string { return "admission_receipt:" + digest(receipt) }
|
||||
func recordReceiptKey(dungeonID uint32, receipt string) string {
|
||||
return "record_receipt:" + strconv.FormatUint(uint64(dungeonID), 10) + ":" + digest(receipt)
|
||||
}
|
||||
func recordRoundKey(dungeonID uint32) string {
|
||||
return "record:" + strconv.FormatUint(uint64(dungeonID), 10) + ":round"
|
||||
}
|
||||
func recordTimeKey(dungeonID uint32) string {
|
||||
return "record:" + strconv.FormatUint(uint64(dungeonID), 10) + ":time"
|
||||
}
|
||||
|
||||
func digest(value string) string {
|
||||
sum := sha256.Sum256([]byte(strings.TrimSpace(value)))
|
||||
return hex.EncodeToString(sum[:16])
|
||||
}
|
||||
|
||||
func formatAdmissionReceipt(value AdmissionSnapshot, state string) string {
|
||||
return fmt.Sprintf("%s:%d:%d:%d:%d:%d:%d:%d:%d", state, value.Fee, value.GoldAfter,
|
||||
value.DailyEnterCount, value.WeeklyEnterCount, value.LottoPointEarned, value.LottoGoldSpent,
|
||||
value.TotalLottoPoints, value.TotalLottoGold)
|
||||
}
|
||||
|
||||
func parseAdmissionReceipt(value string) (AdmissionSnapshot, string, error) {
|
||||
parts := strings.Split(strings.TrimSpace(value), ":")
|
||||
if len(parts) != 9 || (parts[0] != receiptActive && parts[0] != receiptRestored) {
|
||||
return AdmissionSnapshot{}, "", ErrReceiptMismatch
|
||||
}
|
||||
fee, err1 := strconv.ParseInt(parts[1], 10, 64)
|
||||
gold, err2 := strconv.ParseInt(parts[2], 10, 64)
|
||||
daily, err3 := strconv.ParseUint(parts[3], 10, 32)
|
||||
weekly, err4 := strconv.ParseUint(parts[4], 10, 32)
|
||||
earned, err5 := strconv.ParseUint(parts[5], 10, 32)
|
||||
lottoGold, err6 := strconv.ParseInt(parts[6], 10, 64)
|
||||
totalPoints, err7 := strconv.ParseUint(parts[7], 10, 32)
|
||||
totalGold, err8 := strconv.ParseInt(parts[8], 10, 64)
|
||||
if err1 != nil || err2 != nil || err3 != nil || err4 != nil || err5 != nil || err6 != nil ||
|
||||
err7 != nil || err8 != nil || fee < 0 || gold < 0 || lottoGold < 0 || totalGold < 0 ||
|
||||
(parts[0] == receiptActive && (uint32(earned) > uint32(totalPoints) || lottoGold > totalGold)) {
|
||||
return AdmissionSnapshot{}, "", ErrReceiptMismatch
|
||||
}
|
||||
return AdmissionSnapshot{
|
||||
Fee: fee, GoldAfter: gold, DailyEnterCount: uint32(daily), WeeklyEnterCount: uint32(weekly),
|
||||
LottoPointEarned: uint32(earned), LottoGoldSpent: lottoGold,
|
||||
TotalLottoPoints: uint32(totalPoints), TotalLottoGold: totalGold,
|
||||
}, parts[0], nil
|
||||
}
|
||||
|
||||
func formatRecord(value RecordSnapshot) string {
|
||||
changed := 0
|
||||
if value.Changed {
|
||||
changed = 1
|
||||
}
|
||||
return fmt.Sprintf("%d:%d:%d:%d", value.DungeonID, value.BestRound, value.BestTimeMillis, changed)
|
||||
}
|
||||
|
||||
func parseRecord(value string, dungeonID uint32) (RecordSnapshot, error) {
|
||||
parts := strings.Split(strings.TrimSpace(value), ":")
|
||||
if len(parts) != 4 {
|
||||
return RecordSnapshot{}, ErrReceiptMismatch
|
||||
}
|
||||
id, e1 := strconv.ParseUint(parts[0], 10, 32)
|
||||
round, e2 := strconv.ParseUint(parts[1], 10, 32)
|
||||
timeValue, e3 := strconv.ParseUint(parts[2], 10, 32)
|
||||
changed, e4 := strconv.ParseUint(parts[3], 10, 1)
|
||||
if e1 != nil || e2 != nil || e3 != nil || e4 != nil || uint32(id) != dungeonID || round == 0 || round > 15 {
|
||||
return RecordSnapshot{}, ErrReceiptMismatch
|
||||
}
|
||||
return RecordSnapshot{DungeonID: dungeonID, BestRound: uint32(round), BestTimeMillis: uint32(timeValue), Changed: changed == 1}, nil
|
||||
}
|
||||
|
||||
func storedUint32(values map[string]string, key string) (uint32, error) {
|
||||
value := strings.TrimSpace(values[key])
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
parsed, err := strconv.ParseUint(value, 10, 32)
|
||||
if err != nil {
|
||||
return 0, ErrReceiptMismatch
|
||||
}
|
||||
return uint32(parsed), nil
|
||||
}
|
||||
|
||||
func storedInt64(values map[string]string, key string) (int64, error) {
|
||||
value := strings.TrimSpace(values[key])
|
||||
if value == "" {
|
||||
return 0, nil
|
||||
}
|
||||
parsed, err := strconv.ParseInt(value, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
return 0, ErrReceiptMismatch
|
||||
}
|
||||
return parsed, nil
|
||||
}
|
||||
|
||||
func parseInt64(value string) int64 {
|
||||
parsed, err := strconv.ParseInt(strings.TrimSpace(value), 10, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
func contextOrBackground(ctx context.Context) context.Context {
|
||||
if ctx == nil {
|
||||
return context.Background()
|
||||
}
|
||||
return ctx
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
package dungeonaltar
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestOwnerReservesEndlessAdmissionWithEscalatingFeeAndReplay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "7", AccountID: "account-1", Level: 90, Stats: map[string]int64{"gold": 100_000},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, err := NewOwner(repositories)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rule := testAdmissionRule()
|
||||
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
first, err := owner.ReserveEndless(ctx, "7", rule, "attempt-1", now)
|
||||
if err != nil || first.Fee != 22_000 || first.GoldAfter != 78_000 || first.DailyEnterCount != 1 || first.WeeklyEnterCount != 1 ||
|
||||
first.LottoPointEarned != 1 || first.LottoGoldSpent != 10_000 || first.TotalLottoPoints != 1 || first.TotalLottoGold != 10_000 {
|
||||
t.Fatalf("first=%+v err=%v", first, err)
|
||||
}
|
||||
replay, err := owner.ReserveEndless(ctx, "7", rule, "attempt-1", now)
|
||||
if err != nil || !replay.Replayed || replay != (AdmissionSnapshot{
|
||||
CharacterID: "7", Fee: 22_000, GoldAfter: 78_000, DailyEnterCount: 1, WeeklyEnterCount: 1,
|
||||
LottoPointEarned: 1, LottoGoldSpent: 10_000, TotalLottoPoints: 1, TotalLottoGold: 10_000, Replayed: true,
|
||||
}) {
|
||||
t.Fatalf("replay=%+v err=%v", replay, err)
|
||||
}
|
||||
second, err := owner.ReserveEndless(ctx, "7", rule, "attempt-2", now)
|
||||
if err != nil || second.Fee != 29_200 || second.GoldAfter != 48_800 || second.DailyEnterCount != 2 || second.WeeklyEnterCount != 2 ||
|
||||
second.LottoPointEarned != 3 || second.LottoGoldSpent != 17_200 || second.TotalLottoPoints != 4 || second.TotalLottoGold != 27_200 {
|
||||
t.Fatalf("second=%+v err=%v", second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerRestoresEndlessAdmissionAndRejectsReceiptReuse(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "8", AccountID: "account-1", Level: 60, Stats: map[string]int64{"gold": 50_000},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repositories)
|
||||
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
|
||||
reserved, err := owner.ReserveEndless(ctx, "8", testAdmissionRule(), "attempt", now)
|
||||
if err != nil || reserved.Fee != 22_000 {
|
||||
t.Fatalf("reserved=%+v err=%v", reserved, err)
|
||||
}
|
||||
restored, err := owner.RestoreEndless(ctx, "8", "attempt", now)
|
||||
if err != nil || restored.GoldAfter != 50_000 || restored.DailyEnterCount != 0 || restored.WeeklyEnterCount != 0 ||
|
||||
restored.TotalLottoPoints != 0 || restored.TotalLottoGold != 0 {
|
||||
t.Fatalf("restored=%+v err=%v", restored, err)
|
||||
}
|
||||
if _, err := owner.ReserveEndless(ctx, "8", testAdmissionRule(), "attempt", now); !errors.Is(err, ErrReceiptRestored) {
|
||||
t.Fatalf("receipt reuse error=%v", err)
|
||||
}
|
||||
character, _, err := repositories.Character.Load(ctx, "8")
|
||||
if err != nil || character.Stats["gold"] != 50_000 {
|
||||
t.Fatalf("character=%+v err=%v", character, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerResetsDailyCountAtBeijingSixAndKeepsWeeklyCount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "9", AccountID: "account-1", Level: 90, Stats: map[string]int64{"gold": 200_000},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repositories)
|
||||
zone := time.FixedZone("CST", 8*60*60)
|
||||
before := time.Date(2026, 9, 1, 5, 59, 59, 0, zone)
|
||||
a, err := owner.ReserveEndless(ctx, "9", testAdmissionRule(), "before", before)
|
||||
if err != nil || a.DailyEnterCount != 1 || a.WeeklyEnterCount != 1 {
|
||||
t.Fatalf("before=%+v err=%v", a, err)
|
||||
}
|
||||
after := time.Date(2026, 9, 1, 6, 0, 0, 0, zone)
|
||||
b, err := owner.ReserveEndless(ctx, "9", testAdmissionRule(), "after", after)
|
||||
if err != nil || b.DailyEnterCount != 1 || b.WeeklyEnterCount != 2 || b.Fee != 22_000 {
|
||||
t.Fatalf("after=%+v err=%v", b, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerPersistsBestRoundThenLowestTime(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{CharacterID: "10", AccountID: "account-1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repositories)
|
||||
now := time.Now()
|
||||
first, err := owner.UpdateBestRecord(ctx, "10", 11006, 4, 12_000, "clear-1", now)
|
||||
if err != nil || first.BestRound != 4 || first.BestTimeMillis != 12_000 || !first.Changed {
|
||||
t.Fatalf("first=%+v err=%v", first, err)
|
||||
}
|
||||
slower, err := owner.UpdateBestRecord(ctx, "10", 11006, 4, 13_000, "clear-2", now)
|
||||
if err != nil || slower.BestTimeMillis != 12_000 || slower.Changed {
|
||||
t.Fatalf("slower=%+v err=%v", slower, err)
|
||||
}
|
||||
faster, err := owner.UpdateBestRecord(ctx, "10", 11006, 4, 11_000, "clear-3", now)
|
||||
if err != nil || faster.BestTimeMillis != 11_000 || !faster.Changed {
|
||||
t.Fatalf("faster=%+v err=%v", faster, err)
|
||||
}
|
||||
higher, err := owner.UpdateBestRecord(ctx, "10", 11006, 5, 20_000, "clear-4", now)
|
||||
if err != nil || higher.BestRound != 5 || higher.BestTimeMillis != 20_000 || !higher.Changed {
|
||||
t.Fatalf("higher=%+v err=%v", higher, err)
|
||||
}
|
||||
replay, err := owner.UpdateBestRecord(ctx, "10", 11006, 5, 20_000, "clear-4", now)
|
||||
if err != nil || !replay.Replayed || replay.BestRound != 5 || replay.BestTimeMillis != 20_000 {
|
||||
t.Fatalf("replay=%+v err=%v", replay, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerRejectsMalformedDurableAdmissionCounter(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repositories := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repositories.Character.Save(ctx, dnfrepo.CharacterRecord{
|
||||
CharacterID: "11", AccountID: "account-1", Level: 90, Stats: map[string]int64{"gold": 100_000},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.FixedZone("CST", 8*60*60))
|
||||
values := map[string]string{}
|
||||
resetAdmissionWindows(values, now)
|
||||
values["daily_count"] = "not-a-number"
|
||||
values["weekly_count"] = "1"
|
||||
if err := repositories.Settings.Save(ctx, dnfrepo.SettingsRecord{
|
||||
Scope: SettingsScope("11"),
|
||||
Values: values,
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := NewOwner(repositories)
|
||||
if _, err := owner.ReserveEndless(ctx, "11", testAdmissionRule(), "corrupt", now); !errors.Is(err, ErrReceiptMismatch) {
|
||||
t.Fatalf("malformed counter error=%v", err)
|
||||
}
|
||||
character, found, err := repositories.Character.Load(ctx, "11")
|
||||
if err != nil || !found || character.Stats["gold"] != 100_000 {
|
||||
t.Fatalf("character=%+v found=%t err=%v", character, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func testAdmissionRule() AdmissionRule {
|
||||
return AdmissionRule{
|
||||
PriceBands: []PriceBand{
|
||||
{MinimumLevel: 1, MaximumLevel: 50, Price: 8_000},
|
||||
{MinimumLevel: 51, MaximumLevel: 55, Price: 10_000},
|
||||
{MinimumLevel: 56, MaximumLevel: 99, Price: 12_000},
|
||||
},
|
||||
AdmissionIncrease: []AdmissionIncrease{
|
||||
{UpperExclusiveEnterCount: 1, Percent: 0},
|
||||
{UpperExclusiveEnterCount: 2, Percent: 60},
|
||||
{UpperExclusiveEnterCount: 3, Percent: 200},
|
||||
},
|
||||
LottoPoints: []LottoPoint{
|
||||
{UpperExclusiveEnterCount: 1, Points: 1},
|
||||
{UpperExclusiveEnterCount: 2, Points: 3},
|
||||
{UpperExclusiveEnterCount: 3, Points: 5},
|
||||
},
|
||||
LottoCost: 10_000,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package dungeonaltar
|
||||
|
||||
import "errors"
|
||||
|
||||
var (
|
||||
ErrOwnerUnavailable = errors.New("dungeon altar owner is unavailable")
|
||||
ErrCommandInvalid = errors.New("dungeon altar command is invalid")
|
||||
ErrCharacterMissing = errors.New("dungeon altar character is missing")
|
||||
ErrGoldInsufficient = errors.New("dungeon altar gold is insufficient")
|
||||
ErrDailyLimit = errors.New("dungeon altar daily limit is exhausted")
|
||||
ErrReceiptRestored = errors.New("dungeon altar admission receipt was restored")
|
||||
ErrReceiptMismatch = errors.New("dungeon altar admission receipt does not match durable state")
|
||||
)
|
||||
|
||||
type PriceBand struct {
|
||||
MinimumLevel uint16
|
||||
MaximumLevel uint16
|
||||
Price int64
|
||||
}
|
||||
|
||||
type AdmissionIncrease struct {
|
||||
UpperExclusiveEnterCount uint32
|
||||
Percent int64
|
||||
}
|
||||
|
||||
type LottoPoint struct {
|
||||
UpperExclusiveEnterCount uint32
|
||||
Points uint32
|
||||
}
|
||||
|
||||
type AdmissionRule struct {
|
||||
PriceBands []PriceBand
|
||||
AdmissionIncrease []AdmissionIncrease
|
||||
LottoPoints []LottoPoint
|
||||
LottoCost int64
|
||||
DailyLimit uint32
|
||||
}
|
||||
|
||||
type AdmissionSnapshot struct {
|
||||
CharacterID string
|
||||
Fee int64
|
||||
GoldAfter int64
|
||||
DailyEnterCount uint32
|
||||
WeeklyEnterCount uint32
|
||||
LottoPointEarned uint32
|
||||
LottoGoldSpent int64
|
||||
TotalLottoPoints uint32
|
||||
TotalLottoGold int64
|
||||
Replayed bool
|
||||
}
|
||||
|
||||
type RecordSnapshot struct {
|
||||
DungeonID uint32
|
||||
BestRound uint32
|
||||
BestTimeMillis uint32
|
||||
Changed bool
|
||||
Replayed bool
|
||||
}
|
||||
Reference in new issue
Block a user