DNF90 服务端与启动器完整源码(含 GM 工具功能增强)

服务端(Go):
- GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面
- GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新
- 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新)
- 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍
- 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0)
- 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算

启动器(Python/JS):
- 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭
- GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐)
- 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
hanfuit committed 2026-11-04 14:31:55 +08:00
commit 645b64fe21
4388 files changed
+1405457

No files matched your search

+123
View File
@@ -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")
}
}
+576
View File
@@ -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")
}
}
+43
View File
@@ -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)
}
})
}
}
+224
View File
@@ -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)
}
})
}
}