DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,123 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
// sub_1093C00 checks complete Windbell quest chains, not event IDs.
|
||||
func ArcadeTrainingCompleted(quests repo.QuestRecord) bool {
|
||||
for _, chain := range [][2]int64{{2670, 2674}, {2675, 2678}} {
|
||||
complete := true
|
||||
for id := chain[0]; id <= chain[1]; id++ {
|
||||
state, exists := quests.States[id]
|
||||
if !exists {
|
||||
state = quests.Progress[id]
|
||||
}
|
||||
complete = complete && strings.EqualFold(strings.TrimSpace(state.Status), "completed")
|
||||
}
|
||||
if complete {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func SaveFatigueAcceleration(ctx context.Context, repos repo.Group, accountID, characterID string, enabled bool, now time.Time) error {
|
||||
if repos.CharacterSettlement == nil {
|
||||
return fmt.Errorf("fatigue preference transaction unavailable")
|
||||
}
|
||||
return repos.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx repo.Group) error {
|
||||
character, found, err := tx.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || character.AccountID != accountID {
|
||||
return fmt.Errorf("fatigue preference character ownership mismatch")
|
||||
}
|
||||
character = repo.CloneCharacter(character)
|
||||
if character.Stats == nil {
|
||||
character.Stats = make(map[string]int64)
|
||||
}
|
||||
character.Stats["fatigue_acceleration"] = 0
|
||||
if enabled {
|
||||
character.Stats["fatigue_acceleration"] = 1
|
||||
}
|
||||
character.UpdatedAt = now
|
||||
return repo.SaveCharacterFields(ctx, tx.Character, character, repo.CharacterFieldStats)
|
||||
})
|
||||
}
|
||||
|
||||
type ArcadeReward struct {
|
||||
Experience uint32
|
||||
Gold uint32
|
||||
}
|
||||
|
||||
type ArcadeRewardSnapshot struct {
|
||||
Character repo.CharacterRecord
|
||||
Skill repo.SkillRecord
|
||||
Reward ArcadeReward
|
||||
}
|
||||
|
||||
type ArcadeRewardPlanner func(repo.CharacterRecord, repo.SkillRecord) (ArcadeRewardSnapshot, error)
|
||||
|
||||
// ApplyArcadeReward commits the progression, wallet and receipt together.
|
||||
// The bridge supplies a PVF-backed plan and publishes only after this returns.
|
||||
func ApplyArcadeReward(ctx context.Context, repos repo.Group, accountID, characterID, receipt string, plan ArcadeRewardPlanner, now time.Time) (ArcadeRewardSnapshot, error) {
|
||||
var result ArcadeRewardSnapshot
|
||||
if repos.CharacterSettlement == nil || receipt == "" || plan == nil {
|
||||
return result, fmt.Errorf("arcade reward transaction unavailable")
|
||||
}
|
||||
digest := sha256.Sum256([]byte(receipt))
|
||||
err := repos.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx repo.Group) error {
|
||||
character, found, err := tx.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || character.AccountID != accountID {
|
||||
return fmt.Errorf("arcade reward character ownership mismatch")
|
||||
}
|
||||
skill, found, err := tx.Skill.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("arcade reward skill ledger missing")
|
||||
}
|
||||
replay := true
|
||||
for i := 0; i < 4; i++ {
|
||||
value, exists := character.Stats[fmt.Sprintf("pvp_arcade_receipt_%d", i)]
|
||||
replay = replay && exists && value == int64(binary.LittleEndian.Uint64(digest[i*8:]))
|
||||
}
|
||||
if replay {
|
||||
result = ArcadeRewardSnapshot{Character: character, Skill: skill, Reward: ArcadeReward{
|
||||
Experience: uint32(character.Stats["pvp_arcade_reward_exp"]), Gold: uint32(character.Stats["pvp_arcade_reward_gold"]),
|
||||
}}
|
||||
return nil
|
||||
}
|
||||
result, err = plan(repo.CloneCharacter(character), repo.CloneSkill(skill))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if result.Character.CharacterID != characterID || result.Skill.CharacterID != characterID || result.Character.AccountID != accountID || result.Character.Stats == nil {
|
||||
return fmt.Errorf("invalid arcade reward plan")
|
||||
}
|
||||
for i := 0; i < 4; i++ {
|
||||
result.Character.Stats[fmt.Sprintf("pvp_arcade_receipt_%d", i)] = int64(binary.LittleEndian.Uint64(digest[i*8:]))
|
||||
}
|
||||
result.Character.Stats["pvp_arcade_reward_exp"] = int64(result.Reward.Experience)
|
||||
result.Character.Stats["pvp_arcade_reward_gold"] = int64(result.Reward.Gold)
|
||||
result.Character.UpdatedAt, result.Skill.UpdatedAt = now, now
|
||||
if err := repo.SaveCharacterFields(ctx, tx.Character, result.Character, repo.CharacterFieldBase, repo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
return repo.SaveSkillFields(ctx, tx.Skill, result.Skill, repo.SkillFieldPoints)
|
||||
})
|
||||
return result, err
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestArcadeRewardAtomicReceiptAndTrainingGate(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := memory.NewMemoryGroup()
|
||||
c := repo.CharacterRecord{CharacterID: "29", AccountID: "a", Level: 50, Stats: map[string]int64{"exp": 100, "gold": 10}}
|
||||
if err := repos.Character.Save(ctx, c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Skill.Save(ctx, repo.SkillRecord{CharacterID: "29", Points: repo.SkillPointState{SyncedLevel: 50}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
called := 0
|
||||
planner := func(c repo.CharacterRecord, skill repo.SkillRecord) (ArcadeRewardSnapshot, error) {
|
||||
called++
|
||||
c.Stats["exp"] += 20
|
||||
c.Stats["gold"] += 3
|
||||
c.Level++
|
||||
skill.Points.SyncedLevel++
|
||||
skill.Points.TotalSP += 10
|
||||
skill.Points.RemainingSP += 10
|
||||
return ArcadeRewardSnapshot{Character: c, Skill: skill, Reward: ArcadeReward{20, 3}}, nil
|
||||
}
|
||||
failed := repos
|
||||
failed.CharacterSettlement = failCommit{repos.CharacterSettlement}
|
||||
if _, err := ApplyArcadeReward(ctx, failed, "a", "29", "run1", planner, time.Now()); err == nil {
|
||||
t.Fatal("commit failure ignored")
|
||||
}
|
||||
stored, _, _ := repos.Character.Load(ctx, "29")
|
||||
skill, _, _ := repos.Skill.Load(ctx, "29")
|
||||
if stored.Level != 50 || stored.Stats["exp"] != 100 || stored.Stats["gold"] != 10 || skill.Points.TotalSP != 0 {
|
||||
t.Fatal("reward transaction partially committed")
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
r, err := ApplyArcadeReward(ctx, repos, "a", "29", "run1", planner, time.Now())
|
||||
if err != nil || r.Character.Level != 51 || r.Character.Stats["gold"] != 13 || r.Skill.Points.TotalSP != 10 || r.Reward != (ArcadeReward{20, 3}) {
|
||||
t.Fatalf("reward=%+v error=%v", r, err)
|
||||
}
|
||||
}
|
||||
if called != 2 {
|
||||
t.Fatal("receipt replay recalculated reward")
|
||||
}
|
||||
if _, err := ApplyArcadeReward(ctx, repos, "other", "29", "run2", planner, time.Now()); err == nil {
|
||||
t.Fatal("unowned reward accepted")
|
||||
}
|
||||
r, err := ApplyArcadeReward(ctx, repos, "a", "29", "run2", planner, time.Now())
|
||||
if err != nil || r.Character.Stats["gold"] != 16 {
|
||||
t.Fatal("new run reused old receipt")
|
||||
}
|
||||
q := repo.QuestRecord{States: map[int64]repo.QuestState{}, Progress: map[int64]repo.QuestState{}}
|
||||
for id := int64(2670); id <= 2674; id++ {
|
||||
q.States[id] = repo.QuestState{Status: "completed"}
|
||||
}
|
||||
if !ArcadeTrainingCompleted(q) {
|
||||
t.Fatal("completed training rejected")
|
||||
}
|
||||
q.States[2674] = repo.QuestState{Status: "active", ProgressValue: 262657}
|
||||
q.Progress[2674] = repo.QuestState{Status: "completed"}
|
||||
if ArcadeTrainingCompleted(q) {
|
||||
t.Fatal("active canonical quest treated as completed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,576 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,341 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
type textSource map[string]string
|
||||
|
||||
func (s textSource) ReadText(p string) (string, error) {
|
||||
if v, ok := s[p]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", fmt.Errorf("missing %s", p)
|
||||
}
|
||||
|
||||
func dailyTestSource() textSource {
|
||||
s := textSource{
|
||||
"etc/missionsystem.etc": "[casual daily mission]\n0 113 10\n[/casual daily mission]\n[casual daily mission]\n1 118 10\n[/casual daily mission]\n[casual daily mission]\n2 131 10\n[/casual daily mission]\n",
|
||||
"pvp_mission/mission.lst": "113 `daily/a.msn`\n118 `daily/b.msn`\n131 `daily/c.msn`\n",
|
||||
"etc/pvp_ref.etc": "[pvp experience]\n1 12000 2 27000\n[/pvp experience]\n[pvp grade point]\n0 1000 0 1 1000 -429\n[/pvp grade point]\n[pvp match exp set]\n100 -1 -1 50 -1 50\n[/pvp match exp set]\n[pvp manual match etc set]\n45 -1 1 45 -1 0\n[/pvp manual match etc set]\n",
|
||||
"etc/matchingmode.etc": "[initial rating point]\n1000\n[pvp initial rating point]\n0\n[warm up games]\n10\n[warm up default weight]\n120\n[warm up weight]\n0 100\n[/warm up weight]\n[rating]\n2000 8 1700 10 1500 14 1400 16 1100 20 700 22 0 24\n[/rating]\n[streak win]\n8 180 6 140 4 120\n[/streak win]\n[streak lose]\n6 200 4 140 2 120\n[/streak lose]\n[addtional weight]\n2000 0 -1 1700 0 0 800 0 0 500 0 1 0 0 2\n[addtional weight]\n[first after warm up bonus]\n40\n[/first after warm up bonus]\n",
|
||||
}
|
||||
for i, condition := range []string{"hereafter join", "first aerial attack", "winning streak"} {
|
||||
s[fmt.Sprintf("pvp_mission/daily/%c.msn", 'a'+i)] = fmt.Sprintf("[grade] `[daily]` %d\n[rank range] 0 30\n[condition] `[%s]` `[all]` 3 1\n[/condition]\n[reward victory emblem] 40\n[/reward victory emblem]\n", i, condition)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func testCatalog(t *testing.T) *Catalog {
|
||||
t.Helper()
|
||||
s := dailyTestSource()
|
||||
c, err := Load(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Pools[0]) != 1 || c.Pools[2][0].ID != 131 || !reflect.DeepEqual(c.ExperienceBounds, []uint32{0, 12000, 27000}) {
|
||||
t.Fatalf("catalog=%+v", c)
|
||||
}
|
||||
delete(s, "pvp_mission/daily/b.msn")
|
||||
if _, err := Load(s); err == nil {
|
||||
t.Fatal("missing mission was silently substituted")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func TestNormalTargetMissionsPreservePVFAndMatchFacts(t *testing.T) {
|
||||
s := dailyTestSource()
|
||||
definitions := []struct {
|
||||
id int
|
||||
category, condition, tag string
|
||||
target, parameter, pre int
|
||||
}{
|
||||
{499, "battle", "hereafter join", "all", 100, -1, 0},
|
||||
{500, "battle", "hereafter join", "all", 333, -1, 499},
|
||||
{522, "victory", "hereafter winning count", "all", 100, -1, 0},
|
||||
{545, "rating", "rank", "all", 2, -1, 0},
|
||||
{565, "training", "combo clear", "swordman", 1, 0, 0},
|
||||
{566, "training", "combo clear", "fighter", 1, 0, 0},
|
||||
{604, "training", "arcade mode", "all", -1, 1, 0},
|
||||
}
|
||||
for _, d := range definitions {
|
||||
p := fmt.Sprintf("hidden/%d.msn", d.id)
|
||||
s["pvp_mission/mission.lst"] += fmt.Sprintf("%d `%s`\n", d.id, p)
|
||||
s["pvp_mission/"+p] = fmt.Sprintf("[grade] `[normal]`\n[normal category] `[%s]`\n[rank range] 0 30\n[condition] `[%s]` `[%s]` %d %d\n[/condition]\n", d.category, d.condition, d.tag, d.target, d.parameter)
|
||||
if d.pre != 0 {
|
||||
s["pvp_mission/"+p] += fmt.Sprintf("[pre required mission] %d\n", d.pre)
|
||||
}
|
||||
}
|
||||
s["pvp_mission/mission.lst"] += "800 `hidden/800.msn`\n"
|
||||
s["pvp_mission/hidden/800.msn"] = "[grade] `[total match normal]`\n"
|
||||
c, err := Load(s)
|
||||
if err != nil || len(c.Normal) != len(definitions) || c.Normal[1].PreRequired != 499 {
|
||||
t.Fatalf("catalog=%+v err=%v", c, err)
|
||||
}
|
||||
repos := memory.NewMemoryGroup()
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC)
|
||||
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: "29", AccountID: "a", Job: "0", Stats: map[string]int64{CertificationKey: 98, WinsKey: 40}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := Prepare(ctx, repos, c, "a", "29", now)
|
||||
want := []Row{{499, 98, 100}, {500, 98, 333}, {522, 40, 100}, {545, 0, 2}, {565, 0, 1}, {604, 0, math.MaxUint32}}
|
||||
if err != nil || len(before.Rows) != 3 || !reflect.DeepEqual(before.NormalRows, want) {
|
||||
t.Fatalf("before=%+v err=%v", before, err)
|
||||
}
|
||||
match := Match{Key: "target-progress", StartedAt: now, CompletedAt: now.Add(time.Minute), Won: true}
|
||||
failed := repos
|
||||
failed.CharacterSettlement = failCommit{repos.CharacterSettlement}
|
||||
if _, err := Complete(ctx, failed, c, "a", "29", match); err == nil {
|
||||
t.Fatal("commit failure ignored")
|
||||
}
|
||||
restored, err := Prepare(ctx, repos, c, "a", "29", now)
|
||||
if err != nil || !reflect.DeepEqual(restored.NormalRows, before.NormalRows) {
|
||||
t.Fatal("failed transaction changed target progress")
|
||||
}
|
||||
want[0].Progress, want[1].Progress, want[2].Progress = 99, 99, 41
|
||||
for retry := 0; retry < 2; retry++ {
|
||||
after, err := Complete(ctx, repos, c, "a", "29", match)
|
||||
if err != nil || !reflect.DeepEqual(after.NormalRows, want) {
|
||||
t.Fatalf("retry=%d after=%+v err=%v", retry, after, err)
|
||||
}
|
||||
if retry == 0 && (!reflect.DeepEqual(after.BeforeNormalRows, before.NormalRows) || len(after.ChangedRows) != 4) {
|
||||
t.Fatal("daily and normal result deltas were not both retained")
|
||||
}
|
||||
if retry == 1 && len(after.ChangedRows) != 0 {
|
||||
t.Fatal("target replay emitted completion again")
|
||||
}
|
||||
}
|
||||
reset, err := Prepare(ctx, repos, c, "a", "29", now.Add(24*time.Hour))
|
||||
if err != nil || !reflect.DeepEqual(reset.NormalRows, want) || reset.Rows[0].Progress != 0 {
|
||||
t.Fatal("daily reset discarded normal progress")
|
||||
}
|
||||
reset.Character.Job = "1"
|
||||
rows, err := c.normalRows(reset.Character)
|
||||
if err != nil || rows[4].ID != 566 {
|
||||
t.Fatal("another job's combo cards leaked")
|
||||
}
|
||||
for _, bad := range []string{
|
||||
"[grade] `[normal]`\n[normal category] `[battle]`\n[rank range] 0 30\n[condition] `[hereafter join]` `[all]` 100\n[/condition]",
|
||||
s["pvp_mission/hidden/499.msn"] + "[pre required mission] 500\n",
|
||||
s["pvp_mission/hidden/499.msn"] + "[pre required mission] 999\n",
|
||||
} {
|
||||
s["pvp_mission/hidden/499.msn"] = bad
|
||||
if _, err := Load(s); err == nil {
|
||||
t.Fatal("malformed target or prerequisite accepted")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankedRecordHistoryCommitReplayAndWindow(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := memory.NewMemoryGroup()
|
||||
c := testCatalog(t)
|
||||
now := time.Date(2026, 9, 6, 10, 0, 0, 0, time.UTC)
|
||||
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: "29", AccountID: "a", Job: "0",
|
||||
Stats: map[string]int64{WinsKey: 16, LossesKey: 7, "pvp_win_streak": 4}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
outcomes := []int64{2, 1, 1, 3, 2, 1, 2, 3, 1, 1, 1, 2}
|
||||
var recent int64
|
||||
wins, losses, draws := int64(16), int64(7), int64(0)
|
||||
for i, outcome := range outcomes {
|
||||
match := Match{Key: fmt.Sprintf("history-%d", i), StartedAt: now.Add(time.Duration(i) * time.Minute), CompletedAt: now.Add(time.Duration(i+1) * time.Minute), Won: outcome == 1, Draw: outcome == 3}
|
||||
before, _, _ := repos.Character.Load(ctx, "29")
|
||||
failed := repos
|
||||
failed.CharacterSettlement = failCommit{repos.CharacterSettlement}
|
||||
if _, err := Complete(ctx, failed, c, "a", "29", match); err == nil {
|
||||
t.Fatal("injected commit failure ignored")
|
||||
}
|
||||
unchanged, _, _ := repos.Character.Load(ctx, "29")
|
||||
if !reflect.DeepEqual(before.Stats, unchanged.Stats) {
|
||||
t.Fatal("failed transaction leaked record")
|
||||
}
|
||||
recent = (recent<<2 | outcome) & ((1 << 20) - 1)
|
||||
switch outcome {
|
||||
case 1:
|
||||
wins++
|
||||
case 2:
|
||||
losses++
|
||||
case 3:
|
||||
draws++
|
||||
}
|
||||
for retry := 0; retry < 2; retry++ {
|
||||
state, err := Complete(ctx, repos, c, "a", "29", match)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := state.Character.Stats
|
||||
if s[WinsKey] != wins || s[LossesKey] != losses || s[DrawsKey] != draws || s[RecentResultsKey] != recent || s[MaxWinStreakKey] != 4 {
|
||||
t.Fatalf("record=%v", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
manual := Match{Key: "manual-history-exclusion", StartedAt: now.Add(time.Hour), CompletedAt: now.Add(2 * time.Hour), Won: true, Manual: true}
|
||||
state, err := Complete(ctx, repos, c, "a", "29", manual)
|
||||
if err != nil || state.Character.Stats[RecentResultsKey] != recent || state.Character.Stats[WinsKey] != wins {
|
||||
t.Fatalf("manual record=%+v err=%v", state, err)
|
||||
}
|
||||
}
|
||||
|
||||
type failCommit struct {
|
||||
repo.CharacterSettlementUnitOfWork
|
||||
}
|
||||
|
||||
func (u failCommit) WithinCharacterSettlement(ctx context.Context, id string, f func(repo.Group) error) error {
|
||||
return u.CharacterSettlementUnitOfWork.WithinCharacterSettlement(ctx, id, func(tx repo.Group) error {
|
||||
if err := f(tx); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("injected commit failure")
|
||||
})
|
||||
}
|
||||
|
||||
func TestDailyAssignmentSettlementAndReplay(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
catalog := testCatalog(t)
|
||||
repos := memory.NewMemoryGroup()
|
||||
character := repo.CharacterRecord{CharacterID: "29", AccountID: "a", Stats: map[string]int64{"exp": 555, "pvp_rank_point": 777}}
|
||||
if err := repos.Character.Save(ctx, character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC)
|
||||
state, err := Prepare(ctx, repos, catalog, "a", "29", now)
|
||||
if err != nil || len(state.Rows) != 3 || state.Rows[0].Target != 3 {
|
||||
t.Fatalf("state=%+v err=%v", state, err)
|
||||
}
|
||||
match := Match{Key: "match1", StartedAt: now, CompletedAt: now.Add(time.Minute), Won: true, Reports: map[uint16]bool{118: true}}
|
||||
failed := repos
|
||||
failed.CharacterSettlement = failCommit{repos.CharacterSettlement}
|
||||
if _, err := Complete(ctx, failed, catalog, "a", "29", match); err == nil {
|
||||
t.Fatal("commit failure ignored")
|
||||
}
|
||||
after, _, _ := repos.Character.Load(ctx, "29")
|
||||
if !reflect.DeepEqual(after.Stats, state.Character.Stats) {
|
||||
t.Fatal("failed settlement committed")
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
state, err = Complete(ctx, repos, catalog, "a", "29", match)
|
||||
if err != nil || state.Character.Stats[CertificationKey] != 1 || state.Character.Stats[WinsKey] != 1 || state.Character.Stats[LossesKey] != 0 || state.Rows[0].Progress != 1 || state.Rows[1].Progress != 1 {
|
||||
t.Fatalf("replay=%d state=%+v err=%v", i, state, err)
|
||||
}
|
||||
}
|
||||
if state.Rows[2].Progress != 0 || state.Character.Stats["exp"] != 555 || state.Character.Stats[RankPointKey] != 855 || state.Character.Stats[ExperienceKey] != 1000 {
|
||||
t.Fatal("ranked rewards or independent normal experience are incorrect")
|
||||
}
|
||||
restored, err := Prepare(ctx, repos, catalog, "a", "29", now.Add(2*time.Minute))
|
||||
if err != nil || !reflect.DeepEqual(restored.Rows, state.Rows) {
|
||||
t.Fatal("reconnect lost progress")
|
||||
}
|
||||
if _, err = Complete(ctx, repos, catalog, "b", "29", match); err == nil {
|
||||
t.Fatal("wrong account accepted")
|
||||
}
|
||||
reset, err := Prepare(ctx, repos, catalog, "a", "29", now.Add(24*time.Hour))
|
||||
if err != nil || reset.Rows[0].Progress != 0 || reset.Character.Stats[CertificationKey] != 1 {
|
||||
t.Fatal("daily reset changed certification or retained progress")
|
||||
}
|
||||
match.Key = "spanning-reset"
|
||||
match.CompletedAt = now.Add(24 * time.Hour)
|
||||
state, err = Complete(ctx, repos, catalog, "a", "29", match)
|
||||
if err != nil || state.Rows[0].Progress != 0 || state.Character.Stats[CertificationKey] != 2 {
|
||||
t.Fatal("yesterday's match progressed today's missions")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyEmblemsCommitReplayRepairAndReset(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
for _, reward := range []int64{0, 40} {
|
||||
t.Run(fmt.Sprint(reward), func(t *testing.T) {
|
||||
catalog := testCatalog(t)
|
||||
catalog.Pools[0][0].Emblems = reward
|
||||
repos := memory.NewMemoryGroup()
|
||||
now := time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC)
|
||||
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: "29", AccountID: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := Prepare(ctx, repos, catalog, "a", "29", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A completed row from the old server has no paid marker.
|
||||
state.Character.Stats[dailyKey(0, "progress")] = 3
|
||||
state.Character.Stats[EmblemsKey] = 7
|
||||
if err := repos.Character.Save(ctx, state.Character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
failed := repos
|
||||
failed.CharacterSettlement = failCommit{repos.CharacterSettlement}
|
||||
if _, err := Prepare(ctx, failed, catalog, "a", "29", now); err == nil {
|
||||
t.Fatal("reward commit failure ignored")
|
||||
}
|
||||
after, _, _ := repos.Character.Load(ctx, "29")
|
||||
if !reflect.DeepEqual(after.Stats, state.Character.Stats) {
|
||||
t.Fatal("failed reward mutated saved state")
|
||||
}
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
repaired, err := Prepare(ctx, repos, catalog, "a", "29", now)
|
||||
if err != nil || repaired.Character.Stats[EmblemsKey] != 7+reward || repaired.Character.Stats[dailyKey(0, "paid")] != 1 {
|
||||
t.Fatalf("repair=%+v err=%v", repaired, err)
|
||||
}
|
||||
want := int64(0)
|
||||
if attempt == 0 {
|
||||
want = reward
|
||||
}
|
||||
if repaired.EmblemsGranted != want {
|
||||
t.Fatal("reward replayed")
|
||||
}
|
||||
}
|
||||
reset, err := Prepare(ctx, repos, catalog, "a", "29", now.Add(24*time.Hour))
|
||||
if err != nil || reset.Rows[0].Progress != 0 || reset.Character.Stats[EmblemsKey] != 7+reward || reset.Character.Stats[dailyKey(0, "paid")] != 0 {
|
||||
t.Fatalf("reset=%+v err=%v", reset, err)
|
||||
}
|
||||
for i := 0; i < 3; i++ {
|
||||
match := Match{Key: fmt.Sprintf("match%d", i), StartedAt: now.Add(24 * time.Hour), CompletedAt: now.Add(24*time.Hour + time.Duration(i)*time.Minute), Won: i == 0, Draw: i == 2}
|
||||
for retry := 0; retry < 2; retry++ {
|
||||
state, err = Complete(ctx, repos, catalog, "a", "29", match)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if state.Character.Stats[EmblemsKey] != 7+reward*2 || state.Character.Stats[WinsKey] != 1 || state.Character.Stats[LossesKey] != 1 || state.Character.Stats[CertificationKey] != 3 {
|
||||
t.Fatalf("final=%+v", state)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDailyRewardOverflowRollsBackProgressAndWins(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
catalog := testCatalog(t)
|
||||
repos := memory.NewMemoryGroup()
|
||||
now := time.Date(2026, 9, 6, 8, 0, 0, 0, time.UTC)
|
||||
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: "29", AccountID: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state, err := Prepare(ctx, repos, catalog, "a", "29", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
state.Character.Stats[EmblemsKey] = math.MaxInt32 - 39
|
||||
state.Character.Stats[dailyKey(0, "progress")] = 2
|
||||
if err := repos.Character.Save(ctx, state.Character); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := Complete(ctx, repos, catalog, "a", "29", Match{Key: "overflow", StartedAt: now, CompletedAt: now, Won: true}); err == nil {
|
||||
t.Fatal("overflow accepted")
|
||||
}
|
||||
after, _, _ := repos.Character.Load(ctx, "29")
|
||||
if !reflect.DeepEqual(after.Stats, state.Character.Stats) {
|
||||
t.Fatal("overflow partially committed")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const DojoSkillModeKey = "pvp_dojo_skill_mode"
|
||||
|
||||
// Native sub_10938F0 permits the tutorial skill tree only for mode zero.
|
||||
// Modes one/two are the player's later keep/restore selection (C2S1266).
|
||||
func DojoSkillMode(character repo.CharacterRecord) byte {
|
||||
mode := character.Stats[DojoSkillModeKey]
|
||||
if mode < 0 || mode > 2 {
|
||||
return 0
|
||||
}
|
||||
return byte(mode)
|
||||
}
|
||||
|
||||
func SaveDojoSkillMode(ctx context.Context, repos repo.Group, accountID, characterID string, mode byte, now time.Time) error {
|
||||
if mode < 1 || mode > 2 || repos.CharacterSettlement == nil {
|
||||
return fmt.Errorf("invalid dojo skill mode or missing settlement")
|
||||
}
|
||||
return repos.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx repo.Group) error {
|
||||
character, found, err := tx.Character.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || character.AccountID != accountID {
|
||||
return fmt.Errorf("dojo skill mode character ownership mismatch")
|
||||
}
|
||||
character = repo.CloneCharacter(character)
|
||||
if character.Stats == nil {
|
||||
character.Stats = make(map[string]int64)
|
||||
}
|
||||
character.Stats[DojoSkillModeKey] = int64(mode)
|
||||
character.UpdatedAt = now
|
||||
return repo.SaveCharacterFields(ctx, tx.Character, character, repo.CharacterFieldStats)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"path"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/jobmap"
|
||||
"longheng.io/server/internal/modules/dnf/pvf"
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const PrivateCharacterKey = "pvp_private_character"
|
||||
|
||||
type PrivateCreateError byte
|
||||
|
||||
func (e PrivateCreateError) Error() string {
|
||||
return fmt.Sprintf("PVP private character creation rejected: %d", e)
|
||||
}
|
||||
|
||||
type PrivateEquipment struct {
|
||||
Slot string
|
||||
ItemID int64
|
||||
Equipped bool
|
||||
}
|
||||
|
||||
type PrivateCreation struct {
|
||||
Job, Grow byte
|
||||
Level, CheckLevel, RecreateDays int
|
||||
CharacterPath string
|
||||
Equipment []PrivateEquipment
|
||||
}
|
||||
|
||||
func IsPrivateCharacter(character repo.CharacterRecord) bool {
|
||||
return character.Stats[PrivateCharacterKey] == 1
|
||||
}
|
||||
|
||||
func LoadPrivateCreation(source pvf.Source, job, grow byte) (*PrivateCreation, error) {
|
||||
if source == nil || !jobmap.Valid(int(job)) || grow > 5 {
|
||||
return nil, fmt.Errorf("invalid PVP private character job/grow: %d/%d", job, grow)
|
||||
}
|
||||
text, err := source.ReadText("etc/pvpparameter.etc")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err := pvf.Parse("etc/pvpparameter.etc", text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pairs := doc.Ints("pvp matching possible character")
|
||||
allowed := false
|
||||
for i := 0; i+1 < len(pairs); i += 2 {
|
||||
allowed = allowed || pairs[i] == int64(job) && pairs[i+1] == int64(grow)
|
||||
}
|
||||
if !allowed || len(pairs)%2 != 0 {
|
||||
return nil, fmt.Errorf("PVP private profession is not enabled: %d/%d", job, grow)
|
||||
}
|
||||
level, levelOK := doc.Int("pvp revision level")
|
||||
check, checkOK := doc.Int("check level create private charac")
|
||||
if !levelOK || !checkOK || level < 1 || level > 255 || check < 1 || check > 255 {
|
||||
return nil, fmt.Errorf("invalid PVP private character levels")
|
||||
}
|
||||
plan := &PrivateCreation{Job: job, Grow: grow, Level: int(level), CheckLevel: int(check)}
|
||||
text, err = source.ReadText("character/character.lst")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err = pvf.Parse("character/character.lst", text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, row := range pvf.ParseList(doc) {
|
||||
if row.ID == int64(job) {
|
||||
plan.CharacterPath = path.Join("character", strings.ReplaceAll(row.Path, "\\", "/"))
|
||||
break
|
||||
}
|
||||
}
|
||||
if plan.CharacterPath == "" {
|
||||
return nil, fmt.Errorf("PVP private character profile missing")
|
||||
}
|
||||
text, err = source.ReadText(plan.CharacterPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc, err = pvf.Parse(plan.CharacterPath, text)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
inGrow := false
|
||||
for _, section := range doc.Sections {
|
||||
if strings.HasPrefix(section.Name, "growtype ") {
|
||||
inGrow = section.Name == fmt.Sprintf("growtype %d", int(grow)+1)
|
||||
}
|
||||
if !inGrow {
|
||||
continue
|
||||
}
|
||||
values := doc.Tokens[section.Start:section.End]
|
||||
switch section.Name {
|
||||
case "pvp private recreate penaly period":
|
||||
if len(values) != 1 || values[0].Kind != pvf.TokenInt || values[0].Int < 0 || values[0].Int > 365 {
|
||||
return nil, fmt.Errorf("invalid PVP private recreate period")
|
||||
}
|
||||
plan.RecreateDays = int(values[0].Int)
|
||||
case "pvp private create equipment list":
|
||||
if len(values)%3 != 0 {
|
||||
return nil, fmt.Errorf("invalid PVP private equipment triples")
|
||||
}
|
||||
for i := 0; i < len(values); i += 3 {
|
||||
if values[i].Kind != pvf.TokenString || values[i+1].Kind != pvf.TokenInt || values[i+1].Int <= 0 ||
|
||||
values[i+2].Kind != pvf.TokenInt || values[i+2].Int < 0 || values[i+2].Int > 1 {
|
||||
return nil, fmt.Errorf("invalid PVP private equipment entry")
|
||||
}
|
||||
plan.Equipment = append(plan.Equipment, PrivateEquipment{Slot: strings.Trim(values[i].Value, "[]"), ItemID: values[i+1].Int, Equipped: values[i+2].Int == 1})
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(plan.Equipment) == 0 {
|
||||
return nil, fmt.Errorf("PVP private equipment missing for %d/%d", job, grow)
|
||||
}
|
||||
return plan, nil
|
||||
}
|
||||
|
||||
// Check is used before the native confirmation and again in the creation transaction.
|
||||
func (p *PrivateCreation) Check(ctx context.Context, characters repo.CharacterRepository, accountID string, now time.Time) (int64, error) {
|
||||
active, err := characters.ListByAccount(ctx, accountID, repo.MaxCharacterSlotLimit)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rating := int64(0)
|
||||
for _, character := range active {
|
||||
if character.AccountID != accountID || character.Job != strconv.Itoa(int(p.Job)) || byte(character.Stats["grow_type"])&15 != p.Grow {
|
||||
continue
|
||||
}
|
||||
if IsPrivateCharacter(character) || character.Level >= p.CheckLevel {
|
||||
return 0, PrivateCreateError(150)
|
||||
}
|
||||
rating = max(rating, character.Stats[RankPointKey])
|
||||
}
|
||||
history, ok := characters.(repo.CharacterDeletedProfessionReader)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("PVP private character deletion history unavailable")
|
||||
}
|
||||
deleted, found, err := history.LastDeletedProfession(ctx, accountID, p.Job, p.Grow)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if found {
|
||||
if deleted.AccountID != accountID || deleted.Job != strconv.Itoa(int(p.Job)) || byte(deleted.Stats["grow_type"])&15 != p.Grow || deleted.Stats["delete_flag"] == 0 {
|
||||
return 0, fmt.Errorf("PVP private character history owner mismatch")
|
||||
}
|
||||
if deleted.UpdatedAt.Add(time.Duration(p.RecreateDays) * 24 * time.Hour).After(now) {
|
||||
return 0, PrivateCreateError(151)
|
||||
}
|
||||
rating = max(rating, deleted.Stats[RankPointKey])
|
||||
}
|
||||
return rating, nil
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestPrivateCreationPVFAndEligibility(t *testing.T) {
|
||||
source := textSource{
|
||||
"etc/pvpparameter.etc": "[pvp revision level]\n85\n[check level create private charac]\n85\n[pvp matching possible character]\n0 3 1 2\n[/pvp matching possible character]\n",
|
||||
"character/character.lst": "0 `swordman.chr`\n1 `fighter.chr`\n",
|
||||
"character/swordman.chr": "[growtype 2]\n[pvp private create equipment list]\n`[weapon]` 11 1\n[/pvp private create equipment list]\n[growtype 4]\n[pvp private create equipment list]\n`[weapon]` 22 1 `[coat]` 23 1 `[weapon]` 24 0\n[/pvp private create equipment list]\n[pvp private recreate penaly period]\n30\n",
|
||||
"character/fighter.chr": "[growtype 3]\n[pvp private create equipment list]\n`[weapon]` 44 1\n[/pvp private create equipment list]\n[pvp private recreate penaly period]\n30\n",
|
||||
}
|
||||
plan, err := LoadPrivateCreation(source, 0, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if plan.Level != 85 || plan.RecreateDays != 30 || len(plan.Equipment) != 3 || plan.Equipment[0].ItemID != 22 || plan.Equipment[2].Equipped {
|
||||
t.Fatalf("plan=%+v", plan)
|
||||
}
|
||||
other, err := LoadPrivateCreation(source, 1, 2)
|
||||
if err != nil || other.Equipment[0].ItemID != 44 {
|
||||
t.Fatalf("other job=%+v err=%v", other, err)
|
||||
}
|
||||
if _, err := LoadPrivateCreation(source, 0, 2); err == nil {
|
||||
t.Fatal("accepted disabled grow")
|
||||
}
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 9, 8, 12, 0, 0, 0, time.UTC)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
level int
|
||||
private, deleted bool
|
||||
age time.Duration
|
||||
want byte
|
||||
rp int64
|
||||
}{
|
||||
{name: "empty account"},
|
||||
{name: "low ordinary RP", level: 84, rp: 1234},
|
||||
{name: "high ordinary duplicate", level: 85, want: 150},
|
||||
{name: "dedicated duplicate", level: 1, private: true, want: 150},
|
||||
{name: "deleted cooldown", level: 85, private: true, deleted: true, age: 30*24*time.Hour - time.Second, want: 151},
|
||||
{name: "cooldown boundary inherits RP", level: 85, private: true, deleted: true, age: 30 * 24 * time.Hour, rp: 1400},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
repos := memory.NewMemoryGroup()
|
||||
if tc.level > 0 {
|
||||
stats := map[string]int64{"grow_type": 19, RankPointKey: tc.rp}
|
||||
if tc.private {
|
||||
stats[PrivateCharacterKey] = 1
|
||||
}
|
||||
if tc.deleted {
|
||||
stats["delete_flag"] = 1
|
||||
}
|
||||
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: "1", AccountID: "account", Job: "0", Level: tc.level, Stats: stats, UpdatedAt: now.Add(-tc.age)}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
rp, err := plan.Check(ctx, repos.Character, "account", now)
|
||||
if tc.want == 0 {
|
||||
if err != nil || rp != tc.rp {
|
||||
t.Fatalf("rp=%d err=%v", rp, err)
|
||||
}
|
||||
} else if !errors.Is(err, PrivateCreateError(tc.want)) {
|
||||
t.Fatalf("err=%v want=%d", err, tc.want)
|
||||
}
|
||||
if _, err := plan.Check(ctx, repos.Character, "another-account", now); err != nil {
|
||||
t.Fatalf("cross-account restriction: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/pvf"
|
||||
)
|
||||
|
||||
const (
|
||||
ExperienceKey = "pvp_experience"
|
||||
RankPointKey = "pvp_rank_point"
|
||||
MaxRankKey = "pvp_max_rank_point"
|
||||
GradeKey = "pvp_grade"
|
||||
)
|
||||
|
||||
type Weight struct{ Threshold, Value int64 }
|
||||
type AdditionalWeight struct{ Threshold, Win, Loss int64 }
|
||||
|
||||
// RatingRules supplies the missing local server calculation. The PVF contains
|
||||
// its parameters, not the original server's Elo rounding/placement algorithm.
|
||||
type RatingRules struct {
|
||||
GradePoints [][2]int64 // loss, win
|
||||
MatchExperiencePercent [2]int64
|
||||
ManualExperiencePercent [2]int64
|
||||
InitialRating, InitialRP int64
|
||||
WarmupGames, WarmupWeight int64
|
||||
AfterWarmupBonus int64
|
||||
Warmup, Rating, WinStreak, LossStreak []Weight
|
||||
Additional []AdditionalWeight
|
||||
}
|
||||
|
||||
func loadRatingRules(ref, matching *pvf.Document, grades int) (*RatingRules, error) {
|
||||
return loadRatingRulesWithPoints(ref, matching, grades, "pvp grade point")
|
||||
}
|
||||
|
||||
func loadRatingRulesWithPoints(ref, matching *pvf.Document, grades int, pointTag string) (*RatingRules, error) {
|
||||
r := &RatingRules{}
|
||||
points := ref.Ints(pointTag)
|
||||
if len(points) != grades*3 {
|
||||
return nil, fmt.Errorf("PVP grade points do not cover experience grades")
|
||||
}
|
||||
for i := 0; i < len(points); i += 3 {
|
||||
if points[i] != int64(i/3) || points[i+1] <= 0 || points[i+1] > math.MaxInt32 || points[i+2] > 0 || points[i+2] < math.MinInt32 {
|
||||
return nil, fmt.Errorf("invalid PVP grade point row")
|
||||
}
|
||||
r.GradePoints = append(r.GradePoints, [2]int64{points[i+2], points[i+1]})
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
dst *[2]int64
|
||||
}{
|
||||
{"pvp match exp set", &r.MatchExperiencePercent},
|
||||
{"pvp manual match etc set", &r.ManualExperiencePercent},
|
||||
} {
|
||||
v := ref.Ints(row.name)
|
||||
if len(v) != 6 || v[0] < 0 || v[0] > 10000 || v[3] < 0 || v[3] > 10000 {
|
||||
return nil, fmt.Errorf("invalid PVP experience mode %s", row.name)
|
||||
}
|
||||
*row.dst = [2]int64{v[3], v[0]}
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
dst *int64
|
||||
}{
|
||||
{"initial rating point", &r.InitialRating}, {"pvp initial rating point", &r.InitialRP},
|
||||
{"warm up games", &r.WarmupGames}, {"warm up default weight", &r.WarmupWeight},
|
||||
{"first after warm up bonus", &r.AfterWarmupBonus},
|
||||
} {
|
||||
v := matching.Ints(row.name)
|
||||
if len(v) != 1 || v[0] < 0 || v[0] > math.MaxInt32 {
|
||||
return nil, fmt.Errorf("invalid PVP rating parameter %s", row.name)
|
||||
}
|
||||
*row.dst = v[0]
|
||||
}
|
||||
if r.WarmupWeight == 0 || r.WarmupWeight > 10000 || r.WarmupGames > 10000 || r.AfterWarmupBonus > 10000 {
|
||||
return nil, fmt.Errorf("invalid PVP warmup bounds")
|
||||
}
|
||||
for _, row := range []struct {
|
||||
name string
|
||||
dst *[]Weight
|
||||
}{
|
||||
{"warm up weight", &r.Warmup}, {"rating", &r.Rating},
|
||||
{"streak win", &r.WinStreak}, {"streak lose", &r.LossStreak},
|
||||
} {
|
||||
v := matching.Ints(row.name)
|
||||
if len(v) == 0 || len(v)%2 != 0 {
|
||||
return nil, fmt.Errorf("invalid PVP weight table %s", row.name)
|
||||
}
|
||||
for i := 0; i < len(v); i += 2 {
|
||||
if v[i] < 0 || v[i] > math.MaxInt32 || v[i+1] <= 0 || v[i+1] > 10000 || i > 0 && v[i] >= v[i-2] {
|
||||
return nil, fmt.Errorf("invalid PVP weight row %s", row.name)
|
||||
}
|
||||
*row.dst = append(*row.dst, Weight{v[i], v[i+1]})
|
||||
}
|
||||
}
|
||||
if r.Rating[len(r.Rating)-1].Threshold != 0 {
|
||||
return nil, fmt.Errorf("PVP rating weights have no zero floor")
|
||||
}
|
||||
// The shipped PVF repeats this opening tag in place of its closing tag.
|
||||
// Document.Ints returns the first section's values without string rewriting.
|
||||
v := matching.Ints("addtional weight")
|
||||
if len(v) == 0 || len(v)%3 != 0 {
|
||||
return nil, fmt.Errorf("invalid PVP additional weight table")
|
||||
}
|
||||
for i := 0; i < len(v); i += 3 {
|
||||
if v[i] < 0 || v[i] > math.MaxInt32 || v[i+1] < -10000 || v[i+1] > 10000 || v[i+2] < -10000 || v[i+2] > 10000 || i > 0 && v[i] >= v[i-3] {
|
||||
return nil, fmt.Errorf("invalid PVP additional weight row")
|
||||
}
|
||||
r.Additional = append(r.Additional, AdditionalWeight{v[i], v[i+1], v[i+2]})
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func weightAt(rows []Weight, value, fallback int64) int64 {
|
||||
for _, row := range rows {
|
||||
if value >= row.Threshold {
|
||||
return row.Value
|
||||
}
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
func (c *Catalog) applyRating(stats map[string]int64, match Match, played int64) (int64, error) {
|
||||
r := c.RatingRules
|
||||
if r == nil {
|
||||
return 0, fmt.Errorf("PVP rating rules missing")
|
||||
}
|
||||
for _, key := range []string{ExperienceKey, RankPointKey, MaxRankKey, GradeKey, "pvp_max_grade", "pvp_win_streak", "pvp_loss_streak"} {
|
||||
if stats[key] < 0 || stats[key] > math.MaxInt32 {
|
||||
return 0, fmt.Errorf("invalid PVP stat %s", key)
|
||||
}
|
||||
}
|
||||
grade := stats[GradeKey]
|
||||
if grade >= int64(len(r.GradePoints)) || match.OpponentRP < 0 || match.OpponentRP > math.MaxInt32 {
|
||||
return 0, fmt.Errorf("invalid PVP grade/opponent rating")
|
||||
}
|
||||
beforeEXP, beforeRP := stats[ExperienceKey], stats[RankPointKey]
|
||||
stats["pvp_result_before_exp"], stats["pvp_result_before_rp"], stats["pvp_result_before_grade"] = beforeEXP, beforeRP, grade
|
||||
delta := int64(0)
|
||||
if !match.Draw {
|
||||
outcome := 0
|
||||
if match.Won {
|
||||
outcome = 1
|
||||
}
|
||||
// Local compatibility rule: first member of each mode tuple is a
|
||||
// percentage of the PVF grade/outcome points; truncate toward zero.
|
||||
percent := r.MatchExperiencePercent[outcome]
|
||||
if match.Manual {
|
||||
percent = r.ManualExperiencePercent[outcome]
|
||||
}
|
||||
delta = r.GradePoints[grade][outcome] * percent / 100
|
||||
}
|
||||
stats[ExperienceKey] = min(max(beforeEXP+delta, 0), int64(c.ExperienceBounds[len(c.ExperienceBounds)-1]))
|
||||
stats[GradeKey] = int64(min(len(c.ExperienceBounds)-2, int(grade)))
|
||||
for stats[GradeKey]+1 < int64(len(c.ExperienceBounds)-1) && stats[ExperienceKey] >= int64(c.ExperienceBounds[stats[GradeKey]+1]) {
|
||||
stats[GradeKey]++
|
||||
}
|
||||
for stats[GradeKey] > 0 && stats[ExperienceKey] < int64(c.ExperienceBounds[stats[GradeKey]]) {
|
||||
stats[GradeKey]--
|
||||
}
|
||||
stats["pvp_max_grade"] = max(stats["pvp_max_grade"], grade, stats[GradeKey])
|
||||
stats["pvp_result_experience_delta"] = stats[ExperienceKey] - beforeEXP
|
||||
if match.Manual {
|
||||
return stats["pvp_result_experience_delta"], nil
|
||||
}
|
||||
winStreak, lossStreak := stats["pvp_win_streak"], stats["pvp_loss_streak"]
|
||||
score := 0.5
|
||||
switch {
|
||||
case match.Draw:
|
||||
winStreak, lossStreak = 0, 0
|
||||
case match.Won:
|
||||
winStreak, lossStreak, score = min(winStreak+1, math.MaxInt32), 0, 1
|
||||
default:
|
||||
winStreak, lossStreak, score = 0, min(lossStreak+1, math.MaxInt32), 0
|
||||
}
|
||||
stats["pvp_win_streak"], stats["pvp_loss_streak"] = winStreak, lossStreak
|
||||
self, opponent := beforeRP, match.OpponentRP
|
||||
// A zero RP from the old server is unseeded. First settlement seeds the
|
||||
// PVF initial Elo; both players use their frozen pre-settlement RP.
|
||||
if self == r.InitialRP && stats["pvp_rating_initialized"] == 0 {
|
||||
self = r.InitialRating
|
||||
}
|
||||
if opponent == r.InitialRP && !match.OpponentRated {
|
||||
opponent = r.InitialRating
|
||||
}
|
||||
k := weightAt(r.Rating, self, 0)
|
||||
if played < r.WarmupGames {
|
||||
k = weightAt(r.Warmup, played, r.WarmupWeight)
|
||||
}
|
||||
multiplier := int64(100)
|
||||
if match.Won && !match.Draw {
|
||||
multiplier = weightAt(r.WinStreak, winStreak, 100)
|
||||
}
|
||||
if !match.Won && !match.Draw {
|
||||
multiplier = weightAt(r.LossStreak, lossStreak, 100)
|
||||
}
|
||||
expected := 1 / (1 + math.Pow(10, float64(opponent-self)/400))
|
||||
ratingDelta := int64(math.Round(float64(k) * float64(multiplier) / 100 * (score - expected)))
|
||||
if !match.Draw {
|
||||
for _, row := range r.Additional {
|
||||
if self >= row.Threshold {
|
||||
if match.Won {
|
||||
ratingDelta += row.Win
|
||||
} else {
|
||||
ratingDelta += row.Loss
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if match.Won && played == r.WarmupGames {
|
||||
ratingDelta += r.AfterWarmupBonus
|
||||
}
|
||||
if match.Won {
|
||||
ratingDelta = max(ratingDelta, 0)
|
||||
} else {
|
||||
ratingDelta = min(ratingDelta, 0)
|
||||
}
|
||||
}
|
||||
stats[RankPointKey] = min(max(self+ratingDelta, 0), math.MaxInt32)
|
||||
stats["pvp_rating_initialized"] = 1
|
||||
stats[MaxRankKey] = max(stats[MaxRankKey], beforeRP, stats[RankPointKey])
|
||||
return stats["pvp_result_experience_delta"], nil
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math"
|
||||
"reflect"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func TestPVPRatingSettlementRulesAndDurability(t *testing.T) {
|
||||
c := testCatalog(t)
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
exp, grade, rp, played, wins, losses, opponent int64
|
||||
won, draw, manual, rated bool
|
||||
wantEXP, wantGrade, wantRP int64
|
||||
}{
|
||||
{name: "seed win", won: true, wantEXP: 1000, wantRP: 1050},
|
||||
{name: "seed loss", wantRP: 950},
|
||||
{name: "grade up", exp: 11950, rp: 1000, played: 11, opponent: 1000, won: true, wantEXP: 12950, wantGrade: 1, wantRP: 1011},
|
||||
{name: "grade down signed loss", exp: 12010, grade: 1, rp: 1000, played: 11, opponent: 1000, wantEXP: 11796, wantRP: 989},
|
||||
{name: "warmup completion", rp: 1000, played: 9, opponent: 1000, won: true, wantEXP: 1000, wantRP: 1050},
|
||||
{name: "first post warmup bonus", rp: 1000, played: 10, opponent: 1000, won: true, wantEXP: 1000, wantRP: 1051},
|
||||
{name: "four win multiplier", rp: 1000, played: 11, wins: 3, opponent: 1000, won: true, wantEXP: 1000, wantRP: 1013},
|
||||
{name: "two loss multiplier", rp: 1000, played: 11, losses: 1, opponent: 1000, wantRP: 987},
|
||||
{name: "higher rating draw", rp: 1500, played: 11, opponent: 1000, draw: true, wantRP: 1494},
|
||||
{name: "manual win", rp: 1234, manual: true, won: true, wantEXP: 450, wantRP: 1234},
|
||||
{name: "manual loss", exp: 12500, grade: 1, rp: 1234, manual: true, wantEXP: 12307, wantGrade: 1, wantRP: 1234},
|
||||
{name: "zero floor stays initialized", rp: 0, played: 11, opponent: 1000, rated: true, wantRP: 0},
|
||||
{name: "exp cap", exp: 26990, grade: 1, rp: 1000, played: 11, opponent: 1000, won: true, wantEXP: 27000, wantGrade: 1, wantRP: 1011},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := memory.NewMemoryGroup()
|
||||
now := time.Date(2026, 9, 6, 9, 0, 0, 0, time.UTC)
|
||||
stats := map[string]int64{ExperienceKey: tc.exp, GradeKey: tc.grade, RankPointKey: tc.rp, MaxRankKey: tc.rp,
|
||||
CertificationKey: tc.played, "pvp_win_streak": tc.wins, "pvp_loss_streak": tc.losses, "exp": 76543}
|
||||
if tc.rated {
|
||||
stats["pvp_rating_initialized"] = 1
|
||||
}
|
||||
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: "1", AccountID: "a", Stats: stats}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before, err := Prepare(ctx, repos, c, "a", "1", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
match := Match{Key: tc.name, StartedAt: now, CompletedAt: now.Add(time.Minute), Won: tc.won, Draw: tc.draw,
|
||||
Manual: tc.manual, OpponentRP: tc.opponent}
|
||||
failed := repos
|
||||
failed.CharacterSettlement = failCommit{repos.CharacterSettlement}
|
||||
if _, err := Complete(ctx, failed, c, "a", "1", match); err == nil {
|
||||
t.Fatal("commit failure ignored")
|
||||
}
|
||||
unchanged, _, _ := repos.Character.Load(ctx, "1")
|
||||
if !reflect.DeepEqual(unchanged.Stats, before.Character.Stats) {
|
||||
t.Fatal("failed transaction changed rating/experience")
|
||||
}
|
||||
for retry := 0; retry < 2; retry++ {
|
||||
after, err := Complete(ctx, repos, c, "a", "1", match)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s := after.Character.Stats
|
||||
if s[ExperienceKey] != tc.wantEXP || s[GradeKey] != tc.wantGrade || s[RankPointKey] != tc.wantRP || after.ExperienceDelta != tc.wantEXP-tc.exp || after.BeforeGrade == nil || *after.BeforeGrade != uint32(tc.grade) {
|
||||
t.Fatalf("retry=%d exp=%d grade=%d rp=%d delta=%d", retry, s[ExperienceKey], s[GradeKey], s[RankPointKey], after.ExperienceDelta)
|
||||
}
|
||||
if s["exp"] != 76543 || s[MaxRankKey] != max(tc.rp, tc.wantRP) {
|
||||
t.Fatal("normal exp or highest RP corrupted")
|
||||
}
|
||||
wantPlayed := tc.played + 1
|
||||
if tc.manual {
|
||||
wantPlayed = tc.played
|
||||
}
|
||||
if s[CertificationKey] != wantPlayed {
|
||||
t.Fatal("mode or receipt replay altered certification")
|
||||
}
|
||||
if tc.manual && (!reflect.DeepEqual(after.Rows, before.Rows) || s[WinsKey] != 0 || s[LossesKey] != 0 || s["pvp_rating_initialized"] != before.Character.Stats["pvp_rating_initialized"]) {
|
||||
t.Fatal("manual result mutated ranked facts")
|
||||
}
|
||||
}
|
||||
restored, err := Prepare(ctx, repos, c, "a", "1", now.Add(24*time.Hour))
|
||||
if err != nil || restored.Character.Stats[RankPointKey] != tc.wantRP || restored.Character.Stats[ExperienceKey] != tc.wantEXP {
|
||||
t.Fatal("day reset/reconnect lost rating")
|
||||
}
|
||||
})
|
||||
}
|
||||
for _, key := range []string{ExperienceKey, GradeKey, RankPointKey, MaxRankKey, "pvp_win_streak"} {
|
||||
stats := map[string]int64{key: math.MaxInt32 + 1}
|
||||
if _, err := c.applyRating(stats, Match{}, 0); err == nil {
|
||||
t.Fatalf("invalid %s accepted", key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPVFRatingRulesRejectMissingAndMalformedTables(t *testing.T) {
|
||||
for _, replacement := range []string{
|
||||
"[pvp grade point]\n0 1000\n[/pvp grade point]",
|
||||
"[pvp experience]\n1 12000 2 27000\n[/pvp experience]",
|
||||
} {
|
||||
s := dailyTestSource()
|
||||
s["etc/pvp_ref.etc"] = replacement
|
||||
if _, err := Load(s); err == nil {
|
||||
t.Fatal("incomplete grade/mode data accepted")
|
||||
}
|
||||
}
|
||||
s := dailyTestSource()
|
||||
delete(s, "etc/matchingmode.etc")
|
||||
if _, err := Load(s); err == nil {
|
||||
t.Fatal("missing rating configuration silently substituted")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func totalProgressTestCatalog(t *testing.T) *Catalog {
|
||||
t.Helper()
|
||||
s := dailyTestSource()
|
||||
s["etc/missionsystem.etc"] = strings.ReplaceAll(s["etc/missionsystem.etc"], "casual daily mission", "total match daily mission")
|
||||
s["etc/pvp_ref.etc"] = strings.ReplaceAll(s["etc/pvp_ref.etc"], "pvp grade point", "pvp grade point total match")
|
||||
s["etc/pvp_ref.etc"] = strings.ReplaceAll(s["etc/pvp_ref.etc"], "1000", "1500")
|
||||
for i, condition := range []string{"hereafter join", "kill count", "total match win all kill"} {
|
||||
s[fmt.Sprintf("pvp_mission/daily/%c.msn", 'a'+i)] = fmt.Sprintf("[grade] `[total match daily]` %d\n[rank range] 0 30\n[condition] `[%s]` `[total match mode]` %d 1\n[/condition]\n[reward victory emblem] 80\n", i, condition, []int{3, 9, 1}[i])
|
||||
}
|
||||
for _, d := range []struct {
|
||||
id int
|
||||
category, condition string
|
||||
target, pre int
|
||||
}{
|
||||
{800, "battle", "hereafter join", 100, -1},
|
||||
{801, "battle", "hereafter join", 333, 800},
|
||||
{824, "victory", "hereafter winning count", 100, -1},
|
||||
} {
|
||||
p := fmt.Sprintf("hidden/%d.msn", d.id)
|
||||
s["pvp_mission/mission.lst"] += fmt.Sprintf("%d `%s`\n", d.id, p)
|
||||
s["pvp_mission/"+p] = fmt.Sprintf("[grade] `[total match normal]`\n[normal category] `[%s]`\n[rank range] 0 30\n[condition] `[%s]` `[total match mode]` %d -1\n[/condition]\n[pre required mission] %d\n", d.category, d.condition, d.target, d.pre)
|
||||
}
|
||||
s["pvp_mission/mission.lst"] += "499 `hidden/ordinary.msn`\n"
|
||||
s["pvp_mission/hidden/ordinary.msn"] = "[grade] `[normal]`\n"
|
||||
c, err := LoadTotal(s)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(c.Normal) != 3 || c.Normal[1].PreRequired != 800 || c.RatingRules.GradePoints[0][1] != 1500 {
|
||||
t.Fatal("mixed ordinary and total catalog")
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
type totalFailCommit struct{ repo.RentalAssetUnitOfWork }
|
||||
|
||||
func (u totalFailCommit) WithinPVPTotal(ctx context.Context, a string, ids [3]string, apply func(repo.AccountRepository, repo.CharacterRepository) error) error {
|
||||
return u.RentalAssetUnitOfWork.(repo.PVPTotalUnitOfWork).WithinPVPTotal(ctx, a, ids, func(a repo.AccountRepository, c repo.CharacterRepository) error {
|
||||
if err := apply(a, c); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("injected total commit failure")
|
||||
})
|
||||
}
|
||||
|
||||
func TestTotalProgressAtomicReplaySeparateRankAndDayReset(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
|
||||
c := totalProgressTestCatalog(t)
|
||||
repos := memory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, repo.AccountRecord{AccountID: "a", Metadata: map[string]string{"keep": "value"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := [3]string{"1", "2", "3"}
|
||||
for _, id := range ids {
|
||||
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: id, AccountID: "a", Stats: map[string]int64{ExperienceKey: 11000, RankPointKey: 1234, WinsKey: 7, "pvp_daily_day": 991, EmblemsKey: 10}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
before, err := PrepareTotal(ctx, repos, c, "a", "1", ids, now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
match := TotalMatchFacts{Match: Match{Key: "battle1", StartedAt: now, CompletedAt: now.Add(time.Minute), Won: true}, Kills: [3]uint32{0, 3, 0}, Remaining: 3}
|
||||
bad := repos
|
||||
bad.RentalAssets = totalFailCommit{repos.RentalAssets}
|
||||
if _, err := CompleteTotal(ctx, bad, c, "a", "1", ids, match); err == nil {
|
||||
t.Fatal("failed commit succeeded")
|
||||
}
|
||||
for _, id := range ids {
|
||||
v, _, _ := repos.Character.Load(ctx, id)
|
||||
if v.Stats[ExperienceKey] != 11000 || v.Stats[EmblemsKey] != 10 {
|
||||
t.Fatal("partial reward escaped rollback")
|
||||
}
|
||||
}
|
||||
var first TotalSnapshot
|
||||
for i := 0; i < 2; i++ {
|
||||
after, err := CompleteTotal(ctx, repos, c, "a", "1", ids, match)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if i == 0 {
|
||||
first = after
|
||||
} else if !reflect.DeepEqual(first.Stats, after.Stats) || !reflect.DeepEqual(first.ChangedRows, after.ChangedRows) || after.Before[0].Stats[ExperienceKey] != 11000 {
|
||||
t.Fatal("receipt did not restore identical result")
|
||||
}
|
||||
if after.Stats[CertificationKey] != 1 || after.Stats[WinsKey] != 1 || after.Stats[RankPointKey] != 1050 || after.Rows[0].Progress != 1 || after.Rows[1].Progress != 3 || after.Rows[2].Progress != 1 {
|
||||
t.Fatal("total facts not persisted", after.Stats, after.Rows)
|
||||
}
|
||||
if !reflect.DeepEqual(after.NormalRows, []Row{{800, 1, 100}, {801, 1, 333}, {824, 1, 100}}) ||
|
||||
!reflect.DeepEqual(after.BeforeNormalRows, []Row{{800, 0, 100}, {801, 0, 333}, {824, 0, 100}}) || len(after.ChangedRows) != 6 {
|
||||
t.Fatal("total target counters or frozen result deltas missing", after.NormalRows, after.ChangedRows)
|
||||
}
|
||||
for j, v := range after.Characters {
|
||||
if v.Stats[ExperienceKey] != 12500 || v.Stats[GradeKey] != 1 || v.Stats[RankPointKey] != 1234 || v.Stats[WinsKey] != 7 || v.Stats["pvp_daily_day"] != 991 {
|
||||
t.Fatal("actor experience or personal counters corrupted", v.Stats)
|
||||
}
|
||||
want := int64(10)
|
||||
if j == 0 {
|
||||
want += 80
|
||||
}
|
||||
if v.Stats[EmblemsKey] != want {
|
||||
t.Fatal("daily reward duplicated or wrong owner", v.Stats)
|
||||
}
|
||||
}
|
||||
}
|
||||
if before.Characters[0].Stats[ExperienceKey] != 11000 {
|
||||
t.Fatal("input mutated")
|
||||
}
|
||||
a, _, _ := repos.Account.Load(ctx, "a")
|
||||
if a.Metadata["keep"] != "value" {
|
||||
t.Fatal("unrelated account metadata lost")
|
||||
}
|
||||
reset, err := PrepareTotal(ctx, repos, c, "a", "2", ids, now.Add(24*time.Hour))
|
||||
if err != nil || reset.Rows[0].Progress != 0 || reset.Stats[RankPointKey] != 1050 || !reflect.DeepEqual(reset.NormalRows, first.NormalRows) {
|
||||
t.Fatal("day reset lost rating", err)
|
||||
}
|
||||
if _, err := CompleteTotal(ctx, repos, c, "a", "1", [3]string{"1", "2", "4"}, match); err == nil {
|
||||
t.Fatal("receipt reused for another roster")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalMissionsWithoutRosterShareAccountAndRollBack(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
|
||||
c := totalProgressTestCatalog(t)
|
||||
repos := memory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, repo.AccountRecord{AccountID: "a", Metadata: map[string]string{"keep": "value"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"1", "2"} {
|
||||
if err := repos.Character.Save(ctx, repo.CharacterRecord{AccountID: "a", CharacterID: id, Stats: map[string]int64{WinsKey: 99, CertificationKey: 123}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
bad := repos
|
||||
bad.RentalAssets = renameFailCommit{repos.RentalAssets}
|
||||
if _, err := PrepareTotalMissions(ctx, bad, c, "a", "1", now); err == nil {
|
||||
t.Fatal("commit failure ignored")
|
||||
}
|
||||
a, _, _ := repos.Account.Load(ctx, "a")
|
||||
if a.Metadata[TotalProgressMetadataKey] != "" {
|
||||
t.Fatal("mission initialization escaped rollback")
|
||||
}
|
||||
first, err := PrepareTotalMissions(ctx, repos, c, "a", "1", now)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other, err := PrepareTotalMissions(ctx, repos, c, "a", "2", now)
|
||||
if err != nil || !reflect.DeepEqual(first.Rows, other.Rows) || !reflect.DeepEqual(first.NormalRows, other.NormalRows) {
|
||||
t.Fatal("missions changed with character", err)
|
||||
}
|
||||
if first.NormalRows[0].Progress != 0 || first.NormalRows[2].Progress != 0 {
|
||||
t.Fatal("ordinary counters became total progress")
|
||||
}
|
||||
if _, err := PrepareTotalMissions(ctx, repos, c, "foreign", "1", now); err == nil {
|
||||
t.Fatal("foreign owner accepted")
|
||||
}
|
||||
a, _, _ = repos.Account.Load(ctx, "a")
|
||||
if a.Metadata[TotalTeamMetadataKey] != "" || a.Metadata["keep"] != "value" {
|
||||
t.Fatal("initializing missions changed team or unrelated metadata")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalFactsConditionsAndTransactionScope(t *testing.T) {
|
||||
f := TotalMatchFacts{Match: Match{Won: true}, Kills: [3]uint32{1, 2, 0}, Remaining: 2, ReportsCount: map[uint16]uint32{138: 9}}
|
||||
for _, row := range []struct {
|
||||
condition string
|
||||
param int64
|
||||
streak int64
|
||||
want uint32
|
||||
}{
|
||||
{"total match win left character", 2, 0, 1}, {"total match win left character", 3, 0, 0},
|
||||
{"total match win all kill", 1, 0, 0}, {"kill count", 1, 0, 3}, {"winning streak", 3, 3, 1}, {"winning streak", 3, 4, 0}, {"reduce hp", 30, 0, 5},
|
||||
} {
|
||||
if got := totalMissionAdvance(Mission{ID: 138, Condition: row.condition, Parameter: row.param}, f, row.streak); got != row.want {
|
||||
t.Fatal(row, got)
|
||||
}
|
||||
}
|
||||
repos := memory.NewMemoryGroup()
|
||||
ctx := context.Background()
|
||||
err := repos.RentalAssets.(repo.PVPTotalUnitOfWork).WithinPVPTotal(ctx, "a", [3]string{"1", "2", "3"}, func(a repo.AccountRepository, c repo.CharacterRepository) error {
|
||||
if _, _, err := a.Load(ctx, "b"); err == nil {
|
||||
t.Fatal("foreign account admitted")
|
||||
}
|
||||
if _, _, err := c.Load(ctx, "4"); err == nil {
|
||||
t.Fatal("foreign character admitted")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
type renameFailCommit struct{ repo.RentalAssetUnitOfWork }
|
||||
|
||||
func (u renameFailCommit) WithinRentalAssets(ctx context.Context, a, c string, apply func(repo.AccountRepository, repo.CharacterRepository, repo.InventoryRepository, repo.EquipmentRepository) error) error {
|
||||
return u.RentalAssetUnitOfWork.WithinRentalAssets(ctx, a, c, func(a repo.AccountRepository, c repo.CharacterRepository, i repo.InventoryRepository, e repo.EquipmentRepository) error {
|
||||
if err := apply(a, c, i, e); err != nil {
|
||||
return err
|
||||
}
|
||||
return errors.New("injected rename commit failure")
|
||||
})
|
||||
}
|
||||
func TestTotalRenameRollbackPreservesNameAndCard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
repos := memory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, repo.AccountRecord{AccountID: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := SaveTotalTeam(ctx, repos.Account, "a", TotalTeam{Name: "旧队名"}, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Character.Save(ctx, repo.CharacterRecord{AccountID: "a", CharacterID: "1"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, repo.InventoryRecord{CharacterID: "1", Slots: map[string]repo.ItemStack{"0:100": {ItemID: TotalTeamRenameCardID, Count: 1, Extra: map[string]string{"equipment_lock_state": "0"}}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bad := repos
|
||||
bad.RentalAssets = renameFailCommit{repos.RentalAssets}
|
||||
if err := RenameTotalTeam(ctx, bad, "a", "1", "新队名", 100, now); err == nil {
|
||||
t.Fatal("failure not returned")
|
||||
}
|
||||
team, err := ReadTotalTeam(ctx, repos, "a")
|
||||
if err != nil || team.Team.Name != "旧队名" {
|
||||
t.Fatal(team, err)
|
||||
}
|
||||
inv, _, _ := repos.Inventory.Load(ctx, "1")
|
||||
if inv.Slots["0:100"].Count != 1 {
|
||||
t.Fatal("card escaped rollback")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
var ErrTotalRewardInventoryFull = errors.New("total mission reward inventory full")
|
||||
|
||||
type TotalRewardPlacement func(repo.InventoryRecord, int64, int64, time.Time) (repo.InventoryRecord, error)
|
||||
|
||||
type TotalRewards struct {
|
||||
Character repo.CharacterRecord
|
||||
Items map[int16]repo.ItemStack
|
||||
Granted, Pending []uint16
|
||||
}
|
||||
|
||||
func totalRewardKey(id uint16) string { return "pvp_total_target_" + strconv.Itoa(int(id)) + "_paid" }
|
||||
|
||||
// GrantTotalTargets consumes committed account progress. Progress survives an
|
||||
// interrupted delivery; the next result or login resumes it. Items, emblems and
|
||||
// the account-wide paid marker commit together, so changing characters or
|
||||
// replaying a result cannot pay a target twice. No speculative mail is created.
|
||||
func GrantTotalTargets(ctx context.Context, repos repo.Group, c *Catalog, accountID, selected string, now time.Time, place TotalRewardPlacement) (TotalRewards, error) {
|
||||
var result TotalRewards
|
||||
if c == nil || repos.RentalAssets == nil || now.IsZero() {
|
||||
return result, fmt.Errorf("total reward dependencies missing")
|
||||
}
|
||||
err := repo.WithinRentalAssetsScoped(ctx, repos.RentalAssets, accountID, selected,
|
||||
func(accounts repo.AccountRepository, characters repo.CharacterRepository, inventories 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 reward 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 reward character owner mismatch")
|
||||
}
|
||||
character = repo.CloneCharacter(character)
|
||||
if character.Stats == nil {
|
||||
character.Stats = map[string]int64{}
|
||||
}
|
||||
p, err := ReadTotalProgress(account)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rows, err := c.normalRows(totalMissionCharacter(accountID, p.Stats))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
complete := map[uint16]bool{}
|
||||
for _, row := range rows {
|
||||
complete[row.ID] = row.Progress == row.Target
|
||||
}
|
||||
rewardIDs := map[int64]bool{}
|
||||
var inventory repo.InventoryRecord
|
||||
loaded, dirty := false, false
|
||||
for _, m := range c.Normal {
|
||||
paid := p.Stats[totalRewardKey(m.ID)]
|
||||
if paid != 0 && paid != 1 || paid == 1 && !complete[m.ID] {
|
||||
return fmt.Errorf("invalid total reward marker %d", m.ID)
|
||||
}
|
||||
if m.RewardItemID > 0 {
|
||||
rewardIDs[m.RewardItemID] = true
|
||||
}
|
||||
if paid == 1 || !complete[m.ID] || m.PreRequired != 0 && !complete[m.PreRequired] {
|
||||
continue
|
||||
}
|
||||
balance := character.Stats[EmblemsKey]
|
||||
if m.Emblems < 0 || m.Emblems > math.MaxInt32 || balance < 0 || balance > math.MaxInt32-m.Emblems {
|
||||
return fmt.Errorf("total reward emblem overflow")
|
||||
}
|
||||
if m.RewardItemID > 0 {
|
||||
if place == nil {
|
||||
return fmt.Errorf("total mission item placement missing")
|
||||
}
|
||||
if !loaded {
|
||||
inventory, found, err = inventories.Load(ctx, selected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || inventory.CharacterID != selected {
|
||||
return fmt.Errorf("total reward inventory missing")
|
||||
}
|
||||
loaded = true
|
||||
}
|
||||
next, err := place(repo.CloneInventory(inventory), m.RewardItemID, m.RewardItemCount, now)
|
||||
if errors.Is(err, ErrTotalRewardInventoryFull) {
|
||||
result.Pending = append(result.Pending, m.ID)
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if next.CharacterID != selected {
|
||||
return fmt.Errorf("total reward inventory owner changed")
|
||||
}
|
||||
inventory, dirty = next, true
|
||||
}
|
||||
character.Stats[EmblemsKey] = balance + m.Emblems
|
||||
p.Stats[totalRewardKey(m.ID)] = 1
|
||||
result.Granted = append(result.Granted, m.ID)
|
||||
}
|
||||
if len(result.Granted) > 0 {
|
||||
if dirty {
|
||||
if err := repo.SaveInventoryFields(ctx, inventories, inventory, repo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if err := repo.SaveCharacterFields(ctx, characters, character, repo.CharacterFieldStats); err != nil {
|
||||
return err
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
// Publish current absolute values also on an idempotent retry, including
|
||||
// an indeterminate commit that completed before the connection failed.
|
||||
if len(rewardIDs) > 0 && !loaded {
|
||||
inventory, _, err = inventories.Load(ctx, selected)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
result.Items = map[int16]repo.ItemStack{}
|
||||
for key, stack := range inventory.Slots {
|
||||
if !rewardIDs[stack.ItemID] {
|
||||
continue
|
||||
}
|
||||
var list, slot int
|
||||
if _, err := fmt.Sscanf(key, "%d:%d", &list, &slot); err == nil && list == 0 && slot >= 0 && slot <= math.MaxInt16 {
|
||||
result.Items[int16(slot)] = stack
|
||||
}
|
||||
}
|
||||
result.Character = character
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return TotalRewards{}, err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"math"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/alignedcmd"
|
||||
"longheng.io/server/internal/modules/dnf/inventory"
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
func totalRewardFixture(t *testing.T) (repo.Group, *Catalog, time.Time) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
now := time.Date(2026, 9, 17, 10, 0, 0, 0, time.UTC)
|
||||
r := memory.NewMemoryGroup()
|
||||
raw, _ := json.Marshal(TotalProgress{Stats: map[string]int64{CertificationKey: 99, WinsKey: 99}})
|
||||
if err := r.Account.Save(ctx, repo.AccountRecord{AccountID: "a", Metadata: map[string]string{TotalProgressMetadataKey: string(raw), "keep": "yes"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range []string{"1", "2", "3"} {
|
||||
if err := r.Character.Save(ctx, repo.CharacterRecord{AccountID: "a", CharacterID: id, Stats: map[string]int64{EmblemsKey: 7, WinsKey: 12}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := r.Inventory.Save(ctx, repo.InventoryRecord{CharacterID: id, Slots: map[string]repo.ItemStack{"0:10": {ItemID: 99, Count: 4}}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
c := totalProgressTestCatalog(t)
|
||||
c.Normal[0].Emblems, c.Normal[0].RewardItemID, c.Normal[0].RewardItemCount = 10, 99, 2
|
||||
c.Normal[1].Emblems, c.Normal[1].RewardItemID, c.Normal[1].RewardItemCount = 100, 99, 3
|
||||
c.Normal[2].Emblems, c.Normal[2].RewardItemID, c.Normal[2].RewardItemCount = 20, 99, 3
|
||||
return r, c, now
|
||||
}
|
||||
|
||||
func totalPlaceTestReward(record repo.InventoryRecord, id, count int64, now time.Time) (repo.InventoryRecord, error) {
|
||||
out, err := inventory.PlaceStackableReward(record, alignedcmd.MagicBoxRewardItem{ItemID: id, Kind: "stackable", StackLimit: 5, SlotStart: 10, SlotEnd: 12, PVFPath: "fixture.stk"}, count)
|
||||
if errors.Is(err, inventory.ErrRandomRewardInventoryFull) {
|
||||
return repo.InventoryRecord{}, ErrTotalRewardInventoryFull
|
||||
}
|
||||
return out, err
|
||||
}
|
||||
|
||||
func TestTotalTargetRewardsResumeAfterProgressAndAtomicFailure(t *testing.T) {
|
||||
r, c, now := totalRewardFixture(t)
|
||||
ctx := context.Background()
|
||||
before, err := GrantTotalTargets(ctx, r, c, "a", "1", now, totalPlaceTestReward)
|
||||
if err != nil || len(before.Granted) > 0 || before.Character.Stats[EmblemsKey] != 7 {
|
||||
t.Fatal(before, err)
|
||||
}
|
||||
match := TotalMatchFacts{Match: Match{Key: "reward-threshold", StartedAt: now, CompletedAt: now.Add(time.Minute), Won: true}, Remaining: 1}
|
||||
if _, err := CompleteTotal(ctx, r, c, "a", "1", [3]string{"1", "2", "3"}, match); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
accountBefore, _, _ := r.Account.Load(ctx, "a")
|
||||
characterBefore, _, _ := r.Character.Load(ctx, "1")
|
||||
invBefore, _, _ := r.Inventory.Load(ctx, "1")
|
||||
bad := r
|
||||
bad.RentalAssets = renameFailCommit{r.RentalAssets}
|
||||
if _, err := GrantTotalTargets(ctx, bad, c, "a", "1", now, totalPlaceTestReward); err == nil {
|
||||
t.Fatal("injected failure accepted")
|
||||
}
|
||||
a, _, _ := r.Account.Load(ctx, "a")
|
||||
ch, _, _ := r.Character.Load(ctx, "1")
|
||||
inv, _, _ := r.Inventory.Load(ctx, "1")
|
||||
if !reflect.DeepEqual(a, accountBefore) || !reflect.DeepEqual(ch, characterBefore) || !reflect.DeepEqual(inv, invBefore) {
|
||||
t.Fatal("partial reward escaped rollback")
|
||||
}
|
||||
got, err := GrantTotalTargets(ctx, r, c, "a", "1", now, totalPlaceTestReward)
|
||||
if err != nil || !reflect.DeepEqual(got.Granted, []uint16{800, 824}) || got.Character.Stats[EmblemsKey] != 37 || got.Character.Stats[WinsKey] != 12 {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
if got.Items[10].Count != 5 || got.Items[11].Count != 4 {
|
||||
t.Fatal("reward stacks not merged/split", got.Items)
|
||||
}
|
||||
for _, id := range []string{"1", "2", "1"} {
|
||||
retry, err := GrantTotalTargets(ctx, r, c, "a", id, now.Add(24*time.Hour), totalPlaceTestReward)
|
||||
if err != nil || len(retry.Granted) != 0 {
|
||||
t.Fatal("duplicate reward", retry, err)
|
||||
}
|
||||
}
|
||||
// A replay of the match receipt preserves the account paid markers.
|
||||
if _, err := CompleteTotal(ctx, r, c, "a", "1", [3]string{"1", "2", "3"}, match); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if retry, err := GrantTotalTargets(ctx, r, c, "a", "1", now, totalPlaceTestReward); err != nil || len(retry.Granted) != 0 || retry.Character.Stats[EmblemsKey] != 37 {
|
||||
t.Fatal(retry, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalTargetRewardsFullInventoryDefersWithoutPartialItems(t *testing.T) {
|
||||
r, c, now := totalRewardFixture(t)
|
||||
ctx := context.Background()
|
||||
a, _, _ := r.Account.Load(ctx, "a")
|
||||
p, _ := ReadTotalProgress(a)
|
||||
p.Stats[CertificationKey] = 100
|
||||
raw, _ := json.Marshal(p)
|
||||
a.Metadata[TotalProgressMetadataKey] = string(raw)
|
||||
_ = r.Account.Save(ctx, a)
|
||||
inv, _, _ := r.Inventory.Load(ctx, "1")
|
||||
inv.Slots["0:11"] = repo.ItemStack{ItemID: 88, Count: 1}
|
||||
inv.Slots["0:12"] = repo.ItemStack{ItemID: 88, Count: 1}
|
||||
_ = r.Inventory.Save(ctx, inv)
|
||||
got, err := GrantTotalTargets(ctx, r, c, "a", "1", now, totalPlaceTestReward)
|
||||
if err != nil || !reflect.DeepEqual(got.Pending, []uint16{800}) || len(got.Granted) != 0 || got.Character.Stats[EmblemsKey] != 7 || got.Items[10].Count != 4 {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
delete(inv.Slots, "0:11")
|
||||
_ = r.Inventory.Save(ctx, inv)
|
||||
got, err = GrantTotalTargets(ctx, r, c, "a", "1", now, totalPlaceTestReward)
|
||||
if err != nil || !reflect.DeepEqual(got.Granted, []uint16{800}) || got.Items[10].Count != 5 || got.Items[11].Count != 1 || got.Character.Stats[EmblemsKey] != 17 {
|
||||
t.Fatal(got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalTargetRewardsAccountScopeConcurrentCharactersAndOverflow(t *testing.T) {
|
||||
r, c, now := totalRewardFixture(t)
|
||||
ctx := context.Background()
|
||||
a, _, _ := r.Account.Load(ctx, "a")
|
||||
p, _ := ReadTotalProgress(a)
|
||||
p.Stats[CertificationKey] = 333
|
||||
raw, _ := json.Marshal(p)
|
||||
a.Metadata[TotalProgressMetadataKey] = string(raw)
|
||||
_ = r.Account.Save(ctx, a)
|
||||
ch, _, _ := r.Character.Load(ctx, "1")
|
||||
ch.Stats[EmblemsKey] = math.MaxInt32
|
||||
_ = r.Character.Save(ctx, ch)
|
||||
if _, err := GrantTotalTargets(ctx, r, c, "a", "1", now, totalPlaceTestReward); err == nil {
|
||||
t.Fatal("overflow accepted")
|
||||
}
|
||||
if _, err := GrantTotalTargets(ctx, r, c, "foreign", "1", now, totalPlaceTestReward); err == nil {
|
||||
t.Fatal("foreign owner accepted")
|
||||
}
|
||||
ch.Stats[EmblemsKey] = 7
|
||||
_ = r.Character.Save(ctx, ch)
|
||||
var wg sync.WaitGroup
|
||||
results := make(chan TotalRewards, 2)
|
||||
errs := make(chan error, 2)
|
||||
for _, id := range []string{"1", "2"} {
|
||||
wg.Add(1)
|
||||
go func(id string) {
|
||||
defer wg.Done()
|
||||
got, err := GrantTotalTargets(ctx, r, c, "a", id, now, totalPlaceTestReward)
|
||||
results <- got
|
||||
errs <- err
|
||||
}(id)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
count := 0
|
||||
for r := range results {
|
||||
count += len(r.Granted)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatal("account chain paid twice", count)
|
||||
}
|
||||
var wallet, items int64
|
||||
for _, id := range []string{"1", "2"} {
|
||||
ch, _, _ := r.Character.Load(ctx, id)
|
||||
wallet += ch.Stats[EmblemsKey]
|
||||
inv, _, _ := r.Inventory.Load(ctx, id)
|
||||
for _, stack := range inv.Slots {
|
||||
items += stack.Count
|
||||
}
|
||||
}
|
||||
if wallet != 124 || items != 13 {
|
||||
t.Fatal("concurrent reward totals", wallet, items)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"longheng.io/server/internal/modules/dnf/pvf"
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const TotalTeamMetadataKey = "pvp_total_match_team_v1"
|
||||
|
||||
// Stable character IDs survive selector-slot reordering and character deletion.
|
||||
type TotalTeam struct {
|
||||
Name string `json:"name"`
|
||||
Characters [3]string `json:"characters"`
|
||||
}
|
||||
|
||||
type TotalTeamSnapshot struct {
|
||||
Team TotalTeam
|
||||
Characters [3]repo.CharacterRecord
|
||||
Slots [3]byte
|
||||
}
|
||||
|
||||
func ValidateTotalTeamName(name string) error {
|
||||
if strings.TrimSpace(name) != name || len([]rune(name)) < 2 || len([]rune(name)) > 12 {
|
||||
return fmt.Errorf("invalid total-match team name length")
|
||||
}
|
||||
for _, r := range name {
|
||||
if !unicode.IsLetter(r) && !unicode.IsDigit(r) {
|
||||
return fmt.Errorf("invalid total-match team name character")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ReadTotalTeam(ctx context.Context, repos repo.Group, accountID string) (TotalTeamSnapshot, error) {
|
||||
result := TotalTeamSnapshot{Slots: [3]byte{255, 255, 255}}
|
||||
if repos.Account == nil || repos.Character == nil || accountID == "" {
|
||||
return result, fmt.Errorf("total-match repositories or account missing")
|
||||
}
|
||||
a, found, err := repos.Account.Load(ctx, accountID)
|
||||
if err != nil || !found {
|
||||
return result, err
|
||||
}
|
||||
if raw := a.Metadata[TotalTeamMetadataKey]; raw != "" {
|
||||
if err := json.Unmarshal([]byte(raw), &result.Team); err != nil {
|
||||
return result, fmt.Errorf("invalid persisted total-match team: %w", err)
|
||||
}
|
||||
}
|
||||
rows, err := repos.Character.ListByAccount(ctx, accountID, repo.MaxCharacterSlotLimit)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
for i, id := range result.Team.Characters {
|
||||
for _, c := range rows {
|
||||
if id != "" && c.CharacterID == id && c.AccountID == accountID && c.Stats["delete_flag"] == 0 && c.Slot >= 0 && c.Slot < repo.MaxCharacterSlotLimit {
|
||||
result.Characters[i], result.Slots[i] = c, byte(c.Slot)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ResolveTotalTeam validates all three real account characters before any write.
|
||||
func ResolveTotalTeam(accountID, name string, slots [3]byte, rows []repo.CharacterRecord, source pvf.Source) (TotalTeamSnapshot, error) {
|
||||
result := TotalTeamSnapshot{Team: TotalTeam{Name: name}, Slots: slots}
|
||||
if source == nil || accountID == "" {
|
||||
return result, fmt.Errorf("total-match source or account missing")
|
||||
}
|
||||
if err := ValidateTotalTeamName(name); err != nil {
|
||||
return result, err
|
||||
}
|
||||
text, err := source.ReadText("etc/pvpparameter.etc")
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
doc, err := pvf.Parse("etc/pvpparameter.etc", text)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
pairs := doc.Ints("pvp matching possible character")
|
||||
if len(pairs) == 0 || len(pairs)%2 != 0 {
|
||||
return result, fmt.Errorf("missing PVP professions")
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
professions := map[[2]int64]bool{}
|
||||
for i, slot := range slots {
|
||||
if int(slot) >= repo.MaxCharacterSlotLimit {
|
||||
return result, fmt.Errorf("total-match selector slot out of range")
|
||||
}
|
||||
for _, c := range rows {
|
||||
if c.Slot == int(slot) && c.AccountID == accountID && c.Stats["delete_flag"] == 0 {
|
||||
result.Characters[i] = c
|
||||
break
|
||||
}
|
||||
}
|
||||
c := result.Characters[i]
|
||||
id, idErr := strconv.ParseUint(c.CharacterID, 10, 16)
|
||||
job, jobErr := strconv.ParseInt(c.Job, 10, 32)
|
||||
profession := [2]int64{job, c.Stats["grow_type"] & 15}
|
||||
allowed := false
|
||||
for n := 0; n < len(pairs); n += 2 {
|
||||
allowed = allowed || profession == [2]int64{pairs[n], pairs[n+1]}
|
||||
}
|
||||
if idErr != nil || id == 0 || id == 65535 || jobErr != nil || c.Level < 1 || c.Level > 255 || seen[c.CharacterID] || professions[profession] || !allowed {
|
||||
return result, fmt.Errorf("invalid total-match character in slot %d", slot)
|
||||
}
|
||||
seen[c.CharacterID], professions[profession] = true, true
|
||||
result.Team.Characters[i] = c.CharacterID
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func SaveTotalTeam(ctx context.Context, accounts repo.AccountRepository, accountID string, team TotalTeam, now time.Time) error {
|
||||
a, found, err := accounts.Load(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || a.AccountID != accountID {
|
||||
return fmt.Errorf("total-match account missing")
|
||||
}
|
||||
raw, err := json.Marshal(team)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return repo.SaveAccountMetadataEntry(ctx, accounts, a, TotalTeamMetadataKey, string(raw), now)
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const TotalTeamRenameCardID int64 = 490002850
|
||||
|
||||
// The native 873 request selects a real main-inventory card. Name and card
|
||||
// consumption share the account/character transaction; replaying the same
|
||||
// committed name never consumes another card.
|
||||
func RenameTotalTeam(ctx context.Context, repos repo.Group, accountID, characterID, name string, slot uint16, now time.Time) error {
|
||||
if repos.RentalAssets == nil || accountID == "" || characterID == "" || slot > 32767 || now.IsZero() {
|
||||
return fmt.Errorf("total-match rename dependencies missing")
|
||||
}
|
||||
if err := ValidateTotalTeamName(name); err != nil {
|
||||
return err
|
||||
}
|
||||
return repo.WithinRentalAssetsScoped(ctx, repos.RentalAssets, accountID, characterID, func(accounts repo.AccountRepository, characters repo.CharacterRepository, inventories repo.InventoryRepository, _ repo.EquipmentRepository) error {
|
||||
character, found, err := characters.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found || character.AccountID != accountID || character.Stats["delete_flag"] != 0 {
|
||||
return fmt.Errorf("total-match rename character owner mismatch")
|
||||
}
|
||||
a, found, err := accounts.Load(ctx, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("total-match rename account missing")
|
||||
}
|
||||
var team TotalTeam
|
||||
if err := json.Unmarshal([]byte(a.Metadata[TotalTeamMetadataKey]), &team); err != nil {
|
||||
return err
|
||||
}
|
||||
if team.Name == "" {
|
||||
return fmt.Errorf("total-match team missing")
|
||||
}
|
||||
if team.Name == name {
|
||||
return nil
|
||||
}
|
||||
inventory, found, err := inventories.Load(ctx, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("rename inventory missing")
|
||||
}
|
||||
inventory = repo.CloneInventory(inventory)
|
||||
key := fmt.Sprintf("0:%d", slot)
|
||||
card, found := inventory.Slots[key]
|
||||
if !found || card.ItemID != TotalTeamRenameCardID || card.Count < 1 || (!card.ExpireAt.IsZero() && !card.ExpireAt.After(now)) {
|
||||
return fmt.Errorf("valid total-match rename card required")
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(card.Extra["equipment_lock_state"])) {
|
||||
case "1", "2", "active", "locked", "unlocking", "pending_unlock":
|
||||
return fmt.Errorf("rename card locked")
|
||||
}
|
||||
card.Count--
|
||||
if card.Count == 0 {
|
||||
delete(inventory.Slots, key)
|
||||
} else {
|
||||
inventory.Slots[key] = card
|
||||
}
|
||||
inventory.UpdatedAt = now.UTC()
|
||||
if err := repo.SaveInventoryFields(ctx, inventories, inventory, repo.InventoryFieldSlots); err != nil {
|
||||
return err
|
||||
}
|
||||
team.Name = name
|
||||
return SaveTotalTeam(ctx, accounts, accountID, team, now)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package pvp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
repo "longheng.io/server/internal/modules/dnf/repository"
|
||||
"longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestTotalTeamOwnershipProfessionAndStableSlots(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := memory.NewMemoryGroup()
|
||||
now := time.Now()
|
||||
if err := repos.Account.Save(ctx, repo.AccountRecord{AccountID: "account", Metadata: map[string]string{"keep": "value"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows := []repo.CharacterRecord{}
|
||||
for i := 0; i < 3; i++ {
|
||||
rows = append(rows, repo.CharacterRecord{CharacterID: fmt.Sprint(i + 1), AccountID: "account", Slot: i, Name: fmt.Sprint("role", i), Job: fmt.Sprint(i), Level: 90, Stats: map[string]int64{"grow_type": 17}})
|
||||
}
|
||||
source := textSource{"etc/pvpparameter.etc": "[pvp matching possible character]\n0 1 1 1 2 1\n[/pvp matching possible character]"}
|
||||
team, err := ResolveTotalTeam("account", "王者队伍", [3]byte{0, 1, 2}, rows, source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, c := range rows {
|
||||
if err := repos.Character.Save(ctx, c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := SaveTotalTeam(ctx, repos.Account, "account", team.Team, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, bad := range [][3]byte{{0, 0, 2}, {0, 1, 255}} {
|
||||
if _, err := ResolveTotalTeam("account", "王者队伍", bad, rows, source); err == nil {
|
||||
t.Fatal("invalid roster accepted", bad)
|
||||
}
|
||||
}
|
||||
if _, err := ResolveTotalTeam("other", "王者队伍", [3]byte{0, 1, 2}, rows, source); err == nil {
|
||||
t.Fatal("foreign characters accepted")
|
||||
}
|
||||
duplicate := append([]repo.CharacterRecord(nil), rows...)
|
||||
duplicate[1].Job = "0"
|
||||
if _, err := ResolveTotalTeam("account", "王者队伍", [3]byte{0, 1, 2}, duplicate, source); err == nil {
|
||||
t.Fatal("same profession accepted")
|
||||
}
|
||||
rows[0].Slot, rows[2].Slot = 2, 0
|
||||
for _, c := range rows {
|
||||
if err := repos.Character.Save(ctx, c); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
loaded, err := ReadTotalTeam(ctx, repos, "account")
|
||||
if err != nil || loaded.Team.Characters != [3]string{"1", "2", "3"} || loaded.Slots != [3]byte{2, 1, 0} {
|
||||
t.Fatalf("slot reorder rebound team: %+v %v", loaded, err)
|
||||
}
|
||||
rows[1].Stats["delete_flag"] = 1
|
||||
if err := repos.Character.Save(ctx, rows[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
loaded, err = ReadTotalTeam(ctx, repos, "account")
|
||||
if err != nil || loaded.Slots[1] != 255 {
|
||||
t.Fatal("deleted character retained", loaded, err)
|
||||
}
|
||||
account, _, _ := repos.Account.Load(ctx, "account")
|
||||
if account.Metadata["keep"] != "value" {
|
||||
t.Fatal("unrelated metadata overwritten")
|
||||
}
|
||||
finder := repos.Account.(repo.PVPTotalTeamNameFinder)
|
||||
id, found, err := finder.FindAccountIDByPVPTotalTeamName(ctx, "王者队伍")
|
||||
if err != nil || !found || id != "account" {
|
||||
t.Fatal(id, found, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTotalTeamRenameConsumesOnceAndRejectsForeignExpiredWrongCard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
now := time.Now()
|
||||
for _, scenario := range []string{"valid", "wrong", "expired", "foreign", "locked", "no_team"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
repos := memory.NewMemoryGroup()
|
||||
if err := repos.Account.Save(ctx, repo.AccountRecord{AccountID: "a"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
team := TotalTeam{Name: "原来队伍", Characters: [3]string{"1", "2", "3"}}
|
||||
if scenario != "no_team" {
|
||||
if err := SaveTotalTeam(ctx, repos.Account, "a", team, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
owner := "a"
|
||||
if scenario == "foreign" {
|
||||
owner = "b"
|
||||
}
|
||||
if err := repos.Character.Save(ctx, repo.CharacterRecord{CharacterID: "1", AccountID: owner}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
card := repo.ItemStack{ItemID: TotalTeamRenameCardID, Count: 2}
|
||||
if scenario == "wrong" {
|
||||
card.ItemID++
|
||||
}
|
||||
if scenario == "expired" {
|
||||
card.ExpireAt = now
|
||||
}
|
||||
if scenario == "locked" {
|
||||
card.Extra = map[string]string{"equipment_lock_state": "locked"}
|
||||
}
|
||||
if err := repos.Inventory.Save(ctx, repo.InventoryRecord{CharacterID: "1", Slots: map[string]repo.ItemStack{"0:100": card}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
err := RenameTotalTeam(ctx, repos, "a", "1", "新的队伍", 100, now)
|
||||
if scenario == "valid" {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := RenameTotalTeam(ctx, repos, "a", "1", "新的队伍", 100, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
} else if err == nil {
|
||||
t.Fatal("invalid rename accepted")
|
||||
}
|
||||
inv, _, _ := repos.Inventory.Load(ctx, "1")
|
||||
want := int64(2)
|
||||
if scenario == "valid" {
|
||||
want = 1
|
||||
}
|
||||
if inv.Slots["0:100"].Count != want {
|
||||
t.Fatalf("card count=%d want=%d", inv.Slots["0:100"].Count, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user