Files
90CNServer/server/cmd/scratch/invpageinspect/main.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

548 lines
16 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// invpageinspect 只读查看某个角色主背包某一页的落点分布,判断"这一页到底还能不能放"。
//
// 用途(现场 2026-10-03 服主报):时装分解的徽章产出会溢出邮件、领取还报"物品栏不足"。
// 要分清是"页真的满了"、"[stack limit] 顶住"、还是"页没满但发放/领取路径算错了",
// 只能看库里真实的行分布。
//
// 只读:只 SELECT。
//
// 跑法:
//
// go run ./cmd/scratch/invpageinspect -character 1 -page 289-344
// go run ./cmd/scratch/invpageinspect -character 7 -page 289-344 -dsn "<mysql dsn>"
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
_ "github.com/go-sql-driver/mysql"
)
func main() {
characterID := flag.String("character", "", "character id to inspect (required)")
page := flag.String("page", "289-344", "main-inventory slot range, e.g. 289-344")
listType := flag.Int("list", 0, "inventory list type stored in the entry key")
itemID := flag.Int64("item", 0, "optional item id to dump across the whole container")
mailID := flag.Int64("mail", 0, "optional mail id to dump (state + attachments)")
questID := flag.Int64("quest", 0, "optional quest id to dump (state + extra)")
dsn := flag.String("dsn", "", "mysql dsn (default: the deployed runtime logic.toml)")
limit := flag.Int("limit", 200, "max rows to print")
flag.Parse()
if *characterID == "" {
fmt.Fprintln(os.Stderr, "FAILED: -character is required")
os.Exit(2)
}
start, end, err := parsePage(*page)
if err != nil {
fmt.Fprintln(os.Stderr, "FAILED:", err)
os.Exit(2)
}
resolvedDSN := *dsn
if resolvedDSN == "" {
resolvedDSN, err = dsnFromRuntimeConfig()
if err != nil {
fmt.Fprintln(os.Stderr, "FAILED:", err)
os.Exit(2)
}
}
db, err := sql.Open("mysql", resolvedDSN)
if err != nil {
fmt.Fprintln(os.Stderr, "FAILED:", err)
os.Exit(1)
}
defer db.Close()
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := db.PingContext(ctx); err != nil {
fmt.Fprintln(os.Stderr, "FAILED: mysql unreachable:", err)
os.Exit(1)
}
schema, table, err := locateInventoryTable(ctx, db)
if err != nil {
fmt.Fprintln(os.Stderr, "FAILED:", err)
os.Exit(1)
}
fmt.Printf("schema=%s table=%s character=%s list=%d page=%d..%d\n\n",
schema, table, *characterID, *listType, start, end)
rows, err := db.QueryContext(ctx,
"SELECT entry_key, item_id, item_count FROM `"+schema+"`.`"+table+
"` WHERE character_id = ? ORDER BY entry_key", *characterID)
if err != nil {
fmt.Fprintln(os.Stderr, "FAILED: query:", err)
os.Exit(1)
}
defer rows.Close()
type row struct {
key string
item int64
count int64
}
inPage := map[int]row{}
allLists := map[string]int{}
for rows.Next() {
var r row
var item, count sql.NullInt64
if err := rows.Scan(&r.key, &item, &count); err != nil {
fmt.Fprintln(os.Stderr, "FAILED: scan:", err)
os.Exit(1)
}
r.item, r.count = item.Int64, count.Int64
keyList, slot, ok := parseEntryKey(r.key)
if !ok {
allLists["unparsed:"+r.key]++
continue
}
allLists[fmt.Sprintf("list %d", keyList)]++
if keyList != *listType || slot < start || slot > end {
continue
}
inPage[slot] = r
}
if err := rows.Err(); err != nil {
fmt.Fprintln(os.Stderr, "FAILED: rows:", err)
os.Exit(1)
}
slots := make([]int, 0, len(inPage))
for slot := range inPage {
slots = append(slots, slot)
}
sort.Ints(slots)
for index, slot := range slots {
if index >= *limit {
fmt.Printf("... (%d more rows)\n", len(slots)-index)
break
}
r := inPage[slot]
fmt.Printf(" slot %3d item=%-12d count=%-6d key=%s\n", slot, r.item, r.count, r.key)
}
free := make([]int, 0, end-start+1)
for slot := start; slot <= end; slot++ {
if _, occupied := inPage[slot]; !occupied {
free = append(free, slot)
}
}
fmt.Printf("\n这一页:行数=%d 空格数=%d\n", len(slots), len(free))
fmt.Printf("空格: %s\n", compactSlots(free))
if len(slots) > 0 {
fmt.Printf("占用: %s\n", compactSlots(slots))
}
totals := map[int64]int64{}
for _, r := range inPage {
totals[r.item] += r.count
}
if len(totals) > 0 {
fmt.Printf("\n本页各道具总量([stack limit] 判定用的是整个容器,不只是本页):\n")
ids := make([]int64, 0, len(totals))
for id := range totals {
ids = append(ids, id)
}
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
for _, id := range ids {
containerTotal, containerRows, err := containerItemTotal(ctx, db, schema, table, *characterID, *listType, id)
if err != nil {
return
}
fmt.Printf(" item %-12d 本页 %-6d 整容器 %-6d(%d 行)\n", id, totals[id], containerTotal, containerRows)
}
}
otherLists := make([]string, 0, len(allLists))
for name := range allLists {
otherLists = append(otherLists, name)
}
sort.Strings(otherLists)
fmt.Printf("\n该角色所有容器:\n")
for _, name := range otherLists {
fmt.Printf(" %-20s %d 行\n", name, allLists[name])
}
if *itemID > 0 {
fmt.Printf("\n指定道具 item=%d 在容器里的分布:\n", *itemID)
itemRows, err := db.QueryContext(ctx,
"SELECT entry_key, item_count FROM `"+schema+"`.`"+table+
"` WHERE character_id = ? AND item_id = ? ORDER BY entry_key", *characterID, *itemID)
if err != nil {
fmt.Fprintln(os.Stderr, "FAILED: item query:", err)
os.Exit(1)
}
defer itemRows.Close()
var total int64
for itemRows.Next() {
var key string
var count sql.NullInt64
if err := itemRows.Scan(&key, &count); err != nil {
fmt.Fprintln(os.Stderr, "FAILED: item scan:", err)
os.Exit(1)
}
fmt.Printf(" key=%-12s count=%d\n", key, count.Int64)
total += count.Int64
}
fmt.Printf(" 合计 %d\n", total)
}
if *mailID > 0 {
fmt.Printf("\n邮件 mail_id=%d:\n", *mailID)
if err := dumpMail(ctx, db, schema, *mailID); err != nil {
fmt.Fprintln(os.Stderr, "mail dump FAILED:", err)
}
}
if *questID > 0 {
fmt.Printf("\n任务 quest_id=%d(char=%s):\n", *questID, *characterID)
if err := dumpQuestState(ctx, db, schema, *characterID, *questID); err != nil {
fmt.Fprintln(os.Stderr, "quest dump FAILED:", err)
}
}
}
// dumpQuestState 打印一条任务状态行(含 reward_state / completion_key 与结算回执摘要)。
//
// 现场 2026-10-03:交任务后客户端不显示"完成",要小退才看见 —— 需要直接看库里
// 这条任务到底是 completed 还是仍 active,以及回执有没有落库。
func dumpQuestState(ctx context.Context, db *sql.DB, schema, characterID string, questID int64) error {
table, err := firstExistingTable(ctx, db, schema, "quest_states")
if err != nil || table == "" {
return fmt.Errorf("no quest_states table: %v", err)
}
columns, err := tableColumns(ctx, db, schema, table)
if err != nil {
return err
}
rows, err := db.QueryContext(ctx,
"SELECT "+strings.Join(columns, ", ")+" FROM `"+schema+"`.`"+table+"` WHERE character_id = ? AND quest_id = ?",
characterID, questID)
if err != nil {
return err
}
defer rows.Close()
values := make([]any, len(columns))
pointers := make([]any, len(columns))
for i := range values {
pointers[i] = &values[i]
}
found := false
for rows.Next() {
found = true
if err := rows.Scan(pointers...); err != nil {
return err
}
for i, name := range columns {
if strings.Contains(name, "receipt") {
fmt.Printf(" %s = <%d bytes>\n", strings.Trim(name, "`"), len(fmt.Sprint(values[i])))
continue
}
fmt.Printf(" %s = %v\n", strings.Trim(name, "`"), values[i])
}
}
if err := rows.Err(); err != nil {
return err
}
if !found {
fmt.Printf(" 没有 quest_id=%d 的状态行\n", questID)
}
extraTable, err := firstExistingTable(ctx, db, schema, "quest_state_extra")
if err != nil || extraTable == "" {
return nil
}
extraColumns, err := tableColumns(ctx, db, schema, extraTable)
if err != nil {
return err
}
extraRows, err := db.QueryContext(ctx,
"SELECT "+strings.Join(extraColumns, ", ")+" FROM `"+schema+"`.`"+extraTable+"` WHERE character_id = ? AND quest_id = ?",
characterID, questID)
if err != nil {
return err
}
defer extraRows.Close()
extraValues := make([]any, len(extraColumns))
extraPointers := make([]any, len(extraColumns))
for i := range extraValues {
extraPointers[i] = &extraValues[i]
}
fmt.Printf(" extra:\n")
for extraRows.Next() {
if err := extraRows.Scan(extraPointers...); err != nil {
return err
}
parts := make([]string, 0, len(extraColumns))
for i, name := range extraColumns {
value := fmt.Sprint(extraValues[i])
if len(value) > 220 {
value = value[:220] + "..."
}
parts = append(parts, fmt.Sprintf("%s=%s", strings.Trim(name, "`"), value))
}
fmt.Printf(" %s\n", strings.Join(parts, " "))
}
return extraRows.Err()
}
// dumpMail 打印一封邮件的状态与附件。表名与列名都从 information_schema 里现查,
// 分库分表前缀变了也不用改这里。
func dumpMail(ctx context.Context, db *sql.DB, schema string, mailID int64) error {
mailsTable, err := firstExistingTable(ctx, db, schema, "mails")
if err != nil {
return err
}
if mailsTable == "" {
return fmt.Errorf("no mails table in schema %s", schema)
}
columns, err := tableColumns(ctx, db, schema, mailsTable)
if err != nil {
return err
}
if len(columns) == 0 {
return fmt.Errorf("table %s has no columns", mailsTable)
}
fmt.Printf(" 表 %s\n", mailsTable)
rows, err := db.QueryContext(ctx,
"SELECT "+strings.Join(columns, ", ")+" FROM `"+schema+"`.`"+mailsTable+"` WHERE mail_id = ?", mailID)
if err != nil {
return err
}
defer rows.Close()
values := make([]any, len(columns))
pointers := make([]any, len(columns))
for i := range values {
pointers[i] = &values[i]
}
found := false
for rows.Next() {
found = true
if err := rows.Scan(pointers...); err != nil {
return err
}
for i, name := range columns {
fmt.Printf(" %s = %v\n", strings.Trim(name, "`"), values[i])
}
}
if err := rows.Err(); err != nil {
return err
}
if !found {
fmt.Printf(" 没有 mail_id=%d 的行\n", mailID)
}
attachmentsTable, err := firstExistingTable(ctx, db, schema, "mail_attachments")
if err != nil || attachmentsTable == "" {
return err
}
attachmentColumns, err := tableColumns(ctx, db, schema, attachmentsTable)
if err != nil {
return err
}
attachmentRows, err := db.QueryContext(ctx,
"SELECT "+strings.Join(attachmentColumns, ", ")+" FROM `"+schema+"`.`"+attachmentsTable+"` WHERE mail_id = ?", mailID)
if err != nil {
return err
}
defer attachmentRows.Close()
attachmentValues := make([]any, len(attachmentColumns))
attachmentPointers := make([]any, len(attachmentColumns))
for i := range attachmentValues {
attachmentPointers[i] = &attachmentValues[i]
}
fmt.Printf(" 附件(表 %s):\n", attachmentsTable)
for attachmentRows.Next() {
if err := attachmentRows.Scan(attachmentPointers...); err != nil {
return err
}
parts := make([]string, 0, len(attachmentColumns))
for i, name := range attachmentColumns {
parts = append(parts, fmt.Sprintf("%s=%v", strings.Trim(name, "`"), attachmentValues[i]))
}
fmt.Printf(" %s\n", strings.Join(parts, " "))
}
return attachmentRows.Err()
}
// firstExistingTable 在 schema 里找以 suffix 结尾的表(分库分表会带前缀)。
func firstExistingTable(ctx context.Context, db *sql.DB, schema, suffix string) (string, error) {
rows, err := db.QueryContext(ctx,
"SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_name LIKE ? ORDER BY LENGTH(table_name)",
schema, "%_"+suffix)
if err != nil {
return "", err
}
defer rows.Close()
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return "", err
}
return name, nil
}
return "", rows.Err()
}
func tableColumns(ctx context.Context, db *sql.DB, schema, table string) ([]string, error) {
rows, err := db.QueryContext(ctx,
"SELECT column_name FROM information_schema.columns WHERE table_schema = ? AND table_name = ? ORDER BY ordinal_position",
schema, table)
if err != nil {
return nil, err
}
defer rows.Close()
var columns []string
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return nil, err
}
if name == "body" || name == "raw_entry" || name == "extra_json" {
continue
}
columns = append(columns, "`"+name+"`")
}
return columns, rows.Err()
}
func parseEntryKey(key string) (int, int, bool) {
listPart, slotPart, found := strings.Cut(strings.TrimSpace(key), ":")
if !found {
return 0, 0, false
}
list, err := strconv.Atoi(listPart)
if err != nil {
return 0, 0, false
}
slot, err := strconv.Atoi(slotPart)
if err != nil {
return 0, 0, false
}
return list, slot, true
}
func containerItemTotal(
ctx context.Context,
db *sql.DB,
schema, table, characterID string,
listType int,
itemID int64,
) (int64, int, error) {
prefix := strconv.Itoa(listType) + ":%"
row := db.QueryRowContext(ctx,
"SELECT COALESCE(SUM(item_count),0), COUNT(*) FROM `"+schema+"`.`"+table+
"` WHERE character_id = ? AND entry_key LIKE ? AND item_id = ?", characterID, prefix, itemID)
var total int64
var count int
if err := row.Scan(&total, &count); err != nil {
fmt.Fprintln(os.Stderr, "FAILED: container total:", err)
return 0, 0, err
}
return total, count, nil
}
func parsePage(value string) (int, int, error) {
parts := strings.SplitN(strings.TrimSpace(value), "-", 2)
if len(parts) != 2 {
return 0, 0, fmt.Errorf("bad -page %q, want START-END", value)
}
start, err := strconv.Atoi(strings.TrimSpace(parts[0]))
if err != nil {
return 0, 0, fmt.Errorf("bad -page start %q: %w", parts[0], err)
}
end, err := strconv.Atoi(strings.TrimSpace(parts[1]))
if err != nil {
return 0, 0, fmt.Errorf("bad -page end %q: %w", parts[1], err)
}
if start < 0 || end < start {
return 0, 0, fmt.Errorf("bad -page %q", value)
}
return start, end, nil
}
// dsnFromRuntimeConfig 读部署目录里的 logic.toml,用与线上完全同一个 DSN。
func dsnFromRuntimeConfig() (string, error) {
candidates := []string{
`runtime\configs\dnf\logic.toml`,
`..\runtime\configs\dnf\logic.toml`,
}
for _, path := range candidates {
raw, err := os.ReadFile(path)
if err != nil {
continue
}
for _, line := range strings.Split(string(raw), "\n") {
trimmed := strings.TrimSpace(line)
if !strings.HasPrefix(trimmed, "mysql_dsn") {
continue
}
_, value, found := strings.Cut(trimmed, "=")
if !found {
continue
}
return strings.Trim(strings.TrimSpace(value), `"`), nil
}
}
return "", fmt.Errorf("mysql_dsn not found; pass -dsn explicitly")
}
// locateInventoryTable 找出真正的角色背包表(区分表名必须精确,account_inventory_items
// 也含 "inventory_items" 子串,分库分表后还会带区服/世界前缀)。
func locateInventoryTable(ctx context.Context, db *sql.DB) (string, string, error) {
rows, err := db.QueryContext(ctx,
"SELECT table_schema, table_name FROM information_schema.tables WHERE table_name LIKE '%\\_inventory\\_items' ORDER BY table_schema, table_name")
if err != nil {
return "", "", err
}
defer rows.Close()
for rows.Next() {
var schema, table string
if err := rows.Scan(&schema, &table); err != nil {
return "", "", err
}
if strings.Contains(table, "account") {
continue
}
return schema, table, nil
}
if err := rows.Err(); err != nil {
return "", "", err
}
return "", "", fmt.Errorf("no character inventory_items table found")
}
// compactSlots 把连续槽位压成 "289-296, 300" 便于人读。
func compactSlots(slots []int) string {
if len(slots) == 0 {
return "(none)"
}
var builder strings.Builder
runStart, previous := slots[0], slots[0]
flush := func(from, to int) {
if builder.Len() > 0 {
builder.WriteString(", ")
}
if from == to {
fmt.Fprintf(&builder, "%d", from)
return
}
fmt.Fprintf(&builder, "%d-%d", from, to)
}
for _, slot := range slots[1:] {
if slot == previous+1 {
previous = slot
continue
}
flush(runStart, previous)
runStart, previous = slot, slot
}
flush(runStart, previous)
return builder.String()
}