Перейти к основному содержимому
Сервисы вокруг игры

Эта страница ещё не переведена — показана английская версия.

Files & UGC

Файлы приходят частями и обрабатываются по мере поступления. Загрузки, ассеты и их производные варианты, а также контент игроков с модерацией.

Когда это нужно

  • Игроки или сервисы загружают бинарные данные: частями, в сессиях, которые можно возобновить, и с квотами на игрока, которые можно прочитать.
  • Обработка должна начинаться до конца загрузки: файл читается потоком, часть за частью.
  • Контенту, который создают игроки, нужна модерация: SubmitUgc, очередь, вердикт и хуки с обеих сторон.
  • Одно исходное изображение должно обслуживать много платформ: из него получают варианты (масштабирование, перекодирование), а каноническим остаётся оригинал.
  • Не нужен для небольших структурированных данных. Их переносит поле записи Data & Subscriptions — без сессии загрузки.

Кто что делает

АкторЧто делает на этой странице
playerзагружает части, читает файлы потоком, отправляет UGC
moderatorразбирает очередь, одобряет или отклоняет присланное
backend-serviceсоздаёт варианты ассетов; вешает хуки на загрузку и модерацию; задаёт квоты

Коротко

Загрузить tank-07.png частями, прочитать его ещё во время загрузки и прикрепить как декаль
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);
// upload, chunked, resumable
const session = await playserv.files.openUpload('skins/tank-07.png', { contentType: 'image/png' });
await session.write(chunk);
const file = await session.complete();

// consume a file as a stream — start processing before the upload finishes
const read = playserv.files.openRead(file);
for await (const chunk of read) ingest(chunk);

// attach to an entity
await tank.attach('decal', file);
# upload, chunked, resumable
session = await playserv.files.open_upload("skins/tank-07.png", content_type="image/png")
await session.write(chunk)
file = await session.complete()

# consume a file as a stream — start processing before the upload finishes
async with playserv.files.open_read(file) as read:
    async for chunk in read:
        ingest(chunk)

# attach to an entity
await tank.attach("decal", file)
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

// upload, chunked, resumable
Client->Files->Of<FSkin>()->Uploads->Create(FPSIdempotencyKey(UploadId),
    FPSUploadSpec{ .Path = TEXT("skins/tank-07.png"), .ContentType = TEXT("image/png") },
    TPSOnResult<FPSUpload*>::CreateWeakLambda(this, [this](const TPSResult<FPSUpload*>& Result)
    {
        if (!Result.HasValue()) { return; }
        FPSUpload* Upload = Result.Value();
        Upload->Parts->Create(PartNumber, Chunk);
        Upload->Complete(TPSOnResult<FPSFileHandle*>::CreateWeakLambda(this, [this](const TPSResult<FPSFileHandle*>& Completed)
        {
            if (!Completed.HasValue()) { return; }
            OnSkinUploaded(Completed.Value());
        }));
    }));

// consume a file as a stream — start processing before the upload finishes
TPSSubscription SkinBytes = Client->Files->Of<FSkin>()->Contents->Subscribe(File,
    [this](const TArray<uint8>& Chunk) { Ingest(Chunk); });

// attach to an entity
Tank->Files->Attach(TEXT("decal"), File);
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — в Unity работает как есть, со сгенерированными типами
// upload, chunked, resumable
var session = await PlayServ.Files.OpenUpload("skins/tank-07.png", contentType: "image/png");
await session.Write(chunk);
var file = await session.Complete();

// consume a file as a stream — start processing before the upload finishes
await using var read = PlayServ.Files.OpenRead(file);
await foreach (var chunk in read) Ingest(chunk);

// attach to an entity
await tank.Attach("decal", file);

UGC — путь со стороны игрока:

SubmitUgc с клиента — один вызов в каждом биндинге
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl
const submission = await playserv.files.submitUgc(file, { kind: 'level' });   // cl
submission = await playserv.files.submit_ugc(file, kind="level")   # cl
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

// client — a submission is keyed; a retried submit returns the same submission
Client->Files->Ugc->Create(FPSIdempotencyKey(SubmitId), File,
    TPSOnResult<FPSSubmission*>::CreateWeakLambda(this, [this](const TPSResult<FPSSubmission*>& Result)
    {
        if (!Result.HasValue()) { return; }
        Hud->ShowPending(Result.Value());
    }));
// co_await support ships later: a built-in SDK coroutine wrapper that respects Unreal's GC and threading.
Unity C# is the same C# API here — в Unity работает как есть, со сгенерированными типами
var submission = await playserv.Files.SubmitUgc(file, kind: "level");   // cl

Проверки вокруг него — это хуки с тем же контрактом, что и везде:

Хуки проверяют размер загрузки и ставят присланный контент в очередь модерации
[Before(Files.Upload)]
public static Verdict CheckUpload(UploadIntent u) =>
    u.Size > 20.Mb() ? Hook.Reject("too large") : Hook.Continue(u);

[After(Files.SubmitUgc)]
public static Task Screen(UgcSubmission s) => PlayServ.Files.Moderation.Enqueue(s);
export const checkUpload = before(Files.upload, (u: UploadIntent) =>
  u.size > mb(20) ? Hook.reject('too large') : Hook.continue(u));

export const screen = after(Files.submitUgc,
  (s: UgcSubmission) => playserv.files.moderation.enqueue(s));
@before(files.upload)
def check_upload(u: UploadIntent) -> Verdict:
    return hook.reject("too large") if u.size > mb(20) else hook.continue_(u)

