服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
369 lines
12 KiB
Go
369 lines
12 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"database/sql"
|
||
"encoding/binary"
|
||
"encoding/json"
|
||
"errors"
|
||
"flag"
|
||
"fmt"
|
||
"math"
|
||
"net"
|
||
"os"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
mysqldriver "github.com/go-sql-driver/mysql"
|
||
_ "modernc.org/sqlite"
|
||
|
||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||
repomysql "longheng.io/server/internal/modules/dnf/repository/mysql"
|
||
reposqlite "longheng.io/server/internal/modules/dnf/repository/sqlite"
|
||
)
|
||
|
||
const grantSource = "manual_ancient_spirit_potion_five_20260819"
|
||
|
||
var grantExpireAt = time.Date(2037, time.December, 31, 15, 59, 59, 0, time.UTC)
|
||
|
||
type potionSpec struct {
|
||
Multiplier string
|
||
ItemID int64
|
||
PVFPath string
|
||
}
|
||
|
||
type instanceConfig struct {
|
||
Database struct {
|
||
Mode string `json:"mode"`
|
||
Host string `json:"host"`
|
||
Port int `json:"port"`
|
||
Name string `json:"name"`
|
||
User string `json:"user"`
|
||
Password string `json:"password"`
|
||
} `json:"database"`
|
||
Game struct {
|
||
ShardID string `json:"shardId"`
|
||
} `json:"game"`
|
||
}
|
||
|
||
var potionSpecs = []potionSpec{
|
||
{Multiplier: "1.5倍", ItemID: 490007794, PVFPath: "stackable/490007001/chn_490007794.stk"},
|
||
{Multiplier: "2倍", ItemID: 490007817, PVFPath: "stackable/490007001/chn_490007817.stk"},
|
||
{Multiplier: "3倍", ItemID: 10164642, PVFPath: "stackable/10164001/10164642.stk"},
|
||
{Multiplier: "4倍", ItemID: 10005802, PVFPath: "stackable/event/chn_event_20150702_attendance_check/chn_event_super_grow_potion_30min_10005802.stk"},
|
||
{Multiplier: "5倍", ItemID: 490701622, PVFPath: "stackable/cash/chn_490701622.stk"},
|
||
}
|
||
|
||
func main() {
|
||
databasePath := flag.String("db", "", "offline SQLite save database")
|
||
configPath := flag.String("config", "", "portable-MySQL instance.json")
|
||
characterID := flag.String("character", "", "recipient character id")
|
||
apply := flag.Bool("apply", false, "commit the five system mails")
|
||
flag.Parse()
|
||
|
||
if strings.TrimSpace(*characterID) == "" || (strings.TrimSpace(*databasePath) == "") == (strings.TrimSpace(*configPath) == "") {
|
||
fmt.Fprintln(os.Stderr, "FAILED: -character and exactly one of -db or -config are required")
|
||
os.Exit(2)
|
||
}
|
||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||
defer cancel()
|
||
group, closeRepository, err := openRepository(ctx, *databasePath, *configPath)
|
||
if err != nil {
|
||
fail(err)
|
||
}
|
||
defer func() {
|
||
if err := closeRepository(); err != nil {
|
||
fmt.Fprintln(os.Stderr, "FAILED: close repository:", err)
|
||
}
|
||
}()
|
||
if group.Character == nil || group.Mailbox == nil || group.MailboxAssets == nil {
|
||
fail(errors.New("repository group is unavailable"))
|
||
}
|
||
recipient, found, err := group.Character.Load(ctx, strings.TrimSpace(*characterID))
|
||
if err != nil || !found {
|
||
fail(fmt.Errorf("load recipient character: found=%t: %w", found, err))
|
||
}
|
||
mailbox, mailboxFound, err := group.Mailbox.Load(ctx, recipient.CharacterID)
|
||
if err != nil {
|
||
fail(fmt.Errorf("load recipient mailbox: %w", err))
|
||
}
|
||
|
||
fmt.Printf("recipient=%s name=%s apply=%t existing_batch_mails=%d mailbox_found=%t\n",
|
||
recipient.CharacterID, recipient.Name, *apply, grantedPotionCount(mailbox), mailboxFound)
|
||
for _, spec := range potionSpecs {
|
||
fmt.Printf(" %s item=%d path=%s\n", spec.Multiplier, spec.ItemID, spec.PVFPath)
|
||
}
|
||
if !*apply {
|
||
fmt.Println("DRY RUN: no mailbox state changed")
|
||
return
|
||
}
|
||
|
||
err = group.MailboxAssets.WithinMailboxAssets(ctx, recipient.CharacterID, recipient.CharacterID, func(
|
||
_ dnfrepo.AccountRepository,
|
||
_ dnfrepo.AccountInventoryRepository,
|
||
_ dnfrepo.CharacterRepository,
|
||
_ dnfrepo.InventoryRepository,
|
||
mailboxes dnfrepo.MailboxRepository,
|
||
) error {
|
||
record, exists, loadErr := mailboxes.Load(ctx, recipient.CharacterID)
|
||
if loadErr != nil {
|
||
return loadErr
|
||
}
|
||
if !exists {
|
||
record = dnfrepo.MailboxRecord{CharacterID: recipient.CharacterID}
|
||
} else {
|
||
record = dnfrepo.CloneMailbox(record)
|
||
}
|
||
if record.Mails == nil {
|
||
record.Mails = make(map[string]dnfrepo.MailRecord)
|
||
}
|
||
now := time.Now().UTC()
|
||
existing := grantedPotionCount(record)
|
||
if existing != 0 && existing != len(potionSpecs) {
|
||
return fmt.Errorf("grant batch %s is incomplete: mails=%d", grantSource, existing)
|
||
}
|
||
if existing == 0 {
|
||
nextID, nextErr := nextMailID(record.Mails)
|
||
if nextErr != nil {
|
||
return nextErr
|
||
}
|
||
for index, spec := range potionSpecs {
|
||
mailID := strconv.FormatUint(nextID+uint64(index), 10)
|
||
record.Mails[mailID] = newPotionMail(mailID, recipient, spec, now.Add(time.Duration(index)*time.Nanosecond))
|
||
}
|
||
} else {
|
||
for mailID, mail := range record.Mails {
|
||
if strings.TrimSpace(mail.Metadata["source"]) != grantSource {
|
||
continue
|
||
}
|
||
spec, ok := potionSpecForMultiplier(mail.Metadata["potion_multiplier"])
|
||
if !ok {
|
||
return fmt.Errorf("grant mail %s has unknown multiplier %q", mailID, mail.Metadata["potion_multiplier"])
|
||
}
|
||
mail.Attachments = []dnfrepo.MailAttachment{newPotionAttachment(spec)}
|
||
mail.Metadata["system_mail"] = "true"
|
||
mail.Metadata["unlimited"] = "true"
|
||
record.Mails[mailID] = mail
|
||
}
|
||
}
|
||
record.UpdatedAt = now
|
||
return dnfrepo.SaveMailboxFields(ctx, mailboxes, record, dnfrepo.MailboxFieldMails)
|
||
})
|
||
if err != nil {
|
||
fail(err)
|
||
}
|
||
|
||
record, found, err := group.Mailbox.Load(ctx, recipient.CharacterID)
|
||
if err != nil || !found {
|
||
fail(fmt.Errorf("verify mailbox: found=%t: %w", found, err))
|
||
}
|
||
if err := validatePotionBatch(record); err != nil {
|
||
fail(fmt.Errorf("verify grant: %w", err))
|
||
}
|
||
ids := grantedMailIDs(record)
|
||
fmt.Printf("COMMITTED: mails=%s count=%d\n", strings.Join(ids, ","), len(ids))
|
||
}
|
||
|
||
func newPotionMail(mailID string, recipient dnfrepo.CharacterRecord, spec potionSpec, createdAt time.Time) dnfrepo.MailRecord {
|
||
return dnfrepo.MailRecord{
|
||
MailID: mailID,
|
||
SenderCharacterID: "0",
|
||
SenderName: "系统",
|
||
RecipientCharacterID: recipient.CharacterID,
|
||
RecipientName: recipient.Name,
|
||
Title: "远古精灵秘药(" + spec.Multiplier + ")",
|
||
Body: "按要求发放:" + spec.Multiplier + "远古精灵秘药 1 个。",
|
||
Attachments: []dnfrepo.MailAttachment{newPotionAttachment(spec)},
|
||
CreatedAt: createdAt,
|
||
Metadata: map[string]string{
|
||
"source": grantSource,
|
||
"system_mail": "true",
|
||
"unlimited": "true",
|
||
"potion_multiplier": spec.Multiplier,
|
||
},
|
||
}
|
||
}
|
||
|
||
func newPotionAttachment(spec potionSpec) dnfrepo.MailAttachment {
|
||
raw := make([]byte, 0x77)
|
||
binary.LittleEndian.PutUint32(raw[2:6], uint32(spec.ItemID))
|
||
binary.LittleEndian.PutUint32(raw[6:10], 1)
|
||
binary.LittleEndian.PutUint16(raw[0x0b:0x0d], math.MaxUint16)
|
||
raw[0x0d] = 1
|
||
binary.LittleEndian.PutUint32(raw[0x38:0x3c], uint32(grantExpireAt.Unix()))
|
||
expireUnix := strconv.FormatInt(grantExpireAt.Unix(), 10)
|
||
return dnfrepo.MailAttachment{
|
||
ItemID: spec.ItemID,
|
||
Count: 1,
|
||
Bind: true,
|
||
ExpireAt: grantExpireAt,
|
||
RawEntry: raw,
|
||
Extra: map[string]string{
|
||
"item_kind": "stackable",
|
||
"pvf_path": spec.PVFPath,
|
||
"stackable_type": "[waste]",
|
||
"expire_time": expireUnix,
|
||
"expire_unix": expireUnix,
|
||
"expiration_source": "manual_long_lived_grant",
|
||
"last_grant_source": grantSource,
|
||
},
|
||
}
|
||
}
|
||
|
||
func potionSpecForMultiplier(multiplier string) (potionSpec, bool) {
|
||
for _, spec := range potionSpecs {
|
||
if strings.TrimSpace(multiplier) == spec.Multiplier {
|
||
return spec, true
|
||
}
|
||
}
|
||
return potionSpec{}, false
|
||
}
|
||
|
||
func validatePotionBatch(record dnfrepo.MailboxRecord) error {
|
||
if count := grantedPotionCount(record); count != len(potionSpecs) {
|
||
return fmt.Errorf("count=%d want=%d", count, len(potionSpecs))
|
||
}
|
||
for mailID, mail := range record.Mails {
|
||
if strings.TrimSpace(mail.Metadata["source"]) != grantSource {
|
||
continue
|
||
}
|
||
spec, ok := potionSpecForMultiplier(mail.Metadata["potion_multiplier"])
|
||
if !ok || len(mail.Attachments) != 1 {
|
||
return fmt.Errorf("mail=%s has invalid multiplier or attachment count", mailID)
|
||
}
|
||
attachment := mail.Attachments[0]
|
||
if attachment.ItemID != spec.ItemID || attachment.Count != 1 || !attachment.Bind ||
|
||
!attachment.ExpireAt.Equal(grantExpireAt) || len(attachment.RawEntry) != 0x77 ||
|
||
binary.LittleEndian.Uint16(attachment.RawEntry[0x0b:0x0d]) != math.MaxUint16 ||
|
||
binary.LittleEndian.Uint32(attachment.RawEntry[0x38:0x3c]) != uint32(grantExpireAt.Unix()) {
|
||
return fmt.Errorf("mail=%s attachment expiration projection is invalid", mailID)
|
||
}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func openRepository(ctx context.Context, databasePath, configPath string) (dnfrepo.Group, func() error, error) {
|
||
databasePath = strings.TrimSpace(databasePath)
|
||
configPath = strings.TrimSpace(configPath)
|
||
if databasePath != "" {
|
||
database, err := sql.Open("sqlite", databasePath)
|
||
if err != nil {
|
||
return dnfrepo.Group{}, nil, err
|
||
}
|
||
component := reposqlite.NewComponent(database, databasePath)
|
||
if err := component.Start(ctx); err != nil {
|
||
database.Close()
|
||
return dnfrepo.Group{}, nil, err
|
||
}
|
||
group, ok := component.Group()
|
||
if !ok {
|
||
component.Stop(context.Background())
|
||
database.Close()
|
||
return dnfrepo.Group{}, nil, errors.New("SQLite repository group is unavailable")
|
||
}
|
||
return group, func() error {
|
||
stopCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||
defer cancel()
|
||
return errors.Join(component.Stop(stopCtx), database.Close())
|
||
}, nil
|
||
}
|
||
|
||
raw, err := os.ReadFile(configPath)
|
||
if err != nil {
|
||
return dnfrepo.Group{}, nil, err
|
||
}
|
||
var config instanceConfig
|
||
if err := json.Unmarshal(raw, &config); err != nil {
|
||
return dnfrepo.Group{}, nil, err
|
||
}
|
||
host := strings.TrimSpace(config.Database.Host)
|
||
name := strings.TrimSpace(config.Database.Name)
|
||
if !strings.EqualFold(config.Database.Mode, "portable") ||
|
||
(host != "127.0.0.1" && !strings.EqualFold(host, "localhost")) ||
|
||
config.Database.Port < 1 || config.Database.Port > 65535 || name == "" || strings.ContainsAny(name, "`; /\\") {
|
||
return dnfrepo.Group{}, nil, errors.New("portable MySQL configuration is invalid or not loopback-only")
|
||
}
|
||
driverConfig := mysqldriver.Config{
|
||
User: config.Database.User,
|
||
Passwd: config.Database.Password,
|
||
Net: "tcp",
|
||
Addr: net.JoinHostPort(host, strconv.Itoa(config.Database.Port)),
|
||
DBName: name,
|
||
ParseTime: true,
|
||
Loc: time.UTC,
|
||
Timeout: 5 * time.Second,
|
||
ReadTimeout: 10 * time.Second,
|
||
WriteTimeout: 10 * time.Second,
|
||
}
|
||
database, err := sql.Open("mysql", driverConfig.FormatDSN())
|
||
if err != nil {
|
||
return dnfrepo.Group{}, nil, err
|
||
}
|
||
database.SetMaxOpenConns(2)
|
||
database.SetMaxIdleConns(1)
|
||
if err := database.PingContext(ctx); err != nil {
|
||
database.Close()
|
||
return dnfrepo.Group{}, nil, err
|
||
}
|
||
group, err := repomysql.NewMySQLGroupFromDB(database, repomysql.MySQLGroupOptions{
|
||
DatabasePlan: dnfrepo.DatabasePlan{
|
||
ShardID: strings.TrimSpace(config.Game.ShardID),
|
||
GroupID: "manual-ancient-spirit-potion-grant",
|
||
WriteDatabases: []string{name},
|
||
ReadDatabases: []string{name},
|
||
},
|
||
TablePrefix: "dnf",
|
||
})
|
||
if err != nil {
|
||
database.Close()
|
||
return dnfrepo.Group{}, nil, err
|
||
}
|
||
return group, database.Close, nil
|
||
}
|
||
|
||
func grantedPotionCount(record dnfrepo.MailboxRecord) int {
|
||
count := 0
|
||
for _, mail := range record.Mails {
|
||
if strings.TrimSpace(mail.Metadata["source"]) == grantSource {
|
||
count++
|
||
}
|
||
}
|
||
return count
|
||
}
|
||
|
||
func grantedMailIDs(record dnfrepo.MailboxRecord) []string {
|
||
ids := make([]string, 0, len(potionSpecs))
|
||
for key, mail := range record.Mails {
|
||
if strings.TrimSpace(mail.Metadata["source"]) == grantSource {
|
||
ids = append(ids, key)
|
||
}
|
||
}
|
||
sort.Slice(ids, func(left, right int) bool {
|
||
leftID, _ := strconv.ParseUint(ids[left], 10, 64)
|
||
rightID, _ := strconv.ParseUint(ids[right], 10, 64)
|
||
return leftID < rightID
|
||
})
|
||
return ids
|
||
}
|
||
|
||
func nextMailID(mails map[string]dnfrepo.MailRecord) (uint64, error) {
|
||
var maxID uint64
|
||
for key := range mails {
|
||
value, err := strconv.ParseUint(strings.TrimSpace(key), 10, 32)
|
||
if err == nil && value > maxID {
|
||
maxID = value
|
||
}
|
||
}
|
||
if maxID > math.MaxUint32-uint64(len(potionSpecs)) {
|
||
return 0, errors.New("mail id range is exhausted")
|
||
}
|
||
return maxID + 1, nil
|
||
}
|
||
|
||
func fail(err error) {
|
||
fmt.Fprintln(os.Stderr, "FAILED:", err)
|
||
os.Exit(1)
|
||
}
|