Files
90CNServer/server/internal/modules/dnf/onlineevent/labor_day.go
T
hanfuit 645b64fe21 DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
2026-11-04 14:31:55 +08:00

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
}