服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
432 lines
12 KiB
Go
432 lines
12 KiB
Go
package guild
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
)
|
|
|
|
var (
|
|
ErrWarClosed = errors.New("guild war is closed")
|
|
ErrWarNoOpponent = errors.New("no eligible rival guild")
|
|
ErrWarActive = errors.New("guild war already active")
|
|
ErrWarNotActive = errors.New("guild war is not active")
|
|
ErrWarLimit = errors.New("guild war daily participation limit")
|
|
ErrWarGuardian = errors.New("guild war requires an active guardian")
|
|
)
|
|
|
|
type WarRules struct {
|
|
Days [7]bool
|
|
PauseStart, PauseEnd int
|
|
PlaySeconds, DailyMax, EnemyHP, ClearDamage uint32
|
|
FriendlyHP uint32
|
|
EnemyDungeon uint32
|
|
Dungeons map[uint32]bool
|
|
RequiredClear []uint32
|
|
ChallengeCosts []uint32
|
|
}
|
|
|
|
func LoadWarRules(source dnfpvf.Source) (*WarRules, error) {
|
|
doc, err := readWarDocument(source, "etc/agitwar.etc")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
r := &WarRules{Dungeons: make(map[uint32]bool)}
|
|
values := func(name string) ([]uint32, error) {
|
|
t, ok := doc.Section(name)
|
|
if !ok {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
return warUnsigned(t)
|
|
}
|
|
days, err := values("agitwar day")
|
|
if err != nil || len(days) == 0 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
for _, day := range days {
|
|
if day > 6 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
r.Days[day] = true
|
|
}
|
|
pause, ok := doc.Section("war limit time")
|
|
if !ok || len(pause) != 2 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
for i, t := range pause {
|
|
v := strings.Split(t.Value, ":")
|
|
if len(v) != 2 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
h, e := strconv.Atoi(v[0])
|
|
m, e2 := strconv.Atoi(v[1])
|
|
if e != nil || e2 != nil || h < 0 || h > 23 || m < 0 || m > 59 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
if i == 0 {
|
|
r.PauseStart = h*60 + m
|
|
} else {
|
|
r.PauseEnd = h*60 + m
|
|
}
|
|
}
|
|
for _, s := range []struct {
|
|
name string
|
|
dst *uint32
|
|
}{{"play time", &r.PlaySeconds}, {"daily max challenge", &r.DailyMax}, {"enemy hp", &r.EnemyHP}, {"clear damage", &r.ClearDamage}} {
|
|
v, e := values(s.name)
|
|
if e != nil || len(v) != 1 || v[0] == 0 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
*s.dst = v[0]
|
|
}
|
|
if r.PlaySeconds > 86400 || r.DailyMax > 100 || r.ClearDamage > r.EnemyHP {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
if v, e := values("hp"); e == nil && len(v) > 0 {
|
|
r.FriendlyHP = v[0]
|
|
} else {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
r.ChallengeCosts, err = values("challenge chance")
|
|
if err != nil || len(r.ChallengeCosts) == 0 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
r.RequiredClear, err = values("required clear dungeon")
|
|
if err != nil || len(r.RequiredClear) == 0 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
scope := ""
|
|
for _, sec := range doc.Sections {
|
|
switch sec.Name {
|
|
case "enemy agit", "friend agit", "cannon", "support dungeon", "underpass dungeon":
|
|
scope = sec.Name
|
|
case "/enemy agit", "/friend agit", "/cannon", "/support dungeon", "/underpass dungeon":
|
|
scope = ""
|
|
case "index":
|
|
if scope != "" {
|
|
v, e := warUnsigned(doc.Tokens[sec.Start:sec.End])
|
|
if e != nil || len(v) != 1 || v[0] == 0 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
r.Dungeons[v[0]] = true
|
|
if scope == "enemy agit" {
|
|
r.EnemyDungeon = v[0]
|
|
}
|
|
}
|
|
case "enemy barricade dungeon", "friend barricade dungeon":
|
|
v, e := warUnsigned(doc.Tokens[sec.Start:sec.End])
|
|
if e != nil {
|
|
return nil, e
|
|
}
|
|
for _, id := range v {
|
|
if id == 0 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
r.Dungeons[id] = true
|
|
}
|
|
}
|
|
}
|
|
if r.EnemyDungeon == 0 {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
for _, id := range r.RequiredClear {
|
|
if !r.Dungeons[id] {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
}
|
|
return r, nil
|
|
}
|
|
|
|
func (r *WarRules) Open(now time.Time) bool {
|
|
if r == nil {
|
|
return false
|
|
}
|
|
local := now.In(time.FixedZone("CST", 8*3600))
|
|
day, _ := WarDay(now)
|
|
minute := local.Hour()*60 + local.Minute()
|
|
paused := minute >= r.PauseStart && minute < r.PauseEnd
|
|
if r.PauseStart > r.PauseEnd {
|
|
paused = minute >= r.PauseStart || minute < r.PauseEnd
|
|
}
|
|
return r.Days[int(day.Weekday())] && !paused
|
|
}
|
|
|
|
// D13FD0 computes the next Monday at 06:00 for the native season boundary.
|
|
func WarSeason(now time.Time) (time.Time, time.Time) {
|
|
day, _ := WarDay(now)
|
|
start := day.AddDate(0, 0, -(int(day.Weekday())+6)%7)
|
|
return start, start.AddDate(0, 0, 7)
|
|
}
|
|
|
|
type WarBattle struct {
|
|
Key string `json:"key"`
|
|
Opponent uint32 `json:"opponent"`
|
|
OpponentGuardian Guardian `json:"opponent_guardian"`
|
|
OwnGuardian Guardian `json:"own_guardian"`
|
|
StartedAt, EndsAt int64
|
|
EnemyHP uint32 `json:"enemy_hp"`
|
|
Clears map[uint32]uint32 `json:"clears,omitempty"`
|
|
Members map[string]bool `json:"members,omitempty"`
|
|
Receipts map[string]bool `json:"receipts,omitempty"`
|
|
}
|
|
type WarState struct {
|
|
DayStart, SeasonStart int64
|
|
NextBattle uint64 `json:"next_battle"`
|
|
Rivals []uint32 `json:"rivals,omitempty"`
|
|
Results map[uint32]uint32 `json:"results,omitempty"`
|
|
Participants map[string]uint32 `json:"participants,omitempty"`
|
|
AttackWins, AttackLosses, DefenceWins, DefenceLosses uint32
|
|
Battle *WarBattle `json:"battle,omitempty"`
|
|
LastBattle *WarBattle `json:"last_battle,omitempty"`
|
|
}
|
|
|
|
func (r *Registry) AdvanceWars(now time.Time) {
|
|
for _, g := range r.Guilds {
|
|
if g != nil && g.War.Battle != nil && now.Unix() >= g.War.Battle.EndsAt {
|
|
r.finishWar(g, false)
|
|
}
|
|
}
|
|
day, _ := WarDay(now)
|
|
season, _ := WarSeason(now)
|
|
for _, g := range r.Guilds {
|
|
if g == nil {
|
|
continue
|
|
}
|
|
w := &g.War
|
|
if w.DayStart != day.Unix() {
|
|
w.DayStart = day.Unix()
|
|
w.Rivals = nil
|
|
w.Results = nil
|
|
w.Participants = nil
|
|
}
|
|
if w.SeasonStart != season.Unix() {
|
|
w.SeasonStart = season.Unix()
|
|
w.AttackWins = 0
|
|
w.AttackLosses = 0
|
|
w.DefenceWins = 0
|
|
w.DefenceLosses = 0
|
|
}
|
|
}
|
|
}
|
|
|
|
func (r *Registry) AddWarRivals(characterID string, initial bool, rules *WarRules, now time.Time) (*Guild, error) {
|
|
g, m, ok := r.GuildForCharacter(characterID)
|
|
if !ok {
|
|
return nil, ErrNotMember
|
|
}
|
|
if !rules.Open(now) {
|
|
return nil, ErrWarClosed
|
|
}
|
|
if !initial && g.Permissions(m.Grade)&(1<<16) == 0 {
|
|
return nil, ErrPermissionDenied
|
|
}
|
|
r.AdvanceWars(now)
|
|
if initial && len(g.War.Rivals) > 0 {
|
|
return g, nil
|
|
}
|
|
if len(g.War.Rivals) >= 40 {
|
|
return nil, ErrWarLimit
|
|
}
|
|
existing := map[uint32]bool{g.ID: true}
|
|
for _, id := range g.War.Rivals {
|
|
existing[id] = true
|
|
}
|
|
candidates := []uint32{}
|
|
for id, other := range r.Guilds {
|
|
_, hasGuardian := other.WarGuardian()
|
|
if other != nil && !existing[id] && hasGuardian && len(other.Members) > 0 {
|
|
candidates = append(candidates, id)
|
|
}
|
|
}
|
|
sort.Slice(candidates, func(i, j int) bool { return candidates[i] < candidates[j] })
|
|
if len(candidates) == 0 {
|
|
return nil, ErrWarNoOpponent
|
|
}
|
|
costIndex := len(g.War.Rivals) / 8
|
|
if costIndex >= len(rules.ChallengeCosts) {
|
|
return nil, ErrWarLimit
|
|
}
|
|
cost := rules.ChallengeCosts[costIndex]
|
|
if initial {
|
|
cost = 0
|
|
}
|
|
if g.GuardianGP < cost {
|
|
return nil, ErrInsufficientGuardian
|
|
}
|
|
g.GuardianGP -= cost
|
|
g.War.Rivals = append(g.War.Rivals, candidates[:min(8, min(len(candidates), 40-len(g.War.Rivals)))]...)
|
|
return g, nil
|
|
}
|
|
|
|
func (r *Registry) StartWar(characterID string, target uint32, rules *WarRules, now time.Time) (*Guild, error) {
|
|
g, m, ok := r.GuildForCharacter(characterID)
|
|
if !ok {
|
|
return nil, ErrNotMember
|
|
}
|
|
if g.Permissions(m.Grade)&(1<<16) == 0 {
|
|
return nil, ErrPermissionDenied
|
|
}
|
|
if !rules.Open(now) {
|
|
return nil, ErrWarClosed
|
|
}
|
|
r.AdvanceWars(now)
|
|
if b := g.War.Battle; b != nil {
|
|
if b.Opponent == target {
|
|
return g, nil
|
|
}
|
|
return nil, ErrWarActive
|
|
}
|
|
ownGuardian, ownReady := g.WarGuardian()
|
|
if !ownReady {
|
|
return nil, ErrWarGuardian
|
|
}
|
|
candidate := false
|
|
for _, id := range g.War.Rivals {
|
|
candidate = candidate || id == target
|
|
}
|
|
enemy := r.Guilds[target]
|
|
enemyGuardian, enemyReady := enemy.WarGuardian()
|
|
if !candidate || enemy == nil || target == g.ID || !enemyReady {
|
|
return nil, ErrWarNoOpponent
|
|
}
|
|
if g.War.Results[target] != 0 {
|
|
return nil, ErrWarLimit
|
|
}
|
|
if g.War.Participants[characterID] >= rules.DailyMax {
|
|
return nil, ErrWarLimit
|
|
}
|
|
g.War.NextBattle++
|
|
end := now.Add(time.Duration(rules.PlaySeconds) * time.Second)
|
|
_, dayEnd := WarDay(now)
|
|
pause := dayEnd.Add(time.Duration(rules.PauseStart-360) * time.Minute)
|
|
if pause.After(now) && pause.Before(end) {
|
|
end = pause
|
|
}
|
|
g.War.Battle = &WarBattle{Key: fmt.Sprintf("%d:%d:%d", g.ID, g.War.NextBattle, now.UnixNano()), Opponent: target, StartedAt: now.Unix(), EndsAt: end.Unix(), EnemyHP: rules.EnemyHP, Clears: make(map[uint32]uint32), Members: make(map[string]bool), Receipts: make(map[string]bool)}
|
|
g.War.Battle.OpponentGuardian, g.War.Battle.OwnGuardian = enemyGuardian, ownGuardian
|
|
return g, nil
|
|
}
|
|
|
|
func (r *Registry) finishWar(g *Guild, won bool) {
|
|
b := g.War.Battle
|
|
if b == nil {
|
|
return
|
|
}
|
|
if g.War.Results == nil {
|
|
g.War.Results = make(map[uint32]uint32)
|
|
}
|
|
enemy := r.Guilds[b.Opponent]
|
|
if won {
|
|
g.War.AttackWins++
|
|
g.War.Results[b.Opponent] = 1
|
|
if enemy != nil {
|
|
enemy.War.DefenceLosses++
|
|
}
|
|
} else {
|
|
g.War.AttackLosses++
|
|
g.War.Results[b.Opponent] = 2
|
|
if enemy != nil {
|
|
enemy.War.DefenceWins++
|
|
}
|
|
}
|
|
g.War.LastBattle = b
|
|
g.War.Battle = nil
|
|
}
|
|
|
|
func (r *Registry) CancelWar(characterID string, now time.Time) (*Guild, error) {
|
|
g, m, ok := r.GuildForCharacter(characterID)
|
|
if !ok {
|
|
return nil, ErrNotMember
|
|
}
|
|
if g.Permissions(m.Grade)&(1<<16) == 0 {
|
|
return nil, ErrPermissionDenied
|
|
}
|
|
r.AdvanceWars(now)
|
|
r.finishWar(g, false)
|
|
return g, nil
|
|
}
|
|
|
|
func (r Registry) WarAdmission(characterID string, dungeon uint32, rules *WarRules, now time.Time) (*Guild, error) {
|
|
g, _, ok := r.GuildForCharacter(characterID)
|
|
if !ok {
|
|
return nil, ErrNotMember
|
|
}
|
|
b := g.War.Battle
|
|
if !rules.Open(now) || b == nil || now.Unix() >= b.EndsAt || !rules.Dungeons[dungeon] {
|
|
return nil, ErrWarNotActive
|
|
}
|
|
if !b.Members[characterID] && g.War.Participants[characterID] >= rules.DailyMax {
|
|
return nil, ErrWarLimit
|
|
}
|
|
if dungeon == rules.EnemyDungeon {
|
|
unlocked := false
|
|
for _, id := range rules.RequiredClear {
|
|
unlocked = unlocked || b.Clears[id] > 0
|
|
}
|
|
if !unlocked {
|
|
return nil, ErrInvalidContent
|
|
}
|
|
}
|
|
return g, nil
|
|
}
|
|
|
|
func (r *Registry) JoinWar(characterID, key string, dungeon uint32, rules *WarRules, now time.Time) (bool, error) {
|
|
g, err := r.WarAdmission(characterID, dungeon, rules, now)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
b := g.War.Battle
|
|
if b.Key != key {
|
|
return false, ErrWarNotActive
|
|
}
|
|
if b.Members[characterID] {
|
|
return false, nil
|
|
}
|
|
if b.Members == nil {
|
|
b.Members = make(map[string]bool)
|
|
}
|
|
if g.War.Participants == nil {
|
|
g.War.Participants = make(map[string]uint32)
|
|
}
|
|
b.Members[characterID] = true
|
|
g.War.Participants[characterID]++
|
|
return true, nil
|
|
}
|
|
|
|
func (r *Registry) ClearWar(characterID, key, receipt string, dungeon uint32, rules *WarRules, now time.Time) error {
|
|
g, _, ok := r.GuildForCharacter(characterID)
|
|
if !ok {
|
|
return ErrNotMember
|
|
}
|
|
b := g.War.Battle
|
|
if last := g.War.LastBattle; last != nil && last.Key == key && last.Members[characterID] && last.Receipts[receipt] {
|
|
return nil
|
|
}
|
|
if b == nil || b.Key != key || !b.Members[characterID] || now.Unix() >= b.EndsAt || !rules.Dungeons[dungeon] || receipt == "" {
|
|
return ErrWarNotActive
|
|
}
|
|
if b.Receipts[receipt] {
|
|
return nil
|
|
}
|
|
if b.Receipts == nil {
|
|
b.Receipts = make(map[string]bool)
|
|
}
|
|
if b.Clears == nil {
|
|
b.Clears = make(map[uint32]uint32)
|
|
}
|
|
b.Receipts[receipt] = true
|
|
b.Clears[dungeon]++
|
|
if dungeon == rules.EnemyDungeon {
|
|
b.EnemyHP -= min(b.EnemyHP, rules.ClearDamage)
|
|
if b.EnemyHP == 0 {
|
|
r.finishWar(g, true)
|
|
}
|
|
}
|
|
return nil
|
|
}
|