Files
90CNServer/server/internal/modules/dnf/repository/mysql/mysql_pet.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

494 lines
15 KiB
Go

package mysql
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"longheng.io/server/internal/modules/dnf/repository"
"slices"
"strings"
)
const mysqlPetTable = "pets"
type mysqlPetStore struct {
mysqlStoreBase
}
// Load reads one character creature record from MySQL.
func (s *mysqlPetStore) Load(ctx context.Context, characterID string) (repository.PetRecord, bool, error) {
table, err := s.router.readTable(mysqlPetTable, characterID)
if err != nil {
return repository.PetRecord{}, false, err
}
entriesTable, err := s.router.readTable(mysqlPetEntriesTable, characterID)
if err != nil {
return repository.PetRecord{}, false, err
}
extraTable, err := s.router.readTable(mysqlPetExtraTable, characterID)
if err != nil {
return repository.PetRecord{}, false, err
}
tokensTable, err := s.router.readTable(mysqlPetTokensTable, characterID)
if err != nil {
return repository.PetRecord{}, false, err
}
artifactsTable, err := s.router.readTable(mysqlPetArtifactsTable, characterID)
if err != nil {
return repository.PetRecord{}, false, err
}
artifactExtraTable, err := s.router.readTable(mysqlPetArtifactExtraTable, characterID)
if err != nil {
return repository.PetRecord{}, false, err
}
query := s.router.selectQuery("SELECT character_id, equipped_key, town_display, updated_at FROM " + table + " WHERE character_id = ?")
var record repository.PetRecord
var updatedAt sql.NullTime
err = s.router.db.QueryRowContext(ctx, query, characterID).Scan(
&record.CharacterID,
&record.EquippedKey,
&record.TownDisplay,
&updatedAt,
)
if err != nil {
ok, scanErr := scanErr(err)
return repository.PetRecord{}, ok, scanErr
}
record.Entries, err = loadPetEntries(ctx, s.router, entriesTable, extraTable, tokensTable, characterID)
if err != nil {
return repository.PetRecord{}, false, err
}
record.Artifacts, err = loadItemStackCollection(
ctx, s.router.db, artifactsTable, artifactExtraTable, "character_id", characterID, "", false,
)
if err != nil {
return repository.PetRecord{}, false, err
}
record.UpdatedAt = scanTime(updatedAt)
return repository.ClonePet(record), true, nil
}
// Save writes the complete creature record.
func (s *mysqlPetStore) Save(ctx context.Context, record repository.PetRecord) error {
return s.SaveFields(ctx, record, repository.AllPetFields()...)
}
// SaveFields writes selected creature fields.
func (s *mysqlPetStore) SaveFields(ctx context.Context, record repository.PetRecord, fields ...repository.PetField) error {
characterID, err := requireRecordKey(repository.PetKey, record, "pet")
if err != nil {
return err
}
fields = repository.PetFields.Normalize(fields)
if len(fields) == 0 {
return nil
}
table, err := s.router.writeTable(mysqlPetTable, characterID)
if err != nil {
return err
}
entriesTable, err := s.router.writeTable(mysqlPetEntriesTable, characterID)
if err != nil {
return err
}
extraTable, err := s.router.writeTable(mysqlPetExtraTable, characterID)
if err != nil {
return err
}
tokensTable, err := s.router.writeTable(mysqlPetTokensTable, characterID)
if err != nil {
return err
}
artifactsTable, err := s.router.writeTable(mysqlPetArtifactsTable, characterID)
if err != nil {
return err
}
artifactExtraTable, err := s.router.writeTable(mysqlPetArtifactExtraTable, 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 {
switch field {
case repository.PetFieldEntries:
saveEntries = true
case repository.PetFieldEquipped:
columns = append(columns, "equipped_key")
args = append(args, record.EquippedKey)
updates = append(updates, updateValue("equipped_key"))
case repository.PetFieldDisplay:
columns = append(columns, "town_display")
args = append(args, record.TownDisplay)
updates = append(updates, updateValue("town_display"))
}
}
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
}
if err := replacePetEntries(ctx, database, entriesTable, extraTable, tokensTable, characterID, record.Entries); err != nil {
return err
}
return replaceItemStackCollection(
ctx,
database,
artifactsTable,
artifactExtraTable,
"character_id",
characterID,
"",
false,
record.Artifacts,
)
})
}
func loadPetEntries(
ctx context.Context,
router mysqlRouter,
entriesTable, extraTable, tokensTable, characterID string,
) (map[string]repository.PetEntry, error) {
return loadPetEntryRows(ctx, router.db, router.selectQuery, entriesTable, extraTable, tokensTable, characterID)
}
// loadPetEntryRows 经 database 读出宠物行;selectQuery 决定加锁后缀
// (读路径用 router 的,差异写用 " FOR UPDATE")。
//
// 移植上游 0897707:本函数由原来的 loadPetEntries 改名并参数化而来,
// 函数体逐行未动,只把 router.selectQuery / router.db 换成传入的两个参数。
func loadPetEntryRows(
ctx context.Context,
database SQLDB,
selectQuery func(string) string,
entriesTable, extraTable, tokensTable, characterID string,
) (map[string]repository.PetEntry, error) {
query := selectQuery("SELECT pet_key, creature_key, item_id, source_list_type, source_slot_index, pet_name, name_raw, satiety, satiety_micros, mode_flag, mode1_field_0a, mode1_field_0b, pet_level, pet_exp, tail_flag, raw_entry FROM " + entriesTable + " WHERE character_id = ? ORDER BY pet_key")
rows, err := database.QueryContext(ctx, query, characterID)
if err != nil {
return nil, err
}
var entries map[string]repository.PetEntry
for rows.Next() {
var key string
var entry repository.PetEntry
var nameRaw, rawEntry []byte
if err := rows.Scan(
&key,
&entry.CreatureKey,
&entry.ItemID,
&entry.SourceListType,
&entry.SourceSlotIndex,
&entry.Name,
&nameRaw,
&entry.Satiety,
&entry.SatietyMicros,
&entry.ModeFlag,
&entry.Mode1Field0A,
&entry.Mode1Field0B,
&entry.Level,
&entry.Exp,
&entry.TailFlag,
&rawEntry,
); err != nil {
rows.Close()
return nil, err
}
entry.PetKey = key
entry.NameRaw = append([]byte(nil), nameRaw...)
entry.RawEntry = append([]byte(nil), rawEntry...)
if entries == nil {
entries = make(map[string]repository.PetEntry)
}
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,
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
}
for extras.Next() {
var petKey, key, value string
if err := extras.Scan(&petKey, &key, &value); err != nil {
extras.Close()
return nil, err
}
entry, ok := entries[petKey]
if !ok {
continue
}
if entry.Extra == nil {
entry.Extra = make(map[string]string)
}
entry.Extra[key] = value
entries[petKey] = entry
}
if err := extras.Err(); err != nil {
extras.Close()
return nil, err
}
if err := extras.Close(); err != nil {
return nil, err
}
tokens, err := database.QueryContext(
ctx,
selectQuery("SELECT pet_key, token, applied FROM "+tokensTable+" WHERE character_id = ? ORDER BY pet_key, token_order"),
characterID,
)
if err != nil {
return nil, err
}
defer tokens.Close()
for tokens.Next() {
var petKey, token string
var applied bool
if err := tokens.Scan(&petKey, &token, &applied); err != nil {
return nil, err
}
entry, ok := entries[petKey]
if !ok {
continue
}
if entry.AppliedClearTokens == nil {
entry.AppliedClearTokens = make(map[string]bool)
}
entry.AppliedClearTokens[token] = applied
entry.AppliedClearTokenOrder = append(entry.AppliedClearTokenOrder, token)
entries[petKey] = entry
}
if err := tokens.Err(); err != nil {
return nil, err
}
return entries, nil
}
func replacePetEntries(
ctx context.Context,
database SQLDB,
entriesTable, extraTable, tokensTable, characterID string,
entries map[string]repository.PetEntry,
) error {
// 与锁住的持久行做差异,**按宠物、按部分**(主行 / 附加键 / 清除令牌)。
// 上游 2026-09-24 的抓包:每次宠物保存 —— 包括每次移动前的那次成长结算 ——
// 都会把全部宠物连同它们 119 个清除令牌(147 行)重写一遍,只为改一个饱食度。
//
// 移植上游 0897707。本函数原来是本树的「逐表全量删 + 批量插」形态;
// 差异写的等价比它更强:没事的部分一条语句都不发。
current, err := loadPetEntryRows(ctx, database, func(query string) string { return query + " FOR UPDATE" },
entriesTable, extraTable, tokensTable, characterID)
if err != nil {
return err
}
var rowKeys, extraKeys, tokenKeys []string
for _, petKey := range sortedStringKeys(entries) {
entry := entries[petKey]
old, found := current[petKey]
if !found {
// 新键在每一部分都要重写,这同时会清掉「有附加键/令牌却没有主行」的残留
// (loader 会把那种行跳过)。
rowKeys, extraKeys, tokenKeys = append(rowKeys, petKey), append(extraKeys, petKey), append(tokenKeys, petKey)
continue
}
if !petEntryRowStoredEqual(old, entry) {
rowKeys = append(rowKeys, petKey)
}
if !stringMapsEqual(old.Extra, entry.Extra) {
extraKeys = append(extraKeys, petKey)
}
if !slices.Equal(petClearTokenRows(old), petClearTokenRows(entry)) {
tokenKeys = append(tokenKeys, petKey)
}
}
for _, petKey := range sortedStringKeys(current) {
if _, kept := entries[petKey]; !kept {
rowKeys, extraKeys, tokenKeys = append(rowKeys, petKey), append(extraKeys, petKey), append(tokenKeys, petKey)
}
}
owner := []any{characterID}
if err := deleteMySQLKeys(ctx, database, tokensTable, "character_id = ?", owner, "pet_key", tokenKeys); err != nil {
return err
}
if err := deleteMySQLKeys(ctx, database, extraTable, "character_id = ?", owner, "entry_key", extraKeys); err != nil {
return err
}
if err := deleteMySQLKeys(ctx, database, entriesTable, "character_id = ?", owner, "pet_key", rowKeys); err != nil {
return err
}
entryRows := newMySQLBulkInserter(ctx, database, entriesTable,
"character_id", "pet_key", "creature_key", "item_id", "source_list_type", "source_slot_index",
"pet_name", "name_raw", "satiety", "satiety_micros", "mode_flag", "mode1_field_0a",
"mode1_field_0b", "pet_level", "pet_exp", "tail_flag", "raw_entry")
extraRows := newMySQLBulkInserter(ctx, database, extraTable, "character_id", "entry_key", "extra_key", "extra_value")
tokenRows := newMySQLBulkInserter(ctx, database, tokensTable, "character_id", "pet_key", "token_order", "token", "applied")
writeRow := keySet(rowKeys)
writeExtra := keySet(extraKeys)
writeTokens := keySet(tokenKeys)
for _, petKey := range sortedStringKeys(entries) {
entry := entries[petKey]
if _, ok := writeRow[petKey]; ok {
if err := entryRows.add(
characterID,
petKey,
entry.CreatureKey,
entry.ItemID,
entry.SourceListType,
entry.SourceSlotIndex,
entry.Name,
entry.NameRaw,
entry.Satiety,
entry.SatietyMicros,
entry.ModeFlag,
entry.Mode1Field0A,
entry.Mode1Field0B,
entry.Level,
entry.Exp,
entry.TailFlag,
entry.RawEntry,
); err != nil {
return err
}
}
if _, ok := writeExtra[petKey]; ok {
for _, key := range sortedStringKeys(entry.Extra) {
if err := extraRows.add(characterID, petKey, key, entry.Extra[key]); err != nil {
return err
}
}
}
if _, ok := writeTokens[petKey]; ok {
for index, row := range petClearTokenRows(entry) {
if err := tokenRows.add(characterID, petKey, index, row.token, row.applied); err != nil {
return err
}
}
}
}
for _, rows := range []*mysqlBulkInserter{entryRows, extraRows, tokenRows} {
if err := rows.flush(); err != nil {
return err
}
}
return nil
}
type petClearTokenRow struct {
token string
applied bool
}
// petClearTokenRows 是**落库时的令牌顺序**:先按 AppliedClearTokenOrder 过滤掉未知令牌
// 与重复项,剩下的再按 key 排序补上。读出来的行原样过一遍,所以新旧可以直接比较。
func petClearTokenRows(entry repository.PetEntry) []petClearTokenRow {
rows := make([]petClearTokenRow, 0, len(entry.AppliedClearTokens))
seen := make(map[string]struct{}, len(entry.AppliedClearTokens))
for _, token := range entry.AppliedClearTokenOrder {
if _, ok := entry.AppliedClearTokens[token]; !ok {
continue
}
if _, duplicate := seen[token]; duplicate {
continue
}
seen[token] = struct{}{}
rows = append(rows, petClearTokenRow{token: token, applied: entry.AppliedClearTokens[token]})
}
for _, token := range sortedStringKeys(entry.AppliedClearTokens) {
if _, ok := seen[token]; ok {
continue
}
rows = append(rows, petClearTokenRow{token: token, applied: entry.AppliedClearTokens[token]})
}
return rows
}
// petEntryRowStoredEqual 比较 pet_entries 的列;NameRaw/RawEntry 与 loader 一样
// 把 nil 与空视为相同。
func petEntryRowStoredEqual(a, b repository.PetEntry) bool {
return a.CreatureKey == b.CreatureKey && a.ItemID == b.ItemID &&
a.SourceListType == b.SourceListType && a.SourceSlotIndex == b.SourceSlotIndex &&
a.Name == b.Name && bytes.Equal(a.NameRaw, b.NameRaw) &&
a.Satiety == b.Satiety && a.SatietyMicros == b.SatietyMicros &&
a.ModeFlag == b.ModeFlag && a.Mode1Field0A == b.Mode1Field0A && a.Mode1Field0B == b.Mode1Field0B &&
a.Level == b.Level && a.Exp == b.Exp && a.TailFlag == b.TailFlag &&
bytes.Equal(a.RawEntry, b.RawEntry)
}
func keySet(keys []string) map[string]struct{} {
out := make(map[string]struct{}, len(keys))
for _, key := range keys {
out[key] = struct{}{}
}
return out
}
const petEntriesEnvelopeVersion = 1
// petEntriesEnvelope extends the existing entries_json column without a
// schema migration. Legacy rows are a plain map of creature key to PetEntry;
// rows with equipped artifacts use this versioned envelope instead.
type petEntriesEnvelope struct {
Version int `json:"version"`
Creatures map[string]repository.PetEntry `json:"creatures,omitempty"`
Artifacts map[string]repository.ItemStack `json:"artifacts,omitempty"`
}
func petEntriesJSONArg(record repository.PetRecord) (any, error) {
if len(record.Artifacts) == 0 {
return jsonArg(record.Entries)
}
return jsonArg(petEntriesEnvelope{
Version: petEntriesEnvelopeVersion,
Creatures: record.Entries,
Artifacts: record.Artifacts,
})
}
func scanPetEntries(value sql.NullString, record *repository.PetRecord) error {
if record == nil {
return fmt.Errorf("pet record is nil")
}
if !value.Valid || strings.TrimSpace(value.String) == "" {
return nil
}
var shape map[string]json.RawMessage
if err := json.Unmarshal([]byte(value.String), &shape); err != nil {
return err
}
_, hasVersion := shape["version"]
_, hasCreatures := shape["creatures"]
_, hasArtifacts := shape["artifacts"]
if !hasVersion && !hasCreatures && !hasArtifacts {
return json.Unmarshal([]byte(value.String), &record.Entries)
}
var envelope petEntriesEnvelope
if err := json.Unmarshal([]byte(value.String), &envelope); err != nil {
return err
}
if envelope.Version != petEntriesEnvelopeVersion {
return fmt.Errorf("unsupported pet entries envelope version %d", envelope.Version)
}
record.Entries = envelope.Creatures
record.Artifacts = envelope.Artifacts
return nil
}