服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
577 lines
20 KiB
Go
577 lines
20 KiB
Go
package pvp
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"fmt"
|
|
"math"
|
|
"path"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/modules/dnf/jobmap"
|
|
"longheng.io/server/internal/modules/dnf/premium"
|
|
"longheng.io/server/internal/modules/dnf/pvf"
|
|
repo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
CertificationKey = "pvp_certification_count"
|
|
WinsKey = "pvp_wins"
|
|
LossesKey = "pvp_losses"
|
|
DrawsKey = "pvp_draws"
|
|
MaxWinStreakKey = "pvp_max_win_streak"
|
|
RecentResultsKey = "pvp_recent_results"
|
|
EmblemsKey = "finish_loading_currency_slot2_total"
|
|
)
|
|
|
|
type Mission struct {
|
|
ID uint16
|
|
Tier int
|
|
Weight int64
|
|
RankMin, RankMax int64
|
|
Condition string
|
|
Target uint32
|
|
Parameter int64
|
|
Jobs []int64
|
|
Emblems int64
|
|
RewardItemID, RewardItemCount int64
|
|
Category string
|
|
PreRequired uint16
|
|
JobTag string
|
|
}
|
|
|
|
type Catalog struct {
|
|
Pools [3][]Mission
|
|
// Normal target cards are independent of the three daily pool indexes.
|
|
Normal []Mission
|
|
ExperienceBounds []uint32
|
|
RatingRules *RatingRules
|
|
}
|
|
|
|
// Load reads ordinary daily pools and normal target cards. Total-match
|
|
// definitions have separate native grades and are not assigned here.
|
|
func Load(source pvf.Source) (*Catalog, error) {
|
|
return loadCatalog(source, false)
|
|
}
|
|
|
|
func LoadTotal(source pvf.Source) (*Catalog, error) {
|
|
return loadCatalog(source, true)
|
|
}
|
|
|
|
func loadCatalog(source pvf.Source, totalMatch bool) (*Catalog, error) {
|
|
if source == nil {
|
|
return nil, pvf.ErrSourceRequired
|
|
}
|
|
read := func(p string) (*pvf.Document, error) {
|
|
s, err := source.ReadText(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return pvf.Parse(p, s)
|
|
}
|
|
config, err := read("etc/missionsystem.etc")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
list, err := read("pvp_mission/mission.lst")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
paths := make(map[int64]string)
|
|
for _, entry := range pvf.ParseList(list) {
|
|
if _, exists := paths[entry.ID]; exists {
|
|
return nil, fmt.Errorf("duplicate PVP mission %d", entry.ID)
|
|
}
|
|
paths[entry.ID] = path.Join("pvp_mission", entry.Path)
|
|
}
|
|
c := &Catalog{}
|
|
poolTag, gradeTag, modeTag := "casual daily mission", "[daily]", "[all]"
|
|
if totalMatch {
|
|
poolTag, gradeTag, modeTag = "total match daily mission", "[total match daily]", "[total match mode]"
|
|
}
|
|
for _, section := range config.Sections {
|
|
if section.Name != poolTag {
|
|
continue
|
|
}
|
|
var values []int64
|
|
for _, token := range config.Tokens[section.Start:section.End] {
|
|
if token.Kind == pvf.TokenInt {
|
|
values = append(values, token.Int)
|
|
}
|
|
}
|
|
if len(values) < 3 || len(values)%2 != 1 || values[0] < 0 || values[0] > 3 {
|
|
return nil, fmt.Errorf("invalid PVF daily mission pool")
|
|
}
|
|
if values[0] == 3 {
|
|
continue
|
|
}
|
|
tier := int(values[0])
|
|
if len(c.Pools[tier]) != 0 {
|
|
return nil, fmt.Errorf("duplicate daily tier %d", tier)
|
|
}
|
|
seen := map[int64]bool{}
|
|
for i := 1; i < len(values); i += 2 {
|
|
id, weight := values[i], values[i+1]
|
|
if id <= 0 || id > math.MaxInt16 || weight <= 0 || weight > math.MaxInt32 || seen[id] || paths[id] == "" {
|
|
return nil, fmt.Errorf("invalid daily mission %d", id)
|
|
}
|
|
seen[id] = true
|
|
d, err := read(paths[id])
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
grade, ranks, args := d.Ints("grade"), d.Ints("rank range"), d.Ints("condition")
|
|
kind, _ := d.Text("grade")
|
|
tags := d.Texts("condition")
|
|
if kind != gradeTag || len(grade) != 1 || grade[0] != int64(tier) || len(ranks) != 2 ||
|
|
ranks[0] < 0 || ranks[1] < ranks[0] || len(tags) != 2 || tags[1] != modeTag ||
|
|
len(args) < 2 || args[0] <= 0 || args[0] > math.MaxInt32 {
|
|
return nil, fmt.Errorf("invalid daily mission definition %s", paths[id])
|
|
}
|
|
m := Mission{ID: uint16(id), Tier: tier, Weight: weight, RankMin: ranks[0], RankMax: ranks[1],
|
|
Condition: strings.ToLower(strings.Trim(tags[0], "[]")), Target: uint32(args[0]), Parameter: args[1], Jobs: args[2:]}
|
|
if m.Condition == "winning job" && (m.Parameter != int64(len(m.Jobs)) || m.Parameter <= 0) {
|
|
return nil, fmt.Errorf("invalid mission %d job list", id)
|
|
}
|
|
if reward := d.Ints("reward victory emblem"); len(reward) == 1 {
|
|
m.Emblems = reward[0]
|
|
}
|
|
if m.Emblems < 0 || m.Emblems > math.MaxInt32 {
|
|
return nil, fmt.Errorf("invalid mission %d emblem reward", id)
|
|
}
|
|
c.Pools[tier] = append(c.Pools[tier], m)
|
|
}
|
|
}
|
|
for tier, pool := range c.Pools {
|
|
if len(pool) == 0 {
|
|
return nil, fmt.Errorf("missing PVF daily mission tier %d", tier)
|
|
}
|
|
}
|
|
normalGrade := "[normal]"
|
|
if totalMatch {
|
|
normalGrade = "[total match normal]"
|
|
}
|
|
for id, p := range paths {
|
|
d, err := read(p)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
grade, _ := d.Text("grade")
|
|
if grade != normalGrade {
|
|
continue
|
|
}
|
|
category, categoryOK := d.Text("normal category")
|
|
ranks, args, tags := d.Ints("rank range"), d.Ints("condition"), d.Texts("condition")
|
|
if id <= 0 || id > math.MaxInt16 || !categoryOK || len(ranks) != 2 || ranks[0] < 0 || ranks[1] < ranks[0] || len(tags) != 2 || len(args) < 2 || args[0] < -1 || args[0] == 0 || args[0] > math.MaxInt32 {
|
|
return nil, fmt.Errorf("invalid normal mission definition %s", p)
|
|
}
|
|
switch category {
|
|
case "[training]", "[rating]", "[battle]", "[victory]":
|
|
default:
|
|
return nil, fmt.Errorf("invalid normal mission category %s", p)
|
|
}
|
|
pre, _ := d.Int("pre required mission")
|
|
if pre < -1 || pre > math.MaxInt16 || pre == id {
|
|
return nil, fmt.Errorf("invalid normal mission prerequisite %d", id)
|
|
}
|
|
if pre == -1 {
|
|
pre = 0
|
|
}
|
|
m := Mission{ID: uint16(id), RankMin: ranks[0], RankMax: ranks[1], Category: strings.ToLower(strings.Trim(category, "[]")),
|
|
Condition: strings.ToLower(strings.Trim(tags[0], "[]")), Target: uint32(args[0]), Parameter: args[1], PreRequired: uint16(pre)}
|
|
if m.Condition == "combo clear" {
|
|
m.JobTag = tags[1]
|
|
known := false
|
|
for job := 0; job <= jobmap.MaxID; job++ {
|
|
known = known || jobmap.MatchesQuestTag(m.JobTag, job)
|
|
}
|
|
if !known {
|
|
return nil, fmt.Errorf("invalid normal mission job %s", p)
|
|
}
|
|
} else if tags[1] != modeTag {
|
|
return nil, fmt.Errorf("invalid normal mission mode %s", p)
|
|
}
|
|
if totalMatch {
|
|
v := d.Ints("reward victory emblem")
|
|
if len(v) > 1 || len(v) == 1 && (v[0] < 0 || v[0] > math.MaxInt32) {
|
|
return nil, fmt.Errorf("invalid total mission emblem reward %d", id)
|
|
}
|
|
if len(v) == 1 {
|
|
m.Emblems = v[0]
|
|
}
|
|
v = d.Ints("reward item")
|
|
if len(v) != 0 {
|
|
if len(v) != 2 || v[0] <= 0 || v[0] > math.MaxUint32 || v[1] <= 0 || v[1] > math.MaxInt32 {
|
|
return nil, fmt.Errorf("invalid total mission item reward %d", id)
|
|
}
|
|
m.RewardItemID, m.RewardItemCount = v[0], v[1]
|
|
}
|
|
}
|
|
c.Normal = append(c.Normal, m)
|
|
}
|
|
sort.Slice(c.Normal, func(i, j int) bool { return c.Normal[i].ID < c.Normal[j].ID })
|
|
normalByID := make(map[uint16]Mission, len(c.Normal))
|
|
for _, mission := range c.Normal {
|
|
normalByID[mission.ID] = mission
|
|
}
|
|
for _, mission := range c.Normal {
|
|
seen := map[uint16]bool{mission.ID: true}
|
|
for id := mission.PreRequired; id != 0; {
|
|
pre, found := normalByID[id]
|
|
if !found || seen[id] || pre.JobTag != mission.JobTag {
|
|
return nil, fmt.Errorf("invalid normal mission prerequisite chain %d", mission.ID)
|
|
}
|
|
seen[id] = true
|
|
id = pre.PreRequired
|
|
}
|
|
}
|
|
ref, err := read("etc/pvp_ref.etc")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
values := ref.Ints("pvp experience")
|
|
if len(values) == 0 || len(values)%2 != 0 {
|
|
return nil, fmt.Errorf("invalid PVP experience bounds")
|
|
}
|
|
c.ExperienceBounds = []uint32{0}
|
|
for i := 0; i < len(values); i += 2 {
|
|
if values[i] != int64(len(c.ExperienceBounds)) || values[i+1] <= int64(c.ExperienceBounds[len(c.ExperienceBounds)-1]) || values[i+1] > math.MaxInt32 {
|
|
return nil, fmt.Errorf("invalid PVP grade threshold")
|
|
}
|
|
c.ExperienceBounds = append(c.ExperienceBounds, uint32(values[i+1]))
|
|
}
|
|
matching, err := read("etc/matchingmode.etc")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
pointTag := "pvp grade point"
|
|
if totalMatch {
|
|
pointTag = "pvp grade point total match"
|
|
}
|
|
c.RatingRules, err = loadRatingRulesWithPoints(ref, matching, len(c.ExperienceBounds)-1, pointTag)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c, nil
|
|
}
|
|
|
|
type Row struct {
|
|
ID uint16
|
|
Progress, Target uint32
|
|
}
|
|
type Snapshot struct {
|
|
Character repo.CharacterRecord
|
|
Day int64
|
|
Rows []Row
|
|
BeforeRows []Row
|
|
ChangedRows []Row
|
|
NormalRows []Row
|
|
BeforeNormalRows []Row
|
|
EmblemsGranted int64
|
|
ExperienceDelta int64
|
|
BeforeGrade *uint32
|
|
}
|
|
|
|
func dailyKey(tier int, field string) string { return "pvp_daily_" + strconv.Itoa(tier) + "_" + field }
|
|
|
|
func (c *Catalog) snapshot(character repo.CharacterRecord, now time.Time) (Snapshot, bool, error) {
|
|
character = repo.CloneCharacter(character)
|
|
if character.Stats == nil {
|
|
character.Stats = map[string]int64{}
|
|
}
|
|
stats := character.Stats
|
|
// Settle completed rows before the service-day reset discards them. The
|
|
// marker also repairs rows completed by the previous progress-only server.
|
|
granted, paid, err := c.grantDailyEmblems(stats)
|
|
if err != nil {
|
|
return Snapshot{}, false, err
|
|
}
|
|
day := premium.ServiceDayID(now)
|
|
fresh := stats["pvp_daily_day"] != day
|
|
out := Snapshot{Character: character, Day: day, EmblemsGranted: granted}
|
|
for tier, pool := range c.Pools {
|
|
var chosen *Mission
|
|
if !fresh {
|
|
for i := range pool {
|
|
if int64(pool[i].ID) == stats[dailyKey(tier, "id")] {
|
|
chosen = &pool[i]
|
|
break
|
|
}
|
|
}
|
|
if chosen == nil {
|
|
return Snapshot{}, false, fmt.Errorf("persisted PVP mission missing from tier %d", tier)
|
|
}
|
|
} else {
|
|
var total int64
|
|
for _, m := range pool {
|
|
if stats["pvp_grade"] >= m.RankMin && stats["pvp_grade"] <= m.RankMax {
|
|
total += m.Weight
|
|
}
|
|
}
|
|
if total == 0 {
|
|
return Snapshot{}, false, fmt.Errorf("no daily mission for grade %d", stats["pvp_grade"])
|
|
}
|
|
// Stable weighted selection prevents reconnects and transaction retries
|
|
// from rerolling a character's missions within the same service day.
|
|
digest := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", character.CharacterID, day, tier)))
|
|
pick := int64(binary.LittleEndian.Uint64(digest[:8]) % uint64(total))
|
|
for i, m := range pool {
|
|
if stats["pvp_grade"] < m.RankMin || stats["pvp_grade"] > m.RankMax {
|
|
continue
|
|
}
|
|
pick -= m.Weight
|
|
if pick < 0 {
|
|
chosen = &pool[i]
|
|
break
|
|
}
|
|
}
|
|
stats[dailyKey(tier, "id")] = int64(chosen.ID)
|
|
stats[dailyKey(tier, "progress")] = 0
|
|
stats[dailyKey(tier, "paid")] = 0
|
|
}
|
|
progress := stats[dailyKey(tier, "progress")]
|
|
if progress < 0 || progress > int64(chosen.Target) {
|
|
return Snapshot{}, false, fmt.Errorf("invalid daily mission progress")
|
|
}
|
|
out.Rows = append(out.Rows, Row{chosen.ID, uint32(progress), chosen.Target})
|
|
}
|
|
out.NormalRows, err = c.normalRows(character)
|
|
if err != nil {
|
|
return Snapshot{}, false, err
|
|
}
|
|
stats["pvp_daily_day"] = day
|
|
return out, fresh || paid, nil
|
|
}
|
|
|
|
func normalKey(id uint16) string { return "pvp_normal_" + strconv.Itoa(int(id)) + "_progress" }
|
|
|
|
func (c *Catalog) normalRows(character repo.CharacterRecord) ([]Row, error) {
|
|
var rows []Row
|
|
job, jobErr := strconv.Atoi(character.Job)
|
|
for _, mission := range c.Normal {
|
|
if mission.JobTag != "" && (jobErr != nil || !jobmap.MatchesQuestTag(mission.JobTag, job)) {
|
|
continue
|
|
}
|
|
progress := character.Stats[normalKey(mission.ID)]
|
|
if progress < 0 || progress > math.MaxInt32 {
|
|
return nil, fmt.Errorf("invalid normal mission progress %d", mission.ID)
|
|
}
|
|
// Only persisted match facts can restore cumulative participation/wins.
|
|
// ponytail: other conditions retain saved progress until their event and
|
|
// reward transactions are implemented; never infer dojo/combo completion.
|
|
switch mission.Condition {
|
|
case "hereafter join":
|
|
progress = max(progress, character.Stats[CertificationKey])
|
|
case "hereafter winning count":
|
|
progress = max(progress, character.Stats[WinsKey])
|
|
case "rank":
|
|
progress = max(progress, character.Stats[GradeKey], character.Stats["pvp_max_grade"])
|
|
}
|
|
if progress > math.MaxInt32 {
|
|
return nil, fmt.Errorf("invalid normal mission counter %d", mission.ID)
|
|
}
|
|
// Preserve -1 as FFFFFFFF: sub_10A9D80 suppresses its count label and
|
|
// sub_1C500C0 compares it unsigned. Zero would falsely complete the card.
|
|
rows = append(rows, Row{mission.ID, min(uint32(progress), mission.Target), mission.Target})
|
|
}
|
|
return rows, nil
|
|
}
|
|
|
|
func (c *Catalog) grantDailyEmblems(stats map[string]int64) (int64, bool, error) {
|
|
var granted int64
|
|
changed := false
|
|
for tier, pool := range c.Pools {
|
|
id, progress, paid := stats[dailyKey(tier, "id")], stats[dailyKey(tier, "progress")], stats[dailyKey(tier, "paid")]
|
|
if id == 0 && progress == 0 && paid == 0 {
|
|
continue
|
|
}
|
|
if paid != 0 && paid != 1 {
|
|
return 0, false, fmt.Errorf("invalid PVP reward marker")
|
|
}
|
|
found := false
|
|
for _, mission := range pool {
|
|
if int64(mission.ID) != id {
|
|
continue
|
|
}
|
|
found = true
|
|
if progress < 0 || progress > int64(mission.Target) || (paid == 1 && progress != int64(mission.Target)) {
|
|
return 0, false, fmt.Errorf("invalid PVP reward progress")
|
|
}
|
|
if paid == 1 || progress != int64(mission.Target) {
|
|
break
|
|
}
|
|
balance := stats[EmblemsKey]
|
|
if mission.Emblems < 0 || mission.Emblems > math.MaxInt32 || balance < 0 || balance > math.MaxInt32-mission.Emblems {
|
|
return 0, false, fmt.Errorf("invalid PVP emblem balance/reward")
|
|
}
|
|
stats[EmblemsKey] = balance + mission.Emblems
|
|
stats[dailyKey(tier, "paid")] = 1
|
|
granted += mission.Emblems
|
|
changed = true
|
|
break
|
|
}
|
|
if !found {
|
|
return 0, false, fmt.Errorf("persisted PVP mission missing from tier %d", tier)
|
|
}
|
|
}
|
|
return granted, changed, nil
|
|
}
|
|
|
|
func Prepare(ctx context.Context, repos repo.Group, catalog *Catalog, accountID, characterID string, now time.Time) (Snapshot, error) {
|
|
return update(ctx, repos, catalog, accountID, characterID, now, nil)
|
|
}
|
|
|
|
// Match carries facts from a validated ranked 1v1 or manual room. Reports are
|
|
// admitted by the bridge only for assigned conditions in the current match.
|
|
type Match struct {
|
|
Key string
|
|
StartedAt, CompletedAt time.Time
|
|
Won, Draw bool
|
|
Manual bool
|
|
OpponentJob int64
|
|
OpponentRP int64
|
|
OpponentRated bool
|
|
Reports map[uint16]bool
|
|
}
|
|
|
|
func Complete(ctx context.Context, repos repo.Group, catalog *Catalog, accountID, characterID string, match Match) (Snapshot, error) {
|
|
if match.Key == "" || match.StartedAt.IsZero() || match.CompletedAt.Before(match.StartedAt) {
|
|
return Snapshot{}, fmt.Errorf("invalid PVP completion identity/time")
|
|
}
|
|
return update(ctx, repos, catalog, accountID, characterID, match.CompletedAt, &match)
|
|
}
|
|
|
|
func update(ctx context.Context, repos repo.Group, catalog *Catalog, accountID, characterID string, now time.Time, match *Match) (Snapshot, error) {
|
|
if repos.CharacterSettlement == nil || catalog == nil || accountID == "" || characterID == "" || now.IsZero() {
|
|
return Snapshot{}, fmt.Errorf("PVP state dependencies or identity missing")
|
|
}
|
|
var result Snapshot
|
|
err := repos.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx repo.Group) error {
|
|
if tx.Character == nil {
|
|
return fmt.Errorf("PVP character repository missing")
|
|
}
|
|
character, found, err := tx.Character.Load(ctx, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found || character.CharacterID != characterID || character.AccountID != accountID {
|
|
return fmt.Errorf("PVP character owner mismatch")
|
|
}
|
|
var changed bool
|
|
result, changed, err = catalog.snapshot(character, now)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
stats := result.Character.Stats
|
|
result.BeforeRows = append([]Row(nil), result.Rows...)
|
|
result.BeforeNormalRows = append([]Row(nil), result.NormalRows...)
|
|
if match != nil {
|
|
digest := sha256.Sum256([]byte(match.Key))
|
|
replay := true
|
|
for i := 0; i < 4; i++ {
|
|
v, exists := stats[fmt.Sprintf("pvp_match_receipt_%d", i)]
|
|
replay = replay && exists && v == int64(binary.LittleEndian.Uint64(digest[i*8:]))
|
|
}
|
|
if !replay {
|
|
if previous := stats["pvp_match_completed_at"]; previous > match.CompletedAt.UnixNano() {
|
|
return fmt.Errorf("stale PVP completion")
|
|
}
|
|
count := stats[CertificationKey]
|
|
if count < 0 || count >= math.MaxInt32 {
|
|
return fmt.Errorf("invalid certification count %d", count)
|
|
}
|
|
previousStreak := stats["pvp_win_streak"]
|
|
if _, err := catalog.applyRating(stats, *match, count); err != nil {
|
|
return err
|
|
}
|
|
if !match.Manual {
|
|
stats[CertificationKey] = count + 1
|
|
key, outcome := LossesKey, int64(2)
|
|
if match.Draw {
|
|
key, outcome = DrawsKey, 3
|
|
} else if match.Won {
|
|
key, outcome = WinsKey, 1
|
|
}
|
|
if stats[key] < 0 || stats[key] >= math.MaxInt32 || stats[MaxWinStreakKey] < 0 ||
|
|
stats[MaxWinStreakKey] > math.MaxInt32 || stats[RecentResultsKey] < 0 || stats[RecentResultsKey] >= 1<<20 {
|
|
return fmt.Errorf("invalid PVP result count/history")
|
|
}
|
|
stats[key]++
|
|
stats[MaxWinStreakKey] = max(stats[MaxWinStreakKey], previousStreak, stats["pvp_win_streak"])
|
|
// Local recent-history policy: last ten committed ranked outcomes,
|
|
// two bits each (1 win, 2 loss, 3 draw); no historical backfill.
|
|
stats[RecentResultsKey] = (stats[RecentResultsKey]<<2 | outcome) & ((1 << 20) - 1)
|
|
// A match begun yesterday must not satisfy newly assigned missions.
|
|
if premium.ServiceDayID(match.StartedAt) == result.Day {
|
|
for tier, row := range result.Rows {
|
|
for _, m := range catalog.Pools[tier] {
|
|
if m.ID != row.ID || row.Progress >= row.Target {
|
|
continue
|
|
}
|
|
advance := false
|
|
switch m.Condition {
|
|
case "hereafter join":
|
|
advance = true
|
|
case "hereafter winning count":
|
|
advance = match.Won && !match.Draw
|
|
case "winning job":
|
|
for _, job := range m.Jobs {
|
|
advance = advance || (match.Won && !match.Draw && job == match.OpponentJob)
|
|
}
|
|
case "reduce hp", "first aerial attack", "combo damage rate":
|
|
advance = match.Reports[m.ID]
|
|
case "remains hp", "remain hp":
|
|
advance = match.Won && !match.Draw && match.Reports[m.ID]
|
|
// Streak-group and pose tuple semantics are not yet proven;
|
|
// retain their PVF cards without granting speculative progress.
|
|
}
|
|
if advance {
|
|
row.Progress++
|
|
result.Rows[tier] = row
|
|
result.ChangedRows = append(result.ChangedRows, row)
|
|
stats[dailyKey(tier, "progress")] = int64(row.Progress)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for i := 0; i < 4; i++ {
|
|
stats[fmt.Sprintf("pvp_match_receipt_%d", i)] = int64(binary.LittleEndian.Uint64(digest[i*8:]))
|
|
}
|
|
stats["pvp_match_completed_at"] = match.CompletedAt.UnixNano()
|
|
changed = true
|
|
}
|
|
result.ExperienceDelta = stats["pvp_result_experience_delta"]
|
|
beforeGrade := uint32(stats["pvp_result_before_grade"])
|
|
result.BeforeGrade = &beforeGrade
|
|
}
|
|
granted, paid, err := catalog.grantDailyEmblems(stats)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
result.EmblemsGranted += granted
|
|
changed = changed || paid
|
|
result.NormalRows, err = catalog.normalRows(result.Character)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for i, row := range result.NormalRows {
|
|
if row != result.BeforeNormalRows[i] {
|
|
result.ChangedRows = append(result.ChangedRows, row)
|
|
}
|
|
}
|
|
if changed {
|
|
return repo.SaveCharacterFields(ctx, tx.Character, result.Character, repo.CharacterFieldStats)
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Snapshot{}, err
|
|
}
|
|
return result, nil
|
|
}
|