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,308 @@
|
||||
// 本文件实现 DNF 穿戴装备仓储的 MySQL 字段化读写。
|
||||
package mysql
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"longheng.io/server/internal/modules/dnf/repository"
|
||||
)
|
||||
|
||||
const mysqlEquipmentTable = "equipments"
|
||||
|
||||
type mysqlEquipmentStore struct {
|
||||
mysqlStoreBase
|
||||
}
|
||||
|
||||
// Load 按角色 ID 从 MySQL 读取穿戴装备记录。
|
||||
func (s *mysqlEquipmentStore) Load(ctx context.Context, characterID string) (repository.EquipmentRecord, bool, error) {
|
||||
table, err := s.router.readTable(mysqlEquipmentTable, characterID)
|
||||
if err != nil {
|
||||
return repository.EquipmentRecord{}, false, err
|
||||
}
|
||||
entriesTable, err := s.router.readTable(mysqlEquipmentEntriesTable, characterID)
|
||||
if err != nil {
|
||||
return repository.EquipmentRecord{}, false, err
|
||||
}
|
||||
extraTable, err := s.router.readTable(mysqlEquipmentExtraTable, characterID)
|
||||
if err != nil {
|
||||
return repository.EquipmentRecord{}, false, err
|
||||
}
|
||||
query := s.router.selectQuery("SELECT character_id, updated_at FROM " + table + " WHERE character_id = ?")
|
||||
var record repository.EquipmentRecord
|
||||
var updatedAt sql.NullTime
|
||||
err = s.router.db.QueryRowContext(ctx, query, characterID).Scan(
|
||||
&record.CharacterID,
|
||||
&updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
ok, scanErr := scanErr(err)
|
||||
return repository.EquipmentRecord{}, ok, scanErr
|
||||
}
|
||||
record.Entries, err = loadEquipmentEntries(ctx, s.router, entriesTable, extraTable, characterID)
|
||||
if err != nil {
|
||||
return repository.EquipmentRecord{}, false, err
|
||||
}
|
||||
record.UpdatedAt = scanTime(updatedAt)
|
||||
return repository.CloneEquipment(record), true, nil
|
||||
}
|
||||
|
||||
// Save 保存完整穿戴装备记录。
|
||||
func (s *mysqlEquipmentStore) Save(ctx context.Context, record repository.EquipmentRecord) error {
|
||||
return s.SaveFields(ctx, record, repository.AllEquipmentFields()...)
|
||||
}
|
||||
|
||||
// SaveFields 保存穿戴装备 dirty 字段。
|
||||
func (s *mysqlEquipmentStore) SaveFields(ctx context.Context, record repository.EquipmentRecord, fields ...repository.EquipmentField) error {
|
||||
characterID, err := requireRecordKey(repository.EquipmentKey, record, "equipment")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fields = repository.EquipmentFields.Normalize(fields)
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
table, err := s.router.writeTable(mysqlEquipmentTable, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entriesTable, err := s.router.writeTable(mysqlEquipmentEntriesTable, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
extraTable, err := s.router.writeTable(mysqlEquipmentExtraTable, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
columns := []string{"character_id", "updated_at"}
|
||||
args := []any{characterID, timeOrNow(record.UpdatedAt, s.router.now)}
|
||||
updates := []string{updateValue("updated_at")}
|
||||
saveEntries := false
|
||||
for _, field := range fields {
|
||||
if field == repository.EquipmentFieldEntries {
|
||||
saveEntries = true
|
||||
}
|
||||
}
|
||||
return withMySQLWriteExecutor(ctx, s.router.db, func(database SQLDB) error {
|
||||
if _, execErr := database.ExecContext(ctx, buildUpsert(table, columns, updates), args...); execErr != nil {
|
||||
return execErr
|
||||
}
|
||||
if !saveEntries {
|
||||
return nil
|
||||
}
|
||||
return replaceEquipmentEntries(ctx, database, entriesTable, extraTable, characterID, record.Entries)
|
||||
})
|
||||
}
|
||||
|
||||
func loadEquipmentEntries(
|
||||
ctx context.Context,
|
||||
router mysqlRouter,
|
||||
entriesTable, extraTable, characterID string,
|
||||
) (map[string]repository.EquipmentEntry, error) {
|
||||
query := router.selectQuery("SELECT entry_key, slot_index, item_id, bind_flag, expire_at, raw_entry FROM " + entriesTable + " WHERE character_id = ? ORDER BY entry_key")
|
||||
rows, err := router.db.QueryContext(ctx, query, characterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries map[string]repository.EquipmentEntry
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var entry repository.EquipmentEntry
|
||||
var expireAt sql.NullTime
|
||||
var rawEntry []byte
|
||||
if err := rows.Scan(&key, &entry.SlotIndex, &entry.ItemID, &entry.Bind, &expireAt, &rawEntry); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
entry.ExpireAt = scanTime(expireAt)
|
||||
entry.RawEntry = append([]byte(nil), rawEntry...)
|
||||
if entries == nil {
|
||||
entries = make(map[string]repository.EquipmentEntry)
|
||||
}
|
||||
entries[key] = entry
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extras, err := router.db.QueryContext(ctx, router.selectQuery("SELECT entry_key, extra_key, extra_value FROM "+extraTable+" WHERE character_id = ? ORDER BY entry_key, extra_key"), characterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer extras.Close()
|
||||
for extras.Next() {
|
||||
var entryKey, extraKey, extraValue string
|
||||
if err := extras.Scan(&entryKey, &extraKey, &extraValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, ok := entries[entryKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if entry.Extra == nil {
|
||||
entry.Extra = make(map[string]string)
|
||||
}
|
||||
entry.Extra[extraKey] = extraValue
|
||||
entries[entryKey] = entry
|
||||
}
|
||||
if err := extras.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// loadEquipmentEntriesForUpdate 在写者的事务里读出并锁住已存的条目,
|
||||
// 返回的形态与 loadEquipmentEntries 相同。
|
||||
//
|
||||
// 移植上游 c0ebdce。FOR UPDATE 直接写在查询里(不走 router.selectQuery,
|
||||
// 那只在 lockReads 时才加锁)—— 否则读写分离下会读到旧行、误判差异。
|
||||
func loadEquipmentEntriesForUpdate(
|
||||
ctx context.Context,
|
||||
database SQLDB,
|
||||
entriesTable, extraTable, characterID string,
|
||||
) (map[string]repository.EquipmentEntry, error) {
|
||||
rows, err := database.QueryContext(ctx, "SELECT entry_key, slot_index, item_id, bind_flag, expire_at, raw_entry FROM "+entriesTable+" WHERE character_id = ? ORDER BY entry_key FOR UPDATE", characterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entries := make(map[string]repository.EquipmentEntry)
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var entry repository.EquipmentEntry
|
||||
var expireAt sql.NullTime
|
||||
var rawEntry []byte
|
||||
if err := rows.Scan(&key, &entry.SlotIndex, &entry.ItemID, &entry.Bind, &expireAt, &rawEntry); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
entry.ExpireAt = scanTime(expireAt)
|
||||
entry.RawEntry = append([]byte(nil), rawEntry...)
|
||||
entries[key] = entry
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return nil, err
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
extras, err := database.QueryContext(ctx, "SELECT entry_key, extra_key, extra_value FROM "+extraTable+" WHERE character_id = ? ORDER BY entry_key, extra_key FOR UPDATE", characterID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer extras.Close()
|
||||
for extras.Next() {
|
||||
var entryKey, extraKey, extraValue string
|
||||
if err := extras.Scan(&entryKey, &extraKey, &extraValue); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
entry, ok := entries[entryKey]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if entry.Extra == nil {
|
||||
entry.Extra = make(map[string]string)
|
||||
}
|
||||
entry.Extra[extraKey] = extraValue
|
||||
entries[entryKey] = entry
|
||||
}
|
||||
return entries, extras.Err()
|
||||
}
|
||||
|
||||
func replaceEquipmentEntries(
|
||||
ctx context.Context,
|
||||
database SQLDB,
|
||||
entriesTable, extraTable, characterID string,
|
||||
entries map[string]repository.EquipmentEntry,
|
||||
) error {
|
||||
// 与锁住的持久行做差异,只碰真正变化的条目;理由同 replaceItemStackCollection
|
||||
// (上游 c0ebdce)。
|
||||
current, err := loadEquipmentEntriesForUpdate(ctx, database, entriesTable, extraTable, characterID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
writeKeys := make([]string, 0)
|
||||
for _, key := range sortedStringKeys(entries) {
|
||||
if old, ok := current[key]; !ok || !equipmentEntryStoredEqual(old, entries[key]) {
|
||||
writeKeys = append(writeKeys, key)
|
||||
}
|
||||
}
|
||||
deleteKeys := append([]string(nil), writeKeys...)
|
||||
for _, key := range sortedStringKeys(current) {
|
||||
if _, ok := entries[key]; !ok {
|
||||
deleteKeys = append(deleteKeys, key)
|
||||
}
|
||||
}
|
||||
owner := []any{characterID}
|
||||
if err := deleteMySQLEntryKeys(ctx, database, extraTable, "character_id = ?", owner, deleteKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := deleteMySQLEntryKeys(ctx, database, entriesTable, "character_id = ?", owner, deleteKeys); err != nil {
|
||||
return err
|
||||
}
|
||||
// 改前是「每个条目一条 ExecContext + 每个附加键再一条」,按 128 行一批改写后
|
||||
// 语句数与条目数无关。与背包 / 任务 / roster 路径用的同一个 buildMySQLBulkInsert。
|
||||
//
|
||||
// 两张表的写入顺序由「条目与其附加键交错」变为「先全部条目、再全部附加键」:
|
||||
// 它们分属不同的表且键在两处都唯一,因此结果集完全一致;整段仍在该替换事务内,
|
||||
// 中途失败由事务回滚,不产生半写状态。
|
||||
entryColumns := []string{
|
||||
"character_id", "entry_key", "slot_index", "item_id", "bind_flag", "expire_at", "raw_entry",
|
||||
}
|
||||
extraColumns := []string{"character_id", "entry_key", "extra_key", "extra_value"}
|
||||
// 只写差异出来的条目(上游 c0ebdce)。writeKeys 由 sortedStringKeys(entries) 过滤而来,
|
||||
// 所以顺序与原来的全量写一致。
|
||||
entryKeys := writeKeys
|
||||
for start := 0; start < len(entryKeys); start += mysqlReplaceBatchSize {
|
||||
end := min(start+mysqlReplaceBatchSize, len(entryKeys))
|
||||
chunk := entryKeys[start:end]
|
||||
args := make([]any, 0, len(chunk)*len(entryColumns))
|
||||
for _, entryKey := range chunk {
|
||||
entry := entries[entryKey]
|
||||
args = append(args,
|
||||
characterID, entryKey, entry.SlotIndex, entry.ItemID, entry.Bind,
|
||||
sqlTime(entry.ExpireAt), entry.RawEntry)
|
||||
}
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
buildMySQLBulkInsert(entriesTable, entryColumns, len(chunk)),
|
||||
args...,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
type equipmentExtraRow struct {
|
||||
entryKey string
|
||||
extraKey string
|
||||
value any
|
||||
}
|
||||
extras := make([]equipmentExtraRow, 0, len(entryKeys))
|
||||
for _, entryKey := range entryKeys {
|
||||
entry := entries[entryKey]
|
||||
for _, extraKey := range sortedStringKeys(entry.Extra) {
|
||||
extras = append(extras, equipmentExtraRow{
|
||||
entryKey: entryKey,
|
||||
extraKey: extraKey,
|
||||
value: entry.Extra[extraKey],
|
||||
})
|
||||
}
|
||||
}
|
||||
for start := 0; start < len(extras); start += mysqlReplaceBatchSize {
|
||||
end := min(start+mysqlReplaceBatchSize, len(extras))
|
||||
chunk := extras[start:end]
|
||||
args := make([]any, 0, len(chunk)*len(extraColumns))
|
||||
for _, row := range chunk {
|
||||
args = append(args, characterID, row.entryKey, row.extraKey, row.value)
|
||||
}
|
||||
if _, err := database.ExecContext(
|
||||
ctx,
|
||||
buildMySQLBulkInsert(extraTable, extraColumns, len(chunk)),
|
||||
args...,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user