服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
376 lines
13 KiB
Go
376 lines
13 KiB
Go
package pvp
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"math"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/premium"
|
|
repo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const TotalProgressMetadataKey = "pvp_total_match_progress_v1"
|
|
|
|
type TotalMatchFacts struct {
|
|
Match
|
|
Kills [3]uint32
|
|
Remaining int
|
|
ReportsCount map[uint16]uint32
|
|
}
|
|
|
|
type TotalProgress struct {
|
|
Stats map[string]int64 `json:"stats"`
|
|
Last *totalReceipt `json:"last,omitempty"`
|
|
}
|
|
|
|
type totalReceipt struct {
|
|
Key string `json:"key"`
|
|
CompletedAt int64 `json:"completed_at"`
|
|
Characters [3]string `json:"characters"`
|
|
Before [3]map[string]int64 `json:"before"`
|
|
After [3]map[string]int64 `json:"after"`
|
|
BeforeTeam map[string]int64 `json:"before_team"`
|
|
AfterTeam map[string]int64 `json:"after_team"`
|
|
BeforeRows, Rows, ChangedRows []Row
|
|
BeforeNormalRows, NormalRows []Row
|
|
}
|
|
|
|
type TotalSnapshot struct {
|
|
Rewards TotalRewards
|
|
Characters [3]repo.CharacterRecord
|
|
Before [3]repo.CharacterRecord
|
|
Stats, BeforeStats map[string]int64
|
|
Rows, BeforeRows, ChangedRows []Row
|
|
NormalRows, BeforeNormalRows []Row
|
|
}
|
|
|
|
// TotalMissionSnapshot is account progress, independent of a registered team.
|
|
// Opening the native task UI must not require or invent three combat actors.
|
|
type TotalMissionSnapshot struct {
|
|
Character repo.CharacterRecord
|
|
Stats map[string]int64
|
|
Rows, NormalRows []Row
|
|
Day int64
|
|
EmblemsGranted int64
|
|
}
|
|
|
|
func totalMissionCharacter(accountID string, stats map[string]int64) repo.CharacterRecord {
|
|
return repo.CharacterRecord{CharacterID: accountID + ":total-match", Stats: stats}
|
|
}
|
|
|
|
func PrepareTotalMissions(ctx context.Context, repos repo.Group, c *Catalog, accountID, selected string, now time.Time) (TotalMissionSnapshot, error) {
|
|
var result TotalMissionSnapshot
|
|
if repos.RentalAssets == nil || c == nil || now.IsZero() {
|
|
return result, fmt.Errorf("total-match mission dependencies missing")
|
|
}
|
|
err := repo.WithinRentalAssetsScoped(ctx, repos.RentalAssets, accountID, selected,
|
|
func(accounts repo.AccountRepository, characters repo.CharacterRepository, _ repo.InventoryRepository, _ repo.EquipmentRepository) error {
|
|
account, found, err := accounts.Load(ctx, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || account.AccountID != accountID {
|
|
return fmt.Errorf("total-match account missing")
|
|
}
|
|
character, found, err := characters.Load(ctx, selected)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || character.AccountID != accountID || character.Stats["delete_flag"] != 0 {
|
|
return fmt.Errorf("total-match mission character owner mismatch")
|
|
}
|
|
p, err := ReadTotalProgress(account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
daily, _, err := c.snapshot(totalMissionCharacter(accountID, p.Stats), now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Stats = daily.Character.Stats
|
|
character = repo.CloneCharacter(character)
|
|
if character.Stats == nil {
|
|
character.Stats = map[string]int64{}
|
|
}
|
|
grant := p.Stats[EmblemsKey]
|
|
if grant < 0 || character.Stats[EmblemsKey] < 0 || character.Stats[EmblemsKey] > math.MaxInt32-grant {
|
|
return fmt.Errorf("total-match emblem overflow")
|
|
}
|
|
if grant > 0 {
|
|
character.Stats[EmblemsKey] += grant
|
|
if err := repo.SaveCharacterFields(ctx, characters, character, repo.CharacterFieldStats); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
delete(p.Stats, EmblemsKey)
|
|
raw, err := json.Marshal(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if err := repo.SaveAccountMetadataEntry(ctx, accounts, account, TotalProgressMetadataKey, string(raw), now); err != nil {
|
|
return err
|
|
}
|
|
result = TotalMissionSnapshot{Character: character, Stats: copyTotalStats(p.Stats), Rows: daily.Rows,
|
|
NormalRows: daily.NormalRows, Day: daily.Day, EmblemsGranted: grant}
|
|
return nil
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func ReadTotalProgress(account repo.AccountRecord) (TotalProgress, error) {
|
|
var p TotalProgress
|
|
if raw := account.Metadata[TotalProgressMetadataKey]; raw != "" {
|
|
if err := json.Unmarshal([]byte(raw), &p); err != nil {
|
|
return p, err
|
|
}
|
|
}
|
|
if p.Stats == nil {
|
|
p.Stats = map[string]int64{}
|
|
}
|
|
return p, nil
|
|
}
|
|
|
|
func copyTotalStats(src map[string]int64) map[string]int64 {
|
|
dst := make(map[string]int64, len(src))
|
|
for k, v := range src {
|
|
dst[k] = v
|
|
}
|
|
return dst
|
|
}
|
|
|
|
func PrepareTotal(ctx context.Context, repos repo.Group, catalog *Catalog, accountID, selected string, roster [3]string, now time.Time) (TotalSnapshot, error) {
|
|
return updateTotal(ctx, repos, catalog, accountID, selected, roster, now, nil)
|
|
}
|
|
|
|
func CompleteTotal(ctx context.Context, repos repo.Group, catalog *Catalog, accountID, selected string, roster [3]string, facts TotalMatchFacts) (TotalSnapshot, error) {
|
|
if facts.Key == "" || facts.StartedAt.IsZero() || facts.CompletedAt.Before(facts.StartedAt) || facts.Remaining < 0 || facts.Remaining > 3 {
|
|
return TotalSnapshot{}, fmt.Errorf("invalid total-match facts")
|
|
}
|
|
var kills uint32
|
|
for _, n := range facts.Kills {
|
|
if n > 3 {
|
|
return TotalSnapshot{}, fmt.Errorf("invalid total-match kills")
|
|
}
|
|
kills += n
|
|
}
|
|
if kills > 3 {
|
|
return TotalSnapshot{}, fmt.Errorf("invalid total-match total kills")
|
|
}
|
|
return updateTotal(ctx, repos, catalog, accountID, selected, roster, facts.CompletedAt, &facts)
|
|
}
|
|
|
|
func updateTotal(ctx context.Context, repos repo.Group, c *Catalog, accountID, selected string, roster [3]string, now time.Time, match *TotalMatchFacts) (TotalSnapshot, error) {
|
|
var result TotalSnapshot
|
|
uow, ok := repos.RentalAssets.(repo.PVPTotalUnitOfWork)
|
|
if !ok || c == nil || c.RatingRules == nil || now.IsZero() {
|
|
return result, fmt.Errorf("total-match progress dependencies missing")
|
|
}
|
|
selectedIndex := -1
|
|
for i, id := range roster {
|
|
if id == selected {
|
|
selectedIndex = i
|
|
}
|
|
}
|
|
if selectedIndex < 0 {
|
|
return result, fmt.Errorf("selected character is outside total-match roster")
|
|
}
|
|
err := uow.WithinPVPTotal(ctx, accountID, roster, func(accounts repo.AccountRepository, characters repo.CharacterRepository) error {
|
|
account, found, err := accounts.Load(ctx, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || account.AccountID != accountID {
|
|
return fmt.Errorf("total-match account missing")
|
|
}
|
|
p, err := ReadTotalProgress(account)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, id := range roster {
|
|
v, found, err := characters.Load(ctx, id)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || v.AccountID != accountID || v.Stats["delete_flag"] != 0 {
|
|
return fmt.Errorf("total-match character owner mismatch")
|
|
}
|
|
v = repo.CloneCharacter(v)
|
|
if v.Stats == nil {
|
|
v.Stats = map[string]int64{}
|
|
}
|
|
result.Characters[i], result.Before[i] = v, repo.CloneCharacter(v)
|
|
}
|
|
if match != nil && p.Last != nil && p.Last.Key == match.Key {
|
|
if p.Last.Characters != roster {
|
|
return fmt.Errorf("total-match receipt roster mismatch")
|
|
}
|
|
for i := range roster {
|
|
for k, v := range p.Last.Before[i] {
|
|
result.Before[i].Stats[k] = v
|
|
}
|
|
for k, v := range p.Last.After[i] {
|
|
result.Characters[i].Stats[k] = v
|
|
}
|
|
}
|
|
result.Stats, result.BeforeStats = copyTotalStats(p.Last.AfterTeam), copyTotalStats(p.Last.BeforeTeam)
|
|
result.Rows, result.BeforeRows, result.ChangedRows = p.Last.Rows, p.Last.BeforeRows, p.Last.ChangedRows
|
|
// Rebuild from the frozen receipt facts, also covering receipts written
|
|
// before total target rows were published.
|
|
result.BeforeNormalRows, err = c.normalRows(totalMissionCharacter(accountID, result.BeforeStats))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result.NormalRows, err = c.normalRows(totalMissionCharacter(accountID, result.Stats))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
if match != nil && p.Last != nil && p.Last.CompletedAt >= match.CompletedAt.UnixNano() {
|
|
return fmt.Errorf("stale total-match completion")
|
|
}
|
|
// Daily selection is account-owned; this internal projection is never an
|
|
// actor or a database character. It reuses the PVF weighted daily rules.
|
|
daily, _, err := c.snapshot(totalMissionCharacter(accountID, p.Stats), now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
p.Stats = daily.Character.Stats
|
|
result.BeforeStats = copyTotalStats(p.Stats)
|
|
result.BeforeRows = append([]Row(nil), daily.Rows...)
|
|
result.BeforeNormalRows = append([]Row(nil), daily.NormalRows...)
|
|
if match != nil {
|
|
for i := range roster {
|
|
// The three real actors gain PVF total-match EXP. Their ordinary
|
|
// personal rating/win/daily counters are untouched.
|
|
m := match.Match
|
|
m.Manual = true
|
|
rules := *c.RatingRules
|
|
if !match.Manual {
|
|
rules.ManualExperiencePercent = rules.MatchExperiencePercent
|
|
}
|
|
actorCatalog := *c
|
|
actorCatalog.RatingRules = &rules
|
|
if _, err := actorCatalog.applyRating(result.Characters[i].Stats, m, 0); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if !match.Manual {
|
|
played := p.Stats[CertificationKey]
|
|
if played < 0 || played >= math.MaxInt32 {
|
|
return fmt.Errorf("invalid total-match played count")
|
|
}
|
|
if _, err := c.applyRating(p.Stats, match.Match, played); err != nil {
|
|
return err
|
|
}
|
|
p.Stats[CertificationKey] = played + 1
|
|
key := LossesKey
|
|
if match.Draw {
|
|
key = DrawsKey
|
|
} else if match.Won {
|
|
key = WinsKey
|
|
}
|
|
if p.Stats[key] < 0 || p.Stats[key] >= math.MaxInt32 {
|
|
return fmt.Errorf("invalid total-match result counter")
|
|
}
|
|
p.Stats[key]++
|
|
if premium.ServiceDayID(match.StartedAt) == daily.Day {
|
|
for tier, row := range daily.Rows {
|
|
for _, m := range c.Pools[tier] {
|
|
if m.ID != row.ID || row.Progress == row.Target {
|
|
continue
|
|
}
|
|
advance := totalMissionAdvance(m, *match, p.Stats["pvp_win_streak"])
|
|
if advance > 0 {
|
|
row.Progress += min(advance, row.Target-row.Progress)
|
|
p.Stats[dailyKey(tier, "progress")] = int64(row.Progress)
|
|
daily.Rows[tier] = row
|
|
result.ChangedRows = append(result.ChangedRows, row)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if _, _, err := c.grantDailyEmblems(p.Stats); err != nil {
|
|
return err
|
|
}
|
|
wallet := result.Characters[selectedIndex].Stats
|
|
grant := p.Stats[EmblemsKey]
|
|
if grant < 0 || wallet[EmblemsKey] < 0 || wallet[EmblemsKey] > math.MaxInt32-grant {
|
|
return fmt.Errorf("total-match emblem overflow")
|
|
}
|
|
wallet[EmblemsKey] += grant
|
|
delete(p.Stats, EmblemsKey)
|
|
result.Stats, result.Rows = copyTotalStats(p.Stats), daily.Rows
|
|
result.NormalRows, err = c.normalRows(totalMissionCharacter(accountID, p.Stats))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, row := range result.NormalRows {
|
|
if row != result.BeforeNormalRows[i] {
|
|
result.ChangedRows = append(result.ChangedRows, row)
|
|
}
|
|
}
|
|
if match != nil {
|
|
r := &totalReceipt{Key: match.Key, CompletedAt: match.CompletedAt.UnixNano(), Characters: roster, BeforeTeam: result.BeforeStats, AfterTeam: result.Stats, BeforeRows: result.BeforeRows, Rows: result.Rows, ChangedRows: result.ChangedRows}
|
|
r.BeforeNormalRows, r.NormalRows = result.BeforeNormalRows, result.NormalRows
|
|
for i := range roster {
|
|
r.Before[i], r.After[i] = map[string]int64{}, map[string]int64{}
|
|
for _, k := range []string{ExperienceKey, GradeKey, EmblemsKey, "pvp_max_grade", "pvp_result_before_exp", "pvp_result_before_grade", "pvp_result_experience_delta"} {
|
|
r.Before[i][k], r.After[i][k] = result.Before[i].Stats[k], result.Characters[i].Stats[k]
|
|
}
|
|
}
|
|
p.Last = r
|
|
}
|
|
for i := range roster {
|
|
if match != nil || i == selectedIndex && grant > 0 {
|
|
if err := repo.SaveCharacterFields(ctx, characters, result.Characters[i], repo.CharacterFieldStats); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
raw, err := json.Marshal(p)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
return repo.SaveAccountMetadataEntry(ctx, accounts, account, TotalProgressMetadataKey, string(raw), now)
|
|
})
|
|
return result, err
|
|
}
|
|
|
|
func totalMissionAdvance(m Mission, match TotalMatchFacts, streak int64) uint32 {
|
|
win := match.Won && !match.Draw
|
|
switch m.Condition {
|
|
case "hereafter join":
|
|
return 1
|
|
case "hereafter winning count":
|
|
if win {
|
|
return 1
|
|
}
|
|
case "total match win left character":
|
|
if win && int64(match.Remaining) >= m.Parameter {
|
|
return 1
|
|
}
|
|
case "kill count":
|
|
return match.Kills[0] + match.Kills[1] + match.Kills[2]
|
|
case "total match win all kill":
|
|
if win {
|
|
for _, n := range match.Kills {
|
|
if n == 3 {
|
|
return 1
|
|
}
|
|
}
|
|
}
|
|
case "winning streak":
|
|
if win && m.Parameter > 0 && streak%m.Parameter == 0 {
|
|
return 1
|
|
}
|
|
case "reduce hp":
|
|
return min(match.ReportsCount[m.ID], 5)
|
|
}
|
|
return 0
|
|
}
|