Перейти к содержанию

Redux - каталог модов

Это главная секция админ-панели. Тут заводятся все redux-моды (полные графические сборки), которые юзеры видят в Browse → Redux каталоге.

Таблица Redux в админке: имя, автор, версии, скачивания, статус

Что такое redux

Redux в нашей терминологии - это полная графическая сборка GTA V: timecycle, ENB, shaders, weather, lighting. Юзер ставит redux одной кнопкой, получает целостный визуальный пакет. В отличие от gunpack'ов (только оружие) или armor pack'ов (только броня), redux трогает много файлов в update.rpf и dlcpacks.

Каждый redux в БД - это redux_items row + одна или несколько redux_versions. Версионирование нужно потому что мод эволюционирует: автор выпускает v1, потом v2 с фиксами, потом v3 с новой ENB. Юзер может выбрать какую ставить.

Структура таблиц

erDiagram
  redux_items ||--o{ redux_versions : "has many"
  redux_items ||--o{ redux_featured_picks : "may be featured"

  redux_items {
    uuid    id PK
    text    name
    text    author
    text    description
    text    preview_url
    text    glb_url
    int     popularity
    bool    is_deleted
    timestamptz created_at
  }
  redux_versions {
    uuid    id PK
    uuid    redux_id FK
    text    version_label
    text    patch_url
    text    sha256
    int    size_bytes
    jsonb   component_urls
    timestamptz created_at
  }

component_urls - JSON map типа {"minimap": "https://cdn.../minimap.bin", "tracers": "https://cdn.../tracers.bin"}. Используется при customize-импорте - юзер берёт отдельные компоненты с разных redux'ов.

Workflow «загрузить новый redux»

Самый частый сценарий для админа. Состоит из 4 шагов:

Шаг 1. Analyze

Админ кидает в UI 2 файла: чистый update.rpf (baseline) и модифицированный update.rpf (мод). UI вызывает adminReduxAnalyze:

MiamiGraphics.Shell/Bridge/AppBridge.cs
public async Task<ReduxAnalysisDto> AdminReduxAnalyzeAsync(string cleanRpfPath, string modRpfPath, string modName)
{
    var pipeline = new ReduxParserPipeline(_logger);
    var analysis = await pipeline.AnalyzeAsync(cleanRpfPath, modRpfPath, modName);
    return analysis;
}

ReduxParserPipeline.AnalyzeAsync:

  1. Открывает оба update.rpf через RageLib.GTA5.
  2. Рекурсивно обходит файлы, считает SHA-256 каждого.
  3. Diff: actions = [] - что Replace, что Import, что Delete.
  4. Для каждого actionа определяет «компонент» через ComponentScanner (minimap, tracers, hud_reticle, и так далее).
  5. Опционально рендерит GLB-превью если в моде есть armor .ydr - через headless renderer.
  6. Возвращает ReduxAnalysisDto:
public sealed record ReduxAnalysisDto(
    List<PatchAction> Actions,            // что менять
    List<DetectedComponent> Components,   // что extractable
    string? GlbDataUri,                   // 3D preview если есть
    long EstimatedPatchSize);

UI показывает превью: «27 файлов изменено, 3 удалено, 2 новых. Извлекаем: minimap, hud_reticle. Размер patch.zip - 218 МБ».

Шаг 2. Заполнение метаданных

Форма загрузки Redux: имя, ID, автор, ссылка, описание, медиа

Админ вводит:

  • Name - например «Hunter Reborn 4.0».
  • Author - например «Hunter».
  • Description - markdown в textarea.
  • Preview image - drop PNG, она зальётся в R2.
  • Tags - visual style, time of day, season.
MiamiGraphics.Shell/ui/src/admin/ReduxUploadForm.tsx
const form = useForm<ReduxItemDraft>({
  defaultValues: {
    name: '', author: '', description: '',
    previewFile: null, tags: [],
  },
});

Шаг 3. Upload в R2 + insert в Supabase

Раздел «Скриншоты компонентов» в форме upload - превью каждого extractable компонента

adminQueueAdd:

MiamiGraphics.Shell/Bridge/AppBridge.cs
public async Task AdminQueueAddAsync(ReduxUploadItemDto item)
{
    // 1. Собираем patch.zip из analysis result
    var patchZipPath = await _patchZipBuilder.BuildAsync(item.Analysis);

    // 2. Считаем SHA patch'а
    var sha = await Sha256OfFileAsync(patchZipPath);

    // 3. Upload в R2 - patch, preview, glb, per-component
    await _r2.UploadAsync($"redux/{item.Id}/v1/patch.zip", patchZipPath);
    await _r2.UploadAsync($"redux/{item.Id}/preview.png", item.PreviewBytes);
    if (item.GlbBytes is not null)
        await _r2.UploadAsync($"redux/{item.Id}/preview.glb", item.GlbBytes);

    foreach (var comp in item.Analysis.Components)
        await _r2.UploadAsync(
            $"redux/{item.Id}/v1/components/{comp.Type}.bin",
            comp.ExtractedBytes);

    // 4. Insert в Supabase
    await _supa.InsertReduxItemAsync(item);
    await _supa.InsertReduxVersionAsync(...);
}

R2-пути жестко детерминированы: redux/<reduxId>/v<n>/patch.zip, redux/<reduxId>/v<n>/components/<type>.bin. Это позволяет donor-cache знать заранее URL для конкретной версии без лишних DB-запросов.

Шаг 4. Заявка в очереди

Если у админа много модов на залив (распространённый сценарий - раз в месяц приходит 5-10 новых), он добавляет их в очередь:

  • adminQueueList - get all pending uploads.
  • adminQueueRun - start sequential upload.
  • adminQueueCancel - abort current.

Очередь хранится в памяти (не persisted между запусками лаунчера). Если админ закрыл лаунчер - pending заявки потеряны. Это intentionally - потому что upload-state включает MemoryStream'ы с тяжёлыми payload'ами, persist на диск дорог.

Добавить версию к существующему редуксу

Открой редактирование редукса в Database, найди карточку «Версии редукса (X/5)», справа кнопка +. Клик ведёт на форму «Добавление версии VN», где:

  1. Выбираешь новый update.rpf.
  2. Парсер читает его в фоне.
  3. Вводишь короткий label (по умолчанию VN, можно «Grey», «Blue», «Beta»).
  4. «В очередь».

Очередь обрабатывает запись как multi-version upload, но с флагом appendToReduxId. В этом режиме:

  • Не purge'им префикс redux/{id}/ на R2 целиком, только конкретный слот redux/{id}/v{slot}/.
  • Не делаем _catalog.AddAsync(snapshot) (row уже существует).
  • Вместо этого читаем существующие redux_versions из БД, мерджим components нового слота с union'ом старых, делаем _catalog.UpdateAsync(reduxId, r => r.Components = mergedComponents).
  • _versions.UpsertAsync пишет новый redux_versions row.

Этот путь снимает необходимость каждый раз перезаливать весь редукс ради добавления одной версии под новый патч GTA.

Лимит 5 версий жёсткий - кнопка + дизейблится когда versions.length === 5. Если нужна 6-я, удали ненужную в той же карточке.

Сменить проект без перезаливки

В Editor редукса есть pill-селектор «Проект» с тремя вариантами: Majestic, 5RP, Both. Меняет supported_servers в redux_items. Save шлёт adminCatalogUpdate, тот вызывает _catalog.UpdateAsync который делает upsert в Supabase. Не трогает R2, не пересчитывает patch.

Автозеркалирование картинок в R2

При сохранении редукса (кнопка «Сохранить» в Editor) UI проходит по полям previewUrl, galleryUrls[], componentScreenshots{}. Если URL не на нашем R2 (*.miamigraphicsstorage.uk, *.r2.dev, *.r2.cloudflarestorage.com), вызывается bridge.adminMirrorImageToR2(reduxId, url, slot):

MiamiGraphics.Shell/Bridge/AppBridge.cs
public async Task<string> AdminMirrorImageToR2Async(string reduxId, string externalUrl, string slot)
{
    var uri = new Uri(externalUrl);
    if (uri.Host.EndsWith("miamigraphicsstorage.uk") || ...) return externalUrl;

    using var http = Services.HttpClientFactory.CreateFragmenting(TimeSpan.FromMinutes(2));
    using var resp = await http.GetAsync(uri, ...);
    var bytes = await resp.Content.ReadAsByteArrayAsync();
    var ext = ResolveExt(resp, uri);

    var temp = Path.Combine(Path.GetTempPath(), $"mg_mirror_{Guid.NewGuid():N}{ext}");
    await File.WriteAllBytesAsync(temp, bytes);
    var url = await _remoteStorage.UploadHashedAsync(temp, $"redux/{reduxId}/mirror/{slot}{ext}", null, ct);
    File.Delete(temp);
    return url;
}

UploadHashedAsync пишет в R2 как redux/{id}/mirror/{slot}-{sha1}.{ext} с Cache-Control: immutable. После того как URL обновился в redux_items, юзеры качают картинку через WebView2 bypass interceptor, который перехватывает запросы к нашему R2 и шлёт их через FragmentingHttpHandler. В РФ работает.

Если зеркалирование упало (хост недоступен, 404, таймаут), оригинальный URL остаётся как был. Save проходит, в БД пишется внешний URL, картинка просто не загрузится у РФ юзеров с DPI.

Версионирование

«Hunter Reborn» может выйти в v1, v1.5, v2. Все три версии хранятся в redux_versions и доступны юзеру через dropdown в UI. По дефолту показываем последнюю.

public async Task<JsonElement> AdminVersionUpsertAsync(JsonElement payload)
{
    var reduxId = payload.GetProperty("reduxId").GetString()!;
    var versionLabel = payload.GetProperty("versionLabel").GetString()!;
    var patchPath = payload.GetProperty("patchPath").GetString()!;

    // upload в R2 в новый под-путь
    var url = await _r2.UploadAsync($"redux/{reduxId}/v{versionLabel}/patch.zip", patchPath);

    // insert новую version row
    return await _supa.InsertReduxVersionAsync(reduxId, versionLabel, url);
}

adminVersionDelete - softdelete. Сама row помечается is_deleted = true, но R2-файлы не удаляются (на случай юзеров которые уже скачали и ссылаются на эту версию).

В каталоге сверху есть «Топ редаксы» секция - redux_featured_picks table. Админ через adminFeaturedPickSet ставит до 6 модов в spotlight (с display_order). adminFeaturedPickDelete убирает из топа.

Recalculate patch sizes

Иногда после миграции R2 или ручного fixup размеры в БД отстают от реальных. Кнопка «Пересчитать размеры» вызывает adminRecalculateReduxPatchSizes - HEAD-request на каждый patch_url, обновляет size_bytes.

public async Task<int> AdminRecalculateReduxPatchSizesAsync()
{
    var versions = await _supa.GetAllReduxVersionsAsync();
    int updated = 0;
    foreach (var v in versions)
    {
        var resp = await _http.SendAsync(new HttpRequestMessage(HttpMethod.Head, v.PatchUrl));
        var size = resp.Content.Headers.ContentLength ?? 0;
        if (size != v.SizeBytes)
        {
            await _supa.UpdateReduxVersionSizeAsync(v.Id, size);
            updated++;
        }
    }
    return updated;
}

Rebuild components

adminRebuildReduxComponents - re-parse всех existing redux'ов и пересоздать component_urls map'ы. Используется когда мы добавили новый тип компонента (например начали извлекать armor отдельно) и хотим заставить старые redux'ы тоже его иметь.

Pipeline:

  1. Скачиваем patch.zip из R2.
  2. Запускаем ReduxParserPipeline.AnalyzeFromPatchZipAsync.
  3. Re-upload компонентов под новой схемой.
  4. Update Supabase row.

Это тяжёлая операция (1-2 часа на полный каталог, ~200 модов). Запускается редко, только при schema migration.

Дальше: Guns →