DNF90 服务端与启动器完整源码(含 GM 工具功能增强)
服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
This commit is contained in:
commit
645b64fe21
4388 files changed
+1405457
No files matched your search
@@ -0,0 +1,512 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
|
||||
"pvfine/internal/pvf"
|
||||
pvfversion "pvfine/internal/version"
|
||||
)
|
||||
|
||||
const (
|
||||
dropRateSectionName = "basis of rarity dicision"
|
||||
dropRateGroupSize = 7
|
||||
dropRateLegacyGroupSize = 6
|
||||
dropRate100CNGroupSize = 8
|
||||
dropRate110USGroupSize = 9
|
||||
dropRateValueCount = 5
|
||||
dropRateTotal = 10000 // 百分比的百分之一: 100.00% == 10000
|
||||
dropRateRawPerUnit = 100 // PVF 原始刻度: 0.01% == 100
|
||||
)
|
||||
|
||||
var (
|
||||
ErrDropRateUnsupported = errors.New("基础掉率编辑器仅支持 90US/90CN/TW/110US/100CN/115CN PVF")
|
||||
ErrDropRateStale = errors.New("掉率数据已过期,请重新加载")
|
||||
)
|
||||
|
||||
type dropRateFileSpec struct {
|
||||
key string
|
||||
path string
|
||||
offset int
|
||||
groupCount int
|
||||
}
|
||||
|
||||
var dropRateFileSpecs = []dropRateFileSpec{
|
||||
{key: "hell", path: "etc/itemdropinfo_monster_hell.etc", offset: 1, groupCount: 2},
|
||||
{key: "flip", path: "etc/itemdropinfo_clearreward.etc", offset: 0, groupCount: 1},
|
||||
{key: "monster", path: "etc/itemdropinfo_monseter.etc", offset: 0, groupCount: 4},
|
||||
{key: "elite", path: "etc/itemdropinfo_monseter_extra.etc", offset: 0, groupCount: 4},
|
||||
}
|
||||
|
||||
// DropRateDocument is the structured view consumed by the drop-rate editor.
|
||||
// Rates are percentage hundredths: 1234 means 12.34%.
|
||||
type DropRateDocument struct {
|
||||
PVFVersion string `json:"pvfVersion"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Sections []*DropRateSection `json:"sections"`
|
||||
}
|
||||
|
||||
// DropRateSection identifies one of the four supported drop files.
|
||||
type DropRateSection struct {
|
||||
Key string `json:"key"`
|
||||
Groups []*DropRateGroup `json:"groups"`
|
||||
}
|
||||
|
||||
// DropRateGroup contains five rarity probabilities in percentage hundredths.
|
||||
type DropRateGroup struct {
|
||||
Rates []int `json:"rates"`
|
||||
}
|
||||
|
||||
// DropRateApplyRequest applies the complete editor snapshot atomically.
|
||||
type DropRateApplyRequest struct {
|
||||
Revision uint64 `json:"revision"`
|
||||
Sections []*DropRateSection `json:"sections"`
|
||||
}
|
||||
|
||||
// DropRateApplyResult reports the in-memory archive state after applying.
|
||||
type DropRateApplyResult struct {
|
||||
Revision uint64 `json:"revision"`
|
||||
FileIndexes []int32 `json:"fileIndexes"`
|
||||
ModifiedCount int `json:"modifiedCount"`
|
||||
}
|
||||
|
||||
// DropService reads and applies the four structured drop-rate files without
|
||||
// opening them as editor tabs. Changes remain in the archive overlay until the
|
||||
// regular editor save operation writes the PVF source file.
|
||||
type DropService struct{ c *core }
|
||||
|
||||
func NewDropService(c *core) *DropService { return &DropService{c: c} }
|
||||
|
||||
// Read returns the current drop-rate snapshot for a verified client family.
|
||||
func (s *DropService) Read() (*DropRateDocument, error) {
|
||||
s.c.mu.RLock()
|
||||
defer s.c.mu.RUnlock()
|
||||
if s.c.archive == nil {
|
||||
return nil, ErrNoArchive
|
||||
}
|
||||
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
version := s.c.archive.ClientVersion()
|
||||
if !supportedDropRateVersion(version) {
|
||||
return nil, ErrDropRateUnsupported
|
||||
}
|
||||
|
||||
document, err := readDropRateDocument(s.c.archive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
document.PVFVersion = version
|
||||
document.Revision = s.c.batchRevision
|
||||
return document, nil
|
||||
}
|
||||
|
||||
// Apply validates and applies a complete editor snapshot atomically. The
|
||||
// revision check prevents a stale modal from overwriting a newer archive edit.
|
||||
func (s *DropService) Apply(request DropRateApplyRequest) (DropRateApplyResult, error) {
|
||||
s.c.mu.Lock()
|
||||
if s.c.archive == nil {
|
||||
s.c.mu.Unlock()
|
||||
return DropRateApplyResult{}, ErrNoArchive
|
||||
}
|
||||
if err := s.c.ensureVersionReadyLocked(); err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return DropRateApplyResult{}, err
|
||||
}
|
||||
version := s.c.archive.ClientVersion()
|
||||
if !supportedDropRateVersion(version) {
|
||||
s.c.mu.Unlock()
|
||||
return DropRateApplyResult{}, ErrDropRateUnsupported
|
||||
}
|
||||
if request.Revision != s.c.batchRevision {
|
||||
s.c.mu.Unlock()
|
||||
return DropRateApplyResult{}, ErrDropRateStale
|
||||
}
|
||||
|
||||
replacements, err := prepareDropRateReplacements(s.c.archive, request.Sections)
|
||||
if err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return DropRateApplyResult{}, err
|
||||
}
|
||||
if len(replacements) == 0 {
|
||||
result := DropRateApplyResult{
|
||||
Revision: s.c.batchRevision,
|
||||
FileIndexes: []int32{},
|
||||
ModifiedCount: s.c.archive.ModifiedCount(),
|
||||
}
|
||||
s.c.mu.Unlock()
|
||||
return result, nil
|
||||
}
|
||||
|
||||
paths := make([]string, 0, len(replacements))
|
||||
for _, replacement := range replacements {
|
||||
paths = append(paths, replacement.path)
|
||||
}
|
||||
var before pvfversion.ContentSnapshot
|
||||
if s.c.versionRepo != nil {
|
||||
before, err = pvfversion.ContentSnapshotFromArchive(s.c.archive, paths)
|
||||
if err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return DropRateApplyResult{}, err
|
||||
}
|
||||
}
|
||||
|
||||
a := s.c.archive
|
||||
mutationCheckpoint := a.MutationCheckpoint()
|
||||
fileIndexes := make([]int32, 0, len(replacements))
|
||||
for _, replacement := range replacements {
|
||||
if err := a.SetRawBytes(replacement.index, replacement.raw); err != nil {
|
||||
s.c.mu.Unlock()
|
||||
return DropRateApplyResult{}, err
|
||||
}
|
||||
fileIndexes = append(fileIndexes, replacement.index)
|
||||
}
|
||||
|
||||
if s.c.versionRepo != nil {
|
||||
after, snapshotErr := pvfversion.ContentSnapshotFromArchive(a, paths)
|
||||
if snapshotErr != nil {
|
||||
s.c.mu.Unlock()
|
||||
return DropRateApplyResult{}, snapshotErr
|
||||
}
|
||||
if recordErr := s.c.recordVersionMutationLocked("基础掉率", before, after); recordErr != nil {
|
||||
s.c.mu.Unlock()
|
||||
return DropRateApplyResult{}, recordErr
|
||||
}
|
||||
}
|
||||
|
||||
mutationSummary := a.MutationsSince(mutationCheckpoint)
|
||||
s.c.markAdvancedSearchStaleLocked(a)
|
||||
a.ClearMutations()
|
||||
s.c.batchRevision++
|
||||
s.c.batchPlan = nil
|
||||
s.c.invalidateScriptLocked()
|
||||
s.c.editorAnnotation = editorAnnotationCache{}
|
||||
s.c.annotationRelations = make(map[string]map[string]*relationTarget)
|
||||
s.c.markAdvancedSearchStaleLocked(s.c.archive)
|
||||
for _, index := range fileIndexes {
|
||||
delete(s.c.editorText, index)
|
||||
delete(s.c.visualsByFile, index)
|
||||
}
|
||||
info := a.Info()
|
||||
revision := s.c.batchRevision
|
||||
versioned := s.c.versionRepo != nil
|
||||
s.c.mu.Unlock()
|
||||
|
||||
if err := s.c.scheduleArchiveMutations(a, mutationSummary); err != nil {
|
||||
return DropRateApplyResult{}, err
|
||||
}
|
||||
emitEvent("archive:advanced-search-stale", map[string]any{"dropRate": true})
|
||||
emitEvent("archive:batch-applied", map[string]any{
|
||||
"fileIndexes": fileIndexes, "modifiedCount": info.ModifiedCount,
|
||||
"revision": revision, "source": "drop-rate", "structural": false,
|
||||
})
|
||||
if versioned {
|
||||
emitVersionState(s.c, "drop-rate-applied")
|
||||
}
|
||||
|
||||
return DropRateApplyResult{
|
||||
Revision: revision,
|
||||
FileIndexes: fileIndexes,
|
||||
ModifiedCount: info.ModifiedCount,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type dropRateReplacement struct {
|
||||
index int32
|
||||
path string
|
||||
raw []byte
|
||||
}
|
||||
|
||||
func supportedDropRateVersion(version string) bool {
|
||||
return version == "90US" || version == "90CN" || version == "TW" || version == "110US" || version == "100CN" || version == "115CN"
|
||||
}
|
||||
|
||||
func readDropRateDocument(a *pvf.Archive) (*DropRateDocument, error) {
|
||||
sections := make([]*DropRateSection, 0, len(dropRateFileSpecs))
|
||||
for _, spec := range dropRateFileSpecs {
|
||||
section, err := readDropRateSection(a, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sections = append(sections, section)
|
||||
}
|
||||
return &DropRateDocument{Sections: sections}, nil
|
||||
}
|
||||
|
||||
type parsedDropRateFile struct {
|
||||
index int32
|
||||
raw []byte
|
||||
document *pvf.ScriptDocument
|
||||
section *pvf.ScriptSection
|
||||
values []int64
|
||||
groupSize int
|
||||
}
|
||||
|
||||
func loadDropRateFile(a *pvf.Archive, spec dropRateFileSpec) (*parsedDropRateFile, error) {
|
||||
index, ok := a.Find(spec.path)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("找不到掉率文件: %s", spec.path)
|
||||
}
|
||||
if a.File(index).DataType != pvf.TypeScript {
|
||||
return nil, fmt.Errorf("掉率文件不是脚本类型: %s", spec.path)
|
||||
}
|
||||
raw, err := a.RawBytes(index)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("读取掉率文件 %s 失败: %w", spec.path, err)
|
||||
}
|
||||
document, err := a.ParseScriptDocument(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("解析掉率文件 %s 失败: %w", spec.path, err)
|
||||
}
|
||||
section, ok := document.Section([]string{dropRateSectionName}, 0)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("掉率文件缺少 section [%s]: %s", dropRateSectionName, spec.path)
|
||||
}
|
||||
values, err := scriptIntegerValues(section.Values(), spec.path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
groupSize, err := dropRateGroupWidth(values, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &parsedDropRateFile{
|
||||
index: index,
|
||||
raw: raw,
|
||||
document: document,
|
||||
section: section,
|
||||
values: values,
|
||||
groupSize: groupSize,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readDropRateSection(a *pvf.Archive, spec dropRateFileSpec) (*DropRateSection, error) {
|
||||
file, err := loadDropRateFile(a, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
groups := make([]*DropRateGroup, 0, spec.groupCount)
|
||||
for groupIndex := 0; groupIndex < spec.groupCount; groupIndex++ {
|
||||
start := spec.offset + groupIndex*file.groupSize
|
||||
rates, err := decodeDropRateGroup(file.values[start : start+file.groupSize])
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("掉率文件 %s 第 %d 组无效: %w", spec.path, groupIndex+1, err)
|
||||
}
|
||||
groups = append(groups, &DropRateGroup{Rates: rates})
|
||||
}
|
||||
return &DropRateSection{Key: spec.key, Groups: groups}, nil
|
||||
}
|
||||
|
||||
func prepareDropRateReplacements(a *pvf.Archive, sections []*DropRateSection) ([]dropRateReplacement, error) {
|
||||
byKey := make(map[string]*DropRateSection, len(sections))
|
||||
for _, section := range sections {
|
||||
if section == nil || section.Key == "" {
|
||||
return nil, errors.New("掉率 section 不能为空")
|
||||
}
|
||||
if _, exists := byKey[section.Key]; exists {
|
||||
return nil, fmt.Errorf("掉率 section 重复: %s", section.Key)
|
||||
}
|
||||
byKey[section.Key] = section
|
||||
}
|
||||
if len(byKey) != len(dropRateFileSpecs) {
|
||||
return nil, fmt.Errorf("掉率 section 数量无效,需要 %d 个", len(dropRateFileSpecs))
|
||||
}
|
||||
|
||||
replacements := make([]dropRateReplacement, 0, len(dropRateFileSpecs))
|
||||
for _, spec := range dropRateFileSpecs {
|
||||
sectionInput, ok := byKey[spec.key]
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("缺少掉率 section: %s", spec.key)
|
||||
}
|
||||
replacement, err := prepareDropRateFileReplacement(a, spec, sectionInput)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if replacement != nil {
|
||||
replacements = append(replacements, *replacement)
|
||||
}
|
||||
}
|
||||
return replacements, nil
|
||||
}
|
||||
|
||||
func prepareDropRateFileReplacement(a *pvf.Archive, spec dropRateFileSpec, sectionInput *DropRateSection) (*dropRateReplacement, error) {
|
||||
if len(sectionInput.Groups) != spec.groupCount {
|
||||
return nil, fmt.Errorf("掉率 section %s 有 %d 组,需要 %d 组", spec.key, len(sectionInput.Groups), spec.groupCount)
|
||||
}
|
||||
|
||||
file, err := loadDropRateFile(a, spec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for groupIndex, group := range sectionInput.Groups {
|
||||
if group == nil {
|
||||
return nil, fmt.Errorf("掉率 section %s 第 %d 组为空", spec.key, groupIndex+1)
|
||||
}
|
||||
if err := validateDropRates(group.Rates); err != nil {
|
||||
return nil, fmt.Errorf("掉率 section %s 第 %d 组无效: %w", spec.key, groupIndex+1, err)
|
||||
}
|
||||
start := spec.offset + groupIndex*file.groupSize
|
||||
groupSlice := file.values[start : start+file.groupSize]
|
||||
offset, err := detectDropRateGroupOffset(groupSlice)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("掉率文件 %s 第 %d 组无效: %w", spec.path, groupIndex+1, err)
|
||||
}
|
||||
currentRates, err := decodeDropRateGroupWithOffset(groupSlice, offset)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("掉率文件 %s 第 %d 组无效: %w", spec.path, groupIndex+1, err)
|
||||
}
|
||||
if equalDropRates(currentRates, group.Rates) {
|
||||
continue
|
||||
}
|
||||
cumulative := int64(0)
|
||||
for rateIndex := 0; rateIndex < dropRateValueCount-1; rateIndex++ {
|
||||
cumulative += int64(group.Rates[rateIndex]) * dropRateRawPerUnit
|
||||
if err := file.section.Set(pvf.ScriptValue{
|
||||
Type: pvf.ScriptTokenInteger,
|
||||
Value: cumulative,
|
||||
}, start+offset+rateIndex); err != nil {
|
||||
return nil, fmt.Errorf("修改掉率文件 %s 第 %d 组失败: %w", spec.path, groupIndex+1, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded, err := a.EncodeScriptDocument(file.document)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("编码掉率文件 %s 失败: %w", spec.path, err)
|
||||
}
|
||||
if !bytes.Equal(file.raw, encoded) {
|
||||
return &dropRateReplacement{index: file.index, path: spec.path, raw: encoded}, nil
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func equalDropRates(left, right []int) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index] != right[index] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func scriptIntegerValues(values []pvf.ScriptValue, path string) ([]int64, error) {
|
||||
result := make([]int64, len(values))
|
||||
for index, value := range values {
|
||||
if value.Type != pvf.ScriptTokenInteger {
|
||||
return nil, fmt.Errorf("掉率文件 %s 第 %d 个值不是整数", path, index+1)
|
||||
}
|
||||
number, ok := value.Value.(int64)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("掉率文件 %s 第 %d 个整数值类型无效", path, index+1)
|
||||
}
|
||||
result[index] = number
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func isDropTerminal(val int64) bool {
|
||||
return val >= 1_000_000 && val <= 1_000_002
|
||||
}
|
||||
|
||||
func detectDropRateGroupOffset(group []int64) (int, error) {
|
||||
if len(group) < dropRateLegacyGroupSize {
|
||||
return 0, fmt.Errorf("分组至少需要 %d 个值", dropRateLegacyGroupSize)
|
||||
}
|
||||
if isDropTerminal(group[4]) {
|
||||
return 0, nil
|
||||
}
|
||||
if group[0] == 0 {
|
||||
if isDropTerminal(group[5]) {
|
||||
return 1, nil
|
||||
}
|
||||
return 0, fmt.Errorf("终止阈值 %d 不在兼容范围内", group[5])
|
||||
}
|
||||
return 0, fmt.Errorf("终止阈值 %d 不在兼容范围内", group[4])
|
||||
}
|
||||
|
||||
// decodeDropRateGroup accepts the legacy six-value group. The sixth value is
|
||||
// intentionally kept unchanged by the five-rarity editor; newer revisions
|
||||
// append more thresholds, which are also retained without reinterpretation.
|
||||
// Hell drop files prepend a leading 0 threshold (skipping common drops), so
|
||||
// five-rarity thresholds start at index 1 and terminate at index 5.
|
||||
func decodeDropRateGroup(group []int64) ([]int, error) {
|
||||
offset, err := detectDropRateGroupOffset(group)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return decodeDropRateGroupWithOffset(group, offset)
|
||||
}
|
||||
|
||||
func decodeDropRateGroupWithOffset(group []int64, offset int) ([]int, error) {
|
||||
previous := int64(0)
|
||||
diffs := make([]int64, dropRateValueCount)
|
||||
for index := range diffs {
|
||||
current := group[offset+index]
|
||||
if current < previous {
|
||||
return nil, fmt.Errorf("累计阈值递减")
|
||||
}
|
||||
diffs[index] = current - previous
|
||||
previous = current
|
||||
}
|
||||
|
||||
rates := make([]int, len(diffs))
|
||||
remainders := make([]int64, len(diffs))
|
||||
base := 0
|
||||
for index, diff := range diffs {
|
||||
rates[index] = int(diff / dropRateRawPerUnit)
|
||||
remainders[index] = diff % dropRateRawPerUnit
|
||||
base += rates[index]
|
||||
}
|
||||
if base > dropRateTotal {
|
||||
return nil, fmt.Errorf("量化后的掉率总和超过 100.00%%")
|
||||
}
|
||||
remaining := dropRateTotal - base
|
||||
if remaining > len(rates) {
|
||||
return nil, fmt.Errorf("量化后的掉率无法归一到 100.00%%")
|
||||
}
|
||||
order := []int{0, 1, 2, 3, 4}
|
||||
sort.SliceStable(order, func(left, right int) bool {
|
||||
if remainders[order[left]] != remainders[order[right]] {
|
||||
return remainders[order[left]] > remainders[order[right]]
|
||||
}
|
||||
return order[left] < order[right]
|
||||
})
|
||||
for index := 0; index < remaining; index++ {
|
||||
rates[order[index]]++
|
||||
}
|
||||
return rates, nil
|
||||
}
|
||||
|
||||
func validateDropRates(rates []int) error {
|
||||
if len(rates) != dropRateValueCount {
|
||||
return fmt.Errorf("需要 %d 个概率值", dropRateValueCount)
|
||||
}
|
||||
sum := 0
|
||||
for index, rate := range rates {
|
||||
if rate < 0 || rate > dropRateTotal {
|
||||
return fmt.Errorf("第 %d 个概率超出 0.00%%~100.00%%", index+1)
|
||||
}
|
||||
sum += rate
|
||||
}
|
||||
if sum != dropRateTotal {
|
||||
return fmt.Errorf("概率总和为 %.2f%%,必须为 100.00%%", float64(sum)/100)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Known group widths: TW=6, 90US/90CN=7, 100CN=8, 110US=9.
|
||||
// Validate each section's exact layout before interpreting or changing it.
|
||||
func dropRateGroupWidth(values []int64, spec dropRateFileSpec) (int, error) {
|
||||
for _, width := range []int{dropRateLegacyGroupSize, dropRateGroupSize, dropRate100CNGroupSize, dropRate110USGroupSize} {
|
||||
if len(values) == spec.offset+spec.groupCount*width {
|
||||
return width, nil
|
||||
}
|
||||
}
|
||||
return 0, fmt.Errorf("掉率文件 %s 的 section 数据长度为 %d,需要 %d、%d、%d 或 %d", spec.path, len(values), spec.offset+spec.groupCount*dropRateLegacyGroupSize, spec.offset+spec.groupCount*dropRateGroupSize, spec.offset+spec.groupCount*dropRate100CNGroupSize, spec.offset+spec.groupCount*dropRate110USGroupSize)
|
||||
}
|
||||
Reference in new issue
Block a user