Как реализовать безопасную database integration для игровой системы инвентаря при осмотре предметов
Коротко о главном
В статье рассматривается создание безопасной системы игрового инвентаря с интеграцией базы данных, защищенной от читерства на стороне клиента. Описывается жизненный цикл осмотра предметов и их переноса в инвентарь с использованием C++ логики в Unreal Engine и server-authoritative валидации. Приводятся примеры проектирования схем реляционных (PostgreSQL) и документных (NoSQL) баз данных, а также Node.js-код backend для безопасной верификации транзакций. В завершение рассматриваются ключевые best practices для оптимизации синхронизации и возможность упрощения разработки с помощью облачной платформы horizOn.
Игроки будут использовать любую уязвимость для дюпа редких предметов, и client-authoritative переходы состояний (state transitions) — их любимая мишень. В хоррорах, приключениях и RPG механика осмотра объектов — когда игрок берет ключ, вращает его в 3D для поиска подсказки и добавляет в инвентарь — стала классикой. Если игровой клиент напрямую диктует, когда предмет добавляется в инвентарь, без проверки на сервере, простейшая packet injection-утилита вроде Cheat Engine или Fiddler может заставить клиент отправлять сигналы «добавить предмет» для вещей, которые игрок даже не видел. Чтобы предотвратить это, разработчикам необходимо реализовать надежную database integration для системы игрового инвентаря, которая свяжет клиентские взаимодействия с server-authoritative логикой и безопасным сохранением в облаке.
Жизненный цикл перехода от осмотра к инвентарю
Чтобы построить безопасную синхронизацию инвентаря, сначала нужно разобрать жизненный цикл осмотра предметов. Этот процесс координирует физических акторов (actors) в мире, локальные клиентские состояния осмотра, server-authoritative компоненты инвентаря и сохранение в базе данных.
- Обнаружение взаимодействия (Interaction Detection): Игрок приближается к физическому актору (
AInspectableActor) в игровом мире. Line trace или коллизия помечают объект как интерактивный. - Переход в режим осмотра (Inspection Mode Transition): Игрок нажимает клавишу взаимодействия. Клиент переходит в локальное состояние осмотра, блокируя движение персонажа, вращая объект в выделенном контейнере экранных координат (screen-space coordinates) и отображая 2D-оверлей с описанием.
- Этап верификации (Verification Stage): Игрок нажимает «Взять». Вместо того чтобы клиент сам добавлял предмет в инвентарь, он отправляет на сервер запрос с interaction token.
- Server Validation: Сервер проверяет, находится ли игрок в радиусе физического взаимодействия (~250 Unreal-единиц) от актора, и активен ли сам актор.
- Database Integration: Сервер добавляет предмет в массив инвентаря, записывает обновление в постоянную базу данных и рассылает событие уничтожения (destruction event) физического актора в мире.
Если вы разрабатываете multiplayer-проект, управление этим состоянием на сервере критически важно. Многие команды сталкиваются с кошмарами разработки multiplayer-инвентаря и подменой actor component owners в Unreal Engine при настройке replication и network authority для кастомных компонентов инвентаря.
Разработка C++ логики осмотра в Unreal Engine
Для реализации этого флоу мы создадим три компонента: интерфейс (IInspectableInterface), inspectable actor (AInspectableActor) и replicated-компонент инвентаря игрока (UInventoryComponent).
Вот заголовочный файл интерфейса, описывающий, как акторы получают команды осмотра:
// InspectableInterface.h
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "InspectableInterface.generated.h"
UINTERFACE(MinimalAPI)
class UInspectableInterface : public UInterface
{
GENERATED_BODY()
};
class HORIZON_GAME_API IInspectableInterface
{
GENERATED_BODY()
public:
virtual void OnInspectStarted(APlayerController* InspectingPlayer) = 0;
virtual void OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake) = 0;
};
Далее реализуем класс AInspectableActor. Этот класс отвечает за физический объект в мире, сохраняя его уникальный идентификатор, максимальную дистанцию взаимодействия и текущее состояние.
// InspectableActor.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "InspectableInterface.h"
#include "InspectableActor.generated.h"
UCLASS()
class HORIZON_GAME_API AInspectableActor : public AActor, public IInspectableInterface
{
GENERATED_BODY()
public:
AInspectableActor();
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
FName ItemID;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
FString ItemDisplayName;
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Inspection")
float MaxInteractionDistance;
virtual void OnInspectStarted(APlayerController* InspectingPlayer) override;
virtual void OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake) override;
protected:
virtual void BeginPlay() override;
private:
bool bIsBeingInspected;
TWeakObjectPtr<APlayerController> CurrentInspectingPlayer;
};
Вот файл реализации для inspectable actor:
// InspectableActor.cpp
#include "InspectableActor.h"
#include "GameFramework/PlayerController.h"
AInspectableActor::AInspectableActor()
{
PrimaryActorTick.bCanEverTick = false;
bIsBeingInspected = false;
MaxInteractionDistance = 250.0f;
ItemID = "item_default";
ItemDisplayName = "Generic Item";
}
void AInspectableActor::BeginPlay()
{
Super::BeginPlay();
}
void AInspectableActor::OnInspectStarted(APlayerController* InspectingPlayer)
{
if (!InspectingPlayer || bIsBeingInspected) return;
APawn* PlayerPawn = InspectingPlayer->GetPawn();
if (!PlayerPawn) return;
float Distance = FVector::Dist(PlayerPawn->GetActorLocation(), GetActorLocation());
if (Distance > MaxInteractionDistance)
{
UE_LOG(LogTemp, Warning, TEXT("Player too far to inspect %s"), *GetName());
return;
}
bIsBeingInspected = true;
CurrentInspectingPlayer = InspectingPlayer;
}
void AInspectableActor::OnInspectCompleted(APlayerController* InspectingPlayer, bool bWantsToTake)
{
if (InspectingPlayer != CurrentInspectingPlayer.Get()) return;
if (bWantsToTake && HasAuthority())
{
UActorComponent* InvComp = InspectingPlayer->GetComponentByClass(UInventoryComponent::StaticClass());
if (InvComp)
{
UInventoryComponent* Inventory = Cast<UInventoryComponent>(InvComp);
if (Inventory)
{
Inventory->Server_TryAddInspectedItem(this);
}
}
}
bIsBeingInspected = false;
CurrentInspectingPlayer.Reset();
}
Теперь создадим UInventoryComponent, который управляет списком инвентаря игрока и реплицирует эти данные по сети клиентам.
// InventoryComponent.h
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "InventoryComponent.generated.h"
class AInspectableActor;
USTRUCT(BlueprintType)
struct FInventoryItem
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
FName ItemID;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
int32 Quantity;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Inventory")
FString InspectedTimestamp;
};
UCLASS(ClassGroup=(Custom), meta=(BlueprintSpawnableComponent))
class HORIZON_GAME_API UInventoryComponent : public UActorComponent
{
GENERATED_BODY()
public:
UInventoryComponent();
UFUNCTION(Server, Reliable, WithValidation)
void Server_TryAddInspectedItem(AInspectableActor* TargetActor);
bool AddItemToLocalState(FName InItemID, int32 Quantity);
void SaveInventoryToDatabase();
protected:
UPROPERTY(Replicated, BlueprintReadOnly, Category = "Inventory")
TArray<FInventoryItem> Items;
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
};
И файл реализации, в котором мы определим логику RPC replication и выполним write validation:
// InventoryComponent.cpp
#include "InventoryComponent.cpp"
#include "InspectableActor.h"
#include "Net/UnrealNetwork.h"
UInventoryComponent::UInventoryComponent()
{
SetIsReplicatedByDefault(true);
}
void UInventoryComponent::GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UInventoryComponent, Items);
}
bool UInventoryComponent::Server_TryAddInspectedItem_Validate(AInspectableActor* TargetActor)
{
if (!TargetActor) return false;
APawn* OwnerPawn = Cast<APawn>(GetOwner());
if (!OwnerPawn) return false;
// Server-side distance check
float Distance = FVector::Dist(OwnerPawn->GetActorLocation(), TargetActor->GetActorLocation());
return Distance <= TargetActor->MaxInteractionDistance;
}
void UInventoryComponent::Server_TryAddInspectedItem_Implementation(AInspectableActor* TargetActor)
{
if (!TargetActor) return;
AddItemToLocalState(TargetActor->ItemID, 1);
SaveInventoryToDatabase();
TargetActor->Destroy();
}
bool UInventoryComponent::AddItemToLocalState(FName InItemID, int32 Quantity)
{
for (FInventoryItem& Item : Items)
{
if (Item.ItemID == InItemID)
{
Item.Quantity += Quantity;
return true;
}
}
FInventoryItem NewItem;
NewItem.ItemID = InItemID;
NewItem.Quantity = Quantity;
NewItem.InspectedTimestamp = FDateTime::UtcNow().ToString();
Items.Add(NewItem);
return true;
}
void UInventoryComponent::SaveInventoryToDatabase()
{
// Database integration trigger goes here
}
Разработка схемы базы данных инвентаря
Как только сервер подтверждает, что игрок действительно осмотрел предмет, это изменение состояния должно быть сохранено. Выбор между relational (SQL) и document (NoSQL) хранилищем влияет на то, как вы структурируете свои database schemas.
| Метрика оценки | Relational (PostgreSQL) | Document Store (NoSQL / MongoDB) |
|---|---|---|
| Структура данных | Нормализованные таблицы, строгие внешние ключи | Вложенные key-value документы и массивы |
| Безопасность транзакций | Полная ACID-совместимость из коробки | Атомарные операции ограничены одним документом |
| Сложность запросов | Высокая (требует SQL JOIN запросов для поиска предметов) | Низкая (прямой поиск профиля игрока) |
| Масштабируемость | Вертикальная (требуется ручной sharding для масштабирования) | Горизонтальная (встроенные возможности sharding) |
Relational Schema (PostgreSQL)
В реляционной базе данных необходимо разделить игроков, глобальные метаданные предметов и активные списки инвентаря, чтобы избежать избыточности данных. Для этого требуются три таблицы:
CREATE TABLE players (
player_id VARCHAR(64) PRIMARY KEY,
username VARCHAR(100) NOT NULL,
created_at TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE game_items (
item_id VARCHAR(64) PRIMARY KEY,
display_name VARCHAR(100) NOT NULL,
item_type VARCHAR(50) DEFAULT 'QuestItem'
);
CREATE TABLE inventory_items (
id SERIAL PRIMARY KEY,
player_id VARCHAR(64) REFERENCES players(player_id) ON DELETE CASCADE,
item_id VARCHAR(64) REFERENCES game_items(item_id),
quantity INTEGER CHECK (quantity > 0),
inspected_timestamp TIMESTAMP WITH TIME ZONE DEFAULT CURRENT_TIMESTAMP,
UNIQUE(player_id, item_id)
);
Document Schema (NoSQL)
В документной базе данных инвентарь игрока хранится в виде вложенного массива внутри основного документа игрока. Это позволяет вашему backend получать состояние игрока целиком за один запрос:
{
"_id": "usr_90a82b3d11ef",
"username": "SurvivorX",
"inventory": [
{
"item_id": "key_rusted_01",
"quantity": 1,
"inspected_timestamp": "2026-06-25T00:02:20Z"
},
{
"item_id": "document_diary_03",
"quantity": 1,
"inspected_timestamp": "2026-06-25T00:05:12Z"
}
],
"last_updated": "2026-06-25T00:05:12Z"
}
Дилемма синхронизации: Client-Side против Server-Authoritative сохранений
Синхронизация этого состояния влияет на то, как игроки воспринимают игру, и насколько она защищена от попыток читерства. Если client-side код сохраняет данные напрямую в базу данных, игрок может изменить адреса памяти и записать любые значения в базу данных.
Чтобы этого не произошло, необходимо реализовать server-authoritative валидации:
- Interaction Tokens: Когда игрок начинает осматривать предмет, сервер генерирует временный interaction token (действительный в течение 60 секунд) и регистрирует его на активном экземпляре актора в мире.
- Double-Spend Protection: Когда игрок подбирает предмет, сервер расходует (consumes) токен. Если получен дублирующий пакет о подборе предмета, сервер его отклоняет.
- Synchronous Replication: Убедитесь, что изменения инвентаря немедленно отправляются на экраны клиентов.
Кроме того, для обновления пользовательского интерфейса инвентаря на подключенных игровых клиентах в real-time использование тяжелых механизмов polling снизит tick rate вашего сервера. Вместо этого вам стоит отказаться от HTTP polling в пользу выделенного WebSockets-соединения в Unreal Engine для real-time синхронизации инвентаря.
Реализация Backend-кода верификации
Если вы решите написать собственный выделенный сервер, вам понадобится API-эндпоинт, который безопасно обрабатывает обновления базы данных. Ниже приведен пример Node.js Express backend с использованием PostgreSQL. Этот скрипт обрабатывает connection pooling, transaction isolation и token consumption для записи подобранных предметов:
const express = require('express');
const { Pool } = require('pg');
const app = express();
app.use(express.json());
const dbPool = new Pool({
connectionString: process.env.DATABASE_URL,
});
app.post('/api/v1/inventory/add', async (req, res) => {
const { playerId, itemId, interactionToken } = req.body;
if (!playerId || !itemId || !interactionToken) {
return res.status(400).json({ error: 'Missing required parameters' });
}
try {
// 1. Verify interaction token exists and is active
const tokenResult = await dbPool.query(
'SELECT status FROM interactions WHERE token = $1 AND player_id = $2',
[interactionToken, playerId]
);
if (tokenResult.rows.length === 0 || tokenResult.rows[0].status !== 'active') {
return res.status(400).json({ error: 'Invalid or expired interaction token' });
}
// 2. Begin transaction
await dbPool.query('BEGIN');
// Consume the token to prevent double-spending
await dbPool.query(
"UPDATE interactions SET status = 'consumed' WHERE token = $1",
[interactionToken]
);
// Insert or increment inventory record
await dbPool.query(
`INSERT INTO inventory_items (player_id, item_id, quantity, inspected_timestamp)
VALUES ($1, $2, 1, NOW())
ON CONFLICT (player_id, item_id)
DO UPDATE SET quantity = inventory_items.quantity + 1`,
[playerId, itemId]
);
await dbPool.query('COMMIT');
return res.status(200).json({ success: true });
} catch (error) {
await dbPool.query('ROLLBACK');
console.error('Database transaction error:', error);
return res.status(500).json({ error: 'Internal Server Error' });
}
});
Создание такой инфраструктуры своими силами требует огромных временных затрат. Вам придется настраивать load balancers, разворачивать database clusters, писать собственные client-side retry-менеджеры и разрабатывать безопасные протоколы аутентификации. Это легко отнимет от 4 до 6 недель инфраструктурной работы еще до того, как вы напишете первую строчку логики game loop.
Упрощение сохранения состояния с помощью horizOn
Вместо написания кастомного Express middleware, управления PostgreSQL connection pools и борьбы с сетевыми сбоями вы можете переложить эту сложность на horizOn. С horizOn вы получаете полностью управляемое решение базы данных, разработанное специально для game development.
Используя возможности cloud database, вам не придется писать backend-скрипты для валидации состояний. Вы можете инициировать запись в постоянную базу данных напрямую из вашего server-authoritative инстанса Unreal Engine, используя client library:
void UInventoryComponent::SaveInventoryToCloud(FName InItemID, int32 InQuantity)
{
TSharedPtr<FJsonObject> RequestData = MakeShareable(new FJsonObject());
RequestData->SetStringField("player_id", PlayerID);
RequestData->SetStringField("item_id", InItemID.ToString());
RequestData->SetNumberField("quantity", InQuantity);
// Single call to [horizOn](https://horizon.pm)'s secure database client
FHorizonClient::Get()->Database("inventories")
->Upsert(RequestData)
->OnSuccess(this, &UInventoryComponent::OnSaveSuccess)
->OnFailure(this, &UInventoryComponent::OnSaveFailure)
->Execute();
}
Этот метод транзакционно обновляет базу данных, гарантирует, что игроки не смогут подделать записи на своих клиентах, и автоматически кэширует обновления локально при временных перебоях с интернетом у игрока.
Best Practices для постоянных игровых инвентарей
Чтобы построить высокопроизводительную систему инвентаря, следуйте этим best practices:
- Используйте distance-based валидацию на сервере: Проверяйте
FVector::Distмежду pawn и inspectable actor перед добавлением предметов. Если дистанция физически невозможна, логируйте это как подозрительную активность клиента. - Используйте Idempotent Transaction Tokens: Генерируйте уникальные UUID транзакций на сервере в момент начала осмотра. Это предотвратит double-increment баги при повторной отправке сетевых пакетов из-за скачков задержки (latency spikes).
- Используйте Optimistic Local Updates с коррекцией на стороне сервера: Чтобы интерфейс казался отзывчивым, обновляйте экран инвентаря на стороне клиента немедленно, но сохраняйте состояние как «Ожидает подтверждения» до тех пор, пока сервер не вернет подтверждение успешной записи.
- Логируйте State Anomalies: Отслеживайте, как часто клиенты пытаются подобрать предметы, которые не были отмечены как осмотренные в их активной сессии — это главный индикатор использования cheat injection-инструментов.
Готовы масштабировать свой multiplayer backend? Попробуйте horizOn бесплатно или изучите наши developer docs, чтобы начать работу уже сегодня.
Источник: How do you put an item into the inventory after inspecting it?