服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
197 lines
6.0 KiB
Go
197 lines
6.0 KiB
Go
package onlineevent
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type creditedInterval struct {
|
|
Start uint32 `json:"start"`
|
|
End uint32 `json:"end"`
|
|
}
|
|
|
|
type claimReceipt struct {
|
|
StageID string `json:"stage_id"`
|
|
CharacterID string `json:"character_id"`
|
|
Items []CommittedItem `json:"items,omitempty"`
|
|
GoldBefore int64 `json:"gold_before,omitempty"`
|
|
GoldAfter int64 `json:"gold_after,omitempty"`
|
|
ClaimedAt time.Time `json:"claimed_at"`
|
|
}
|
|
|
|
type persistedState struct {
|
|
Version int `json:"version"`
|
|
EventID string `json:"event_id"`
|
|
CalendarDate string `json:"calendar_date,omitempty"`
|
|
OnlineSeconds uint64 `json:"online_seconds,omitempty"`
|
|
Intervals []creditedInterval `json:"intervals,omitempty"`
|
|
Claims map[string]claimReceipt `json:"claims,omitempty"`
|
|
}
|
|
|
|
func newState(eventID string) persistedState {
|
|
return persistedState{
|
|
Version: stateVersion,
|
|
EventID: eventID,
|
|
Claims: make(map[string]claimReceipt),
|
|
}
|
|
}
|
|
|
|
func parseState(raw string, definition Definition) (persistedState, error) {
|
|
raw = strings.TrimSpace(raw)
|
|
if raw == "" {
|
|
return newState(definition.ID), nil
|
|
}
|
|
decoder := json.NewDecoder(bytes.NewBufferString(raw))
|
|
decoder.DisallowUnknownFields()
|
|
var state persistedState
|
|
if err := decoder.Decode(&state); err != nil {
|
|
return persistedState{}, fmt.Errorf("%w: decode: %v", ErrStateInvalid, err)
|
|
}
|
|
if err := rejectTrailingJSON(decoder); err != nil {
|
|
return persistedState{}, err
|
|
}
|
|
if err := validateState(state, definition); err != nil {
|
|
return persistedState{}, err
|
|
}
|
|
if state.Claims == nil {
|
|
state.Claims = make(map[string]claimReceipt)
|
|
}
|
|
state.Intervals = append([]creditedInterval(nil), state.Intervals...)
|
|
for stageID, receipt := range state.Claims {
|
|
receipt.Items = cloneCommittedItems(receipt.Items)
|
|
state.Claims[stageID] = receipt
|
|
}
|
|
return state, nil
|
|
}
|
|
|
|
func rejectTrailingJSON(decoder *json.Decoder) error {
|
|
var trailing any
|
|
err := decoder.Decode(&trailing)
|
|
if err == io.EOF {
|
|
return nil
|
|
}
|
|
if err == nil {
|
|
return fmt.Errorf("%w: trailing json", ErrStateInvalid)
|
|
}
|
|
return fmt.Errorf("%w: trailing json: %v", ErrStateInvalid, err)
|
|
}
|
|
|
|
func validateState(state persistedState, definition Definition) error {
|
|
if state.Version != stateVersion || state.EventID != definition.ID {
|
|
return fmt.Errorf(
|
|
"%w: version=%d event=%q",
|
|
ErrStateInvalid,
|
|
state.Version,
|
|
state.EventID,
|
|
)
|
|
}
|
|
if state.CalendarDate == "" {
|
|
if state.OnlineSeconds != 0 || len(state.Intervals) != 0 || len(state.Claims) != 0 {
|
|
return fmt.Errorf("%w: undated state contains progress", ErrStateInvalid)
|
|
}
|
|
return nil
|
|
}
|
|
if _, err := time.ParseInLocation(time.DateOnly, state.CalendarDate, definition.calendar()); err != nil {
|
|
return fmt.Errorf("%w: calendar_date=%q", ErrStateInvalid, state.CalendarDate)
|
|
}
|
|
var total uint64
|
|
var previousEnd uint32
|
|
for index, interval := range state.Intervals {
|
|
if interval.Start >= interval.End || uint64(interval.End) > secondsPerDay ||
|
|
(index > 0 && interval.Start <= previousEnd) {
|
|
return fmt.Errorf("%w: interval[%d]=%+v", ErrStateInvalid, index, interval)
|
|
}
|
|
total += uint64(interval.End - interval.Start)
|
|
previousEnd = interval.End
|
|
}
|
|
if total != state.OnlineSeconds {
|
|
return fmt.Errorf(
|
|
"%w: seconds=%d interval_total=%d",
|
|
ErrStateInvalid,
|
|
state.OnlineSeconds,
|
|
total,
|
|
)
|
|
}
|
|
for stageID, receipt := range state.Claims {
|
|
stage, found := definition.stage(stageID)
|
|
if !found || receipt.StageID != stageID || strings.TrimSpace(receipt.CharacterID) == "" ||
|
|
receipt.ClaimedAt.IsZero() || receipt.GoldBefore < 0 || receipt.GoldAfter < receipt.GoldBefore ||
|
|
receipt.GoldAfter-receipt.GoldBefore != stage.Gold || len(receipt.Items) != len(stage.Items) {
|
|
return fmt.Errorf("%w: claim=%q", ErrStateInvalid, stageID)
|
|
}
|
|
claimDate, _ := calendarDay(definition.calendar(), definition.boundary(), receipt.ClaimedAt)
|
|
if claimDate != state.CalendarDate {
|
|
return fmt.Errorf("%w: claim=%q date=%s", ErrStateInvalid, stageID, claimDate)
|
|
}
|
|
for index, item := range receipt.Items {
|
|
if err := validateCommittedItem(item, stage.Items[index]); err != nil {
|
|
return fmt.Errorf("%w: claim=%q item=%d", ErrStateInvalid, stageID, index)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func encodeState(state persistedState) (string, error) {
|
|
encoded, err := json.Marshal(state)
|
|
if err != nil {
|
|
return "", fmt.Errorf("%w: encode: %v", ErrStateInvalid, err)
|
|
}
|
|
return string(encoded), nil
|
|
}
|
|
|
|
func (s persistedState) snapshot() Snapshot {
|
|
return Snapshot{
|
|
EventID: s.EventID,
|
|
CalendarDate: s.CalendarDate,
|
|
OnlineSeconds: s.OnlineSeconds,
|
|
ClaimedStageIDs: sortedClaimIDs(s.Claims),
|
|
}
|
|
}
|
|
|
|
func (s *persistedState) resetTo(calendarDate string) {
|
|
s.CalendarDate = calendarDate
|
|
s.OnlineSeconds = 0
|
|
s.Intervals = nil
|
|
s.Claims = make(map[string]claimReceipt)
|
|
}
|
|
|
|
// creditInterval merges one half-open second interval into the day's union.
|
|
// Keeping the compact union makes retries, overlapping sessions, and
|
|
// out-of-order server ticks idempotent without trusting a client counter.
|
|
func (s *persistedState) creditInterval(start uint32, end uint32) uint64 {
|
|
if start >= end {
|
|
return 0
|
|
}
|
|
before := s.OnlineSeconds
|
|
intervals := append(append([]creditedInterval(nil), s.Intervals...), creditedInterval{Start: start, End: end})
|
|
sort.Slice(intervals, func(left, right int) bool {
|
|
if intervals[left].Start == intervals[right].Start {
|
|
return intervals[left].End < intervals[right].End
|
|
}
|
|
return intervals[left].Start < intervals[right].Start
|
|
})
|
|
merged := make([]creditedInterval, 0, len(intervals))
|
|
for _, interval := range intervals {
|
|
if len(merged) == 0 || interval.Start > merged[len(merged)-1].End {
|
|
merged = append(merged, interval)
|
|
continue
|
|
}
|
|
if interval.End > merged[len(merged)-1].End {
|
|
merged[len(merged)-1].End = interval.End
|
|
}
|
|
}
|
|
var total uint64
|
|
for _, interval := range merged {
|
|
total += uint64(interval.End - interval.Start)
|
|
}
|
|
s.Intervals = merged
|
|
s.OnlineSeconds = total
|
|
return total - before
|
|
}
|