@after(files.submit_ugc)
async def screen(s: UgcSubmission):
    await playserv.files.moderation.enqueue(s)
Скоро — Go

Как объявлять через атрибуты в Go, пока не решено (O-1). Примеры появятся, когда выберут способ записи.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Пишите его на C#, TypeScript или Python, а Unreal подписывается на события, которые он порождает. Функции, написанные в движке, запланированы: графы Blueprint и C++ (его будут транслировать в скриптовый формат, который проверяет платформа). И те и другие анализируются и отправляются на платформу как любое объявление, а выполняются по-прежнему на платформе.

Выполняется вне движка

Хук — это облачная функция: он выполняется на платформе, а не в движке. Пишите его на C#, TypeScript или Python, а Unity подписывается на события, которые он порождает.

Оба хука оборачивают шаг операции, а не событие. Before(Files.Upload) решает, начнётся ли загрузка. After(Files.SubmitUgc) срабатывает, когда присланный контент уже существует, и ставит его в очередь модерации через собственный Moderation.Enqueue модуля. События — загрузка завершена, вариант готов, UGC прислан, вынесен вердикт модерации — получают подписчики, а подписчик ничего запретить не может.

Модель

Файл — это непрозрачные байты плюс объявленные метаданные: источник, тип содержимого, размер. Хранилищем состояния, по которому принимаются решения, он не служит. Связь с моделью игры идёт в обратную сторону: ссылку хранит поле вашего типа, а сам файл об игре ничего не знает.

Что объявляет вид файла.

ОбъявлениеЧто это
originавторский контент, созданный игрой или созданный пользователями; от этого зависят ограничения размера и политики
допустимые типы содержимогозадаются объявленным списком и никогда не угадываются по байтам
ограничения размерапроверяются по объявленному размеру при открытии сессии, а не на последней части
размер и порядок частейзагрузку выполняет сессия: объявленный размер части, порядок частей, точка возобновления
derivativesпри желании — именованные варианты, которые создаёт обработчик. Готовность каждого объявленного варианта видна, поэтому клиенту не приходится гадать, есть ли уже миниатюра
префикс хранилищазадаётся на поле схемы, которое хранит ссылку, и определяет только то, где лежат байты. Это не каталог: переименования, перемещения, разрешений на префикс и рекурсивных операций нет. Файл по-прежнему адресуется по id или key, и префикс в этом не участвует

Что верно для любого файла.

ВсегдаЧто это
завершениеидемпотентно в пределах сессии: повторное завершение возвращает тот же файл, а не второй
контрольная суммаобязательна, а несовпадение — это отказ, а не молчаливое принятие испорченных байтов
опубликованный файлнеизменяем: правка создаёт новую версию, а ссылка на версию продолжает указывать туда же, куда указывала
авторский контентадресуется по ключу и версии и находится в режиме managed: в админ-консоли его не редактируют, потому что им владеет код
незавершённая сессиязавершается заметно: после истечения срока её прерывают с событием, а её части освобождаются
владениеследует предикату владельца: у файлов, созданных пользователями и игрой, есть владелец, как у любой строки с владельцем. Файлы владельца подчиняются политике удаления игрока — каскадное удаление, отказ или анонимизация, и эта политика объявляется, а не подразумевается
доступ на чтениеможет зависеть от права владения (entitlement): платный ассет закрыт правом владения из модуля Catalog & Commerce, а не второй системой разрешений

Каждая точка расширения называет тип, который она передаёт хуку: до загрузки — намерение загрузки, после — присланный контент. Нетипизированный набор полей хук не получает никогда.

Ошибки

  • Без права владения ответ — not found, а не forbidden, чтобы по списку отказов нельзя было понять, какие дополнения существуют. Снятый с публикации файл отвечает так же.
  • Истёкшая сессия — конфликт: откройте новую.
  • Часть вне объявленного порядка или размера, необъявленный тип содержимого и превышение допустимого размера — отказы валидации. Отказ по размеру приходит при открытии сессии, а не после того, как байты уже переданы.
  • Несовпадение контрольной суммы — отказ валидации, который стоит повторить: отправьте часть заново.
  • Квота исчерпана — конфликт; повторить можно, освободив место.
  • Истёк срок выданных прав (grant) на чтение — ответ not authenticated: запросите права заново, а не разбирайтесь с этим как с проблемой разрешений.
  • Отклонение после проверки — это вердикт, а не отказ: присланное рассмотрели, ответ отрицательный, и у него есть объявленная причина. Что делать дальше, зависит от причины.
  • Превышена частота загрузок — ответ из категории ограничения частоты, с указанием срока.

Ограничения

Конкретные числа появятся вместе с главой об ограничениях платформы, а поведение на каждом пределе зафиксировано уже сейчас.

  • Размер файла в зависимости от источника: загрузка отклоняется до того, как принята первая часть, а не на последней.
  • Размер части: часть отклоняется как не прошедшая валидацию.
  • Время жизни сессии: expired с событием, а части освобождаются.
  • Квота хранилища на проект и на игрока: новая сессия отклоняется как конфликт, и уже опубликованное никогда не удаляется молча, чтобы освободить место.
  • Число хранимых версий авторского контента: самая старая снимается с публикации, но версия, на которую ссылается действующее окружение (environment), — никогда.
  • Частота загрузок на актора: ограничение частоты с указанием срока.
  • Хранение контента, созданного игрой: после срока — снятие с публикации с событием.