服务端(Go): - GM 塞背包/带属性装备发放:在线角色推送 op14 增量物品行,背包实时刷新,无需回角色选择界面 - GM 改金币/改等级:去掉在线拒绝,落库后推送 op14 钱包/op37 等级状态,在线实时刷新 - 等级端点统一切换到新版 GMSetCharacterLevel(自带在线刷新) - 无限/锁死疲劳模式 + 燃烧疲劳:每房间消耗 2 点、打怪与通关经验双倍 - 燃烧疲劳开关时给所有在线角色推送系统提示(op12 mode 0) - 深渊史诗掉率:GM 全局倍率 + 城镇增益药剂窗口倍率,真实接入掉落结算 启动器(Python/JS): - 标题栏自绘、窗口 1204x717、禁止拖拽改大小、仅最小化/关闭 - GM 工具:疲劳与燃烧、深渊史诗掉率、拍卖行上架(单一定价对齐) - 拍卖行上架前端对齐服务端单一价格语义,修复 400 报错
366 lines
11 KiB
Go
366 lines
11 KiB
Go
package achievement
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
|
)
|
|
|
|
const (
|
|
titleBookListType byte = 100
|
|
titleBookSlotBase int16 = 1000
|
|
titleBookMaxPerCategory = 200
|
|
|
|
statusActive = "title_book_active"
|
|
statusCompleted = "title_book_completed"
|
|
|
|
extraInitialized = "title_book_initialized"
|
|
extraRemain1 = "title_book_remain_1"
|
|
extraRemain2 = "title_book_remain_2"
|
|
extraRemain3 = "title_book_remain_3"
|
|
)
|
|
|
|
var (
|
|
ErrOwnerUnavailable = errors.New("achievement owner unavailable")
|
|
ErrCharacterRequired = errors.New("selected character id required")
|
|
ErrInventoryNotFound = errors.New("achievement inventory not found")
|
|
ErrDefinitionNotFound = errors.New("achievement definition not found")
|
|
)
|
|
|
|
type TitleReward struct {
|
|
ItemID int32
|
|
Category int32
|
|
BookIndex int32
|
|
}
|
|
|
|
type Definition struct {
|
|
QuestID int32
|
|
Target1 uint16
|
|
Target2 uint16
|
|
Target3 uint16
|
|
Reward TitleReward
|
|
}
|
|
|
|
type DefinitionResolver interface {
|
|
ResolveAchievementDefinition(context.Context, int32) (Definition, error)
|
|
}
|
|
|
|
type Command struct {
|
|
SelectedCharacterID uint16
|
|
QuestID int32
|
|
Delta1 uint16
|
|
Delta2 uint16
|
|
Delta3 uint16
|
|
UpdatedAt time.Time
|
|
}
|
|
|
|
type Result struct {
|
|
CharacterID string
|
|
QuestID int32
|
|
Remain1 uint16
|
|
Remain2 uint16
|
|
Remain3 uint16
|
|
Completed bool
|
|
AlreadyCompleted bool
|
|
TitleGranted bool
|
|
TitleItemID int32
|
|
TitleCategory int32
|
|
TitleSlot int16
|
|
LegacyRowsRemoved int
|
|
}
|
|
|
|
type Progress struct {
|
|
QuestID int32
|
|
Remain1 uint16
|
|
Remain2 uint16
|
|
Remain3 uint16
|
|
Completed bool
|
|
}
|
|
|
|
// Owner atomically owns the durable title-achievement state and its optional
|
|
// physical title reward. Progress deliberately lives in Quest.Progress, not
|
|
// inventory list 100: list 100 is the client-visible physical title book.
|
|
type Owner struct {
|
|
settlements dnfrepo.CharacterSettlementUnitOfWork
|
|
resolver DefinitionResolver
|
|
}
|
|
|
|
func NewOwner(repos dnfrepo.Group, resolver DefinitionResolver) (*Owner, error) {
|
|
if repos.CharacterSettlement == nil || repos.Quest == nil || repos.Inventory == nil {
|
|
return nil, ErrOwnerUnavailable
|
|
}
|
|
return &Owner{settlements: repos.CharacterSettlement, resolver: resolver}, nil
|
|
}
|
|
|
|
func (o *Owner) Trigger(ctx context.Context, cmd Command) (Result, error) {
|
|
if o == nil || o.settlements == nil || o.resolver == nil {
|
|
return Result{}, ErrOwnerUnavailable
|
|
}
|
|
if cmd.SelectedCharacterID == 0 {
|
|
return Result{}, ErrCharacterRequired
|
|
}
|
|
definition, err := o.resolver.ResolveAchievementDefinition(ctx, cmd.QuestID)
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
if definition.QuestID != cmd.QuestID ||
|
|
(definition.Target1 == 0 && definition.Target2 == 0 && definition.Target3 == 0) {
|
|
return Result{}, fmt.Errorf("%w: quest=%d", ErrDefinitionNotFound, cmd.QuestID)
|
|
}
|
|
|
|
characterID := strconv.FormatUint(uint64(cmd.SelectedCharacterID), 10)
|
|
result := Result{CharacterID: characterID, QuestID: cmd.QuestID}
|
|
err = o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
|
quests, _, loadErr := tx.Quest.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
quests = dnfrepo.CloneQuest(quests)
|
|
quests.CharacterID = characterID
|
|
if quests.Progress == nil {
|
|
quests.Progress = make(map[int64]dnfrepo.QuestState)
|
|
}
|
|
|
|
inventory, found, loadErr := tx.Inventory.Load(ctx, characterID)
|
|
if loadErr != nil {
|
|
return loadErr
|
|
}
|
|
if !found || strings.TrimSpace(inventory.CharacterID) != characterID {
|
|
return fmt.Errorf("%w: character=%s", ErrInventoryNotFound, characterID)
|
|
}
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
if inventory.Slots == nil {
|
|
inventory.Slots = make(map[string]dnfrepo.ItemStack)
|
|
}
|
|
for key, stack := range inventory.Slots {
|
|
if IsLegacyInventoryProgress(key, stack) {
|
|
delete(inventory.Slots, key)
|
|
result.LegacyRowsRemoved++
|
|
}
|
|
}
|
|
|
|
state, known := quests.Progress[int64(cmd.QuestID)]
|
|
if known && strings.EqualFold(strings.TrimSpace(state.Status), statusCompleted) {
|
|
result.AlreadyCompleted = true
|
|
if result.LegacyRowsRemoved > 0 {
|
|
inventory.UpdatedAt = commandTime(cmd.UpdatedAt)
|
|
return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
remain1, remain2, remain3 := definition.Target1, definition.Target2, definition.Target3
|
|
if known && strings.EqualFold(strings.TrimSpace(state.Status), statusActive) &&
|
|
state.Extra[extraInitialized] == "1" {
|
|
remain1 = extraUint16(state.Extra, extraRemain1, definition.Target1)
|
|
remain2 = extraUint16(state.Extra, extraRemain2, definition.Target2)
|
|
remain3 = extraUint16(state.Extra, extraRemain3, definition.Target3)
|
|
}
|
|
remain1 = subtractProgress(remain1, cmd.Delta1)
|
|
remain2 = subtractProgress(remain2, cmd.Delta2)
|
|
remain3 = subtractProgress(remain3, cmd.Delta3)
|
|
|
|
now := commandTime(cmd.UpdatedAt)
|
|
state.Status = statusActive
|
|
state.ProgressValue = int64(remain1)
|
|
state.UpdatedAt = now
|
|
if state.Extra == nil {
|
|
state.Extra = make(map[string]string, 4)
|
|
}
|
|
state.Extra[extraInitialized] = "1"
|
|
state.Extra[extraRemain1] = strconv.FormatUint(uint64(remain1), 10)
|
|
state.Extra[extraRemain2] = strconv.FormatUint(uint64(remain2), 10)
|
|
state.Extra[extraRemain3] = strconv.FormatUint(uint64(remain3), 10)
|
|
|
|
result.Remain1 = remain1
|
|
result.Remain2 = remain2
|
|
result.Remain3 = remain3
|
|
result.Completed = remain1 == 0 && remain2 == 0 && remain3 == 0
|
|
if result.Completed {
|
|
state.Status = statusCompleted
|
|
state.ProgressValue = 0
|
|
grantTitle(&inventory, definition.Reward, cmd.QuestID, &result)
|
|
}
|
|
quests.Progress[int64(cmd.QuestID)] = state
|
|
quests.UpdatedAt = now
|
|
inventory.UpdatedAt = now
|
|
|
|
if err := dnfrepo.SaveQuestFields(ctx, tx.Quest, quests, dnfrepo.QuestFieldProgress); err != nil {
|
|
return err
|
|
}
|
|
if result.TitleGranted || result.LegacyRowsRemoved > 0 {
|
|
if err := dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return Result{}, err
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
// RepairLegacyRows removes only rows carrying the exact signature emitted by
|
|
// the former broken implementation. Their stored counters were initialized
|
|
// from the first delta and are not trustworthy enough to migrate.
|
|
func (o *Owner) RepairLegacyRows(ctx context.Context, selectedCharacterID uint16) (int, error) {
|
|
if o == nil || o.settlements == nil {
|
|
return 0, ErrOwnerUnavailable
|
|
}
|
|
if selectedCharacterID == 0 {
|
|
return 0, ErrCharacterRequired
|
|
}
|
|
characterID := strconv.FormatUint(uint64(selectedCharacterID), 10)
|
|
removed := 0
|
|
err := o.settlements.WithinCharacterSettlement(ctx, characterID, func(tx dnfrepo.Group) error {
|
|
inventory, found, err := tx.Inventory.Load(ctx, characterID)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !found {
|
|
return nil
|
|
}
|
|
inventory = dnfrepo.CloneInventory(inventory)
|
|
for key, stack := range inventory.Slots {
|
|
if IsLegacyInventoryProgress(key, stack) {
|
|
delete(inventory.Slots, key)
|
|
removed++
|
|
}
|
|
}
|
|
if removed == 0 {
|
|
return nil
|
|
}
|
|
inventory.UpdatedAt = time.Now().UTC()
|
|
return dnfrepo.SaveInventoryFields(ctx, tx.Inventory, inventory, dnfrepo.InventoryFieldSlots)
|
|
})
|
|
return removed, err
|
|
}
|
|
|
|
func Snapshot(record dnfrepo.QuestRecord) []Progress {
|
|
rows := make([]Progress, 0)
|
|
for questID, state := range record.Progress {
|
|
status := strings.ToLower(strings.TrimSpace(state.Status))
|
|
if status != statusActive && status != statusCompleted {
|
|
continue
|
|
}
|
|
if questID <= 0 || questID > int64(^uint32(0)) {
|
|
continue
|
|
}
|
|
row := Progress{QuestID: int32(questID), Completed: status == statusCompleted}
|
|
if !row.Completed {
|
|
row.Remain1 = extraUint16(state.Extra, extraRemain1, 0)
|
|
row.Remain2 = extraUint16(state.Extra, extraRemain2, 0)
|
|
row.Remain3 = extraUint16(state.Extra, extraRemain3, 0)
|
|
}
|
|
rows = append(rows, row)
|
|
}
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].QuestID < rows[j].QuestID })
|
|
return rows
|
|
}
|
|
|
|
func IsLegacyInventoryProgress(key string, stack dnfrepo.ItemStack) bool {
|
|
if stack.Extra == nil || stack.Extra["initialized"] != "1" {
|
|
return false
|
|
}
|
|
questID, err := strconv.ParseInt(strings.TrimSpace(stack.Extra["quest_id"]), 10, 32)
|
|
if err != nil || questID <= 0 || stack.ItemID != questID {
|
|
return false
|
|
}
|
|
parts := strings.Split(key, ":")
|
|
if len(parts) != 2 || parts[0] != strconv.Itoa(int(titleBookListType)) {
|
|
return false
|
|
}
|
|
slot, err := strconv.Atoi(parts[1])
|
|
return err == nil && slot >= 900 && slot <= 999
|
|
}
|
|
|
|
func grantTitle(inventory *dnfrepo.InventoryRecord, reward TitleReward, questID int32, result *Result) {
|
|
if inventory == nil || result == nil || reward.ItemID <= 0 ||
|
|
reward.Category < 0 || reward.Category >= 5 {
|
|
return
|
|
}
|
|
for index := int32(0); index < titleBookMaxPerCategory; index++ {
|
|
slot := int16(reward.Category)*titleBookSlotBase + int16(index)
|
|
if existing, found := inventory.Slots[titleSlotKey(slot)]; found &&
|
|
existing.ItemID == int64(reward.ItemID) {
|
|
result.TitleItemID = reward.ItemID
|
|
result.TitleCategory = reward.Category
|
|
result.TitleSlot = slot
|
|
return
|
|
}
|
|
}
|
|
targetSlot, ok := firstTitleSlot(inventory.Slots, reward.Category, reward.BookIndex)
|
|
if !ok {
|
|
return
|
|
}
|
|
key := titleSlotKey(targetSlot)
|
|
inventory.Slots[key] = dnfrepo.ItemStack{
|
|
ItemID: int64(reward.ItemID),
|
|
Count: 1,
|
|
Extra: map[string]string{
|
|
"source": "achievement_reward",
|
|
"title_book_category": strconv.FormatInt(int64(reward.Category), 10),
|
|
"title_book_index": strconv.FormatInt(int64(targetSlot%titleBookSlotBase), 10),
|
|
"quest_id": strconv.FormatInt(int64(questID), 10),
|
|
},
|
|
}
|
|
result.TitleGranted = true
|
|
result.TitleItemID = reward.ItemID
|
|
result.TitleCategory = reward.Category
|
|
result.TitleSlot = targetSlot
|
|
}
|
|
|
|
func titleSlotKey(slot int16) string {
|
|
return fmt.Sprintf("%d:%d", titleBookListType, slot)
|
|
}
|
|
|
|
func categorySlotKey(category, index int32) string {
|
|
return titleSlotKey(int16(category)*titleBookSlotBase + int16(index))
|
|
}
|
|
|
|
func firstTitleSlot(items map[string]dnfrepo.ItemStack, category, preferredIndex int32) (int16, bool) {
|
|
if preferredIndex >= 0 && preferredIndex < titleBookMaxPerCategory {
|
|
slot := int16(category)*titleBookSlotBase + int16(preferredIndex)
|
|
existing, occupied := items[titleSlotKey(slot)]
|
|
if !occupied || existing.ItemID <= 0 {
|
|
return slot, true
|
|
}
|
|
}
|
|
for index := int32(0); index < titleBookMaxPerCategory; index++ {
|
|
existing, occupied := items[categorySlotKey(category, index)]
|
|
if !occupied || existing.ItemID <= 0 {
|
|
return int16(category)*titleBookSlotBase + int16(index), true
|
|
}
|
|
}
|
|
return -1, false
|
|
}
|
|
|
|
func extraUint16(extra map[string]string, key string, fallback uint16) uint16 {
|
|
value, err := strconv.ParseUint(strings.TrimSpace(extra[key]), 10, 16)
|
|
if err != nil {
|
|
return fallback
|
|
}
|
|
return uint16(value)
|
|
}
|
|
|
|
func subtractProgress(remaining, delta uint16) uint16 {
|
|
if delta >= remaining {
|
|
return 0
|
|
}
|
|
return remaining - delta
|
|
}
|
|
|
|
func commandTime(value time.Time) time.Time {
|
|
if value.IsZero() {
|
|
return time.Now().UTC()
|
|
}
|
|
return value.UTC()
|
|
}
|