服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
783 lines
34 KiB
Go
783 lines
34 KiB
Go
// 本文件实现 DNF 角色仓储的 MySQL 字段化读写。
|
|
package mysql
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"errors"
|
|
"longheng.io/server/internal/modules/dnf/repository"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"longheng.io/server/internal/platform/db"
|
|
)
|
|
|
|
const mysqlCharTable = "characters"
|
|
|
|
const mysqlCharacterSlotMin = -1 << 31
|
|
|
|
const (
|
|
mysqlCharacterTutorialCompletedStatColumn = "tutorial_completed"
|
|
mysqlCharacterTutorialCompletedStatDDL = "TINYINT UNSIGNED NOT NULL DEFAULT 0"
|
|
mysqlCharacterTutorialRewardProgress38StatColumn = "tutorial_reward_progress_38"
|
|
mysqlCharacterTutorialRewardProgress38StatDDL = "TINYINT UNSIGNED NOT NULL DEFAULT 0"
|
|
mysqlCharacterStoryDigestLastLevelStatColumn = repository.CharacterStoryDigestLastLevelStatKey
|
|
mysqlCharacterStoryDigestLastLevelStatDDL = "INT UNSIGNED NOT NULL DEFAULT 0"
|
|
mysqlCharacterStoryDigestMigrationVersionColumn = repository.CharacterStoryDigestMigrationVersionStatKey
|
|
mysqlCharacterStoryDigestMigrationVersionDDL = "SMALLINT UNSIGNED NOT NULL DEFAULT 0"
|
|
mysqlCharacterCeraStatColumn = "cera"
|
|
mysqlCharacterCeraStatDDL = "BIGINT NOT NULL DEFAULT 0"
|
|
mysqlCharacterCrystalSelectionStatColumn = "premium_crystal_selection"
|
|
mysqlCharacterCrystalSelectionStatDDL = "TINYINT NOT NULL DEFAULT -1"
|
|
)
|
|
|
|
type mysqlCharacterStatColumn struct {
|
|
column string
|
|
key string
|
|
ddl string
|
|
fallback int64
|
|
allowNegative bool
|
|
}
|
|
|
|
var mysqlCharacterStatColumns = buildMySQLCharacterStatColumns()
|
|
|
|
func buildMySQLCharacterStatColumns() []mysqlCharacterStatColumn {
|
|
columns := []mysqlCharacterStatColumn{
|
|
{column: "grow_type", key: "grow_type", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "exp", key: "exp", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "ex_equip_slot_stat", key: "ex_equip_slot_stat", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "bonus_sp", key: "bonus_sp", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "bonus_tp", key: "bonus_tp", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "pvp_grade", key: "pvp_grade", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "pvp_rating_grade", key: "pvp_rating_grade", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "user_state", key: "user_state", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: mysqlCharacterTutorialCompletedStatColumn, key: mysqlCharacterTutorialCompletedStatColumn, ddl: mysqlCharacterTutorialCompletedStatDDL},
|
|
{column: mysqlCharacterTutorialRewardProgress38StatColumn, key: mysqlCharacterTutorialRewardProgress38StatColumn, ddl: mysqlCharacterTutorialRewardProgress38StatDDL},
|
|
{column: mysqlCharacterStoryDigestLastLevelStatColumn, key: repository.CharacterStoryDigestLastLevelStatKey, ddl: mysqlCharacterStoryDigestLastLevelStatDDL},
|
|
{column: mysqlCharacterStoryDigestMigrationVersionColumn, key: repository.CharacterStoryDigestMigrationVersionStatKey, ddl: mysqlCharacterStoryDigestMigrationVersionDDL},
|
|
{column: "gold", key: "gold", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: mysqlCharacterCeraStatColumn, key: mysqlCharacterCeraStatColumn, ddl: mysqlCharacterCeraStatDDL},
|
|
{column: "coin", key: "coin", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "town_id", key: "town_id", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 38", fallback: 38},
|
|
{column: "area_id", key: "area_id", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 1", fallback: 1},
|
|
{column: "pos_x", key: "pos_x", ddl: "SMALLINT NOT NULL DEFAULT 450", fallback: 450},
|
|
{column: "pos_y", key: "pos_y", ddl: "SMALLINT NOT NULL DEFAULT 234", fallback: 234},
|
|
{column: "direction", key: "direction", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 5", fallback: 5},
|
|
{column: "area_state", key: "area_state", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 3", fallback: 3},
|
|
{column: "delete_flag", key: "delete_flag", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "is_event_character", key: "is_event_character", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "name_tag_item_id", key: "name_tag_item_id", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "stamina", key: "stamina", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "fatigue", key: "fatigue", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 156", fallback: 156},
|
|
{column: "fatigue_limit", key: "fatigue_limit", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 156", fallback: 156},
|
|
{column: "fatigue_battery", key: "fatigue_battery", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "fatigue_penalty", key: "fatigue_penalty", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "pc_room_id", key: "pc_room_id", ddl: "BIGINT NOT NULL DEFAULT 65537", fallback: 0x00010001},
|
|
{column: "is_private_store", key: "is_private_store", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "is_premium_pc_room", key: "is_premium_pc_room", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "server_group_id", key: "server_group_id", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "black_count", key: "black_count", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "guild_level", key: "guild_level", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "chaos_point", key: "chaos_point", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "disguise_kind", key: "disguise_kind", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "is_disguised", key: "is_disguised", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "expert_job_type", key: "expert_job_type", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "expert_job_exp", key: "expert_job_exp", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "extra46", key: "extra46", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "extra47", key: "extra47", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "extra51", key: "extra51", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "is_hardcore_mode", key: "is_hardcore_mode", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "is_hardcore_dead", key: "is_hardcore_dead", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "hardcore_death_count", key: "hardcore_death_count", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "user_state_bits", key: "user_state_bits", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 3", fallback: 3},
|
|
{column: "chat_ban_end_time", key: "chat_ban_end_time", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "fatigue_update", key: "fatigue_update", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "return_user_flag", key: "return_user_flag", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 1", fallback: 1},
|
|
{column: "channel_display_mode", key: "channel_display_mode", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "channel_type", key: "channel_type", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "channel_id", key: "channel_id", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 2", fallback: 2},
|
|
{column: "is_return_user", key: "is_return_user", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "link_slot_enabled", key: "link_slot_enabled", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "link_type_a", key: "link_type_a", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "link_type_b", key: "link_type_b", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "emotion_index", key: "emotion_index", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "action_byte", key: "action_byte", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "fatigue_display_update", key: "fatigue_display_update", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "costume_flag", key: "costume_flag", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "aura_flag", key: "aura_flag", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: mysqlCharacterCrystalSelectionStatColumn, key: mysqlCharacterCrystalSelectionStatColumn, ddl: mysqlCharacterCrystalSelectionStatDDL, fallback: -1, allowNegative: true},
|
|
{column: "pet_display_flag", key: "pet_display_flag", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "title_display_flag", key: "title_display_flag", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "pvp_stat_a", key: "pvp_stat_a", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "pvp_win_streak", key: "pvp_win_streak", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "pvp_lose_streak", key: "pvp_lose_streak", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "pvp_rank_point", key: "pvp_rank_point", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "trailing_byte", key: "trailing_byte", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
{column: "stat_block_marker", key: "stat_block_marker", ddl: "BIGINT NOT NULL DEFAULT 83", fallback: 83},
|
|
{column: "stat_hp_max", key: "stat_hp_max", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "stat_mp_max", key: "stat_mp_max", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
{column: "stat_strength", key: "stat_strength", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_intelligence", key: "stat_intelligence", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_vitality", key: "stat_vitality", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_spirit", key: "stat_spirit", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_physical_attack", key: "stat_physical_attack", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_physical_defense", key: "stat_physical_defense", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_magical_attack", key: "stat_magical_attack", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_magical_defense", key: "stat_magical_defense", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_independent_attack", key: "stat_independent_attack", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_fire_resistance", key: "stat_fire_resistance", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_water_resistance", key: "stat_water_resistance", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_dark_resistance", key: "stat_dark_resistance", ddl: "INT NOT NULL DEFAULT 0"},
|
|
{column: "stat_light_resistance", key: "stat_light_resistance", ddl: "INT NOT NULL DEFAULT 0"},
|
|
}
|
|
for idx := 0; idx < 18; idx++ {
|
|
key := "active_status_resistance_" + mysqlTwoDigit(idx)
|
|
columns = append(columns, mysqlCharacterStatColumn{column: key, key: key, ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"})
|
|
}
|
|
columns = append(columns,
|
|
mysqlCharacterStatColumn{column: "stat_inventory_limit", key: "stat_inventory_limit", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "stat_hp_regen_speed", key: "stat_hp_regen_speed", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "stat_mp_regen_speed", key: "stat_mp_regen_speed", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "stat_move_speed", key: "stat_move_speed", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "stat_attack_speed", key: "stat_attack_speed", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "stat_cast_speed", key: "stat_cast_speed", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "stat_hit_recovery", key: "stat_hit_recovery", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "stat_jump_power", key: "stat_jump_power", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "stat_weight", key: "stat_weight", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "stat_level", key: "stat_level", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "name_tag_expire_time", key: "name_tag_expire_time", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "skill_tree_index", key: "skill_tree_index", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "equipped_creature_level", key: "equipped_creature_level", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "equip_list_trailing", key: "equip_list_trailing", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "manage_level", key: "manage_level", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "flag_byte", key: "flag_byte", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "guild_power_war", key: "guild_power_war", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "server_timestamp", key: "server_timestamp", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "quest_shop_count", key: "quest_shop_count", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "progress1", key: "progress1", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "progress2", key: "progress2", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "create_option_len", key: "create_option_len", ddl: "SMALLINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_state0", key: "roster_state0", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_time_a", key: "roster_time_a", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_time_b", key: "roster_time_b", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_value0", key: "roster_value0", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_value1", key: "roster_value1", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_value2", key: "roster_value2", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_reserved_a", key: "roster_reserved_a", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_reserved_b", key: "roster_reserved_b", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
)
|
|
for idx := 0; idx < 8; idx++ {
|
|
key := "roster_linked_id_" + mysqlTwoDigit(idx)
|
|
columns = append(columns, mysqlCharacterStatColumn{column: key, key: key, ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"})
|
|
}
|
|
columns = append(columns,
|
|
mysqlCharacterStatColumn{column: "roster_value3", key: "roster_value3", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_object_id", key: "roster_object_id", ddl: "BIGINT NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_flag0_eq1", key: "roster_flag0_eq1", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_card_flag", key: "roster_card_flag", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_value5", key: "roster_value5", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
mysqlCharacterStatColumn{column: "roster_display_flags", key: "roster_display_flags", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"},
|
|
)
|
|
for idx := 0; idx < 12; idx++ {
|
|
key := "roster_tail_" + mysqlTwoDigit(idx)
|
|
columns = append(columns, mysqlCharacterStatColumn{column: key, key: key, ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"})
|
|
}
|
|
columns = append(columns, mysqlCharacterStatColumn{column: "roster_flag6_eq1", key: "roster_flag6_eq1", ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"})
|
|
for idx := 0; idx < 64; idx++ {
|
|
key := "create_option_byte_" + mysqlTwoDigit(idx)
|
|
columns = append(columns, mysqlCharacterStatColumn{column: key, key: key, ddl: "TINYINT UNSIGNED NOT NULL DEFAULT 0"})
|
|
}
|
|
return columns
|
|
}
|
|
|
|
func mysqlCharacterStatColumnDDL() string {
|
|
lines := make([]string, 0, len(mysqlCharacterStatColumns))
|
|
for _, stat := range mysqlCharacterStatColumns {
|
|
lines = append(lines, " "+quoteSQLIdentifier(stat.column)+" "+stat.ddl+",")
|
|
}
|
|
return strings.Join(lines, "\n")
|
|
}
|
|
|
|
func mysqlCharacterStatSelectList() string {
|
|
columns := make([]string, len(mysqlCharacterStatColumns))
|
|
for idx, stat := range mysqlCharacterStatColumns {
|
|
columns[idx] = quoteSQLIdentifier(stat.column)
|
|
}
|
|
return strings.Join(columns, ", ")
|
|
}
|
|
|
|
func mysqlTwoDigit(value int) string {
|
|
if value < 10 {
|
|
return "0" + strconv.Itoa(value)
|
|
}
|
|
return strconv.Itoa(value)
|
|
}
|
|
|
|
type mysqlCharStore struct {
|
|
mysqlStoreBase
|
|
}
|
|
|
|
func (s *mysqlCharStore) AdvanceStoryDigest(ctx context.Context, characterID string, level, migrationVersion uint32) error {
|
|
characterID = strings.TrimSpace(characterID)
|
|
if characterID == "" {
|
|
return repository.ErrCharacterStoryDigestCharacterMissing
|
|
}
|
|
table, err := s.router.writeTable(mysqlCharTable, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
query := "UPDATE " + table + " SET " +
|
|
quoteSQLIdentifier(mysqlCharacterStoryDigestLastLevelStatColumn) + " = GREATEST(" + quoteSQLIdentifier(mysqlCharacterStoryDigestLastLevelStatColumn) + ", ?), " +
|
|
quoteSQLIdentifier(mysqlCharacterStoryDigestMigrationVersionColumn) + " = GREATEST(" + quoteSQLIdentifier(mysqlCharacterStoryDigestMigrationVersionColumn) + ", ?), " +
|
|
quoteSQLIdentifier("updated_at") + " = ? WHERE " + quoteSQLIdentifier("character_id") + " = ?"
|
|
_, err = s.router.db.ExecContext(ctx, query, int64(level), int64(migrationVersion), s.router.now().UTC(), characterID)
|
|
return err
|
|
}
|
|
|
|
// Load 按角色 ID 从 MySQL 读取 DNF 角色记录。
|
|
func (s *mysqlCharStore) Load(ctx context.Context, characterID string) (repository.CharacterRecord, bool, error) {
|
|
table, err := s.router.readTable(mysqlCharTable, characterID)
|
|
if err != nil {
|
|
return repository.CharacterRecord{}, false, err
|
|
}
|
|
query := s.router.selectQuery("SELECT character_id, account_id, slot, name, job, level, " + mysqlCharacterStatSelectList() + ", created_at, updated_at FROM " + table + " WHERE character_id = ?")
|
|
var record repository.CharacterRecord
|
|
var createdAt, updatedAt sql.NullTime
|
|
mirrorStats := make([]sql.NullInt64, len(mysqlCharacterStatColumns))
|
|
dest := []any{
|
|
&record.CharacterID,
|
|
&record.AccountID,
|
|
&record.Slot,
|
|
&record.Name,
|
|
&record.Job,
|
|
&record.Level,
|
|
}
|
|
for idx := range mirrorStats {
|
|
dest = append(dest, &mirrorStats[idx])
|
|
}
|
|
dest = append(dest, &createdAt, &updatedAt)
|
|
err = s.router.db.QueryRowContext(ctx, query, characterID).Scan(dest...)
|
|
if err != nil {
|
|
ok, scanErr := scanErr(err)
|
|
return repository.CharacterRecord{}, ok, scanErr
|
|
}
|
|
mergeCharacterMirrorStats(&record, mirrorStats)
|
|
if err := loadCharacterSupplement(ctx, s.router, characterID, &record); err != nil {
|
|
return repository.CharacterRecord{}, false, err
|
|
}
|
|
record.CreatedAt = scanTime(createdAt)
|
|
record.UpdatedAt = scanTime(updatedAt)
|
|
return repository.CloneCharacter(record), true, nil
|
|
}
|
|
|
|
// Save 保存完整 DNF 角色记录到 MySQL。
|
|
func (s *mysqlCharStore) Save(ctx context.Context, record repository.CharacterRecord) error {
|
|
return s.SaveFields(ctx, record, repository.AllCharacterFields()...)
|
|
}
|
|
|
|
// CreateCharacter 只执行 INSERT,避免账号槽位唯一键冲突时把旧角色 upsert 覆盖。
|
|
func (s *mysqlCharStore) CreateCharacter(ctx context.Context, record repository.CharacterRecord) error {
|
|
characterID, err := requireRecordKey(repository.CharacterKey, record, "character")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
table, err := s.router.writeTable(mysqlCharTable, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updatedAt := timeOrNow(record.UpdatedAt, s.router.now)
|
|
createdAt := record.CreatedAt
|
|
if createdAt.IsZero() {
|
|
createdAt = updatedAt
|
|
}
|
|
|
|
columns := []string{"character_id", "updated_at", "account_id", "slot", "name", "job", "level", "created_at"}
|
|
args := []any{characterID, updatedAt, record.AccountID, record.Slot, record.Name, record.Job, record.Level, sqlTime(createdAt)}
|
|
for _, stat := range mysqlCharacterStatColumns {
|
|
columns = append(columns, stat.column)
|
|
args = append(args, characterStatMirrorValue(record, stat))
|
|
}
|
|
dirty := make(map[repository.CharacterField]struct{}, 3)
|
|
for key := range record.Stats {
|
|
known := false
|
|
for _, stat := range mysqlCharacterStatColumns {
|
|
if stat.key == key {
|
|
known = true
|
|
break
|
|
}
|
|
}
|
|
if !known {
|
|
dirty[repository.CharacterFieldStats] = struct{}{}
|
|
break
|
|
}
|
|
}
|
|
if record.Location != (repository.CharacterLocation{}) {
|
|
dirty[repository.CharacterFieldLocation] = struct{}{}
|
|
}
|
|
if !isZeroCharacterRoster(record.Roster) {
|
|
dirty[repository.CharacterFieldRoster] = struct{}{}
|
|
}
|
|
return withMySQLWriteExecutor(ctx, s.router.db, func(database SQLDB) error {
|
|
if _, execErr := database.ExecContext(ctx, buildInsert(table, columns), args...); execErr != nil {
|
|
return execErr
|
|
}
|
|
return saveCharacterSupplement(ctx, database, s.router, characterID, record, dirty)
|
|
})
|
|
}
|
|
|
|
// SaveFields 保存 DNF 角色指定字段到 MySQL。
|
|
// 插入路径会补角色基础字段;更新路径只写 dirty 字段,避免整包覆盖。
|
|
func (s *mysqlCharStore) SaveFields(ctx context.Context, record repository.CharacterRecord, fields ...repository.CharacterField) error {
|
|
characterID, err := requireRecordKey(repository.CharacterKey, record, "character")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fields = repository.CharacterFields.Normalize(fields)
|
|
if len(fields) == 0 {
|
|
return nil
|
|
}
|
|
table, err := s.router.writeTable(mysqlCharTable, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
updatedAt := timeOrNow(record.UpdatedAt, s.router.now)
|
|
createdAt := record.CreatedAt
|
|
if createdAt.IsZero() {
|
|
createdAt = updatedAt
|
|
}
|
|
|
|
columns := []string{"character_id", "updated_at"}
|
|
args := []any{characterID, updatedAt}
|
|
updates := []string{updateValue("updated_at")}
|
|
dirty := make(map[repository.CharacterField]struct{}, len(fields))
|
|
for _, field := range fields {
|
|
dirty[field] = struct{}{}
|
|
}
|
|
|
|
columns = append(columns, "account_id", "slot", "name", "job", "level", "created_at")
|
|
args = append(args, record.AccountID, record.Slot, record.Name, record.Job, record.Level, sqlTime(createdAt))
|
|
for _, stat := range mysqlCharacterStatColumns {
|
|
columns = append(columns, stat.column)
|
|
args = append(args, characterStatMirrorValue(record, stat))
|
|
}
|
|
if _, ok := dirty[repository.CharacterFieldBase]; ok {
|
|
updates = append(updates,
|
|
updateValue("account_id"),
|
|
updateValue("slot"),
|
|
updateValue("name"),
|
|
updateValue("job"),
|
|
updateValue("level"),
|
|
keepCreatedAt("created_at"),
|
|
)
|
|
}
|
|
if _, ok := dirty[repository.CharacterFieldStats]; ok {
|
|
for _, stat := range mysqlCharacterStatColumns {
|
|
if stat.key == repository.CharacterStoryDigestLastLevelStatKey || stat.key == repository.CharacterStoryDigestMigrationVersionStatKey {
|
|
updates = append(updates, updateMonotonicValue(stat.column))
|
|
continue
|
|
}
|
|
updates = append(updates, updateValue(stat.column))
|
|
}
|
|
}
|
|
return withMySQLWriteExecutor(ctx, s.router.db, func(database SQLDB) error {
|
|
if _, execErr := database.ExecContext(ctx, buildUpsert(table, columns, updates), args...); execErr != nil {
|
|
return execErr
|
|
}
|
|
return saveCharacterSupplement(ctx, database, s.router, characterID, record, dirty)
|
|
})
|
|
}
|
|
|
|
func updateMonotonicValue(column string) string {
|
|
quoted := quoteSQLIdentifier(column)
|
|
return quoted + " = GREATEST(" + quoted + ", VALUES(" + quoted + "))"
|
|
}
|
|
|
|
func mergeCharacterMirrorStats(record *repository.CharacterRecord, values []sql.NullInt64) {
|
|
record.Stats = make(map[string]int64, len(mysqlCharacterStatColumns))
|
|
for idx, stat := range mysqlCharacterStatColumns {
|
|
value := stat.fallback
|
|
if idx < len(values) && values[idx].Valid {
|
|
value = values[idx].Int64
|
|
}
|
|
record.Stats[stat.key] = value
|
|
}
|
|
}
|
|
|
|
func characterStatMirrorValue(record repository.CharacterRecord, stat mysqlCharacterStatColumn) int64 {
|
|
if record.Stats == nil {
|
|
return stat.fallback
|
|
}
|
|
value, ok := record.Stats[stat.key]
|
|
if !ok {
|
|
return stat.fallback
|
|
}
|
|
if value < 0 && !stat.allowNegative {
|
|
return 0
|
|
}
|
|
return value
|
|
}
|
|
|
|
// ListByAccount 按账号读取角色列表,供旧客户端选择角色列表回包使用。
|
|
// 这里只查询仓储记录,不做职业、槽位或创建规则判断。
|
|
func (s *mysqlCharStore) ListByAccount(ctx context.Context, accountID string, limit int) ([]repository.CharacterRecord, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
accountID = strings.TrimSpace(accountID)
|
|
if accountID == "" {
|
|
return nil, db.ErrRecordKeyRequired
|
|
}
|
|
if limit <= 0 {
|
|
// 不传 limit 的调用方要的是“这个账号的全部角色”,回落值必须是硬上限;
|
|
// 回落到基线会静默截掉扩栏槽位上的角色。
|
|
limit = repository.MaxCharacterSlotLimit
|
|
}
|
|
ids := make([]string, 0, limit)
|
|
for _, database := range s.router.readDBs {
|
|
if len(ids) >= limit {
|
|
break
|
|
}
|
|
table := mysqlTable(database, s.router.tablePrefix, mysqlCharTable)
|
|
query := "SELECT COALESCE(GROUP_CONCAT(character_id ORDER BY slot ASC, character_id ASC SEPARATOR '\n'), '') FROM " + table + " WHERE account_id = ? AND delete_flag = 0"
|
|
var packed sql.NullString
|
|
if err := s.router.db.QueryRowContext(ctx, query, accountID).Scan(&packed); err != nil {
|
|
return nil, err
|
|
}
|
|
if !packed.Valid || strings.TrimSpace(packed.String) == "" {
|
|
continue
|
|
}
|
|
for _, id := range strings.Split(packed.String, "\n") {
|
|
id = strings.TrimSpace(id)
|
|
if id == "" {
|
|
continue
|
|
}
|
|
ids = append(ids, id)
|
|
if len(ids) >= limit {
|
|
break
|
|
}
|
|
}
|
|
}
|
|
records := make([]repository.CharacterRecord, 0, len(ids))
|
|
for _, id := range ids {
|
|
record, ok, err := s.Load(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if ok {
|
|
records = append(records, record)
|
|
}
|
|
}
|
|
return records, nil
|
|
}
|
|
|
|
func (s *mysqlCharStore) LastDeletedProfession(ctx context.Context, accountID string, job, grow byte) (repository.CharacterRecord, bool, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return repository.CharacterRecord{}, false, err
|
|
}
|
|
if strings.TrimSpace(accountID) == "" {
|
|
return repository.CharacterRecord{}, false, db.ErrRecordKeyRequired
|
|
}
|
|
var latest repository.CharacterRecord
|
|
found := false
|
|
for _, database := range s.router.readDBs {
|
|
table := mysqlTable(database, s.router.tablePrefix, mysqlCharTable)
|
|
query := s.router.selectQuery("SELECT character_id FROM " + table + " WHERE account_id = ? AND job = ? AND (grow_type & 15) = ? AND delete_flag = 1 ORDER BY updated_at DESC, character_id DESC LIMIT 1")
|
|
var id string
|
|
err := s.router.db.QueryRowContext(ctx, query, accountID, strconv.Itoa(int(job)), grow).Scan(&id)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
continue
|
|
}
|
|
if err != nil {
|
|
return repository.CharacterRecord{}, false, err
|
|
}
|
|
record, ok, err := s.Load(ctx, id)
|
|
if err != nil {
|
|
return repository.CharacterRecord{}, false, err
|
|
}
|
|
if ok && (!found || record.UpdatedAt.After(latest.UpdatedAt)) {
|
|
latest, found = record, true
|
|
}
|
|
}
|
|
return latest, found, nil
|
|
}
|
|
|
|
func (s *mysqlCharStore) SwapCharacterSlots(ctx context.Context, accountID string, slotA, slotB int) error {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return err
|
|
}
|
|
accountID = strings.TrimSpace(accountID)
|
|
if accountID == "" {
|
|
return db.ErrRecordKeyRequired
|
|
}
|
|
if slotA == slotB {
|
|
return nil
|
|
}
|
|
if slotA < 0 || slotA >= repository.MaxCharacterSlotLimit || slotB < 0 || slotB >= repository.MaxCharacterSlotLimit {
|
|
return repository.ErrCharacterSlotMissing
|
|
}
|
|
beginner, ok := s.router.db.(sqlTxBeginner)
|
|
if !ok {
|
|
return repository.ErrCharacterSlotSwapUnavailable
|
|
}
|
|
tx, err := beginner.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
txRouter := s.router
|
|
txRouter.db = sqlTransactionDB{tx: tx}
|
|
txRouter.readDBs = append([]string(nil), s.router.writeDBs...)
|
|
txRouter.writeDBs = append([]string(nil), s.router.writeDBs...)
|
|
txRouter.lockReads = true
|
|
|
|
type slotRow struct {
|
|
id string
|
|
slot int
|
|
table string
|
|
}
|
|
found := map[int]slotRow{}
|
|
for _, database := range txRouter.writeDBs {
|
|
table := mysqlTable(database, txRouter.tablePrefix, mysqlCharTable)
|
|
query := "SELECT character_id, slot FROM " + table + " WHERE account_id = ? AND delete_flag = 0 AND slot IN (?, ?) FOR UPDATE"
|
|
rows, err := txRouter.db.QueryContext(ctx, query, accountID, slotA, slotB)
|
|
if err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
for rows.Next() {
|
|
var row slotRow
|
|
if err := rows.Scan(&row.id, &row.slot); err != nil {
|
|
_ = rows.Close()
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
row.table = table
|
|
found[row.slot] = row
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
_ = rows.Close()
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
}
|
|
left, okLeft := found[slotA]
|
|
right, okRight := found[slotB]
|
|
now := s.router.now().UTC()
|
|
if !okLeft || !okRight {
|
|
return tx.Commit()
|
|
}
|
|
tempSlot := -1 - slotA - slotB*repository.MaxCharacterSlotLimit
|
|
if err := mysqlSwapCharacterSlotUpdate(ctx, txRouter.db, left.table, left.id, accountID, tempSlot, now); err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
if err := mysqlSwapCharacterSlotUpdate(ctx, txRouter.db, right.table, right.id, accountID, slotA, now); err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
if err := mysqlSwapCharacterSlotUpdate(ctx, txRouter.db, left.table, left.id, accountID, slotB, now); err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func (s *mysqlCharStore) DeleteCharacterAndCompact(ctx context.Context, accountID string, slot int, name string) error {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return err
|
|
}
|
|
accountID = strings.TrimSpace(accountID)
|
|
name = strings.TrimSpace(name)
|
|
if accountID == "" || name == "" {
|
|
return db.ErrRecordKeyRequired
|
|
}
|
|
if slot < 0 || slot >= repository.MaxCharacterSlotLimit {
|
|
return repository.ErrCharacterSlotMissing
|
|
}
|
|
beginner, ok := s.router.db.(sqlTxBeginner)
|
|
if !ok || len(s.router.writeDBs) == 0 {
|
|
return repository.ErrCharacterDeleteUnavailable
|
|
}
|
|
tx, err := beginner.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
table := mysqlTable(
|
|
s.router.writeDBs[pickDatabase(accountID, len(s.router.writeDBs))],
|
|
s.router.tablePrefix,
|
|
mysqlCharTable,
|
|
)
|
|
type rosterRow struct {
|
|
id string
|
|
slot int
|
|
name string
|
|
}
|
|
rows, err := tx.QueryContext(
|
|
ctx,
|
|
"SELECT character_id, slot, name FROM "+table+" WHERE account_id = ? AND delete_flag = 0 ORDER BY slot ASC, character_id ASC FOR UPDATE",
|
|
accountID,
|
|
)
|
|
if err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
roster := make([]rosterRow, 0, repository.MaxCharacterSlotLimit)
|
|
for rows.Next() {
|
|
var row rosterRow
|
|
if err := rows.Scan(&row.id, &row.slot, &row.name); err != nil {
|
|
_ = rows.Close()
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
roster = append(roster, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
_ = rows.Close()
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
if err := rows.Close(); err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
targetIndex := -1
|
|
for index := range roster {
|
|
if roster[index].slot == slot {
|
|
targetIndex = index
|
|
break
|
|
}
|
|
}
|
|
if targetIndex < 0 {
|
|
return errors.Join(repository.ErrCharacterDeleteNotFound, tx.Rollback())
|
|
}
|
|
if strings.TrimSpace(roster[targetIndex].name) != name {
|
|
return errors.Join(repository.ErrCharacterDeleteNameMismatch, tx.Rollback())
|
|
}
|
|
now := sqlTime(s.router.now().UTC())
|
|
deletedSlot, err := mysqlNextCharacterTombstoneSlot(ctx, sqlTransactionDB{tx: tx}, table, accountID)
|
|
if err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
if _, err := tx.ExecContext(
|
|
ctx,
|
|
"UPDATE "+table+" SET delete_flag = 1, slot = ?, updated_at = ? WHERE character_id = ? AND account_id = ? AND delete_flag = 0",
|
|
deletedSlot,
|
|
now,
|
|
roster[targetIndex].id,
|
|
accountID,
|
|
); err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
|
|
active := make([]rosterRow, 0, len(roster)-1)
|
|
for index := range roster {
|
|
if index != targetIndex {
|
|
active = append(active, roster[index])
|
|
}
|
|
}
|
|
for index, row := range active {
|
|
if _, err := tx.ExecContext(
|
|
ctx,
|
|
"UPDATE "+table+" SET slot = ?, updated_at = ? WHERE character_id = ? AND account_id = ? AND delete_flag = 0",
|
|
-1000-index,
|
|
now,
|
|
row.id,
|
|
accountID,
|
|
); err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
}
|
|
for index, row := range active {
|
|
if _, err := tx.ExecContext(
|
|
ctx,
|
|
"UPDATE "+table+" SET slot = ?, updated_at = ? WHERE character_id = ? AND account_id = ? AND delete_flag = 0",
|
|
index,
|
|
now,
|
|
row.id,
|
|
accountID,
|
|
); err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return errors.Join(err, tx.Rollback())
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
// Deleted rows still participate in the account/slot/delete_flag unique key.
|
|
func mysqlNextCharacterTombstoneSlot(ctx context.Context, sqlDB SQLDB, table, accountID string) (int, error) {
|
|
var lowest int
|
|
err := sqlDB.QueryRowContext(
|
|
ctx,
|
|
"SELECT slot FROM "+table+" WHERE account_id = ? AND delete_flag = 1 AND slot < 0 ORDER BY slot ASC LIMIT 1 FOR UPDATE",
|
|
accountID,
|
|
).Scan(&lowest)
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
return -1, nil
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if lowest <= mysqlCharacterSlotMin {
|
|
return 0, repository.ErrCharacterDeleteUnavailable
|
|
}
|
|
return lowest - 1, nil
|
|
}
|
|
|
|
func mysqlSwapCharacterSlotUpdate(ctx context.Context, db SQLDB, table, characterID, accountID string, slot int, updatedAt time.Time) error {
|
|
query := "UPDATE " + table + " SET slot = ?, updated_at = ? WHERE character_id = ? AND account_id = ?"
|
|
result, err := db.ExecContext(ctx, query, slot, sqlTime(updatedAt), characterID, accountID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if result != nil {
|
|
if affected, err := result.RowsAffected(); err == nil && affected != 1 {
|
|
return repository.ErrCharacterSlotMissing
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// FindIDByName 按角色名查找现有角色 ID,用于创建前去重。
|
|
func (s *mysqlCharStore) FindIDByName(ctx context.Context, name string) (string, bool, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return "", false, err
|
|
}
|
|
name = strings.TrimSpace(name)
|
|
if name == "" {
|
|
return "", false, nil
|
|
}
|
|
for _, database := range s.router.readDBs {
|
|
table := mysqlTable(database, s.router.tablePrefix, mysqlCharTable)
|
|
query := "SELECT character_id FROM " + table + " WHERE name = ? AND delete_flag = 0 LIMIT 1"
|
|
var id string
|
|
err := s.router.db.QueryRowContext(ctx, query, name).Scan(&id)
|
|
if err == nil {
|
|
return id, true, nil
|
|
}
|
|
if errors.Is(err, sql.ErrNoRows) {
|
|
continue
|
|
}
|
|
return "", false, err
|
|
}
|
|
return "", false, nil
|
|
}
|
|
|
|
// NextNumericID 分配 latest upper 创建角色需要回传的 u16 数字 ID。
|
|
// 当前 DNF bridge 只在单区服重建链路使用;后续 owner 化时应改为数据库序列或雪花号映射。
|
|
func (s *mysqlCharStore) NextNumericID(ctx context.Context) (int, error) {
|
|
if err := ctxErr(ctx); err != nil {
|
|
return 0, err
|
|
}
|
|
maxID := 0
|
|
for _, database := range s.router.writeDBs {
|
|
table := mysqlTable(database, s.router.tablePrefix, mysqlCharTable)
|
|
query := "SELECT COALESCE(MAX(CAST(character_id AS UNSIGNED)), 0) FROM " + table
|
|
var value int
|
|
if err := s.router.db.QueryRowContext(ctx, query).Scan(&value); err != nil {
|
|
return 0, err
|
|
}
|
|
if value > maxID {
|
|
maxID = value
|
|
}
|
|
}
|
|
return maxID + 1, nil
|
|
}
|