服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
220 lines
6.1 KiB
Go
220 lines
6.1 KiB
Go
package guild
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"time"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
var ErrWarMissionIncomplete = errors.New("guild war mission is incomplete")
|
|
|
|
type WarMissionReward struct{ ItemID, Count uint32 }
|
|
type WarMission struct {
|
|
ID uint32
|
|
Season bool
|
|
Title, Condition string
|
|
Required uint32
|
|
Dungeons []uint32
|
|
Rewards []WarMissionReward
|
|
}
|
|
type WarMissionCatalog struct {
|
|
Missions []WarMission
|
|
DailyTitle, SeasonTitle uint32
|
|
}
|
|
|
|
func (c *WarMissionCatalog) Find(id uint32) (WarMission, bool) {
|
|
if c != nil {
|
|
for _, m := range c.Missions {
|
|
if m.ID == id {
|
|
return m, true
|
|
}
|
|
}
|
|
}
|
|
return WarMission{}, false
|
|
}
|
|
|
|
type WarMissionValue struct {
|
|
Progress uint32 `json:"progress"`
|
|
Claimed bool `json:"claimed,omitempty"`
|
|
}
|
|
type WarMissionState struct {
|
|
DayStart int64 `json:"day_start"`
|
|
SeasonStart int64 `json:"season_start"`
|
|
Values map[uint32]WarMissionValue `json:"values"`
|
|
Events map[string]int64 `json:"events,omitempty"`
|
|
}
|
|
|
|
// Guild war days reset at 06:00 Beijing. No historical progress is synthesized.
|
|
func WarDay(now time.Time) (start, end time.Time) {
|
|
local := now.In(time.FixedZone("CST", 8*60*60))
|
|
start = time.Date(local.Year(), local.Month(), local.Day(), 6, 0, 0, 0, local.Location())
|
|
if local.Before(start) {
|
|
start = start.AddDate(0, 0, -1)
|
|
}
|
|
return start, start.AddDate(0, 0, 1)
|
|
}
|
|
|
|
func (s *WarMissionState) Reset(now time.Time, catalog *WarMissionCatalog) {
|
|
day, _ := WarDay(now)
|
|
season, _ := WarSeason(now)
|
|
if s.Values == nil {
|
|
s.Values = make(map[uint32]WarMissionValue)
|
|
}
|
|
if s.Events == nil {
|
|
s.Events = make(map[string]int64)
|
|
}
|
|
for id := range s.Values {
|
|
m, found := catalog.Find(id)
|
|
if !found || (m.Season && s.SeasonStart != season.Unix()) || (!m.Season && s.DayStart != day.Unix()) {
|
|
delete(s.Values, id)
|
|
}
|
|
}
|
|
for key, at := range s.Events {
|
|
if at < season.Unix() {
|
|
delete(s.Events, key)
|
|
}
|
|
}
|
|
s.DayStart, s.SeasonStart = day.Unix(), season.Unix()
|
|
}
|
|
|
|
func warMissionScope(characterID string) string {
|
|
return "character:" + characterID + ":guild_war_missions:v1"
|
|
}
|
|
func LoadWarMissions(ctx context.Context, repo dnfrepo.SettingsRepository, characterID string, catalog *WarMissionCatalog, now time.Time) (WarMissionState, error) {
|
|
var s WarMissionState
|
|
if repo == nil || characterID == "" || catalog == nil {
|
|
return s, ErrRepositoryUnavailable
|
|
}
|
|
record, found, err := repo.Load(ctx, warMissionScope(characterID))
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
if found && record.Values["state"] != "" {
|
|
if err = json.Unmarshal([]byte(record.Values["state"]), &s); err != nil {
|
|
return s, err
|
|
}
|
|
}
|
|
s.Reset(now, catalog)
|
|
return s, nil
|
|
}
|
|
func SaveWarMissions(ctx context.Context, repo dnfrepo.SettingsRepository, characterID string, s WarMissionState, now time.Time) error {
|
|
if repo == nil || characterID == "" {
|
|
return ErrRepositoryUnavailable
|
|
}
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return repo.Save(ctx, dnfrepo.SettingsRecord{Scope: warMissionScope(characterID), Values: map[string]string{"state": string(b)}, UpdatedAt: now})
|
|
}
|
|
|
|
// Observe is called only for an accepted server-owned battle entry or clear.
|
|
// Its receipt covers retries, including a retry after the daily reset.
|
|
func (s *WarMissionState) Observe(catalog *WarMissionCatalog, kind string, dungeon uint32, key string, occurredAt, now time.Time) error {
|
|
if catalog == nil || key == "" || (kind != "join" && kind != "clear") || occurredAt.After(now) {
|
|
return ErrInvalidContent
|
|
}
|
|
s.Reset(now, catalog)
|
|
if occurredAt.Unix() < s.SeasonStart {
|
|
return ErrInvalidContent
|
|
}
|
|
key = kind + ":" + key
|
|
if _, found := s.Events[key]; found {
|
|
return nil
|
|
}
|
|
s.Events[key] = occurredAt.Unix()
|
|
for _, m := range catalog.Missions {
|
|
if m.Condition != kind || (!m.Season && occurredAt.Unix() < s.DayStart) {
|
|
continue
|
|
}
|
|
if len(m.Dungeons) > 0 {
|
|
matched := false
|
|
for _, id := range m.Dungeons {
|
|
matched = matched || id == dungeon
|
|
}
|
|
if !matched {
|
|
continue
|
|
}
|
|
}
|
|
v := s.Values[m.ID]
|
|
if v.Progress < m.Required {
|
|
v.Progress++
|
|
}
|
|
s.Values[m.ID] = v
|
|
}
|
|
s.refreshTitles(catalog)
|
|
return nil
|
|
}
|
|
|
|
func (s *WarMissionState) refreshTitles(catalog *WarMissionCatalog) {
|
|
for _, title := range catalog.Missions {
|
|
if title.Condition != "mission" {
|
|
continue
|
|
}
|
|
var completed uint32
|
|
for _, m := range catalog.Missions {
|
|
if m.Season == title.Season && m.Condition != "mission" && s.Values[m.ID].Progress >= m.Required {
|
|
completed++
|
|
}
|
|
}
|
|
v := s.Values[title.ID]
|
|
v.Progress = min(completed, title.Required)
|
|
s.Values[title.ID] = v
|
|
}
|
|
}
|
|
|
|
// Claim changes the receipt in memory. The caller commits it with all item
|
|
// grants in one repository transaction. An already claimed request is a no-op.
|
|
func (s *WarMissionState) Claim(catalog *WarMissionCatalog, id uint32, now time.Time) ([]WarMissionReward, error) {
|
|
s.Reset(now, catalog)
|
|
m, found := catalog.Find(id)
|
|
if !found {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
v := s.Values[id]
|
|
if v.Claimed {
|
|
return nil, nil
|
|
}
|
|
if v.Progress < m.Required {
|
|
return nil, ErrWarMissionIncomplete
|
|
}
|
|
v.Claimed = true
|
|
s.Values[id] = v
|
|
return append([]WarMissionReward(nil), m.Rewards...), nil
|
|
}
|
|
|
|
// ExperienceRanks uses competition ranking: 1, 1, 3 for tied experience.
|
|
// It derives ranks from all persisted guilds, including offline guilds.
|
|
func (r Registry) ExperienceRanks() map[uint32]uint32 {
|
|
list := make([]*Guild, 0, len(r.Guilds))
|
|
for _, g := range r.Guilds {
|
|
if g != nil && g.ID != 0 {
|
|
list = append(list, g)
|
|
}
|
|
}
|
|
sort.Slice(list, func(i, j int) bool {
|
|
if list[i].Experience != list[j].Experience {
|
|
return list[i].Experience > list[j].Experience
|
|
}
|
|
return list[i].ID < list[j].ID
|
|
})
|
|
ranks := make(map[uint32]uint32, len(list))
|
|
var rank uint32
|
|
for i, g := range list {
|
|
if i == 0 || g.Experience != list[i-1].Experience {
|
|
rank = uint32(i + 1)
|
|
}
|
|
ranks[g.ID] = rank
|
|
}
|
|
return ranks
|
|
}
|
|
|
|
func invalidWarMission(id uint32) error {
|
|
return fmt.Errorf("guild war mission %d: %w", id, ErrInvalidContent)
|
|
}
|