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,365 @@
|
||||
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()
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package achievement
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
dnfrepo "longheng.io/server/internal/modules/dnf/repository"
|
||||
dnfrepomemory "longheng.io/server/internal/modules/dnf/repository/memory"
|
||||
)
|
||||
|
||||
type definitionStub Definition
|
||||
|
||||
func (r definitionStub) ResolveAchievementDefinition(context.Context, int32) (Definition, error) {
|
||||
return Definition(r), nil
|
||||
}
|
||||
|
||||
func TestOwnerTriggerPersistsRealTargetAndGrantsTitleOnce(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
owner, err := NewOwner(repos, definitionStub{
|
||||
QuestID: 6533,
|
||||
Target1: 4,
|
||||
Reward: TitleReward{
|
||||
ItemID: 9001,
|
||||
Category: 2,
|
||||
BookIndex: 7,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner: %v", err)
|
||||
}
|
||||
|
||||
first, err := owner.Trigger(ctx, Command{
|
||||
SelectedCharacterID: 19,
|
||||
QuestID: 6533,
|
||||
Delta1: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("first Trigger: %v", err)
|
||||
}
|
||||
if first.Remain1 != 3 || first.Completed {
|
||||
t.Fatalf("first result = %+v", first)
|
||||
}
|
||||
second, err := owner.Trigger(ctx, Command{
|
||||
SelectedCharacterID: 19,
|
||||
QuestID: 6533,
|
||||
Delta1: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("second Trigger: %v", err)
|
||||
}
|
||||
if !second.Completed || !second.TitleGranted || second.TitleSlot != 2007 {
|
||||
t.Fatalf("second result = %+v", second)
|
||||
}
|
||||
third, err := owner.Trigger(ctx, Command{
|
||||
SelectedCharacterID: 19,
|
||||
QuestID: 6533,
|
||||
Delta1: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("third Trigger: %v", err)
|
||||
}
|
||||
if third.Completed || third.TitleGranted || !third.AlreadyCompleted ||
|
||||
third.Remain1 != 0 || third.Remain2 != 0 || third.Remain3 != 0 {
|
||||
t.Fatalf("third result = %+v", third)
|
||||
}
|
||||
|
||||
inventory, found, err := repos.Inventory.Load(ctx, "19")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load inventory found=%t err=%v", found, err)
|
||||
}
|
||||
if title := inventory.Slots["100:2007"]; title.ItemID != 9001 || title.Count != 1 {
|
||||
t.Fatalf("title = %+v", title)
|
||||
}
|
||||
for key, stack := range inventory.Slots {
|
||||
if IsLegacyInventoryProgress(key, stack) {
|
||||
t.Fatalf("achievement progress leaked into title inventory: %s=%+v", key, stack)
|
||||
}
|
||||
}
|
||||
quests, found, err := repos.Quest.Load(ctx, "19")
|
||||
if err != nil || !found {
|
||||
t.Fatalf("load quests found=%t err=%v", found, err)
|
||||
}
|
||||
progress := Snapshot(quests)
|
||||
if len(progress) != 1 || progress[0].QuestID != 6533 || !progress[0].Completed {
|
||||
t.Fatalf("progress = %+v", progress)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOwnerTriggerFallsBackToFirstFreeTitleSlot(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"100:2007": {ItemID: 8001, Count: 1},
|
||||
"100:2000": {ItemID: 8002, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
owner, err := NewOwner(repos, definitionStub{
|
||||
QuestID: 6533,
|
||||
Target1: 1,
|
||||
Reward: TitleReward{ItemID: 9001, Category: 2, BookIndex: 7},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner: %v", err)
|
||||
}
|
||||
result, err := owner.Trigger(ctx, Command{SelectedCharacterID: 19, QuestID: 6533, Delta1: 1})
|
||||
if err != nil {
|
||||
t.Fatalf("Trigger: %v", err)
|
||||
}
|
||||
if result.TitleSlot != 2001 {
|
||||
t.Fatalf("title slot = %d, want 2001", result.TitleSlot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepairLegacyRowsRemovesOnlyBrokenProgressSignature(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repos := dnfrepomemory.NewMemoryGroup()
|
||||
if err := repos.Inventory.Save(ctx, dnfrepo.InventoryRecord{
|
||||
CharacterID: "19",
|
||||
Slots: map[string]dnfrepo.ItemStack{
|
||||
"100:933": {
|
||||
ItemID: 6533,
|
||||
Count: 1,
|
||||
Extra: map[string]string{
|
||||
"initialized": "1",
|
||||
"quest_id": "6533",
|
||||
"remain1": "1",
|
||||
},
|
||||
},
|
||||
"100:7": {ItemID: 9001, Count: 1},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("save inventory: %v", err)
|
||||
}
|
||||
owner, err := NewOwner(repos, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("NewOwner: %v", err)
|
||||
}
|
||||
removed, err := owner.RepairLegacyRows(ctx, 19)
|
||||
if err != nil {
|
||||
t.Fatalf("RepairLegacyRows: %v", err)
|
||||
}
|
||||
if removed != 1 {
|
||||
t.Fatalf("removed = %d, want 1", removed)
|
||||
}
|
||||
inventory, _, _ := repos.Inventory.Load(ctx, "19")
|
||||
if _, found := inventory.Slots["100:933"]; found {
|
||||
t.Fatalf("legacy progress survived: %+v", inventory.Slots["100:933"])
|
||||
}
|
||||
if inventory.Slots["100:7"].ItemID != 9001 {
|
||||
t.Fatalf("real title was removed: %+v", inventory.Slots)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user