服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
273 lines
8.4 KiB
Go
273 lines
8.4 KiB
Go
package guild
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"math"
|
|
"time"
|
|
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
ContributionCoinID uint32 = 490003262
|
|
ContributionDungeon = "guild dungeon clear"
|
|
ContributionChat = "guild chat"
|
|
ContributionAttendance = "guild attendance"
|
|
ContributionParty = "party dungeon clear"
|
|
)
|
|
|
|
var ErrContributionInventoryFull = errors.New("guild contribution coin inventory is full")
|
|
|
|
type ContributionRule struct{ Required, Points uint32 }
|
|
type ContributionCatalog map[string]ContributionRule
|
|
|
|
// Point amounts and activity parameters come exclusively from the deployed PVF.
|
|
func LoadContributionCatalog(source dnfpvf.Source) (ContributionCatalog, error) {
|
|
doc, err := readWarDocument(source, "etc/chn_hongbao/chn_hongbao.etc")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
t, ok := doc.Section("hongbao point reward")
|
|
if !ok || len(t) != 12 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
c := make(ContributionCatalog)
|
|
for i := 0; i < len(t); i += 3 {
|
|
kind := t[i].Value
|
|
switch kind {
|
|
case ContributionDungeon, ContributionChat, ContributionAttendance, ContributionParty:
|
|
default:
|
|
return nil, ErrInvalidContent
|
|
}
|
|
v, e := warUnsigned(t[i+1 : i+3])
|
|
if e != nil || len(v) != 2 || v[0] == 0 || v[1] == 0 || v[0] > 10000 || v[1] > 10000 || c[kind].Required != 0 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
c[kind] = ContributionRule{v[0], v[1]}
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
// These economy choices are local server policy, not fields inferred from PVF.
|
|
type ContributionPolicy struct {
|
|
CoinsPerPoint uint32 `json:"coins_per_point"`
|
|
RewardsPerActivityPerDay uint32 `json:"rewards_per_activity_per_day"`
|
|
ResetHour int `json:"reset_hour"`
|
|
}
|
|
|
|
const ContributionPolicyScope = "global:guild_contribution_policy:v1"
|
|
|
|
func LoadContributionPolicy(ctx context.Context, repo dnfrepo.SettingsRepository) (*ContributionPolicy, error) {
|
|
if repo == nil {
|
|
return nil, ErrRepositoryUnavailable
|
|
}
|
|
r, found, err := repo.Load(ctx, ContributionPolicyScope)
|
|
if err != nil || !found {
|
|
return nil, err
|
|
}
|
|
var p ContributionPolicy
|
|
if err = json.Unmarshal([]byte(r.Values["state"]), &p); err != nil {
|
|
return nil, err
|
|
}
|
|
if !p.valid() {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
return &p, nil
|
|
}
|
|
func SaveContributionPolicy(ctx context.Context, repo dnfrepo.SettingsRepository, p ContributionPolicy, now time.Time) error {
|
|
if repo == nil || !p.valid() {
|
|
return ErrInvalidContent
|
|
}
|
|
b, err := json.Marshal(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return repo.Save(ctx, dnfrepo.SettingsRecord{Scope: ContributionPolicyScope, Values: map[string]string{"state": string(b)}, UpdatedAt: now})
|
|
}
|
|
func (p ContributionPolicy) valid() bool {
|
|
return p.CoinsPerPoint > 0 && p.CoinsPerPoint <= 10000 && p.RewardsPerActivityPerDay > 0 && p.RewardsPerActivityPerDay <= 100 && p.ResetHour >= 0 && p.ResetHour < 24
|
|
}
|
|
func (p ContributionPolicy) Day(now time.Time) time.Time {
|
|
n := now.In(time.FixedZone("CST", 8*3600))
|
|
d := time.Date(n.Year(), n.Month(), n.Day(), p.ResetHour, 0, 0, 0, n.Location())
|
|
if n.Before(d) {
|
|
d = d.AddDate(0, 0, -1)
|
|
}
|
|
return d
|
|
}
|
|
|
|
// The character ledger survives leaving a guild: rejoining does not reset daily
|
|
// limits or erase earned, undelivered coins. It never imports guild Mileage.
|
|
type ContributionState struct {
|
|
Version int `json:"version"`
|
|
DayStart int64 `json:"day_start"`
|
|
Progress map[string]uint32 `json:"progress,omitempty"`
|
|
Rewarded map[string]uint32 `json:"rewarded,omitempty"`
|
|
Receipts map[string]bool `json:"receipts,omitempty"`
|
|
Points uint64 `json:"points"`
|
|
PendingCoins uint32 `json:"pending_coins"`
|
|
PaidCoins uint64 `json:"paid_coins"`
|
|
}
|
|
|
|
func contributionScope(id string) string { return "character:" + id + ":guild_contribution:v1" }
|
|
func LoadContribution(ctx context.Context, repo dnfrepo.SettingsRepository, id string) (ContributionState, error) {
|
|
s := ContributionState{Version: 1}
|
|
if repo == nil || id == "" {
|
|
return s, ErrRepositoryUnavailable
|
|
}
|
|
r, found, err := repo.Load(ctx, contributionScope(id))
|
|
if err != nil {
|
|
return s, err
|
|
}
|
|
if found && r.Values["state"] != "" {
|
|
if err = json.Unmarshal([]byte(r.Values["state"]), &s); err != nil {
|
|
return s, err
|
|
}
|
|
if s.Version != 1 {
|
|
return s, ErrInvalidContent
|
|
}
|
|
}
|
|
return s, nil
|
|
}
|
|
func SaveContribution(ctx context.Context, repo dnfrepo.SettingsRepository, id string, s ContributionState, now time.Time) error {
|
|
if repo == nil || id == "" || s.Version != 1 {
|
|
return ErrRepositoryUnavailable
|
|
}
|
|
b, err := json.Marshal(s)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return repo.Save(ctx, dnfrepo.SettingsRecord{Scope: contributionScope(id), Values: map[string]string{"state": string(b)}, UpdatedAt: now})
|
|
}
|
|
|
|
// Observe accepts server-owned events. The party parameter is the distinct
|
|
// same-guild participant count; other activities count successful occurrences.
|
|
func (s *ContributionState) Observe(c ContributionCatalog, p ContributionPolicy, kind, receipt string, partyMembers uint32, occurredAt, now time.Time) (uint32, error) {
|
|
rule, ok := c[kind]
|
|
if s == nil || !p.valid() || !ok || rule.Required == 0 || rule.Points == 0 || receipt == "" || occurredAt.After(now) {
|
|
return 0, ErrInvalidContent
|
|
}
|
|
day := p.Day(now).Unix()
|
|
if occurredAt.Unix() < day || s.DayStart > day {
|
|
return 0, nil
|
|
}
|
|
if s.DayStart != day {
|
|
s.DayStart = day
|
|
s.Progress = nil
|
|
s.Rewarded = nil
|
|
s.Receipts = nil
|
|
}
|
|
if s.Progress == nil {
|
|
s.Progress = make(map[string]uint32)
|
|
}
|
|
if s.Rewarded == nil {
|
|
s.Rewarded = make(map[string]uint32)
|
|
}
|
|
if s.Receipts == nil {
|
|
s.Receipts = make(map[string]bool)
|
|
}
|
|
key := kind + ":" + receipt
|
|
if s.Receipts[key] || s.Rewarded[kind] >= p.RewardsPerActivityPerDay {
|
|
return 0, nil
|
|
}
|
|
if kind == ContributionParty && partyMembers < rule.Required {
|
|
return 0, nil
|
|
}
|
|
if len(s.Receipts) >= 10000 {
|
|
return 0, ErrInvalidContent
|
|
}
|
|
s.Receipts[key] = true
|
|
s.Progress[kind]++
|
|
required := rule.Required
|
|
if kind == ContributionParty {
|
|
required = 1
|
|
}
|
|
if s.Progress[kind] < required {
|
|
return 0, nil
|
|
}
|
|
coins := uint64(rule.Points) * uint64(p.CoinsPerPoint)
|
|
if coins > math.MaxUint32-uint64(s.PendingCoins) || s.Points > math.MaxUint64-uint64(rule.Points) {
|
|
return 0, ErrInvalidContent
|
|
}
|
|
s.Progress[kind] = 0
|
|
s.Rewarded[kind]++
|
|
s.Points += uint64(rule.Points)
|
|
s.PendingCoins += uint32(coins)
|
|
s.Version = 1
|
|
return rule.Points, nil
|
|
}
|
|
|
|
// Deliver is called within the same repository transaction as the ledger save.
|
|
// A grant may partially modify its input before reporting full; clone first.
|
|
func (s *ContributionState) Deliver(inventory *dnfrepo.InventoryRecord, grant func(*dnfrepo.InventoryRecord, uint32) error) (bool, error) {
|
|
if s.PendingCoins == 0 {
|
|
return false, nil
|
|
}
|
|
if inventory == nil || grant == nil || s.PaidCoins > math.MaxUint64-uint64(s.PendingCoins) {
|
|
return false, ErrInvalidContent
|
|
}
|
|
next := dnfrepo.CloneInventory(*inventory)
|
|
if err := grant(&next, s.PendingCoins); err != nil {
|
|
if errors.Is(err, ErrContributionInventoryFull) {
|
|
return false, nil
|
|
}
|
|
return false, err
|
|
}
|
|
*inventory = next
|
|
s.PaidCoins += uint64(s.PendingCoins)
|
|
s.PendingCoins = 0
|
|
return true, nil
|
|
}
|
|
|
|
type ContributionEntry struct {
|
|
GuildID uint32
|
|
CharacterID string
|
|
Members map[string]time.Time
|
|
}
|
|
|
|
func (r Registry) ContributionEntry(id string) ContributionEntry {
|
|
g, _, ok := r.GuildForCharacter(id)
|
|
if !ok {
|
|
return ContributionEntry{}
|
|
}
|
|
e := ContributionEntry{GuildID: g.ID, CharacterID: id, Members: make(map[string]time.Time, len(g.Members))}
|
|
for id, m := range g.Members {
|
|
e.Members[id] = m.JoinedAt
|
|
}
|
|
return e
|
|
}
|
|
func (r Registry) ContributionEligible(e ContributionEntry, party []string) (bool, uint32) {
|
|
g, m, ok := r.GuildForCharacter(e.CharacterID)
|
|
if !ok || g.ID != e.GuildID || !m.JoinedAt.Equal(e.Members[e.CharacterID]) {
|
|
return false, 0
|
|
}
|
|
seen := make(map[string]bool)
|
|
for _, id := range party {
|
|
member, ok := g.Members[id]
|
|
joined, entered := e.Members[id]
|
|
if ok && entered && member.JoinedAt.Equal(joined) {
|
|
seen[id] = true
|
|
}
|
|
}
|
|
if !seen[e.CharacterID] {
|
|
return true, 0
|
|
}
|
|
return true, uint32(len(seen))
|
|
}
|
|
func (r *Registry) AddContributionPoints(id string, points uint32, now time.Time) error {
|
|
g, m, ok := r.GuildForCharacter(id)
|
|
if !ok {
|
|
return ErrNotMember
|
|
}
|
|
if points > math.MaxUint32-m.ContributionPoints {
|
|
return ErrInvalidContent
|
|
}
|
|
m.ContributionPoints += points
|
|
g.Members[id] = m
|
|
g.UpdatedAt = now
|
|
return nil
|
|
}
|