服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
248 lines
6.7 KiB
Go
248 lines
6.7 KiB
Go
// emblemrowcensus 只读统计:每个角色在徽章页 289..344 里有多少行、空格落在哪几段;
|
||
// 以及 321..344 这"最后一整行之后"的格子到底有没有被用过。
|
||
//
|
||
// 现场 2026-10-03(服主报):服主说徽章栏总共 63 格,而服务端把落点夹到 289..320
|
||
// (main_slot_count=0 时),于是 321..344 永远不放东西。要判断这些格子到底能不能用,
|
||
// 就看真实库里**已经**有没有行落在 321..344 —— 有,就说明服务端白留着这 24 格。
|
||
//
|
||
// 只读:只 SELECT。
|
||
//
|
||
// 跑法:
|
||
//
|
||
// go run ./cmd/scratch/emblemrowcensus
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"flag"
|
||
"fmt"
|
||
"os"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
_ "github.com/go-sql-driver/mysql"
|
||
)
|
||
|
||
func main() {
|
||
pageStart := flag.Int("page-start", 289, "emblem page first slot")
|
||
pageEnd := flag.Int("page-end", 344, "emblem page last slot")
|
||
probeStart := flag.Int("probe-start", 321, "the range the server never places into")
|
||
dsn := flag.String("dsn", "", "mysql dsn (default: the deployed runtime logic.toml)")
|
||
flag.Parse()
|
||
|
||
resolvedDSN := *dsn
|
||
var err error
|
||
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(), 30*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)
|
||
}
|
||
|
||
// 逐角色统计这一页。entry_key 形如 "0:<slot>"。
|
||
rows, err := db.QueryContext(ctx,
|
||
"SELECT character_id, entry_key, item_id, item_count FROM `"+schema+"`.`"+table+"` ORDER BY character_id")
|
||
if err != nil {
|
||
fmt.Fprintln(os.Stderr, "FAILED: query:", err)
|
||
os.Exit(1)
|
||
}
|
||
defer rows.Close()
|
||
type pageState struct {
|
||
slots map[int]int64
|
||
}
|
||
byCharacter := map[string]*pageState{}
|
||
probeHits := map[string][]int{}
|
||
probeTotals := 0
|
||
for rows.Next() {
|
||
var characterID, key string
|
||
var itemID, count sql.NullInt64
|
||
if err := rows.Scan(&characterID, &key, &itemID, &count); err != nil {
|
||
fmt.Fprintln(os.Stderr, "FAILED: scan:", err)
|
||
os.Exit(1)
|
||
}
|
||
listType, slot, ok := parseEntryKey(key)
|
||
if !ok || listType != 0 {
|
||
continue
|
||
}
|
||
if slot >= *probeStart && slot <= *pageEnd {
|
||
probeHits[characterID] = append(probeHits[characterID], slot)
|
||
probeTotals++
|
||
}
|
||
if slot < *pageStart || slot > *pageEnd {
|
||
continue
|
||
}
|
||
state := byCharacter[characterID]
|
||
if state == nil {
|
||
state = &pageState{slots: map[int]int64{}}
|
||
byCharacter[characterID] = state
|
||
}
|
||
state.slots[slot] = count.Int64
|
||
}
|
||
if err := rows.Err(); err != nil {
|
||
fmt.Fprintln(os.Stderr, "FAILED: rows:", err)
|
||
os.Exit(1)
|
||
}
|
||
|
||
ids := make([]string, 0, len(byCharacter))
|
||
for id := range byCharacter {
|
||
ids = append(ids, id)
|
||
}
|
||
sort.Slice(ids, func(i, j int) bool {
|
||
left, _ := strconv.Atoi(ids[i])
|
||
right, _ := strconv.Atoi(ids[j])
|
||
return left < right
|
||
})
|
||
fmt.Printf("徽章页 %d..%d 逐角色统计(只列有行的角色):\n", *pageStart, *pageEnd)
|
||
for _, id := range ids {
|
||
state := byCharacter[id]
|
||
used := make([]int, 0, len(state.slots))
|
||
for slot := range state.slots {
|
||
used = append(used, slot)
|
||
}
|
||
sort.Ints(used)
|
||
free := make([]int, 0, *pageEnd-*pageStart+1)
|
||
for slot := *pageStart; slot <= *pageEnd; slot++ {
|
||
if _, occupied := state.slots[slot]; !occupied {
|
||
free = append(free, slot)
|
||
}
|
||
}
|
||
lastUsed := 0
|
||
if len(used) > 0 {
|
||
lastUsed = used[len(used)-1]
|
||
}
|
||
fmt.Printf(" char %-6s 行=%-3d 空格=%-3d 最大已用格=%d 空格段=%s\n",
|
||
id, len(used), len(free), lastUsed, compact(free))
|
||
}
|
||
|
||
fmt.Printf("\n== %d..%d 段(服务端在 main_slot_count=0 时从不放置的区间)==\n", *probeStart, *pageEnd)
|
||
if probeTotals == 0 {
|
||
fmt.Printf("整库没有任何一行落在这里。\n")
|
||
} else {
|
||
fmt.Printf("整库共 %d 行落在这里,涉及 %d 个角色:\n", probeTotals, len(probeHits))
|
||
probeIDs := make([]string, 0, len(probeHits))
|
||
for id := range probeHits {
|
||
probeIDs = append(probeIDs, id)
|
||
}
|
||
sort.Slice(probeIDs, func(i, j int) bool {
|
||
left, _ := strconv.Atoi(probeIDs[i])
|
||
right, _ := strconv.Atoi(probeIDs[j])
|
||
return left < right
|
||
})
|
||
for _, id := range probeIDs {
|
||
slots := probeHits[id]
|
||
sort.Ints(slots)
|
||
fmt.Printf(" char %-6s %d 行:%s\n", id, len(slots), compact(slots))
|
||
}
|
||
}
|
||
}
|
||
|
||
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 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")
|
||
}
|
||
|
||
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
|
||
}
|
||
return "", "", fmt.Errorf("no character inventory_items table found")
|
||
}
|
||
|
||
func compact(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()
|
||
}
|