服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
637 lines
25 KiB
Go
637 lines
25 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"net"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
mysql "github.com/go-sql-driver/mysql"
|
|
dnfpvf "longheng.io/server/internal/modules/dnf/pvf"
|
|
platformpvf "longheng.io/server/internal/platform/pvf"
|
|
)
|
|
|
|
const (
|
|
batchID = "20260803-level90-strength-loadout"
|
|
mainListType = byte(0)
|
|
medalListType = byte(38)
|
|
extraSlotMask = byte(0x13)
|
|
topQualitySeed = uint32(999999998)
|
|
amplifyStamina = byte(1)
|
|
amplifyStrength = byte(3)
|
|
amplifyBaseValue = uint16(7)
|
|
itemRowSize = 0x77
|
|
)
|
|
|
|
type instanceConfig struct {
|
|
Database struct {
|
|
Host string `json:"host"`
|
|
Port int `json:"port"`
|
|
User string `json:"user"`
|
|
Password string `json:"password"`
|
|
Name string `json:"name"`
|
|
} `json:"database"`
|
|
}
|
|
|
|
type itemSpec struct {
|
|
ListType byte
|
|
Slot int16
|
|
ItemID int64
|
|
Name string
|
|
PVFPath string
|
|
Kind string
|
|
PVFType string
|
|
Rarity int64
|
|
Grade int64
|
|
Durability uint16
|
|
Count int64
|
|
UpgradeLevel byte
|
|
Amplify bool
|
|
}
|
|
|
|
type character struct {
|
|
ID string
|
|
AccountID string
|
|
Name string
|
|
Level int
|
|
SlotState byte
|
|
}
|
|
|
|
type amplifyRepairTarget struct {
|
|
Equipped bool
|
|
CollectionName string
|
|
EntryKey string
|
|
ItemID int64
|
|
RawEntry []byte
|
|
}
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "../runtime/config/instance.json", "runtime instance config")
|
|
pvfPath := flag.String("pvf", "../runtime/data/dnf/Script.pvf", "runtime Script.pvf")
|
|
apply := flag.Bool("apply", false, "commit the validated grant")
|
|
repairStrength := flag.Bool("repair-strength", false, "repair this batch from dimensional stamina to dimensional strength")
|
|
flag.Parse()
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
defer cancel()
|
|
|
|
specs := requestedItems()
|
|
if err := validateManifest(*pvfPath, specs); err != nil {
|
|
fatal(err)
|
|
}
|
|
cfg, err := loadConfig(*configPath)
|
|
if err != nil {
|
|
fatal(err)
|
|
}
|
|
db, err := openDatabase(cfg)
|
|
if err != nil {
|
|
fatal(err)
|
|
}
|
|
defer db.Close()
|
|
if err := db.PingContext(ctx); err != nil {
|
|
fatal(fmt.Errorf("ping database: %w", err))
|
|
}
|
|
|
|
if *repairStrength {
|
|
result, repaired, repairErr := repairBatchStrength(ctx, db, specs, *apply)
|
|
if repairErr != nil {
|
|
fatal(repairErr)
|
|
}
|
|
mode := "DRY-RUN"
|
|
if *apply {
|
|
mode = "APPLIED"
|
|
}
|
|
fmt.Printf("%s strength-repair character=%s id=%s level=%d rows=%d old_type=%d new_type=%d batch=%s\n",
|
|
mode, result.Name, result.ID, result.Level, repaired, amplifyStamina, amplifyStrength, batchID)
|
|
return
|
|
}
|
|
|
|
result, err := grant(ctx, db, specs, *apply)
|
|
if err != nil {
|
|
fatal(err)
|
|
}
|
|
mode := "DRY-RUN"
|
|
if *apply {
|
|
mode = "APPLIED"
|
|
}
|
|
fmt.Printf("%s character=%s id=%s level=%d old_slot_mask=%#x new_slot_mask=%#x rows=%d main=%d medals=%d guardian_gem_stacks=%d guardian_gem_units=%d batch=%s\n",
|
|
mode, result.Name, result.ID, result.Level, result.SlotState, result.SlotState|extraSlotMask,
|
|
len(specs), countList(specs, mainListType), countMedals(specs), countGuardianGems(specs), guardianGemUnits(specs), batchID)
|
|
}
|
|
|
|
func loadConfig(path string) (instanceConfig, error) {
|
|
var cfg instanceConfig
|
|
raw, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return cfg, fmt.Errorf("read instance config: %w", err)
|
|
}
|
|
if err := json.Unmarshal(raw, &cfg); err != nil {
|
|
return cfg, fmt.Errorf("parse instance config: %w", err)
|
|
}
|
|
if strings.TrimSpace(cfg.Database.Host) == "" || cfg.Database.Port <= 0 || strings.TrimSpace(cfg.Database.User) == "" || strings.TrimSpace(cfg.Database.Name) == "" {
|
|
return cfg, errors.New("instance config has incomplete database settings")
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func openDatabase(cfg instanceConfig) (*sql.DB, error) {
|
|
mysqlConfig := mysql.Config{
|
|
User: cfg.Database.User,
|
|
Passwd: cfg.Database.Password,
|
|
Net: "tcp",
|
|
Addr: net.JoinHostPort(cfg.Database.Host, strconv.Itoa(cfg.Database.Port)),
|
|
DBName: cfg.Database.Name,
|
|
ParseTime: true,
|
|
Loc: time.UTC,
|
|
Params: map[string]string{"charset": "utf8mb4"},
|
|
}
|
|
dsn := mysqlConfig.FormatDSN()
|
|
db, err := sql.Open("mysql", dsn)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("open database: %w", err)
|
|
}
|
|
return db, nil
|
|
}
|
|
|
|
func grant(ctx context.Context, db *sql.DB, specs []itemSpec, apply bool) (character, error) {
|
|
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
|
|
if err != nil {
|
|
return character{}, fmt.Errorf("begin grant transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
rows, err := tx.QueryContext(ctx, `SELECT character_id, account_id, name, level, ex_equip_slot_stat FROM dnf_characters WHERE level = 90 AND delete_flag = 0 FOR UPDATE`)
|
|
if err != nil {
|
|
return character{}, fmt.Errorf("lock level-90 character: %w", err)
|
|
}
|
|
var characters []character
|
|
for rows.Next() {
|
|
var row character
|
|
if err := rows.Scan(&row.ID, &row.AccountID, &row.Name, &row.Level, &row.SlotState); err != nil {
|
|
rows.Close()
|
|
return character{}, fmt.Errorf("scan level-90 character: %w", err)
|
|
}
|
|
characters = append(characters, row)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return character{}, fmt.Errorf("close character rows: %w", err)
|
|
}
|
|
if len(characters) != 1 {
|
|
return character{}, fmt.Errorf("expected exactly one active level-90 character, found %d", len(characters))
|
|
}
|
|
selected := characters[0]
|
|
|
|
occupiedRows, err := tx.QueryContext(ctx, `SELECT entry_key FROM dnf_inventory_items WHERE character_id = ? AND collection_name = 'slots' FOR UPDATE`, selected.ID)
|
|
if err != nil {
|
|
return character{}, fmt.Errorf("lock inventory: %w", err)
|
|
}
|
|
occupied := make(map[string]struct{})
|
|
for occupiedRows.Next() {
|
|
var key string
|
|
if err := occupiedRows.Scan(&key); err != nil {
|
|
occupiedRows.Close()
|
|
return character{}, fmt.Errorf("scan occupied inventory slot: %w", err)
|
|
}
|
|
occupied[key] = struct{}{}
|
|
}
|
|
if err := occupiedRows.Close(); err != nil {
|
|
return character{}, fmt.Errorf("close inventory rows: %w", err)
|
|
}
|
|
for _, spec := range specs {
|
|
key := itemKey(spec)
|
|
if _, exists := occupied[key]; exists {
|
|
return character{}, fmt.Errorf("required destination slot %s is occupied", key)
|
|
}
|
|
}
|
|
|
|
if !apply {
|
|
return selected, nil
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE dnf_characters SET ex_equip_slot_stat = ex_equip_slot_stat | ?, updated_at = UTC_TIMESTAMP(6) WHERE character_id = ?`, extraSlotMask, selected.ID); err != nil {
|
|
return character{}, fmt.Errorf("unlock extra equipment slots: %w", err)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE dnf_inventories SET updated_at = UTC_TIMESTAMP(6) WHERE character_id = ?`, selected.ID); err != nil {
|
|
return character{}, fmt.Errorf("touch inventory: %w", err)
|
|
}
|
|
for _, spec := range specs {
|
|
if err := insertItem(ctx, tx, selected.ID, spec); err != nil {
|
|
return character{}, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return character{}, fmt.Errorf("commit loadout grant: %w", err)
|
|
}
|
|
return selected, nil
|
|
}
|
|
|
|
func repairBatchStrength(ctx context.Context, db *sql.DB, specs []itemSpec, apply bool) (character, int, error) {
|
|
tx, err := db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
|
|
if err != nil {
|
|
return character{}, 0, fmt.Errorf("begin amplification repair transaction: %w", err)
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
rows, err := tx.QueryContext(ctx, `SELECT character_id, account_id, name, level, ex_equip_slot_stat FROM dnf_characters WHERE level = 90 AND delete_flag = 0 FOR UPDATE`)
|
|
if err != nil {
|
|
return character{}, 0, fmt.Errorf("lock level-90 character: %w", err)
|
|
}
|
|
var characters []character
|
|
for rows.Next() {
|
|
var row character
|
|
if err := rows.Scan(&row.ID, &row.AccountID, &row.Name, &row.Level, &row.SlotState); err != nil {
|
|
rows.Close()
|
|
return character{}, 0, fmt.Errorf("scan level-90 character: %w", err)
|
|
}
|
|
characters = append(characters, row)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return character{}, 0, fmt.Errorf("close character rows: %w", err)
|
|
}
|
|
if len(characters) != 1 {
|
|
return character{}, 0, fmt.Errorf("expected exactly one active level-90 character, found %d", len(characters))
|
|
}
|
|
selected := characters[0]
|
|
|
|
targets, err := loadAmplifyRepairTargets(ctx, tx, selected.ID)
|
|
if err != nil {
|
|
return character{}, 0, err
|
|
}
|
|
expected := make(map[int64]struct{}, 16)
|
|
for _, spec := range specs {
|
|
if spec.Amplify {
|
|
expected[spec.ItemID] = struct{}{}
|
|
}
|
|
}
|
|
if len(targets) != len(expected) {
|
|
return character{}, 0, fmt.Errorf("expected %d stamina-amplified batch rows, found %d", len(expected), len(targets))
|
|
}
|
|
seen := make(map[int64]struct{}, len(targets))
|
|
for _, target := range targets {
|
|
if _, ok := expected[target.ItemID]; !ok {
|
|
return character{}, 0, fmt.Errorf("unexpected batch item %d at %s", target.ItemID, target.EntryKey)
|
|
}
|
|
if _, duplicate := seen[target.ItemID]; duplicate {
|
|
return character{}, 0, fmt.Errorf("duplicate batch item %d", target.ItemID)
|
|
}
|
|
seen[target.ItemID] = struct{}{}
|
|
if len(target.RawEntry) != itemRowSize || target.RawEntry[0x0A]&0x1F != 13 || target.RawEntry[0x13] != amplifyStamina || binary.LittleEndian.Uint16(target.RawEntry[0x14:0x16]) != amplifyBaseValue {
|
|
return character{}, 0, fmt.Errorf("item %d at %s has unexpected raw amplification state", target.ItemID, target.EntryKey)
|
|
}
|
|
}
|
|
if !apply {
|
|
return selected, len(targets), nil
|
|
}
|
|
|
|
for _, target := range targets {
|
|
raw := append([]byte(nil), target.RawEntry...)
|
|
raw[0x13] = amplifyStrength
|
|
if target.Equipped {
|
|
result, err := tx.ExecContext(ctx, `UPDATE dnf_equipment_entries SET raw_entry = ? WHERE character_id = ? AND entry_key = ? AND item_id = ?`, raw, selected.ID, target.EntryKey, target.ItemID)
|
|
if err != nil {
|
|
return character{}, 0, fmt.Errorf("update equipped raw item %d: %w", target.ItemID, err)
|
|
}
|
|
if err := requireAffected(result, 1, "equipped raw", target.ItemID); err != nil {
|
|
return character{}, 0, err
|
|
}
|
|
result, err = tx.ExecContext(ctx, `UPDATE dnf_equipment_entry_extra SET extra_value = ? WHERE character_id = ? AND entry_key = ? AND extra_key IN ('amplify_type','amplification_type','byte_13','value_13','value_c') AND extra_value = ?`,
|
|
strconv.Itoa(int(amplifyStrength)), selected.ID, target.EntryKey, strconv.Itoa(int(amplifyStamina)))
|
|
if err != nil {
|
|
return character{}, 0, fmt.Errorf("update equipped amplification metadata item %d: %w", target.ItemID, err)
|
|
}
|
|
if err := requireAffected(result, 5, "equipped amplification metadata", target.ItemID); err != nil {
|
|
return character{}, 0, err
|
|
}
|
|
continue
|
|
}
|
|
result, err := tx.ExecContext(ctx, `UPDATE dnf_inventory_items SET raw_entry = ? WHERE character_id = ? AND collection_name = ? AND entry_key = ? AND item_id = ?`,
|
|
raw, selected.ID, target.CollectionName, target.EntryKey, target.ItemID)
|
|
if err != nil {
|
|
return character{}, 0, fmt.Errorf("update inventory raw item %d: %w", target.ItemID, err)
|
|
}
|
|
if err := requireAffected(result, 1, "inventory raw", target.ItemID); err != nil {
|
|
return character{}, 0, err
|
|
}
|
|
result, err = tx.ExecContext(ctx, `UPDATE dnf_inventory_item_extra SET extra_value = ? WHERE character_id = ? AND collection_name = ? AND entry_key = ? AND extra_key IN ('amplify_type','amplification_type','byte_13','value_13','value_c') AND extra_value = ?`,
|
|
strconv.Itoa(int(amplifyStrength)), selected.ID, target.CollectionName, target.EntryKey, strconv.Itoa(int(amplifyStamina)))
|
|
if err != nil {
|
|
return character{}, 0, fmt.Errorf("update inventory amplification metadata item %d: %w", target.ItemID, err)
|
|
}
|
|
if err := requireAffected(result, 5, "inventory amplification metadata", target.ItemID); err != nil {
|
|
return character{}, 0, err
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `UPDATE dnf_inventories SET updated_at = UTC_TIMESTAMP(6) WHERE character_id = ?`, selected.ID); err != nil {
|
|
return character{}, 0, fmt.Errorf("touch inventory after amplification repair: %w", err)
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return character{}, 0, fmt.Errorf("commit amplification repair: %w", err)
|
|
}
|
|
return selected, len(targets), nil
|
|
}
|
|
|
|
func loadAmplifyRepairTargets(ctx context.Context, tx *sql.Tx, characterID string) ([]amplifyRepairTarget, error) {
|
|
var targets []amplifyRepairTarget
|
|
rows, err := tx.QueryContext(ctx, `SELECT i.collection_name, i.entry_key, i.item_id, i.raw_entry FROM dnf_inventory_items i JOIN dnf_inventory_item_extra b ON b.character_id=i.character_id AND b.collection_name=i.collection_name AND b.entry_key=i.entry_key AND b.extra_key='grant_batch' AND b.extra_value=? JOIN dnf_inventory_item_extra a ON a.character_id=i.character_id AND a.collection_name=i.collection_name AND a.entry_key=i.entry_key AND a.extra_key='amplify_type' AND a.extra_value=? WHERE i.character_id=? FOR UPDATE`,
|
|
batchID, strconv.Itoa(int(amplifyStamina)), characterID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lock inventory amplification rows: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
var target amplifyRepairTarget
|
|
if err := rows.Scan(&target.CollectionName, &target.EntryKey, &target.ItemID, &target.RawEntry); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("scan inventory amplification row: %w", err)
|
|
}
|
|
targets = append(targets, target)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, fmt.Errorf("close inventory amplification rows: %w", err)
|
|
}
|
|
|
|
rows, err = tx.QueryContext(ctx, `SELECT e.entry_key, e.item_id, e.raw_entry FROM dnf_equipment_entries e JOIN dnf_equipment_entry_extra b ON b.character_id=e.character_id AND b.entry_key=e.entry_key AND b.extra_key='grant_batch' AND b.extra_value=? JOIN dnf_equipment_entry_extra a ON a.character_id=e.character_id AND a.entry_key=e.entry_key AND a.extra_key='amplify_type' AND a.extra_value=? WHERE e.character_id=? FOR UPDATE`,
|
|
batchID, strconv.Itoa(int(amplifyStamina)), characterID)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("lock equipped amplification rows: %w", err)
|
|
}
|
|
for rows.Next() {
|
|
target := amplifyRepairTarget{Equipped: true}
|
|
if err := rows.Scan(&target.EntryKey, &target.ItemID, &target.RawEntry); err != nil {
|
|
rows.Close()
|
|
return nil, fmt.Errorf("scan equipped amplification row: %w", err)
|
|
}
|
|
targets = append(targets, target)
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return nil, fmt.Errorf("close equipped amplification rows: %w", err)
|
|
}
|
|
return targets, nil
|
|
}
|
|
|
|
func requireAffected(result sql.Result, want int64, field string, itemID int64) error {
|
|
got, err := result.RowsAffected()
|
|
if err != nil {
|
|
return fmt.Errorf("read affected rows for %s item %d: %w", field, itemID, err)
|
|
}
|
|
if got != want {
|
|
return fmt.Errorf("%s item %d affected %d rows, want %d", field, itemID, got, want)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func insertItem(ctx context.Context, tx *sql.Tx, characterID string, spec itemSpec) error {
|
|
key := itemKey(spec)
|
|
raw := buildRaw(spec)
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO dnf_inventory_items (character_id, collection_name, entry_key, item_id, item_count, bind_flag, expire_at, raw_entry) VALUES (?, 'slots', ?, ?, ?, 0, NULL, ?)`,
|
|
characterID, key, spec.ItemID, spec.Count, raw); err != nil {
|
|
return fmt.Errorf("insert %s (%d) at %s: %w", spec.Name, spec.ItemID, key, err)
|
|
}
|
|
extra := itemExtra(spec)
|
|
keys := make([]string, 0, len(extra))
|
|
for extraKey := range extra {
|
|
keys = append(keys, extraKey)
|
|
}
|
|
sort.Strings(keys)
|
|
for _, extraKey := range keys {
|
|
if _, err := tx.ExecContext(ctx, `INSERT INTO dnf_inventory_item_extra (character_id, collection_name, entry_key, extra_key, extra_value) VALUES (?, 'slots', ?, ?, ?)`,
|
|
characterID, key, extraKey, extra[extraKey]); err != nil {
|
|
return fmt.Errorf("insert metadata %s for %s at %s: %w", extraKey, spec.Name, key, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildRaw(spec itemSpec) []byte {
|
|
raw := make([]byte, itemRowSize)
|
|
binary.LittleEndian.PutUint16(raw[0x00:0x02], uint16(spec.Slot))
|
|
binary.LittleEndian.PutUint32(raw[0x02:0x06], uint32(spec.ItemID))
|
|
amount := uint32(spec.Count)
|
|
if spec.ListType == mainListType && spec.Kind == "equipment" {
|
|
amount = topQualitySeed
|
|
}
|
|
binary.LittleEndian.PutUint32(raw[0x06:0x0A], amount)
|
|
raw[0x0A] = spec.UpgradeLevel & 0x1F
|
|
binary.LittleEndian.PutUint16(raw[0x0B:0x0D], spec.Durability)
|
|
if spec.Kind == "stackable" {
|
|
binary.LittleEndian.PutUint32(raw[0x0E:0x12], uint32(spec.ItemID))
|
|
}
|
|
if spec.Amplify {
|
|
raw[0x13] = amplifyStrength
|
|
binary.LittleEndian.PutUint16(raw[0x14:0x16], amplifyBaseValue)
|
|
}
|
|
return raw
|
|
}
|
|
|
|
func itemExtra(spec itemSpec) map[string]string {
|
|
extra := map[string]string{
|
|
"source": "operator_direct_database_grant",
|
|
"grant_batch": batchID,
|
|
"item_name": spec.Name,
|
|
"item_kind": spec.Kind,
|
|
"pvf_path": spec.PVFPath,
|
|
}
|
|
if spec.PVFType != "" {
|
|
if spec.Kind == "equipment" {
|
|
extra["equipment_type"] = spec.PVFType
|
|
} else {
|
|
extra["stackable_type"] = spec.PVFType
|
|
}
|
|
}
|
|
if spec.Kind == "equipment" {
|
|
extra["durability"] = strconv.Itoa(int(spec.Durability))
|
|
extra["max_durability"] = strconv.Itoa(int(spec.Durability))
|
|
if spec.ListType == mainListType {
|
|
extra["quality_seed"] = strconv.FormatUint(uint64(topQualitySeed), 10)
|
|
}
|
|
if spec.UpgradeLevel != 0 {
|
|
level := strconv.Itoa(int(spec.UpgradeLevel))
|
|
extra["ext_data0"] = level
|
|
extra["packed_flag_byte"] = level
|
|
extra["reinforce"] = level
|
|
extra["upgrade_level"] = level
|
|
}
|
|
} else {
|
|
count := strconv.FormatInt(spec.Count, 10)
|
|
extra["amount"] = count
|
|
extra["amount_or_count"] = count
|
|
extra["count"] = count
|
|
extra["value_a"] = strconv.FormatInt(spec.ItemID, 10)
|
|
}
|
|
if spec.Amplify {
|
|
typeValue := strconv.Itoa(int(amplifyStrength))
|
|
baseValue := strconv.Itoa(int(amplifyBaseValue))
|
|
for _, key := range []string{"amplify_type", "amplification_type", "byte_13", "value_13", "value_c"} {
|
|
extra[key] = typeValue
|
|
}
|
|
for _, key := range []string{"amplify_value", "amplification_value", "marker_16", "marker16", "value_d"} {
|
|
extra[key] = baseValue
|
|
}
|
|
}
|
|
return extra
|
|
}
|
|
|
|
func itemKey(spec itemSpec) string {
|
|
return fmt.Sprintf("%d:%d", spec.ListType, spec.Slot)
|
|
}
|
|
|
|
func validateManifest(pvfPath string, specs []itemSpec) error {
|
|
archive, err := platformpvf.Open(pvfPath)
|
|
if err != nil {
|
|
return fmt.Errorf("open runtime PVF: %w", err)
|
|
}
|
|
seen := make(map[string]struct{}, len(specs))
|
|
for _, spec := range specs {
|
|
key := itemKey(spec)
|
|
if _, exists := seen[key]; exists {
|
|
return fmt.Errorf("duplicate destination slot %s", key)
|
|
}
|
|
seen[key] = struct{}{}
|
|
text, err := archive.ReadText(spec.PVFPath)
|
|
if err != nil {
|
|
return fmt.Errorf("read PVF item %d at %s: %w", spec.ItemID, spec.PVFPath, err)
|
|
}
|
|
doc, err := dnfpvf.Parse(spec.PVFPath, text)
|
|
if err != nil {
|
|
return fmt.Errorf("parse PVF item %d at %s: %w", spec.ItemID, spec.PVFPath, err)
|
|
}
|
|
name, _ := doc.Text("name")
|
|
if name != spec.Name {
|
|
return fmt.Errorf("PVF name mismatch item %d: got %q want %q", spec.ItemID, name, spec.Name)
|
|
}
|
|
section := "equipment type"
|
|
if spec.Kind == "stackable" {
|
|
section = "stackable type"
|
|
}
|
|
pvfType, _ := doc.Text(section)
|
|
if !strings.EqualFold(strings.TrimSpace(pvfType), spec.PVFType) {
|
|
return fmt.Errorf("PVF type mismatch item %d: got %q want %q", spec.ItemID, pvfType, spec.PVFType)
|
|
}
|
|
rarity, _ := doc.Int("rarity")
|
|
if rarity != spec.Rarity {
|
|
return fmt.Errorf("PVF rarity mismatch item %d: got %d want %d", spec.ItemID, rarity, spec.Rarity)
|
|
}
|
|
if spec.Grade != 0 {
|
|
grade, _ := doc.Int("grade")
|
|
if grade != spec.Grade {
|
|
return fmt.Errorf("PVF grade mismatch item %d: got %d want %d", spec.ItemID, grade, spec.Grade)
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func requestedItems() []itemSpec {
|
|
main := []itemSpec{
|
|
{ItemID: 100070551, Name: "超大陆 - 瓦巴拉的大地", PVFPath: "equipment/character/common/jacket/larmor/100070551.equ", PVFType: "[coat]", Rarity: 4, Durability: 38},
|
|
{ItemID: 100120544, Name: "超大陆 - 盘古大陆的地震", PVFPath: "equipment/character/common/pants/larmor/100120544.equ", PVFType: "[pants]", Rarity: 4, Durability: 32},
|
|
{ItemID: 100170519, Name: "超大陆 - 潘诺西亚的火山", PVFPath: "equipment/character/common/shoulder/larmor/100170519.equ", PVFType: "[shoulder]", Rarity: 4, Durability: 30},
|
|
{ItemID: 100220519, Name: "超大陆 - 罗迪尼亚的熔岩", PVFPath: "equipment/character/common/belt/larmor/100220519.equ", PVFType: "[waist]", Rarity: 4, Durability: 25},
|
|
{ItemID: 100270515, Name: "超大陆 - 凯诺兰的地壳", PVFPath: "equipment/character/common/shoes/larmor/100270515.equ", PVFType: "[shoes]", Rarity: 4, Durability: 25},
|
|
{ItemID: 100300733, Name: "氤氲之息", PVFPath: "equipment/character/common/amulet/100300733.equ", PVFType: "[amulet]", Rarity: 4},
|
|
{ItemID: 100312425, Name: "启明星的指引", PVFPath: "equipment/character/common/wrist/100312425.equ", PVFType: "[wrist]", Rarity: 4},
|
|
{ItemID: 100322294, Name: "清泉流响", PVFPath: "equipment/character/common/ring/100322294.equ", PVFType: "[ring]", Rarity: 4},
|
|
{ItemID: 100390011, Name: "黑暗祭礼", PVFPath: "equipment/character/common/earring/100390011.equ", PVFType: "[earring]", Rarity: 4},
|
|
{ItemID: 100344511, Name: "波利斯的黄金杯", PVFPath: "equipment/character/common/support/100344511.equ", PVFType: "[support]", Rarity: 4},
|
|
{ItemID: 100352822, Name: "罗塞塔石碑", PVFPath: "equipment/character/common/magicstone/100352822.equ", PVFType: "[magic stone]", Rarity: 4},
|
|
{ItemID: 100390003, Name: "英雄王的象征", PVFPath: "equipment/character/common/earring/100390003.equ", PVFType: "[earring]", Rarity: 4},
|
|
{ItemID: 100344527, Name: "王冠非冠", PVFPath: "equipment/character/common/support/100344527.equ", PVFType: "[support]", Rarity: 4},
|
|
{ItemID: 100352839, Name: "王座本源", PVFPath: "equipment/character/common/magicstone/100352839.equ", PVFType: "[magic stone]", Rarity: 4},
|
|
{ItemID: 101010864, Name: "圣耀救赎太刀", PVFPath: "equipment/character/swordman/weapon/katana/101010864.equ", PVFType: "[weapon]", Rarity: 4, Durability: 45},
|
|
{ItemID: 101030741, Name: "圣耀救赎巨剑", PVFPath: "equipment/character/swordman/weapon/hsword/101030741.equ", PVFType: "[weapon]", Rarity: 4, Durability: 45},
|
|
{ItemID: 400330106, Name: "天选之人", PVFPath: "equipment/character/common/title/chn_400330106.equ", PVFType: "[title name]", Rarity: 6},
|
|
}
|
|
for index := range main {
|
|
main[index].ListType = mainListType
|
|
main[index].Slot = int16(9 + index)
|
|
main[index].Kind = "equipment"
|
|
main[index].Count = 1
|
|
if index < len(main)-1 {
|
|
main[index].UpgradeLevel = 13
|
|
main[index].Amplify = true
|
|
}
|
|
}
|
|
|
|
medalNames := []string{
|
|
"完美的野兽图腾勋章", "完美的神速图腾勋章", "完美的斗志图腾勋章", "完美的集中图腾勋章", "完美的减速图腾勋章", "完美的协作图腾勋章", "完美的再生图腾勋章",
|
|
"完美的守护图腾勋章", "完美的祝福图腾勋章", "完美的惩罚图腾勋章", "完美的元气图腾勋章", "完美的觉醒图腾勋章", "完美的坚韧图腾勋章",
|
|
}
|
|
medals := make([]itemSpec, 0, len(medalNames))
|
|
for index, name := range medalNames {
|
|
itemID := int64(100380051 + index)
|
|
medals = append(medals, itemSpec{
|
|
ListType: medalListType, Slot: int16(index), ItemID: itemID, Name: name,
|
|
PVFPath: fmt.Sprintf("equipment/character/common/flag/%d.equ", itemID), Kind: "equipment", PVFType: "[flag]",
|
|
Rarity: 6, Grade: 55, Count: 1, UpgradeLevel: 16,
|
|
})
|
|
}
|
|
|
|
gemIDs := []int64{90003, 90013, 90023, 90033, 90043, 90053, 90063, 90073, 90083, 90093, 90103, 90113, 90123}
|
|
gemNames := []string{
|
|
"玲珑之光守护珠 (物理防御力)", "玲珑之光守护珠 (魔法防御力)", "玲珑之光守护珠 (攻击速度)", "玲珑之光守护珠 (移动速度)", "玲珑之光守护珠 (施放速度)",
|
|
"玲珑之光守护珠 (物理暴击)", "玲珑之光守护珠 (魔法暴击)", "玲珑之光守护珠 (火属性强化)", "玲珑之光守护珠 (冰属性强化)", "玲珑之光守护珠 (暗属性强化)",
|
|
"玲珑之光守护珠 (光属性强化)", "玲珑之光守护珠 (命中率)", "玲珑之光守护珠 (负重上限)",
|
|
}
|
|
gems := make([]itemSpec, 0, len(gemIDs))
|
|
for index, itemID := range gemIDs {
|
|
gems = append(gems, itemSpec{
|
|
ListType: medalListType, Slot: int16(49 + index), ItemID: itemID, Name: gemNames[index],
|
|
PVFPath: fmt.Sprintf("stackable/flaggem/%d.stk", itemID), Kind: "stackable", PVFType: "[flag gem]",
|
|
Rarity: 3, Grade: 3, Count: 10,
|
|
})
|
|
}
|
|
return append(append(main, medals...), gems...)
|
|
}
|
|
|
|
func countList(specs []itemSpec, listType byte) int {
|
|
count := 0
|
|
for _, spec := range specs {
|
|
if spec.ListType == listType {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func countMedals(specs []itemSpec) int {
|
|
count := 0
|
|
for _, spec := range specs {
|
|
if spec.PVFType == "[flag]" {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func countGuardianGems(specs []itemSpec) int {
|
|
count := 0
|
|
for _, spec := range specs {
|
|
if spec.PVFType == "[flag gem]" {
|
|
count++
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func guardianGemUnits(specs []itemSpec) int64 {
|
|
var count int64
|
|
for _, spec := range specs {
|
|
if spec.PVFType == "[flag gem]" {
|
|
count += spec.Count
|
|
}
|
|
}
|
|
return count
|
|
}
|
|
|
|
func fatal(err error) {
|
|
fmt.Fprintln(os.Stderr, "grantlevel90loadout:", err)
|
|
os.Exit(1)
|
|
}
|