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,467 @@
|
||||
package onlineevent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/carrygold"
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// Owner reuses CharacterSettlement because it is the existing transaction
|
||||
// that locks account metadata, the selected character wallet, and inventory
|
||||
// together. That makes the claim receipt and its rewards one atomic commit.
|
||||
type Owner struct {
|
||||
settlements dnfrepo.CharacterSettlementUnitOfWork
|
||||
}
|
||||
|
||||
func NewOwner(repositories dnfrepo.Group) (*Owner, error) {
|
||||
if repositories.CharacterSettlement == nil {
|
||||
return nil, ErrOwnerUnavailable
|
||||
}
|
||||
return &Owner{settlements: repositories.CharacterSettlement}, nil
|
||||
}
|
||||
|
||||
// Status returns the authoritative progress used by a future proved login/UI
|
||||
// projection. Reading status also persists a service-day reset, so a stale
|
||||
// prior-day claim ledger cannot survive indefinitely when no new interval has
|
||||
// yet been credited.
|
||||
func (o *Owner) Status(ctx context.Context, command StatusCommand) (Snapshot, error) {
|
||||
if o == nil || o.settlements == nil {
|
||||
return Snapshot{}, ErrOwnerUnavailable
|
||||
}
|
||||
accountID := strings.TrimSpace(command.AccountID)
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if accountID == "" || characterID == "" {
|
||||
return Snapshot{}, ErrObservationInvalid
|
||||
}
|
||||
if err := command.Definition.Validate(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
observedAt := command.ObservedAt
|
||||
if observedAt.IsZero() {
|
||||
observedAt = time.Now()
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
|
||||
var snapshot Snapshot
|
||||
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
account, _, err := loadAccountAndCharacter(ctx, tx, accountID, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := parseState(account.Metadata[metadataKey(command.Definition.ID)], command.Definition)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
date, _ := calendarDay(command.Definition.calendar(), command.Definition.boundary(), observedAt)
|
||||
if state.CalendarDate != "" && date < state.CalendarDate {
|
||||
return fmt.Errorf(
|
||||
"%w: persisted=%s observed=%s",
|
||||
ErrObservationStale,
|
||||
state.CalendarDate,
|
||||
date,
|
||||
)
|
||||
}
|
||||
if state.CalendarDate != date {
|
||||
state.resetTo(date)
|
||||
if err := saveState(ctx, tx.Account, account, state, observedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
snapshot = state.snapshot()
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
return snapshot.clone(), nil
|
||||
}
|
||||
|
||||
// Observe credits a server-observed presence interval. It stores the union of
|
||||
// intervals for the current local day, so retries, overlapping local sessions,
|
||||
// and reordered ticks cannot increase progress twice. Partial seconds are
|
||||
// conservatively discarded instead of rounded up.
|
||||
func (o *Owner) Observe(ctx context.Context, command ObserveCommand) (ObserveResult, error) {
|
||||
if o == nil || o.settlements == nil {
|
||||
return ObserveResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
accountID := strings.TrimSpace(command.AccountID)
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
if accountID == "" || characterID == "" || !command.IntervalFrom.Before(command.IntervalTo) {
|
||||
return ObserveResult{}, ErrObservationInvalid
|
||||
}
|
||||
if err := command.Definition.Validate(); err != nil {
|
||||
return ObserveResult{}, err
|
||||
}
|
||||
from, to, err := activeObservationInterval(command.Definition, command.IntervalFrom, command.IntervalTo)
|
||||
if err != nil {
|
||||
return ObserveResult{}, err
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ObserveResult{}, err
|
||||
}
|
||||
|
||||
var result ObserveResult
|
||||
err = o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
account, character, err := loadAccountAndCharacter(ctx, tx, accountID, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := parseState(account.Metadata[metadataKey(command.Definition.ID)], command.Definition)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
date, dayStart, intervalStart, intervalEnd := dailyInterval(
|
||||
command.Definition.calendar(),
|
||||
command.Definition.boundary(),
|
||||
from,
|
||||
to,
|
||||
)
|
||||
if state.CalendarDate != "" && date < state.CalendarDate {
|
||||
return fmt.Errorf(
|
||||
"%w: persisted=%s observed=%s",
|
||||
ErrObservationStale,
|
||||
state.CalendarDate,
|
||||
date,
|
||||
)
|
||||
}
|
||||
changed := false
|
||||
if state.CalendarDate != date {
|
||||
state.resetTo(date)
|
||||
changed = true
|
||||
}
|
||||
startSecond, endSecond := intervalSeconds(dayStart, intervalStart, intervalEnd)
|
||||
credited := state.creditInterval(startSecond, endSecond)
|
||||
changed = changed || credited > 0
|
||||
if changed {
|
||||
if err := saveState(ctx, tx.Account, account, state, to); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_ = character // loadAccountAndCharacter proves account ownership.
|
||||
result = ObserveResult{
|
||||
Snapshot: state.snapshot(),
|
||||
CreditedSeconds: credited,
|
||||
Changed: changed,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ObserveResult{}, err
|
||||
}
|
||||
result.Snapshot = result.Snapshot.clone()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Claim grants one unlocked stage. The account claim receipt is written only
|
||||
// after every caller-resolved item and wallet mutation has succeeded inside
|
||||
// the same CharacterSettlement transaction. Replays return the stored receipt
|
||||
// and never invoke the allocator again.
|
||||
func (o *Owner) Claim(ctx context.Context, command ClaimCommand) (ClaimResult, error) {
|
||||
if o == nil || o.settlements == nil {
|
||||
return ClaimResult{}, ErrOwnerUnavailable
|
||||
}
|
||||
accountID := strings.TrimSpace(command.AccountID)
|
||||
characterID := strings.TrimSpace(command.CharacterID)
|
||||
stageID := strings.TrimSpace(command.StageID)
|
||||
if accountID == "" || characterID == "" || stageID == "" {
|
||||
return ClaimResult{}, ErrRewardInvalid
|
||||
}
|
||||
if err := command.Definition.Validate(); err != nil {
|
||||
return ClaimResult{}, err
|
||||
}
|
||||
stage, found := command.Definition.stage(stageID)
|
||||
if !found {
|
||||
return ClaimResult{}, fmt.Errorf("%w: event=%s stage=%s", ErrStageNotFound, command.Definition.ID, stageID)
|
||||
}
|
||||
if len(stage.Items) > 0 && command.Allocate == nil {
|
||||
return ClaimResult{}, ErrAllocatorRequired
|
||||
}
|
||||
claimedAt := command.ClaimedAt
|
||||
if claimedAt.IsZero() {
|
||||
claimedAt = time.Now()
|
||||
}
|
||||
if !command.Definition.activeAt(claimedAt) {
|
||||
return ClaimResult{}, fmt.Errorf("%w: event=%s", ErrEventInactive, command.Definition.ID)
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
if err := ctx.Err(); err != nil {
|
||||
return ClaimResult{}, err
|
||||
}
|
||||
|
||||
var result ClaimResult
|
||||
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
||||
account, character, err := loadAccountAndCharacter(ctx, tx, accountID, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
state, err := parseState(account.Metadata[metadataKey(command.Definition.ID)], command.Definition)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
date, _ := calendarDay(command.Definition.calendar(), command.Definition.boundary(), claimedAt)
|
||||
if state.CalendarDate != "" && date < state.CalendarDate {
|
||||
return fmt.Errorf(
|
||||
"%w: persisted=%s claimed=%s",
|
||||
ErrObservationStale,
|
||||
state.CalendarDate,
|
||||
date,
|
||||
)
|
||||
}
|
||||
if state.CalendarDate != date {
|
||||
state.resetTo(date)
|
||||
}
|
||||
if receipt, replayed := state.Claims[stageID]; replayed {
|
||||
result = resultFromReceipt(command.Definition.ID, state, receipt, true)
|
||||
return nil
|
||||
}
|
||||
if state.OnlineSeconds < stage.RequiredSeconds {
|
||||
return fmt.Errorf(
|
||||
"%w: event=%s stage=%s have=%d need=%d",
|
||||
ErrStageLocked,
|
||||
command.Definition.ID,
|
||||
stageID,
|
||||
state.OnlineSeconds,
|
||||
stage.RequiredSeconds,
|
||||
)
|
||||
}
|
||||
|
||||
character = dnfrepo.CloneCharacter(character)
|
||||
if character.Stats == nil {
|
||||
character.Stats = make(map[string]int64)
|
||||
}
|
||||
goldBefore := character.Stats["gold"]
|
||||
if goldBefore < 0 || stage.Gold > math.MaxInt64-goldBefore {
|
||||
return ErrGoldOverflow
|
||||
}
|
||||
// The stage reward is consumed by the claim receipt, so the carry cap
|
||||
// discards the excess instead of failing a claim that also grants items.
|
||||
goldAfter, _ := carrygold.Credit(character, stage.Gold)
|
||||
|
||||
var inventory dnfrepo.InventoryRecord
|
||||
if len(stage.Items) > 0 {
|
||||
var inventoryFound bool
|
||||
inventory, inventoryFound, err = tx.Inventory.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !inventoryFound || strings.TrimSpace(inventory.CharacterID) != characterID {
|
||||
return fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
|
||||
}
|
||||
inventory = dnfrepo.CloneInventory(inventory)
|
||||
if inventory.Slots == nil {
|
||||
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
||||
}
|
||||
}
|
||||
items := make([]CommittedItem, 0, len(stage.Items))
|
||||
for _, reward := range stage.Items {
|
||||
committed, err := command.Allocate(&inventory, reward)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateCommittedItem(committed, reward); err != nil {
|
||||
return err
|
||||
}
|
||||
committed.RawEntry = append([]byte(nil), committed.RawEntry...)
|
||||
items = append(items, committed)
|
||||
}
|
||||
|
||||
character.Stats["gold"] = goldAfter
|
||||
character.UpdatedAt = claimedAt.UTC()
|
||||
if err := dnfrepo.SaveCharacterFields(ctx, tx.Character, character, dnfrepo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(stage.Items) > 0 {
|
||||
inventory.UpdatedAt = claimedAt.UTC()
|
||||
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
receipt := claimReceipt{
|
||||
StageID: stageID,
|
||||
CharacterID: characterID,
|
||||
Items: cloneCommittedItems(items),
|
||||
GoldBefore: goldBefore,
|
||||
GoldAfter: goldAfter,
|
||||
ClaimedAt: claimedAt.UTC(),
|
||||
}
|
||||
state.Claims[stageID] = receipt
|
||||
if err := saveState(ctx, tx.Account, account, state, claimedAt); err != nil {
|
||||
return err
|
||||
}
|
||||
result = resultFromReceipt(command.Definition.ID, state, receipt, false)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return ClaimResult{}, err
|
||||
}
|
||||
result.Items = cloneCommittedItems(result.Items)
|
||||
result.PostSnapshot = result.PostSnapshot.clone()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func loadAccountAndCharacter(
|
||||
ctx context.Context,
|
||||
tx dnfrepo.Group,
|
||||
accountID string,
|
||||
characterID string,
|
||||
) (dnfrepo.AccountRecord, dnfrepo.CharacterRecord, error) {
|
||||
if tx.Account == nil || tx.Character == nil {
|
||||
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, ErrOwnerUnavailable
|
||||
}
|
||||
character, found, err := tx.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, err
|
||||
}
|
||||
if !found || strings.TrimSpace(character.CharacterID) != characterID {
|
||||
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, fmt.Errorf(
|
||||
"%w: character=%s",
|
||||
ErrCharacterNotFound,
|
||||
characterID,
|
||||
)
|
||||
}
|
||||
if strings.TrimSpace(character.AccountID) != accountID {
|
||||
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, fmt.Errorf(
|
||||
"%w: account=%s character=%s",
|
||||
ErrAccountMismatch,
|
||||
accountID,
|
||||
characterID,
|
||||
)
|
||||
}
|
||||
account, found, err := tx.Account.Load(ctx, accountID)
|
||||
if err != nil {
|
||||
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, err
|
||||
}
|
||||
if !found || strings.TrimSpace(account.AccountID) != accountID {
|
||||
return dnfrepo.AccountRecord{}, dnfrepo.CharacterRecord{}, fmt.Errorf(
|
||||
"%w: account=%s",
|
||||
ErrAccountNotFound,
|
||||
accountID,
|
||||
)
|
||||
}
|
||||
return dnfrepo.CloneAccount(account), dnfrepo.CloneCharacter(character), nil
|
||||
}
|
||||
|
||||
func saveState(
|
||||
ctx context.Context,
|
||||
accounts dnfrepo.AccountRepository,
|
||||
account dnfrepo.AccountRecord,
|
||||
state persistedState,
|
||||
updatedAt time.Time,
|
||||
) error {
|
||||
encoded, err := encodeState(state)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if account.Metadata == nil {
|
||||
account.Metadata = make(map[string]string)
|
||||
}
|
||||
account.Metadata[metadataKey(state.EventID)] = encoded
|
||||
account.UpdatedAt = updatedAt.UTC()
|
||||
return accounts.Save(ctx, account)
|
||||
}
|
||||
|
||||
func resultFromReceipt(eventID string, state persistedState, receipt claimReceipt, replayed bool) ClaimResult {
|
||||
return ClaimResult{
|
||||
EventID: eventID,
|
||||
CalendarDate: state.CalendarDate,
|
||||
StageID: receipt.StageID,
|
||||
CharacterID: receipt.CharacterID,
|
||||
Replayed: replayed,
|
||||
Items: cloneCommittedItems(receipt.Items),
|
||||
GoldBefore: receipt.GoldBefore,
|
||||
GoldAfter: receipt.GoldAfter,
|
||||
ClaimedAt: receipt.ClaimedAt,
|
||||
PostSnapshot: state.snapshot(),
|
||||
}
|
||||
}
|
||||
|
||||
func activeObservationInterval(definition Definition, from time.Time, to time.Time) (time.Time, time.Time, error) {
|
||||
if !definition.ActiveFrom.IsZero() && from.Before(definition.ActiveFrom) {
|
||||
from = definition.ActiveFrom
|
||||
}
|
||||
if !definition.ActiveUntil.IsZero() && to.After(definition.ActiveUntil) {
|
||||
to = definition.ActiveUntil
|
||||
}
|
||||
if !from.Before(to) {
|
||||
return time.Time{}, time.Time{}, fmt.Errorf("%w: event=%s", ErrEventInactive, definition.ID)
|
||||
}
|
||||
return from, to, nil
|
||||
}
|
||||
|
||||
func dailyInterval(
|
||||
location *time.Location,
|
||||
boundary DailyBoundary,
|
||||
from time.Time,
|
||||
to time.Time,
|
||||
) (string, time.Time, time.Time, time.Time) {
|
||||
// Observe owns a half-open [from,to) interval. When to is exactly the
|
||||
// service-day boundary, its final represented instant still belongs to the
|
||||
// preceding day. Selecting by to directly would instead reset the ledger
|
||||
// before those final seconds can be credited or claimed.
|
||||
selector := to
|
||||
if from.Before(to) {
|
||||
selector = to.Add(-time.Nanosecond)
|
||||
}
|
||||
date, dayStart := calendarDay(location, boundary, selector)
|
||||
nextDay := dayStart.AddDate(0, 0, 1)
|
||||
if from.Before(dayStart) {
|
||||
from = dayStart
|
||||
}
|
||||
if to.After(nextDay) {
|
||||
to = nextDay
|
||||
}
|
||||
return date, dayStart, from, to
|
||||
}
|
||||
|
||||
func calendarDay(location *time.Location, boundary DailyBoundary, at time.Time) (string, time.Time) {
|
||||
local := at.In(location)
|
||||
dayStart := time.Date(
|
||||
local.Year(),
|
||||
local.Month(),
|
||||
local.Day(),
|
||||
boundary.Hour,
|
||||
boundary.Minute,
|
||||
boundary.Second,
|
||||
0,
|
||||
location,
|
||||
)
|
||||
if local.Before(dayStart) {
|
||||
dayStart = dayStart.AddDate(0, 0, -1)
|
||||
}
|
||||
return dayStart.Format(time.DateOnly), dayStart
|
||||
}
|
||||
|
||||
func intervalSeconds(dayStart time.Time, from time.Time, to time.Time) (uint32, uint32) {
|
||||
fromFloor := from.Truncate(time.Second)
|
||||
if !fromFloor.Equal(from) {
|
||||
from = fromFloor.Add(time.Second)
|
||||
}
|
||||
to = to.Truncate(time.Second)
|
||||
if !from.Before(to) {
|
||||
return 0, 0
|
||||
}
|
||||
start := uint64(from.Sub(dayStart) / time.Second)
|
||||
end := uint64(to.Sub(dayStart) / time.Second)
|
||||
if start > secondsPerDay {
|
||||
start = secondsPerDay
|
||||
}
|
||||
if end > secondsPerDay {
|
||||
end = secondsPerDay
|
||||
}
|
||||
return uint32(start), uint32(end)
|
||||
}
|
||||
Reference in new issue
Block a user