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

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

577 lines
20 KiB
Go

package pvp
import (
"context"
"crypto/sha256"
"encoding/binary"
"fmt"
"math"
"path"
"sort"
"strconv"
"strings"
"time"
"longheng.io/server/internal/modules/dnf/jobmap"
"longheng.io/server/internal/modules/dnf/premium"
"longheng.io/server/internal/modules/dnf/pvf"
repo "longheng.io/server/internal/modules/dnf/repository"
)
const (
CertificationKey = "pvp_certification_count"
WinsKey = "pvp_wins"
LossesKey = "pvp_losses"
DrawsKey = "pvp_draws"
MaxWinStreakKey = "pvp_max_win_streak"
RecentResultsKey = "pvp_recent_results"
EmblemsKey = "finish_loading_currency_slot2_total"
)
type Mission struct {
ID uint16
Tier int
Weight int64
RankMin, RankMax int64
Condition string
Target uint32
Parameter int64
Jobs []int64
Emblems int64
RewardItemID, RewardItemCount int64
Category string
PreRequired uint16
JobTag string
}
type Catalog struct {
Pools [3][]Mission
// Normal target cards are independent of the three daily pool indexes.
Normal []Mission
ExperienceBounds []uint32
RatingRules *RatingRules
}
// Load reads ordinary daily pools and normal target cards. Total-match
// definitions have separate native grades and are not assigned here.
func Load(source pvf.Source) (*Catalog, error) {
return loadCatalog(source, false)
}
func LoadTotal(source pvf.Source) (*Catalog, error) {
return loadCatalog(source, true)
}
func loadCatalog(source pvf.Source, totalMatch bool) (*Catalog, error) {
if source == nil {
return nil, pvf.ErrSourceRequired
}
read := func(p string) (*pvf.Document, error) {
s, err := source.ReadText(p)
if err != nil {
return nil, err
}
return pvf.Parse(p, s)
}
config, err := read("etc/missionsystem.etc")
if err != nil {
return nil, err
}
list, err := read("pvp_mission/mission.lst")
if err != nil {
return nil, err
}
paths := make(map[int64]string)
for _, entry := range pvf.ParseList(list) {
if _, exists := paths[entry.ID]; exists {
return nil, fmt.Errorf("duplicate PVP mission %d", entry.ID)
}
paths[entry.ID] = path.Join("pvp_mission", entry.Path)
}
c := &Catalog{}
poolTag, gradeTag, modeTag := "casual daily mission", "[daily]", "[all]"
if totalMatch {
poolTag, gradeTag, modeTag = "total match daily mission", "[total match daily]", "[total match mode]"
}
for _, section := range config.Sections {
if section.Name != poolTag {
continue
}
var values []int64
for _, token := range config.Tokens[section.Start:section.End] {
if token.Kind == pvf.TokenInt {
values = append(values, token.Int)
}
}
if len(values) < 3 || len(values)%2 != 1 || values[0] < 0 || values[0] > 3 {
return nil, fmt.Errorf("invalid PVF daily mission pool")
}
if values[0] == 3 {
continue
}
tier := int(values[0])
if len(c.Pools[tier]) != 0 {
return nil, fmt.Errorf("duplicate daily tier %d", tier)
}
seen := map[int64]bool{}
for i := 1; i < len(values); i += 2 {
id, weight := values[i], values[i+1]
if id <= 0 || id > math.MaxInt16 || weight <= 0 || weight > math.MaxInt32 || seen[id] || paths[id] == "" {
return nil, fmt.Errorf("invalid daily mission %d", id)
}
seen[id] = true
d, err := read(paths[id])
if err != nil {
return nil, err
}
grade, ranks, args := d.Ints("grade"), d.Ints("rank range"), d.Ints("condition")
kind, _ := d.Text("grade")
tags := d.Texts("condition")
if kind != gradeTag || len(grade) != 1 || grade[0] != int64(tier) || len(ranks) != 2 ||
ranks[0] < 0 || ranks[1] < ranks[0] || len(tags) != 2 || tags[1] != modeTag ||
len(args) < 2 || args[0] <= 0 || args[0] > math.MaxInt32 {
return nil, fmt.Errorf("invalid daily mission definition %s", paths[id])
}
m := Mission{ID: uint16(id), Tier: tier, Weight: weight, RankMin: ranks[0], RankMax: ranks[1],
Condition: strings.ToLower(strings.Trim(tags[0], "[]")), Target: uint32(args[0]), Parameter: args[1], Jobs: args[2:]}
if m.Condition == "winning job" && (m.Parameter != int64(len(m.Jobs)) || m.Parameter <= 0) {
return nil, fmt.Errorf("invalid mission %d job list", id)
}
if reward := d.Ints("reward victory emblem"); len(reward) == 1 {
m.Emblems = reward[0]
}
if m.Emblems < 0 || m.Emblems > math.MaxInt32 {
return nil, fmt.Errorf("invalid mission %d emblem reward", id)
}
c.Pools[tier] = append(c.Pools[tier], m)
}
}
for tier, pool := range c.Pools {
if len(pool) == 0 {
return nil, fmt.Errorf("missing PVF daily mission tier %d", tier)
}
}
normalGrade := "[normal]"
if totalMatch {
normalGrade = "[total match normal]"
}
for id, p := range paths {
d, err := read(p)
if err != nil {
return nil, err
}
grade, _ := d.Text("grade")
if grade != normalGrade {
continue
}
category, categoryOK := d.Text("normal category")
ranks, args, tags := d.Ints("rank range"), d.Ints("condition"), d.Texts("condition")
if id <= 0 || id > math.MaxInt16 || !categoryOK || len(ranks) != 2 || ranks[0] < 0 || ranks[1] < ranks[0] || len(tags) != 2 || len(args) < 2 || args[0] < -1 || args[0] == 0 || args[0] > math.MaxInt32 {
return nil, fmt.Errorf("invalid normal mission definition %s", p)
}
switch category {
case "[training]", "[rating]", "[battle]", "[victory]":
default:
return nil, fmt.Errorf("invalid normal mission category %s", p)
}
pre, _ := d.Int("pre required mission")
if pre < -1 || pre > math.MaxInt16 || pre == id {
return nil, fmt.Errorf("invalid normal mission prerequisite %d", id)
}
if pre == -1 {
pre = 0
}
m := Mission{ID: uint16(id), RankMin: ranks[0], RankMax: ranks[1], Category: strings.ToLower(strings.Trim(category, "[]")),
Condition: strings.ToLower(strings.Trim(tags[0], "[]")), Target: uint32(args[0]), Parameter: args[1], PreRequired: uint16(pre)}
if m.Condition == "combo clear" {
m.JobTag = tags[1]
known := false
for job := 0; job <= jobmap.MaxID; job++ {
known = known || jobmap.MatchesQuestTag(m.JobTag, job)
}
if !known {
return nil, fmt.Errorf("invalid normal mission job %s", p)
}
} else if tags[1] != modeTag {
return nil, fmt.Errorf("invalid normal mission mode %s", p)
}
if totalMatch {
v := d.Ints("reward victory emblem")
if len(v) > 1 || len(v) == 1 && (v[0] < 0 || v[0] > math.MaxInt32) {
return nil, fmt.Errorf("invalid total mission emblem reward %d", id)
}
if len(v) == 1 {
m.Emblems = v[0]
}
v = d.Ints("reward item")
if len(v) != 0 {
if len(v) != 2 || v[0] <= 0 || v[0] > math.MaxUint32 || v[1] <= 0 || v[1] > math.MaxInt32 {
return nil, fmt.Errorf("invalid total mission item reward %d", id)
}
m.RewardItemID, m.RewardItemCount = v[0], v[1]
}
}
c.Normal = append(c.Normal, m)
}
sort.Slice(c.Normal, func(i, j int) bool { return c.Normal[i].ID < c.Normal[j].ID })
normalByID := make(map[uint16]Mission, len(c.Normal))
for _, mission := range c.Normal {
normalByID[mission.ID] = mission
}
for _, mission := range c.Normal {
seen := map[uint16]bool{mission.ID: true}
for id := mission.PreRequired; id != 0; {
pre, found := normalByID[id]
if !found || seen[id] || pre.JobTag != mission.JobTag {
return nil, fmt.Errorf("invalid normal mission prerequisite chain %d", mission.ID)
}
seen[id] = true
id = pre.PreRequired
}
}
ref, err := read("etc/pvp_ref.etc")
if err != nil {
return nil, err
}
values := ref.Ints("pvp experience")
if len(values) == 0 || len(values)%2 != 0 {
return nil, fmt.Errorf("invalid PVP experience bounds")
}
c.ExperienceBounds = []uint32{0}
for i := 0; i < len(values); i += 2 {
if values[i] != int64(len(c.ExperienceBounds)) || values[i+1] <= int64(c.ExperienceBounds[len(c.ExperienceBounds)-1]) || values[i+1] > math.MaxInt32 {
return nil, fmt.Errorf("invalid PVP grade threshold")
}
c.ExperienceBounds = append(c.ExperienceBounds, uint32(values[i+1]))
}
matching, err := read("etc/matchingmode.etc")
if err != nil {
return nil, err
}
pointTag := "pvp grade point"
if totalMatch {
pointTag = "pvp grade point total match"
}
c.RatingRules, err = loadRatingRulesWithPoints(ref, matching, len(c.ExperienceBounds)-1, pointTag)
if err != nil {
return nil, err
}
return c, nil
}
type Row struct {
ID uint16
Progress, Target uint32
}
type Snapshot struct {
Character repo.CharacterRecord
Day int64
Rows []Row
BeforeRows []Row
ChangedRows []Row
NormalRows []Row
BeforeNormalRows []Row
EmblemsGranted int64
ExperienceDelta int64
BeforeGrade *uint32
}
func dailyKey(tier int, field string) string { return "pvp_daily_" + strconv.Itoa(tier) + "_" + field }
func (c *Catalog) snapshot(character repo.CharacterRecord, now time.Time) (Snapshot, bool, error) {
character = repo.CloneCharacter(character)
if character.Stats == nil {
character.Stats = map[string]int64{}
}
stats := character.Stats
// Settle completed rows before the service-day reset discards them. The
// marker also repairs rows completed by the previous progress-only server.
granted, paid, err := c.grantDailyEmblems(stats)
if err != nil {
return Snapshot{}, false, err
}
day := premium.ServiceDayID(now)
fresh := stats["pvp_daily_day"] != day
out := Snapshot{Character: character, Day: day, EmblemsGranted: granted}
for tier, pool := range c.Pools {
var chosen *Mission
if !fresh {
for i := range pool {
if int64(pool[i].ID) == stats[dailyKey(tier, "id")] {
chosen = &pool[i]
break
}
}
if chosen == nil {
return Snapshot{}, false, fmt.Errorf("persisted PVP mission missing from tier %d", tier)
}
} else {
var total int64
for _, m := range pool {
if stats["pvp_grade"] >= m.RankMin && stats["pvp_grade"] <= m.RankMax {
total += m.Weight
}
}
if total == 0 {
return Snapshot{}, false, fmt.Errorf("no daily mission for grade %d", stats["pvp_grade"])
}
// Stable weighted selection prevents reconnects and transaction retries
// from rerolling a character's missions within the same service day.
digest := sha256.Sum256([]byte(fmt.Sprintf("%s:%d:%d", character.CharacterID, day, tier)))
pick := int64(binary.LittleEndian.Uint64(digest[:8]) % uint64(total))
for i, m := range pool {
if stats["pvp_grade"] < m.RankMin || stats["pvp_grade"] > m.RankMax {
continue
}
pick -= m.Weight
if pick < 0 {
chosen = &pool[i]
break
}
}
stats[dailyKey(tier, "id")] = int64(chosen.ID)
stats[dailyKey(tier, "progress")] = 0
stats[dailyKey(tier, "paid")] = 0
}
progress := stats[dailyKey(tier, "progress")]
if progress < 0 || progress > int64(chosen.Target) {
return Snapshot{}, false, fmt.Errorf("invalid daily mission progress")
}
out.Rows = append(out.Rows, Row{chosen.ID, uint32(progress), chosen.Target})
}
out.NormalRows, err = c.normalRows(character)
if err != nil {
return Snapshot{}, false, err
}
stats["pvp_daily_day"] = day
return out, fresh || paid, nil
}
func normalKey(id uint16) string { return "pvp_normal_" + strconv.Itoa(int(id)) + "_progress" }
func (c *Catalog) normalRows(character repo.CharacterRecord) ([]Row, error) {
var rows []Row
job, jobErr := strconv.Atoi(character.Job)
for _, mission := range c.Normal {
if mission.JobTag != "" && (jobErr != nil || !jobmap.MatchesQuestTag(mission.JobTag, job)) {
continue
}
progress := character.Stats[normalKey(mission.ID)]
if progress < 0 || progress > math.MaxInt32 {
return nil, fmt.Errorf("invalid normal mission progress %d", mission.ID)
}
// Only persisted match facts can restore cumulative participation/wins.
// ponytail: other conditions retain saved progress until their event and
// reward transactions are implemented; never infer dojo/combo completion.
switch mission.Condition {
case "hereafter join":
progress = max(progress, character.Stats[CertificationKey])
case "hereafter winning count":
progress = max(progress, character.Stats[WinsKey])
case "rank":
progress = max(progress, character.Stats[GradeKey], character.Stats["pvp_max_grade"])
}
if progress > math.MaxInt32 {
return nil, fmt.Errorf("invalid normal mission counter %d", mission.ID)
}
// Preserve -1 as FFFFFFFF: sub_10A9D80 suppresses its count label and
// sub_1C500C0 compares it unsigned. Zero would falsely complete the card.
rows = append(rows, Row{mission.ID, min(uint32(progress), mission.Target), mission.Target})
}
return rows, nil
}
func (c *Catalog) grantDailyEmblems(stats map[string]int64) (int64, bool, error) {
var granted int64
changed := false
for tier, pool := range c.Pools {
id, progress, paid := stats[dailyKey(tier, "id")], stats[dailyKey(tier, "progress")], stats[dailyKey(tier, "paid")]
if id == 0 && progress == 0 && paid == 0 {
continue
}
if paid != 0 && paid != 1 {
return 0, false, fmt.Errorf("invalid PVP reward marker")
}
found := false
for _, mission := range pool {
if int64(mission.ID) != id {
continue
}
found = true
if progress < 0 || progress > int64(mission.Target) || (paid == 1 && progress != int64(mission.Target)) {
return 0, false, fmt.Errorf("invalid PVP reward progress")
}
if paid == 1 || progress != int64(mission.Target) {
break
}
balance := stats[EmblemsKey]
if mission.Emblems < 0 || mission.Emblems > math.MaxInt32 || balance < 0 || balance > math.MaxInt32-mission.Emblems {
return 0, false, fmt.Errorf("invalid PVP emblem balance/reward")
}
stats[EmblemsKey] = balance + mission.Emblems
stats[dailyKey(tier, "paid")] = 1
granted += mission.Emblems
changed = true
break
}
if !found {
return 0, false, fmt.Errorf("persisted PVP mission missing from tier %d", tier)
}
}
return granted, changed, nil
}
func Prepare(ctx context.Context, repos repo.Group, catalog *Catalog, accountID, characterID string, now time.Time) (Snapshot, error) {
return update(ctx, repos, catalog, accountID, characterID, now, nil)
}
// Match carries facts from a validated ranked 1v1 or manual room. Reports are
// admitted by the bridge only for assigned conditions in the current match.
type Match struct {
Key string
StartedAt, CompletedAt time.Time
Won, Draw bool
Manual bool
OpponentJob int64
OpponentRP int64
OpponentRated bool
Reports map[uint16]bool
}
func Complete(ctx context.Context, repos repo.Group, catalog *Catalog, accountID, characterID string, match Match) (Snapshot, error) {
if match.Key == "" || match.StartedAt.IsZero() || match.CompletedAt.Before(match.StartedAt) {
return Snapshot{}, fmt.Errorf("invalid PVP completion identity/time")
}
return update(ctx, repos, catalog, accountID, characterID, match.CompletedAt, &match)
}
func update(ctx context.Context, repos repo.Group, catalog *Catalog, accountID, characterID string, now time.Time, match *Match) (Snapshot, error) {
if repos.CharacterSettlement == nil || catalog == nil || accountID == "" || characterID == "" || now.IsZero() {
return Snapshot{}, fmt.Errorf("PVP state dependencies or identity missing")
}
var result Snapshot
err := repos.CharacterSettlement.WithinCharacterSettlement(ctx, characterID, func(tx repo.Group) error {
if tx.Character == nil {
return fmt.Errorf("PVP character repository missing")
}
character, found, err := tx.Character.Load(ctx, characterID)
if err != nil {
return err
}
if !found || character.CharacterID != characterID || character.AccountID != accountID {
return fmt.Errorf("PVP character owner mismatch")
}
var changed bool
result, changed, err = catalog.snapshot(character, now)
if err != nil {
return err
}
stats := result.Character.Stats
result.BeforeRows = append([]Row(nil), result.Rows...)
result.BeforeNormalRows = append([]Row(nil), result.NormalRows...)
if match != nil {
digest := sha256.Sum256([]byte(match.Key))
replay := true
for i := 0; i < 4; i++ {
v, exists := stats[fmt.Sprintf("pvp_match_receipt_%d", i)]
replay = replay && exists && v == int64(binary.LittleEndian.Uint64(digest[i*8:]))
}
if !replay {
if previous := stats["pvp_match_completed_at"]; previous > match.CompletedAt.UnixNano() {
return fmt.Errorf("stale PVP completion")
}
count := stats[CertificationKey]
if count < 0 || count >= math.MaxInt32 {
return fmt.Errorf("invalid certification count %d", count)
}
previousStreak := stats["pvp_win_streak"]
if _, err := catalog.applyRating(stats, *match, count); err != nil {
return err
}
if !match.Manual {
stats[CertificationKey] = count + 1
key, outcome := LossesKey, int64(2)
if match.Draw {
key, outcome = DrawsKey, 3
} else if match.Won {
key, outcome = WinsKey, 1
}
if stats[key] < 0 || stats[key] >= math.MaxInt32 || stats[MaxWinStreakKey] < 0 ||
stats[MaxWinStreakKey] > math.MaxInt32 || stats[RecentResultsKey] < 0 || stats[RecentResultsKey] >= 1<<20 {
return fmt.Errorf("invalid PVP result count/history")
}
stats[key]++
stats[MaxWinStreakKey] = max(stats[MaxWinStreakKey], previousStreak, stats["pvp_win_streak"])
// Local recent-history policy: last ten committed ranked outcomes,
// two bits each (1 win, 2 loss, 3 draw); no historical backfill.
stats[RecentResultsKey] = (stats[RecentResultsKey]<<2 | outcome) & ((1 << 20) - 1)
// A match begun yesterday must not satisfy newly assigned missions.
if premium.ServiceDayID(match.StartedAt) == result.Day {
for tier, row := range result.Rows {
for _, m := range catalog.Pools[tier] {
if m.ID != row.ID || row.Progress >= row.Target {
continue
}
advance := false
switch m.Condition {
case "hereafter join":
advance = true
case "hereafter winning count":
advance = match.Won && !match.Draw
case "winning job":
for _, job := range m.Jobs {
advance = advance || (match.Won && !match.Draw && job == match.OpponentJob)
}
case "reduce hp", "first aerial attack", "combo damage rate":
advance = match.Reports[m.ID]
case "remains hp", "remain hp":
advance = match.Won && !match.Draw && match.Reports[m.ID]
// Streak-group and pose tuple semantics are not yet proven;
// retain their PVF cards without granting speculative progress.
}
if advance {
row.Progress++
result.Rows[tier] = row
result.ChangedRows = append(result.ChangedRows, row)
stats[dailyKey(tier, "progress")] = int64(row.Progress)
}
}
}
}
}
for i := 0; i < 4; i++ {
stats[fmt.Sprintf("pvp_match_receipt_%d", i)] = int64(binary.LittleEndian.Uint64(digest[i*8:]))
}
stats["pvp_match_completed_at"] = match.CompletedAt.UnixNano()
changed = true
}
result.ExperienceDelta = stats["pvp_result_experience_delta"]
beforeGrade := uint32(stats["pvp_result_before_grade"])
result.BeforeGrade = &beforeGrade
}
granted, paid, err := catalog.grantDailyEmblems(stats)
if err != nil {
return err
}
result.EmblemsGranted += granted
changed = changed || paid
result.NormalRows, err = catalog.normalRows(result.Character)
if err != nil {
return err
}
for i, row := range result.NormalRows {
if row != result.BeforeNormalRows[i] {
result.ChangedRows = append(result.ChangedRows, row)
}
}
if changed {
return repo.SaveCharacterFields(ctx, tx.Character, result.Character, repo.CharacterFieldStats)
}
return nil
})
if err != nil {
return Snapshot{}, err
}
return result, nil
}