服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
316 lines
9.8 KiB
Go
316 lines
9.8 KiB
Go
package onlineevent
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const LaborDayPolicyScope = "global:labor_day_2348:v1"
|
|
const LaborDayMetadataKey = "event_labor_day_2348_v1"
|
|
const LaborDayDuration = 30 * 24 * time.Hour
|
|
const LaborDayPVFPath = "event/chn_event/chn_playdnfatlaborday.evt"
|
|
|
|
// The 2016 NPK advertises three login mails and ten daily 10-minute gifts.
|
|
// Original distribution: https://dnf.qq.com/cp/a20160421burning/
|
|
// Daily stages 1..4 are basic, 5..9 intermediate, and 10 advanced.
|
|
type LaborDayPolicy struct {
|
|
Starts time.Time `json:"starts"`
|
|
Ends time.Time `json:"ends"`
|
|
}
|
|
|
|
func (p LaborDayPolicy) ActiveAt(at time.Time) bool { return !at.Before(p.Starts) && at.Before(p.Ends) }
|
|
func (p LaborDayPolicy) valid() bool {
|
|
return !p.Starts.IsZero() && p.Ends.Sub(p.Starts) == LaborDayDuration
|
|
}
|
|
|
|
// Call under the service's catalog lock at startup. A persisted window is
|
|
// never extended, including after it has expired.
|
|
func LoadOrCreateLaborDayPolicy(ctx context.Context, repo dnfrepo.SettingsRepository, now time.Time) (LaborDayPolicy, error) {
|
|
if repo == nil || now.IsZero() {
|
|
return LaborDayPolicy{}, ErrOwnerUnavailable
|
|
}
|
|
r, found, err := repo.Load(ctx, LaborDayPolicyScope)
|
|
if err != nil {
|
|
return LaborDayPolicy{}, err
|
|
}
|
|
var p LaborDayPolicy
|
|
if found {
|
|
if err := json.Unmarshal([]byte(r.Values["state"]), &p); err != nil {
|
|
return p, err
|
|
}
|
|
if !p.valid() {
|
|
return p, ErrStateInvalid
|
|
}
|
|
return p, nil
|
|
}
|
|
p.Starts = now.UTC().Truncate(time.Second)
|
|
p.Ends = p.Starts.Add(LaborDayDuration)
|
|
b, err := json.Marshal(p)
|
|
if err != nil {
|
|
return p, err
|
|
}
|
|
err = repo.Save(ctx, dnfrepo.SettingsRecord{Scope: LaborDayPolicyScope, Values: map[string]string{"state": string(b)}, UpdatedAt: now})
|
|
return p, err
|
|
}
|
|
|
|
type LaborDayCatalog struct {
|
|
Policy LaborDayPolicy
|
|
Login [3]AttendancePVFItemReward
|
|
Online [3]AttendancePVFItemReward
|
|
}
|
|
|
|
func LoadLaborDayCatalog(ctx context.Context, source dnfpvf.Source) (*LaborDayCatalog, error) {
|
|
if source == nil {
|
|
return nil, ErrOwnerUnavailable
|
|
}
|
|
c := &LaborDayCatalog{}
|
|
text, err := source.ReadText(LaborDayPVFPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc, err := dnfpvf.Parse(LaborDayPVFPath, text)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
login, ok := doc.Section("continuous login reward")
|
|
if !ok || len(login) != 6 {
|
|
return nil, fmt.Errorf("labor day login reward section has %d tokens", len(login))
|
|
}
|
|
for i := range c.Login {
|
|
id, count := login[i*2], login[i*2+1]
|
|
if id.Kind != dnfpvf.TokenInt || count.Kind != dnfpvf.TokenInt || id.Int <= 0 || count.Int <= 0 {
|
|
return nil, fmt.Errorf("labor day login reward %d is invalid", i)
|
|
}
|
|
c.Login[i] = AttendancePVFItemReward{ItemID: id.Int, Count: count.Int}
|
|
c.Online[i] = AttendancePVFItemReward{ItemID: int64(490001502 + i), Count: 1}
|
|
}
|
|
rewards := append(append([]AttendancePVFItemReward{}, c.Login[:]...), c.Online[:]...)
|
|
paths, err := resolveAttendanceRewardDefinitions(ctx, source, rewards)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attachAttendanceDefinitionPaths(c.Login[:], paths)
|
|
attachAttendanceDefinitionPaths(c.Online[:], paths)
|
|
// Online boxes must belong to this Labor Day edition. Login boxes are the
|
|
// older 10005051..53 PVF stackables and have a different title by design.
|
|
for _, r := range c.Online {
|
|
t, err := source.ReadText(r.DefinitionPath)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !strings.Contains(t, "惊喜礼盒") {
|
|
return nil, fmt.Errorf("labor day online item %d edition mismatch", r.ItemID)
|
|
}
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
type laborDayProgress struct {
|
|
Intervals []creditedInterval `json:"intervals,omitempty"`
|
|
Paid uint32 `json:"paid"`
|
|
}
|
|
type laborDayState struct {
|
|
Version int `json:"version"`
|
|
Starts time.Time `json:"starts"`
|
|
LoginDays []string `json:"login_days"`
|
|
Days map[string]laborDayProgress `json:"days"`
|
|
}
|
|
type LaborDaySnapshot struct {
|
|
LoginDays, TodaySeconds, TodayPaid uint32
|
|
MailIDs []string
|
|
}
|
|
type LaborDayCommand struct {
|
|
AccountID, CharacterID string
|
|
Now time.Time
|
|
From, To time.Time
|
|
Login bool
|
|
Deliver func(dnfrepo.MailboxRepository, AttendancePVFItemReward, string) (string, error)
|
|
}
|
|
|
|
func LaborDayOnlineTier(paid uint32) int {
|
|
if paid < 4 {
|
|
return 0
|
|
}
|
|
if paid < 9 {
|
|
return 1
|
|
}
|
|
return 2
|
|
}
|
|
|
|
// Mail, distinct login days, merged presence intervals and award receipts are
|
|
// one account-locked transaction. There is no client-submitted time or claim.
|
|
func SettleLaborDay(ctx context.Context, repos dnfrepo.Group, c *LaborDayCatalog, cmd LaborDayCommand) (LaborDaySnapshot, error) {
|
|
var result LaborDaySnapshot
|
|
if c == nil || !c.Policy.valid() || repos.MailboxAssets == nil || cmd.Deliver == nil || cmd.Now.IsZero() || cmd.AccountID == "" || cmd.CharacterID == "" {
|
|
return result, ErrOwnerUnavailable
|
|
}
|
|
if !c.Policy.ActiveAt(cmd.Now) {
|
|
return result, nil
|
|
}
|
|
if cmd.From.After(cmd.To) || cmd.To.After(cmd.Now) {
|
|
return result, ErrObservationInvalid
|
|
}
|
|
err := repos.MailboxAssets.WithinMailboxAssets(ctx, cmd.CharacterID, cmd.CharacterID, func(accounts dnfrepo.AccountRepository, _ dnfrepo.AccountInventoryRepository, characters dnfrepo.CharacterRepository, _ dnfrepo.InventoryRepository, mailboxes dnfrepo.MailboxRepository) error {
|
|
ch, found, err := characters.Load(ctx, cmd.CharacterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || ch.AccountID != cmd.AccountID {
|
|
return ErrAccountMismatch
|
|
}
|
|
a, found, err := accounts.Load(ctx, cmd.AccountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return ErrAccountNotFound
|
|
}
|
|
raw := a.Metadata[LaborDayMetadataKey]
|
|
state := laborDayState{Version: 1, Starts: c.Policy.Starts, Days: map[string]laborDayProgress{}}
|
|
if raw != "" {
|
|
d := json.NewDecoder(strings.NewReader(raw))
|
|
d.DisallowUnknownFields()
|
|
if err := d.Decode(&state); err != nil {
|
|
return err
|
|
}
|
|
if err := rejectTrailingJSON(d); err != nil {
|
|
return err
|
|
}
|
|
if state.Version != 1 || !state.Starts.Equal(c.Policy.Starts) || len(state.LoginDays) > 3 || state.Days == nil || len(state.Days) > 31 {
|
|
return ErrStateInvalid
|
|
}
|
|
previous := ""
|
|
for _, day := range state.LoginDays {
|
|
if day <= previous {
|
|
return ErrStateInvalid
|
|
}
|
|
previous = day
|
|
if _, ok := state.Days[day]; !ok {
|
|
return ErrStateInvalid
|
|
}
|
|
}
|
|
first, _ := calendarDay(ChinaCalendar, defaultDailyBoundary, c.Policy.Starts)
|
|
last, _ := calendarDay(ChinaCalendar, defaultDailyBoundary, c.Policy.Ends.Add(-time.Nanosecond))
|
|
for day, p := range state.Days {
|
|
if _, err := time.Parse(time.DateOnly, day); err != nil || day < first || day > last || p.Paid > 10 {
|
|
return ErrStateInvalid
|
|
}
|
|
var end uint32
|
|
var seconds uint32
|
|
for i, v := range p.Intervals {
|
|
if v.Start >= v.End || v.End > 86400 || (i > 0 && v.Start <= end) {
|
|
return ErrStateInvalid
|
|
}
|
|
seconds += v.End - v.Start
|
|
end = v.End
|
|
}
|
|
if p.Paid > seconds/600 {
|
|
return ErrStateInvalid
|
|
}
|
|
}
|
|
}
|
|
deliver := func(reward AttendancePVFItemReward, key string) error {
|
|
id, err := cmd.Deliver(mailboxes, reward, LaborDayMetadataKey+":"+c.Policy.Starts.Format(time.RFC3339)+":"+key)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if id == "" {
|
|
return ErrRewardInvalid
|
|
}
|
|
result.MailIDs = append(result.MailIDs, id)
|
|
return nil
|
|
}
|
|
ensureDay := func(day string) (laborDayProgress, error) {
|
|
if p, ok := state.Days[day]; ok {
|
|
return p, nil
|
|
}
|
|
// Never invent a new login day behind an already observed day.
|
|
for known := range state.Days {
|
|
if day < known {
|
|
return laborDayProgress{}, ErrObservationStale
|
|
}
|
|
}
|
|
if len(state.LoginDays) < 3 {
|
|
if err := deliver(c.Login[len(state.LoginDays)], fmt.Sprintf("login:%d", len(state.LoginDays)+1)); err != nil {
|
|
return laborDayProgress{}, err
|
|
}
|
|
state.LoginDays = append(state.LoginDays, day)
|
|
}
|
|
p := laborDayProgress{}
|
|
state.Days[day] = p
|
|
return p, nil
|
|
}
|
|
from, to := cmd.From, cmd.To
|
|
if from.Before(c.Policy.Starts) {
|
|
from = c.Policy.Starts
|
|
}
|
|
if to.After(c.Policy.Ends) {
|
|
to = c.Policy.Ends
|
|
}
|
|
if !cmd.From.IsZero() && from.Before(to) {
|
|
for from.Before(to) {
|
|
day, start := calendarDay(ChinaCalendar, defaultDailyBoundary, from)
|
|
end := start.Add(24 * time.Hour)
|
|
if end.After(to) {
|
|
end = to
|
|
}
|
|
p, err := ensureDay(day)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
u := persistedState{Intervals: p.Intervals}
|
|
for _, v := range p.Intervals {
|
|
u.OnlineSeconds += uint64(v.End - v.Start)
|
|
}
|
|
lo, hi := intervalSeconds(start, from, end)
|
|
u.creditInterval(lo, hi)
|
|
p.Intervals = u.Intervals
|
|
eligible := uint32(u.OnlineSeconds / 600)
|
|
if eligible > 10 {
|
|
eligible = 10
|
|
}
|
|
for p.Paid < eligible {
|
|
tier := LaborDayOnlineTier(p.Paid)
|
|
if err := deliver(c.Online[tier], fmt.Sprintf("online:%s:%d", day, p.Paid+1)); err != nil {
|
|
return err
|
|
}
|
|
p.Paid++
|
|
}
|
|
state.Days[day] = p
|
|
from = end
|
|
}
|
|
}
|
|
if cmd.Login && c.Policy.ActiveAt(cmd.Now) {
|
|
day, _ := calendarDay(ChinaCalendar, defaultDailyBoundary, cmd.Now)
|
|
if _, err := ensureDay(day); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
result.LoginDays = uint32(len(state.LoginDays))
|
|
today, _ := calendarDay(ChinaCalendar, defaultDailyBoundary, cmd.Now)
|
|
p := state.Days[today]
|
|
result.TodayPaid = p.Paid
|
|
for _, v := range p.Intervals {
|
|
result.TodaySeconds += v.End - v.Start
|
|
}
|
|
b, err := json.Marshal(state)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if string(b) == raw || (len(state.Days) == 0 && raw == "") {
|
|
return nil
|
|
}
|
|
return dnfrepo.SaveAccountMetadataEntry(ctx, accounts, a, LaborDayMetadataKey, string(b), cmd.Now)
|
|
})
|
|
if err != nil {
|
|
return LaborDaySnapshot{}, err
|
|
}
|
|
return result, nil
|
|
}
|