diff --git a/MODULE_CATALOG_V2_INTEGRATION_PLAN.md b/MODULE_CATALOG_V2_INTEGRATION_PLAN.md new file mode 100644 index 0000000..ba5549a --- /dev/null +++ b/MODULE_CATALOG_V2_INTEGRATION_PLAN.md @@ -0,0 +1,477 @@ +# План интеграции публичного каталога AzerothCore в систему Modules V2 (CSM) + +**Документ:** `MODULE_CATALOG_V2_INTEGRATION_PLAN.md` +**Дата аудита:** Ноябрь 2026 г. +**Проект:** [CoA Server Manager (CSM)](https://github.com/Corfirean/coa-server-manager) +**Исследуемая целевая ветка:** `preview/modules-v2` +**Рабочее дерево Preview:** `C:\games\coa-server-manager-preview` +**Статус:** Исследование и планирование архитектуры (без модификации рабочих компонентов) + +--- + +## Содержание + +1. [Фактическая архитектура Modules V2](#1-фактическая-архитектура-modules-v2) +2. [Карта существующих компонентов с путями и функциями](#2-карта-существующих-компонентов-с-путями-и-функциями) +3. [Точки интеграции каталога](#3-точки-интеграции-каталога) +4. [Перечень уже реализованных возможностей](#4-перечень-уже-реализованных-возможностей) +5. [Перечень отсутствующих компонентов](#5-перечень-отсутствующих-компонентов) +6. [Предлагаемые минимальные изменения Backend](#6-предлагаемые-минимальные-изменения-backend) +7. [Предлагаемые изменения Frontend](#7-предлагаемые-изменения-frontend) +8. [Схема потоков данных](#8-схема-потоков-данных) +9. [Модель кэширования и обновления](#9-модель-кэширования-и-обновления) +10. [Интеграция с анализатором совместимости](#10-интеграция-с-анализатором-совместимости) +11. [План тестирования (Интеграционная матрица)](#11-план-тестирования-интеграционная-матрица) +12. [Риски и потенциальные регрессии](#12-риски-и-потенциальные-регрессии) +13. [План реализации небольшими независимыми этапами](#13-план-реализации-небольшими-независимыми-этапами) +14. [Сводная таблица компонентов](#14-сводная-таблица-компонентов) +15. [Анализ различий веток: `master` vs `preview/modules-v2`](#15-анализ-различий-веток-master-vs-previewmodules-v2) +16. [Главный вопрос и итоговый вердикт](#16-главный-вопрос-и-итоговый-вердикт) + +--- + +## 1. Фактическая архитектура Modules V2 + +В ветке `preview/modules-v2` (развернута в рабочем дереве `C:\games\coa-server-manager-preview`) реализована полноценная платформа динамических модулей и плагинов для Conquest of Azeroth. + +### 1.1. Классификация компонентов (Authoritative Inventory) +В отличие от старой архитектуры, где наличие `.conf` в папке считалось признаком установки, в Modules V2 введена строгая модель типов компонентов ([PREVIEW_DISTRIBUTION_ARCHITECTURE.md](file:///C:/games/coa-server-manager-preview/docs/PREVIEW_DISTRIBUTION_ARCHITECTURE.md)): +- `DYNAMIC_DLL`: динамически загружаемые C++ библиотеки, компилируемые под CoA Plugin SDK v0.3.2. Хранятся в `.manager/plugin-store///` и линкуются в `plugins/`. Поддерживают полный динамический жизненный цикл (`can_remove = true`). +- `CORE_EMBEDDED`: легаси C++ модули, монолитно вкомпилированные в `worldserver.exe`. Не могут быть удалены на лету (`can_remove = false`). +- `CONTENT_PACK`: пакеты контента (DBC, карты, спавны), неизменяемые в рантайме. +- `CLIENT_PATCH`: клиентские MPQ-патчи (`patch-Z.mpq`). +- `LOGICAL_FEATURE`: логические переключатели ядра (`client-compat`). + +### 1.2. Конвейер сборки DLL и границы совместимости +Modules V2 содержит изолированный сборочный движок ([crates/coa-core/src/build_engine.rs](file:///C:/games/coa-server-manager-preview/crates/coa-core/src/build_engine.rs)): +- **Тулчейн:** автоматическое обнаружение MSVC x64 (`vcvars64.bat`), CMake, Ninja и дерева экспортированного SDK (`C:\games\coa-work\plugin-spike\sdk-v03`). +- **Генератор адаптера (`generate_adapter`):** лексически анализирует исходники модуля, находит функцию входа `Add...Scripts()`, генерирует файл `adapter.cpp`, реализующий интерфейс `IPlugin` и функцию запроса контракта `CoaPluginQuery`. +- **Границы совместимости DLL:** + 1. *Поддерживаются:* модули, написанные на чистом C++ с использованием хуков ScriptMgr (`PlayerScript`, `WorldScript`, `CreatureScript`, `ItemScript` и т.д.), не модифицирующие ядро. + 2. *Не поддерживаются:* модули, требующие изменений ядра (`CorePatchRequired` — патчи `Player.cpp`, DBC-структур), модули с внешними нелинкуемыми библиотеками (`ALE` без LuaJIT рантайма), или модули с отсутствующими экспортами ядра (`SdkExportMissing`). + 3. *ABI-контроль:* сборка возможна только при совпадении хэша ABI хоста (`realm_sdk_fingerprint`) со слепком SDK. + +### 1.3. Транзакционная установка и ворота безопасности +Установка модулей управляется через [crates/coa-core/src/preview.rs](file:///C:/games/coa-server-manager-preview/crates/coa-core/src/preview.rs) и [plugins.rs](file:///C:/games/coa-server-manager-preview/crates/coa-core/src/plugins.rs): +- **Stop-Gate (`require_server_stopped`):** установка, удаление или обновление DLL строго блокируются, если процесс `worldserver.exe` активен. +- **SQL Review Gate (`plan_sql_review`):** статический парсер SQL выявляет наличие DDL-мутаций (`CREATE/ALTER TABLE`) и требует явного подтверждения пользователя. +- **Реестр:** состояние фиксируется в `.manager/plugins.json` со слепками SHA-256 файлов, версиями и путями к конфигам. + +--- + +## 2. Карта существующих компонентов с путями и функциями + +```mermaid +graph TD + UI["ModulesPage.tsx (React/Tailwind)"] -->|IPC Invoke| Tauri["src-tauri/src/lib.rs"] + Tauri --> Analyzer["analyzer.rs (Intake & L0 Static Audit)"] + Tauri --> Builder["build_engine.rs (MSVC / CMake / Adapter Gen)"] + Tauri --> Preview["preview.rs (Stop-Gate / SQL Gate / Orchestration)"] + Preview --> Plugins["plugins.rs (Store & .manager/plugins.json)"] +``` + +| Уровень / Назначение | Путь к файлу | Ключевые функции / Точки входа | +|---|---|---| +| **Intake & Static Audit** | `crates/coa-core/src/analyzer.rs` | `analyze_github_module_for_realm(&url, &realm_root, cache_dir)`
`analyze_github_repository(&url, cache_dir)`
`parse_and_validate_github_url(&url)` | +| **Controlled Build Engine** | `crates/coa-core/src/build_engine.rs` | `detect_toolchain()`
`build_module_from_source(&source_dir, &realm_root, ...)`
`generate_adapter(&module_dir)` | +| **Lifecycle & Orchestration** | `crates/coa-core/src/preview.rs` | `install_qualified_module(&realm_root, &id, &ver, apply_sql, ...)`
`list_installed_preview_plugins(&realm_root)`
`toggle_preview_module(&realm_root, &id, enable)`
`remove_preview_module(&realm_root, &id)`
`plan_sql_review(&module_id, &report)` | +| **Plugin Store & Registry** | `crates/coa-core/src/plugins.rs` | `load_registry(&root)` / `save_registry(&root, ®)`
`install(...)` / `remove(...)`
`require_server_stopped(&root)` | +| **Tauri IPC Bridge** | `src-tauri/src/lib.rs` | `analyze_github_module(url, realm_id)` (L2800)
`preview_build_module(realm_id, source)` (L2849)
`preview_install_module(realm_id, id, ver, sql)` (L2895)
`preview_toggle_module(realm_id, id, enable)` (L2919)
`preview_remove_module(realm_id, id)` (L2934)
`preview_list_installed_plugins(realm_id)` (L2948) | +| **Frontend API Layer** | `src/lib/api.ts` | `api.analyzeGithubModule(url, realmId)` (L733)
`api.previewBuildModule(realmId, source)`
`api.previewInstallModule(...)`
`api.previewToggleModule(...)` | +| **Modules V2 UI View** | `src/screens/ModulesPage.tsx` | Вкладка `installed` (L2151)
Вкладка `catalog` (L2813)
Вкладка `builds` (L3431)
Вкладка `toolchain` (L3500)
Функция интейка: `runAnalysis(url)` (L1328)
Функция сборки: `startControlledBuild(url)` (L1365) | + +--- + +## 3. Точки интеграции каталога + +Существующая архитектура Modules V2 **полностью готова к приёму URL репозиториев**. + +Точкой интеграции на стороне Frontend является функция: +```typescript +// src/screens/ModulesPage.tsx, строки 1328–1362 +async function runAnalysis(urlToAnalyze?: string) { + const targetUrl = (urlToAnalyze || githubUrl).trim(); + if (!targetUrl) return; + setGithubUrl(targetUrl); + setAnalyzing(true); + // ... + const rep = await api.analyzeGithubModule(targetUrl, serverId); + setReport(rep); +} +``` + +### Принцип бесшовной стыковки: +1. Каталог модулей отображает карточки доступных репозиториев. +2. При нажатии на карточке кнопки **«Анализировать и подготовить к установке»** вызывается: + ```typescript + runAnalysis(module.html_url); + ``` +3. CSM автоматически переключает контекст на анализ репозитория, скачивает исходники в кэш, выполняет L0-аудит и отображает модальное окно совместимости. +4. Пользователь нажимает **«Собрать DLL»** (`startControlledBuild()`), после чего срабатывает `previewInstallModule()`. + +**Результат:** Не требуется создавать второй установщик, дублировать сборщик или менять логику реестра `.manager/plugins.json`. + +--- + +## 4. Перечень уже реализованных возможностей + +В ветке `preview/modules-v2` уже полностью реализованы и протестированы: +1. Загрузка исходных кодов с GitHub с кэшированием в `%LOCALAPPDATA%\CSMModulesPreview\github_cache` и дедупликацией по SHA дерева коммита. +2. Статический анализатор совместимости (L0 Static Analysis) с проверкой сигнатур функций `Add...Scripts()`, детекцией core patches, парсингом SQL-миграций и проверкой лицензий. +3. Автоматический генератор C++ адаптеров `IPlugin` под CoA Plugin SDK. +4. Сборочный движок MSVC x64 + Ninja + CMake с захватом логов компиляции в реальном времени. +5. Stop-Gate валидация (защита от изменения DLL и баз данных работающего сервера). +6. SQL Review Gate (анализ рисков DDL/DML перед наложением на базу данных). +7. Установка DLL в хранилище `.manager/plugin-store/`, создание символических ссылок в `plugins/` и фиксация в `.manager/plugins.json`. +8. Полный жизненный цикл установленных плагинов: включение, отключение, безопасное удаление (с сохранением пользовательских таблиц БД). +9. Пользовательский интерфейс с вкладками «Установленные плагины», «Сборка & логи MSVC» и «Среда сборки & SDK». + +--- + +## 5. Перечень отсутствующих компонентов + +Для полноценной работы каталога в Modules V2 отсутствуют исключительно компоненты **обнаружения и доставки метаданных**: +1. **Сетевой загрузчик официального JSON (`PublicCatalogueFetcher`):** сервис в `crates/coa-core`, скачивающий `https://www.azerothcore.org/data/catalogue.json` с поддержкой ETag и сохранением на диск. +2. **Локальный дисковый кэш каталога:** файл кэша в `%LOCALAPPDATA%\CSMModulesPreview\catalogue_cache.json` с оффлайн-фоллбэком. +3. **Парсер и фильтр категории `azerothcore-module`:** извлечение 545 репозиториев, нормализация данных, отсеивание заведомо нерелевантных проектов (чистые Lua/SQL). +4. **Кураторский оверлей CoA (`coa-modules-overlay.json`):** встроенный или подгружаемый файл, содержащий экспертные вердикты для ядра CoA (`Verified`, `Incompatible`, `RequiresAdapter`). +5. **Tauri IPC команда `get_public_module_catalog`:** эндпоинт передачи каталога во фронтенд. +6. **Динамический каталог в UI (`ModulesPage.tsx`):** расширение вкладки `catalog`, заменяющее жестко закодированный массив `VERIFIED_CATALOG` на динамический список с поиском, сортировкой и карточкой деталей. + +--- + +## 6. Предлагаемые минимальные изменения Backend + +### 6.1. Новый модуль `crates/coa-core/src/catalogue.rs` +Добавляется один изолированный файл со структурами и логикой: + +```rust +// crates/coa-core/src/catalogue.rs + +use std::collections::BTreeMap; +use std::fs; +use std::path::{Path, PathBuf}; +use serde::{Deserialize, Serialize}; +use crate::error::{Error, Result}; + +pub const OFFICIAL_CATALOGUE_URL: &str = "https://www.azerothcore.org/data/catalogue.json"; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawRepoEntry { + pub id: u64, + pub name: String, + pub full_name: String, + pub description: Option, + pub stargazers_count: u32, + pub default_branch: String, + pub html_url: String, + pub forks_count: u32, + pub watchers_count: u32, + #[serde(default)] + pub pushed_at: Option, + #[serde(default)] + pub updated_at: Option, + #[serde(default)] + pub license: Option, + #[serde(default)] + pub topics: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawLicense { + pub spdx_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct RawCatalogue { + pub global_search: bool, + pub organizations: BTreeMap>>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublicModuleCard { + pub id: u64, + pub name: String, + pub full_name: String, + pub description: String, + pub stars: u32, + pub html_url: String, + pub default_branch: String, + pub last_pushed: String, + pub license: String, + pub topics: Vec, + pub coa_status: String, // "verified", "unverified", "incompatible", "blocked" + pub coa_note: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CatalogueResponse { + pub modules: Vec, + pub cached_at: String, + pub is_offline: bool, +} + +pub fn get_catalogue(cache_dir: &Path, force_refresh: bool) -> Result { + let cache_file = cache_dir.join("catalogue_cache.json"); + let etag_file = cache_dir.join("catalogue_etag.txt"); + + // 1. Проверка необходимости обновления и загрузка с ETag через reqwest + // 2. Слияние с coa-modules-overlay.json + // 3. Возврат нормализованного списка + // ... +} +``` + +### 6.2. Регистрация в `crates/coa-core/src/lib.rs` +Добавить строку: +```rust +pub mod catalogue; +``` + +### 6.3. Экспорт команды в `src-tauri/src/lib.rs` +Добавить одну команду: +```rust +#[tauri::command] +async fn get_public_module_catalog( + state: State<'_, AppState>, + force_refresh: bool, +) -> std::result::Result { + let cache_dir = data_dir().join("catalogue"); + blocking(move || { + coa_core::catalogue::get_catalogue(&cache_dir, force_refresh) + .map_err(Into::into) + }).await +} +``` +И зарегистрировать её в `generate_handler!`. + +--- + +## 7. Предлагаемые изменения Frontend + +### 7.1. Расширение `src/lib/api.ts` +Добавить один метод: +```typescript +getPublicModuleCatalog: (forceRefresh = false) => + invoke("get_public_module_catalog", { forceRefresh }), +``` + +### 7.2. Минимальные изменения в `src/screens/ModulesPage.tsx` +В существующей вкладке `catalog` (строки 2813–3100): +1. **Загрузка данных:** При открытии вкладки `catalog` вызывать `api.getPublicModuleCatalog()`. +2. **Панель фильтров и поиска:** + - Поле поиска: фильтрация списка по `name`, `description`, `topics`. + - Сортировка: «По звёздам (популярные)», «По дате обновления (свежие)». + - Фильтр категорий: «Все» (545), «Проверенные CoA», «Геймплей», «Утилиты». +3. **Карточка модуля:** + - Название, автор, описание, бейдж звёзд и лицензии. + - Бейдж совместимости с CoA: + - Зелёный: `Verified CoA` (из оверлея). + - Серый: `Unverified Public` (из официального каталога). + - Красный: `Incompatible with CoA` (известный конфликт). + - Кнопка действия: + - Если модуль уже установлен (совпадает `repo` с установленными в `installedPlugins`): отображать бейдж `Установлен` с версией. + - Если не установлен: кнопка **«Анализировать и установить»**. +4. **Обработчик кнопки установки:** + ```typescript + onClick={() => { + runAnalysis(module.html_url); + }} + ``` + *Больше ничего не требуется:* клик переключает состояние, запускает L0-анализ, открывает окно совместимости и передает управление в готовый сборочный движок. + +--- + +## 8. Схема потоков данных + +```mermaid +sequenceDiagram + autonumber + actor User as Пользователь + participant UI as ModulesPage (Catalog Tab) + participant IPC as Tauri IPC (lib.rs) + participant Cat as CatalogueFetcher (crates/coa-core) + participant CDN as azerothcore.org (Cloudflare CDN) + participant Analyzer as analyzer.rs (Intake & L0) + participant Builder as build_engine.rs (MSVC) + participant Installer as preview.rs & plugins.rs + + User->>UI: Переход во вкладку "Каталог" + UI->>IPC: get_public_module_catalog(force_refresh=false) + IPC->>Cat: get_catalogue(cache_dir) + alt Кэш отсутствует или устарел + Cat->>CDN: GET /data/catalogue.json (If-None-Match: etag) + CDN-->>Cat: 200 OK (804 KB JSON) + Cat->>Cat: Сохранение cache.json и etag.txt + else Кэш актуален + Cat->>CDN: GET /data/catalogue.json + CDN-->>Cat: 304 Not Modified + end + Cat->>Cat: Слияние с coa-modules-overlay.json + Cat-->>UI: CatalogueResponse (545 модулей) + UI-->>User: Отображение каталога с поиском и бейджами CoA + + User->>UI: Клик "Анализировать и установить" (выбран mod-aoe-loot) + UI->>IPC: analyze_github_module(url, realm_id) + IPC->>Analyzer: analyze_github_module_for_realm() + Analyzer->>Analyzer: Git intake, AST аудит, проверка Add...Scripts, SQL аудит + Analyzer-->>UI: CompatibilityReport (L0 PASS, Buildable) + UI-->>User: Модальное окно аудита совместимости + + User->>UI: Клик "Собрать DLL" + UI->>IPC: preview_build_module(realm_id, url) + IPC->>Builder: build_module_from_source() + Builder->>Builder: Генерация adapter.cpp, компиляция MSVC + Ninja + Builder-->>UI: BuildResult (Success, DLL SHA-256) + + User->>UI: Клик "Установить в сервер" + UI->>IPC: preview_install_module(realm_id, module_id, ver, apply_sql) + IPC->>Installer: install_qualified_module() + Installer->>Installer: Stop-Gate check, копирование DLL, plugins.json + Installer-->>UI: PreviewInstallResult (Success) + UI-->>User: Модуль активен в сервере! +``` + +--- + +## 9. Модель кэширования и обновления + +1. **Заголовки ETag / Conditional Requests:** + Сервер `azerothcore.org` возвращает стандартный HTTP заголовок `ETag: "88aa9d..."`. При повторном запросе CSM отправляет `If-None-Match`. Получение `304 Not Modified` полностью исключает передачу 800 КБ по сети. +2. **Дисковый кэш:** + Кэш сохраняется в `%LOCALAPPDATA%\CSMModulesPreview\catalogue\cache.json`. +3. **Оффлайн-режим:** + Если подключение к сети отсутствует (NetworkUnreachable, DNS failure), клиент не выдает ошибку, а прозрачно возвращает сохранённый `cache.json` с флагом `is_offline = true`. +4. **Защита от повреждений:** + Запись файла кэша производится атомарно через временный файл (`fsx::atomic_write`). При повреждении JSON кэш отбрасывается, и используется встроенный в бинарник минимальный снапшот. +5. **Дедупликация и переименования:** + Естественным ключом в каталоге является числовой `id` репозитория GitHub. При переименовании репозитория `id` сохраняется, обновляется только поле `full_name`. + +--- + +## 10. Интеграция с анализатором совместимости + +Каталог и существующий анализатор [crates/coa-core/src/analyzer.rs](file:///C:/games/coa-server-manager-preview/crates/coa-core/src/analyzer.rs) работают в синергии: +- **В каталоге (до выбора):** пользователь видит метаданные GitHub (звезды, дату обновления) + кураторский статус CoA (`Verified`, `Incompatible`, `Unverified`). +- **После выбора модуля:** запускается глубокий анализ `analyze_github_module_for_realm`: + 1. Вычисляется точный хэш исходного кода (`.coa_tree_digest`). + 2. Проверяется наличие экспортов ядра через PE-парсинг `worldserver.exe` (`parse_pe_exports`). + 3. Проверяется совпадение ABI-фингерпринта. + 4. Проверяется канонический загрузчик `AddScripts()`. + 5. Проводится аудит SQL-миграций на наличие `CREATE/ALTER TABLE`. + 6. Формируется отчет `CompatibilityReport` с многомерным статусом (`BuildStatus`, `RuntimeStatus`, `GameplayStatus`). + +Таким образом, публичный каталог выступает **витриной обнаружения**, а существующий анализатор Modules V2 — **воротами допуска**. + +--- + +## 11. План тестирования (Интеграционная матрица) + +| № | Тестовый сценарий | Ожидаемый результат | Метод проверки | +|---|---|---|---| +| 1 | Загрузка каталога онлайн | Загружено 545 модулей, статус 200 OK, сохранен кэш и ETag | Unit-тест `test_catalogue_fetch_online` с mock-сервером | +| 2 | Повторная загрузка с валидным ETag | Сервер отвечает 304 Not Modified, данные берутся из локального кэша | Mock HTTP тест с проверкой заголовка `If-None-Match` | +| 3 | Загрузка в оффлайн-режиме | Возвращаются данные из `cache.json`, флаг `is_offline == true` | Тест с отключенным сетевым адаптером / mock network error | +| 4 | Поврежденный кэш на диске | Ошибка перехватывается, загружается резервный снапшот, приложение не падает | Повреждение файла `cache.json` битыми байтами | +| 5 | Дедупликация репозиториев | В списке отсутствуют дублирующиеся ID репозиториев | Проверка `Set.len() == list.len()` | +| 6 | Распознавание установленных модулей | Модуль, установленный в сервере, помечается в каталоге бейджем `Установлен` | Сверка `repo` каталога со списком `list_installed_preview_plugins` | +| 7 | Передача URL в `runAnalysis` | При клике по модулю поле `githubUrl` заполняется корректным URL, стартует L0-анализ | Проверка аргументов вызова `api.analyzeGithubModule` | +| 8 | Сохранение ручного ввода URL | Поле ввода произвольного GitHub URL продолжает функционировать | Ручной ввод `https://github.com/custom/repo` и запуск анализа | +| 9 | Передача данных в анализатор | Анализатор получает URL, загружает репозиторий и возвращает `CompatibilityReport` | Прогон анализатора на модуле `mod-aoe-loot` | +| 10 | Ворота безопасности (Stop-Gate) | Попытка установить модуль при работающем `worldserver.exe` блокируется | Интеграционный тест `require_server_stopped` | +| 11 | Отображение ошибок сборки | При ошибке компиляции в логах MSVC пользователь видит подсказку в UI | Сборка заведомо битого C++ модуля | +| 12 | Предупреждение о конфликтах CoA | Модуль `mod-learn-spells` отображает предупреждение о конфликте с драфтом | Проверка поля `coa_status == "incompatible"` | +| 13 | Фиксация версий установленных модулей | Обновление каталога не затрагивает скомпилированные DLL в `.manager/plugins/` | Сравнение хэшей DLL до и после обновления каталога | +| 14 | Производительность UI | Рендеринг 545 карточек с виртуализацией или пагинацией не вызывает фризов UI | React Profiler тест рендеринга страницы | + +--- + +## 12. Риски и потенциальные регрессии + +| Риск | Вероятность | Влияние | Мера предотвращения | +|---|---|---|---| +| **Регрессия существующего ручного ввода URL** | Низкая | Высокое | Ручной ввод GitHub URL остается в интерфейсе как приоритетный альтернативный способ; каталог только передает URL в ту же функцию `runAnalysis`. | +| **Падение UI при рендеринге 500+ элементов** | Средняя | Среднее | Использование поиска/фильтрации на клиенте с лимитом отображения (пагинация по 24 карточки или виртуальный скроллинг). | +| **Изменение формата JSON официального каталога AC** | Низкая | Среднее | Использование `serde(default)` для всех необязательных полей; строгие fallback-значения при отсутствии полей. | +| **Ложная уверенность пользователя в совместимости** | Высокая | Высокое | Яркие бейджи: статус `Unverified Public` для всех сторонних модулей из каталога до прохождения L0–L5 тестов. | +| **Блокировка сервером CDN (HTTP 429)** | Минимальная | Низкое | Запросы отправляются только раз в 12–24 часа или по явному клику пользователя «Обновить»; наличие оффлайн-кэша. | + +--- + +## 13. План реализации небольшими независимыми этапами + +### Этап 1: Backend & Сетевой кэш (Бэкенд-сервис каталога) +- Создать `crates/coa-core/src/catalogue.rs`. +- Реализовать загрузку `https://www.azerothcore.org/data/catalogue.json` с поддержкой ETag. +- Реализовать чтение локального файла `coa-modules-overlay.json` и слияние статусов. +- Написать модульные тесты парсинга и кэширования. +- *Результат:* Автономный протестированный Rust-модуль, готовый к вызову из Tauri. + +### Этап 2: Tauri IPC Bridge & Frontend API +- Зарегистрировать команду `get_public_module_catalog` в `src-tauri/src/lib.rs`. +- Добавить типизированный метод в `src/lib/api.ts`. +- *Результат:* Фронтенд может запрашивать актуальный список модулей одной асинхронной функцией. + +### Этап 3: UI интеграция в `ModulesPage.tsx` +- Во вкладке `catalog` заменить хардкод `VERIFIED_CATALOG` на динамическую загрузку через `api.getPublicModuleCatalog()`. +- Добавить строку поиска, фильтры категорий и селектор сортировки. +- Привязать кнопку действия карточки модуля к существующей функции `runAnalysis(module.html_url)`. +- *Результат:* Пользователь выбирает любой модуль из официального каталога AzerothCore и запускает штатный процесс анализа и сборки Modules V2 в один клик. + +--- + +## 14. Сводная таблица компонентов + +| Компонент | Уже существует | Требуется изменить | Новый компонент | Описание и роль | +|---|---|---|---|---| +| **GitHub downloader** | `analyzer.rs` (`analyze_github_repository`) | **НЕ МЕНЯТЬ** | Нет | Клонирует и кэширует исходники репозитория в `%LOCALAPPDATA%\CSMModulesPreview\github_cache`. Полностью готов. | +| **Compatibility analyzer** | `analyzer.rs` (`analyze_github_module_for_realm`) | **НЕ МЕНЯТЬ** | Нет | Выполняет L0 Static Audit, проверяет хуки, сигнатуру `Add...Scripts`, структуру SQL и лицензии. Полностью готов. | +| **Module builder** | `build_engine.rs` (`build_module_from_source`) | **НЕ МЕНЯТЬ** | Нет | Генерирует C++ адаптер `IPlugin`, компилирует MSVC x64 + Ninja против CoA SDK v0.3. Полностью готов. | +| **Module installer** | `preview.rs` (`install_qualified_module`) & `plugins.rs` | **НЕ МЕНЯТЬ** | Нет | Контролирует Stop-Gate, SQL Review Gate, копирует DLL в `.manager/plugin-store/`, прописывает в `plugins.json`. Полностью готов. | +| **Module registry** | `.manager/plugins.json` (`plugins.rs`) | **НЕ МЕНЯТЬ** | Нет | Авторитетный реестр установленных плагинов realm'а. Полностью готов. | +| **Module Catalogue Fetcher** | Нет | Нет | **ДА (`catalogue.rs`)** | Скачивает официальный JSON с CDN AzerothCore с ETag-кэшированием и оверлеем CoA. Новый легковесный компонент. | +| **Modules UI** | `ModulesPage.tsx` | **Минимально расширить** | Нет | В существующую вкладку `catalog` добавляется вызов `getPublicModuleCatalog()` и связка с `runAnalysis()`. Без переписывания UI. | + +--- + +## 15. Анализ различий веток: `master` vs `preview/modules-v2` + +| Характеристика / Возможность | Состояние в `master` (предыдущий аудит) | Состояние в `preview/modules-v2` (целевая ветка) | Влияние на план интеграции | +|---|---|---|---| +| **Модель модулей** | Только монолитная компиляция в `worldserver.exe` | **Полноценная архитектура динамических DLL плагинов** | Отпала необходимость проектировать систему сборки и загрузки модулей. | +| **Сборочный движок** | Отсутствовал в коде CSM | **Реализован в `build_engine.rs` (MSVC + Ninja + Adapter Gen)** | Сборщик уже написан, отлажен и квалифицирован на тестовой когорте T01–T04. | +| **Анализатор совместимости** | Базовые заготовки | **Мощный модуль `analyzer.rs` (2445 строк, L0–L6 верификация)** | Анализатор готов к приему любого репозитория из каталога. | +| **Установщик и транзакции** | Только текстовая правка `.conf` файлов | **Транзакционный установщик в `preview.rs` со Stop-Gate и SQL Review** | Установщик готов и защищает сервер от крашей на лету. | +| **Интерфейс управления** | Простой список из 9 карточек | **4-вкладочный интерфейс `ModulesPage.tsx` (2737 строк)** | Интерфейс готов: нужно лишь заменить статический каталог на динамический. | +| **Каталог модулей** | Статический `modules/catalog.json` (9 шт.) | Статический `VERIFIED_CATALOG` в TypeScript (8 шт.) | **Единственная недостающая часть — подключение официального JSON к каталогу.** | + +--- + +## 16. Главный вопрос и итоговый вердикт + +### Главный вопрос: +*Как добавить официальный публичный каталог AzerothCore в уже работающую систему Modules V2 с минимальными изменениями, без дублирования существующей логики и без регрессий установки модулей?* + +### Итоговый технический вердикт: + +1. **Готова ли Modules V2 к подключению каталога?** + **Да, на 100%.** Архитектура ветки `preview/modules-v2` находится в состоянии полной готовности: вся сквозная цепочка (`Intake URL -> Analyze -> Build DLL -> Review SQL -> Install -> Toggle -> Remove`) уже функционирует и протестирована. Каталог нужен исключительно как поставщик URL для этой цепочки. + +2. **Сколько новых компонентов действительно необходимо?** + **Ровно один новый модуль на Backend:** `crates/coa-core/src/catalogue.rs` (~200 строк чистого кода на Rust) для загрузки `https://www.azerothcore.org/data/catalogue.json` с ETag-кэшированием и наложением кураторских статусов CoA. + +3. **Какие существующие функции будут переиспользованы?** + - `analyze_github_module_for_realm` ([analyzer.rs](file:///C:/games/coa-server-manager-preview/crates/coa-core/src/analyzer.rs)) — для глубокого аудита выбранного модуля. + - `build_module_from_source` ([build_engine.rs](file:///C:/games/coa-server-manager-preview/crates/coa-core/src/build_engine.rs)) — для компиляции выбранного модуля в DLL. + - `install_qualified_module` ([preview.rs](file:///C:/games/coa-server-manager-preview/crates/coa-core/src/preview.rs)) — для безопасной установки плагина. + - `runAnalysis` ([ModulesPage.tsx](file:///C:/games/coa-server-manager-preview/src/screens/ModulesPage.tsx)) — как готовая точка запуска пайплайна по клику в UI. + +4. **Есть ли архитектурные препятствия?** + **Архитектурных препятствий нет.** Никаких конфликтов зависимостей, ограничений по типам данных или блокеров в дизайне Modules V2 не обнаружено. + +5. **Какой минимальный безопасный объём изменений требуется для первого рабочего релиза?** + 1. Добавить `crates/coa-core/src/catalogue.rs` (загрузка JSON и дисковый кэш). + 2. Зарегистрировать IPC-команду `get_public_module_catalog` в `src-tauri/src/lib.rs`. + 3. В `src/screens/ModulesPage.tsx` заменить хардкод `VERIFIED_CATALOG` на рендеринг списка из API и вызов `runAnalysis(module.html_url)` при клике. + +*Все остальные механизмы Modules V2 остаются нетронутыми.* diff --git a/crates/coa-core/src/catalogue.rs b/crates/coa-core/src/catalogue.rs new file mode 100644 index 0000000..c10808a --- /dev/null +++ b/crates/coa-core/src/catalogue.rs @@ -0,0 +1,838 @@ +//! Public AzerothCore Module Catalogue integration for CoA Server Manager. +//! +//! Provides cached, prioritized, and overlay-enriched discovery of AzerothCore modules +//! sourced from `https://www.azerothcore.org/data/catalogue.json`. +//! +//! Features: +//! - Thread-safe periodic synchronization with ETag (`If-None-Match`) support +//! - Transparent offline caching with atomic disk persistence +//! - Priority ordering: CoA Recommended / Verified modules always appear first +//! - Seamless deduplication by GitHub Repository ID and normalized slug +//! - Deep integration with the CoA Compatibility Overlay (`coa-modules-overlay.json`) +//! - Hard policy blocking to prevent installation of known crashers or security risks + +use std::collections::{BTreeMap, HashSet}; +use std::fs; +use std::path::Path; +use std::sync::Mutex; +use std::time::Duration; + +use chrono::Utc; +use serde::{Deserialize, Serialize}; + +use crate::error::{Error, Result}; +use crate::fsx; + +pub const OFFICIAL_CATALOGUE_URL: &str = "https://www.azerothcore.org/data/catalogue.json"; +pub const DEFAULT_CACHE_TTL_SECS: i64 = 12 * 3600; // 12 hours +const CONNECT_TIMEOUT_SECS: u64 = 10; +const READ_TIMEOUT_SECS: u64 = 25; + +static CATALOGUE_LOCK: Mutex<()> = Mutex::new(()); +static EMBEDDED_OVERLAY_JSON: &str = include_str!("coa-modules-overlay.json"); + +// --------------------------------------------------------------------------- +// External Data Models (matching official AzerothCore JSON) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Deserialize)] +pub struct OfficialCatalogueRaw { + #[serde(default)] + pub global_search: bool, + #[serde(default)] + pub organizations: BTreeMap>>, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OfficialRepoRaw { + pub id: u64, + pub name: String, + pub full_name: String, + #[serde(default)] + pub description: Option, + #[serde(default)] + pub stargazers_count: u32, + #[serde(default)] + pub created_at: Option, + #[serde(default = "default_branch_val")] + pub default_branch: String, + #[serde(default)] + pub owner: Option, + pub html_url: String, + #[serde(default)] + pub forks_count: u32, + #[serde(default)] + pub watchers_count: u32, + #[serde(default)] + pub pushed_at: Option, + #[serde(default)] + pub updated_at: Option, + #[serde(default)] + pub license: Option, + #[serde(default)] + pub topics: Vec, +} + +fn default_branch_val() -> String { + "master".into() +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OfficialOwnerRaw { + pub login: String, + #[serde(default)] + pub avatar_url: Option, + #[serde(default)] + pub html_url: Option, +} + +#[derive(Debug, Clone, Deserialize)] +pub struct OfficialLicenseRaw { + #[serde(default)] + pub spdx_id: Option, +} + +// --------------------------------------------------------------------------- +// CoA Overlay Models +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoaOverlayDocument { + pub version: u32, + #[serde(default)] + pub description: Option, + pub rules: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CoaOverlayRule { + #[serde(default)] + pub repo_id: Option, + pub repo_slug: String, + pub status: CoaCompatibilityStatus, + #[serde(default)] + pub is_recommended: bool, + #[serde(default)] + pub reason: Option, + #[serde(default)] + pub tested_version: Option, + #[serde(default)] + pub tested_abi: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] +pub enum CoaCompatibilityStatus { + Recommended, + Verified, + RequiresAdapter, + Unverified, + Incompatible, + Blocked, +} + +impl std::fmt::Display for CoaCompatibilityStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Recommended => write!(f, "Recommended"), + Self::Verified => write!(f, "Verified"), + Self::RequiresAdapter => write!(f, "RequiresAdapter"), + Self::Unverified => write!(f, "Unverified"), + Self::Incompatible => write!(f, "Incompatible"), + Self::Blocked => write!(f, "Blocked"), + } + } +} + +// --------------------------------------------------------------------------- +// Internal Normalized Item & API Response +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PublicCatalogueItem { + pub id: u64, + pub name: String, + pub full_name: String, + pub owner: String, + pub html_url: String, + pub description: String, + pub stars: u32, + pub forks: u32, + pub default_branch: String, + #[serde(default)] + pub pushed_at: Option, + #[serde(default)] + pub updated_at: Option, + #[serde(default)] + pub license: Option, + #[serde(default)] + pub topics: Vec, + pub coa_status: CoaCompatibilityStatus, + pub coa_status_source: String, // "Overlay", "VerifiedCatalog", "AutoPublic" + pub conflict_reason: Option, + pub is_coa_recommended: bool, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CachedDocument { + pub schema_version: u32, + pub cached_at: String, + pub timestamp: i64, + #[serde(default)] + pub etag: Option, + pub items: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PublicCatalogueResponse { + pub items: Vec, + pub cached_at: String, + pub is_offline: bool, + #[serde(default)] + pub etag: Option, + pub total_count: usize, + pub recommended_count: usize, +} + +// --------------------------------------------------------------------------- +// Helper: Normalization of Slugs & URLs +// --------------------------------------------------------------------------- + +pub fn normalize_repo_slug(url_or_slug: &str) -> String { + let mut s = url_or_slug.trim(); + if let Some(pos) = s.find("://") { + s = &s[pos + 3..]; + } + if let Some(pos) = s.find('@') { + if let Some(colon) = s[pos..].find(':') { + s = &s[pos + colon + 1..]; + } + } + if let Some(stripped) = s.strip_prefix("github.com/") { + s = stripped; + } + // Remove query params or fragments + if let Some(pos) = s.find('?') { + s = &s[..pos]; + } + if let Some(pos) = s.find('#') { + s = &s[..pos]; + } + // Remove tree/branches if present (e.g. repo/tree/branch/sub) + let parts: Vec<&str> = s.split('/').filter(|p| !p.is_empty()).collect(); + if parts.len() >= 2 { + let owner = parts[0].to_ascii_lowercase(); + let mut repo = parts[1].to_ascii_lowercase(); + if repo.ends_with(".git") { + repo.truncate(repo.len() - 4); + } + format!("{owner}/{repo}") + } else { + s.trim_end_matches(".git").to_ascii_lowercase() + } +} + +pub fn load_coa_overlay(optional_overlay_path: Option<&Path>) -> CoaOverlayDocument { + let mut doc: CoaOverlayDocument = serde_json::from_str(EMBEDDED_OVERLAY_JSON) + .unwrap_or_else(|_| CoaOverlayDocument { + version: 1, + description: None, + rules: Vec::new(), + }); + + if let Some(path) = optional_overlay_path { + if path.is_file() { + if let Ok(content) = fs::read_to_string(path) { + if let Ok(external) = serde_json::from_str::(&content) { + for ext_rule in external.rules { + if let Some(existing) = doc + .rules + .iter_mut() + .find(|r| normalize_repo_slug(&r.repo_slug) == normalize_repo_slug(&ext_rule.repo_slug)) + { + *existing = ext_rule; + } else { + doc.rules.push(ext_rule); + } + } + } + } + } + } + + doc +} + +/// Checks if a repository is explicitly blocked by CoA policy. +/// Returns `Some(reason)` if blocked, otherwise `None`. +pub fn is_blocked_repo(url_or_slug: &str) -> Option { + let target = normalize_repo_slug(url_or_slug); + let overlay = load_coa_overlay(None); + for rule in overlay.rules { + if rule.status == CoaCompatibilityStatus::Blocked && normalize_repo_slug(&rule.repo_slug) == target { + return Some(rule.reason.unwrap_or_else(|| "Module blocked by CoA stability/security policy.".into())); + } + } + None +} + +// --------------------------------------------------------------------------- +// Normalization & Deduplication Logic +// --------------------------------------------------------------------------- + +pub fn normalize_catalogue( + raw: &OfficialCatalogueRaw, + overlay: &CoaOverlayDocument, +) -> Vec { + let mut normalized = Vec::new(); + let mut seen_ids = HashSet::new(); + let mut seen_slugs = HashSet::new(); + + // 1. Map overlay rules by slug for O(1) lookup + let mut overlay_map: BTreeMap = BTreeMap::new(); + for rule in &overlay.rules { + overlay_map.insert(normalize_repo_slug(&rule.repo_slug), rule); + } + + // 2. Extract repos from the category "azerothcore-module" + let mut repos = Vec::new(); + for (_, topics) in &raw.organizations { + for (topic_name, list) in topics { + if topic_name == "azerothcore-module" { + repos.extend(list.iter()); + } + } + } + + // If "azerothcore-module" was empty or structure differs, collect all modules + if repos.is_empty() { + for (_, topics) in &raw.organizations { + for (_, list) in topics { + repos.extend(list.iter()); + } + } + } + + // 3. Process and deduplicate + for repo in repos { + let slug = normalize_repo_slug(&repo.full_name); + + if repo.id != 0 && !seen_ids.insert(repo.id) { + continue; + } + if !seen_slugs.insert(slug.clone()) { + continue; + } + + let owner = repo + .owner + .as_ref() + .map(|o| o.login.clone()) + .unwrap_or_else(|| { + slug.split('/').next().unwrap_or("unknown").to_string() + }); + + // Determine CoA status from overlay or default to Unverified + let (coa_status, coa_source, reason, is_rec) = if let Some(rule) = overlay_map.get(&slug) { + ( + rule.status, + "Overlay".to_string(), + rule.reason.clone(), + rule.is_recommended || rule.status == CoaCompatibilityStatus::Recommended, + ) + } else { + ( + CoaCompatibilityStatus::Unverified, + "AutoPublic".to_string(), + None, + false, + ) + }; + + let license = repo.license.as_ref().and_then(|l| l.spdx_id.clone()); + + normalized.push(PublicCatalogueItem { + id: repo.id, + name: repo.name.clone(), + full_name: repo.full_name.clone(), + owner, + html_url: repo.html_url.clone(), + description: repo.description.clone().unwrap_or_default(), + stars: repo.stargazers_count, + forks: repo.forks_count, + default_branch: repo.default_branch.clone(), + pushed_at: repo.pushed_at.clone(), + updated_at: repo.updated_at.clone(), + license, + topics: repo.topics.clone(), + coa_status, + coa_status_source: coa_source, + conflict_reason: reason, + is_coa_recommended: is_rec, + }); + } + + // 4. Also ensure any Recommended overlay rules that were not present in the public raw catalog + // are included so they are never lost + for rule in &overlay.rules { + let slug = normalize_repo_slug(&rule.repo_slug); + if seen_slugs.insert(slug.clone()) { + let parts: Vec<&str> = slug.split('/').collect(); + let (owner, name) = if parts.len() >= 2 { + (parts[0].to_string(), parts[1].to_string()) + } else { + ("corfirean".to_string(), slug.clone()) + }; + + normalized.push(PublicCatalogueItem { + id: rule.repo_id.unwrap_or(0), + name: name.clone(), + full_name: slug.clone(), + owner, + html_url: format!("https://github.com/{}", slug), + description: rule.reason.clone().unwrap_or_default(), + stars: 0, + forks: 0, + default_branch: "master".to_string(), + pushed_at: None, + updated_at: None, + license: Some("AGPL-3.0".to_string()), + topics: vec!["azerothcore-module".to_string(), "coa-verified".to_string()], + coa_status: rule.status, + coa_status_source: "Overlay".to_string(), + conflict_reason: rule.reason.clone(), + is_coa_recommended: rule.is_recommended || rule.status == CoaCompatibilityStatus::Recommended, + }); + } + } + + // 5. Sort: + // Group A (Recommended/Verified) first, then by Stars desc, then by pushed_at desc + normalized.sort_by(|a, b| { + // Priority 1: Recommended modules first + match (a.is_coa_recommended, b.is_coa_recommended) { + (true, false) => return std::cmp::Ordering::Less, + (false, true) => return std::cmp::Ordering::Greater, + _ => {} + } + // Priority 2: Status order (Verified before Unverified, Blocked at end) + if a.coa_status != b.coa_status { + return a.coa_status.cmp(&b.coa_status); + } + // Priority 3: Stars descending + if b.stars != a.stars { + return b.stars.cmp(&a.stars); + } + // Priority 4: pushed_at descending + let pa = a.pushed_at.as_deref().unwrap_or(""); + let pb = b.pushed_at.as_deref().unwrap_or(""); + if pb != pa { + return pb.cmp(pa); + } + // Deterministic tie-breaker + a.full_name.cmp(&b.full_name) + }); + + normalized +} + +// --------------------------------------------------------------------------- +// Network Fetcher & Cache Manager +// --------------------------------------------------------------------------- + +pub fn fetch_or_load_catalogue( + cache_dir: &Path, + force_refresh: bool, +) -> Result { + let _guard = CATALOGUE_LOCK.lock().map_err(|_| { + Error::Invalid("Failed to acquire catalogue cache lock.".into()) + })?; + + fs::create_dir_all(cache_dir)?; + let cache_file = cache_dir.join("catalogue_cache.json"); + let etag_file = cache_dir.join("catalogue_etag.txt"); + let overlay_path = cache_dir.join("coa-modules-overlay.json"); + let overlay = load_coa_overlay(Some(&overlay_path)); + + let now_ts = Utc::now().timestamp(); + + // 1. Check if disk cache exists and is fresh + if !force_refresh && cache_file.is_file() { + if let Ok(content) = fs::read_to_string(&cache_file) { + if let Ok(cached) = serde_json::from_str::(&content) { + let age = now_ts - cached.timestamp; + if age < DEFAULT_CACHE_TTL_SECS && !cached.items.is_empty() { + let rec_count = cached.items.iter().filter(|i| i.is_coa_recommended).count(); + return Ok(PublicCatalogueResponse { + total_count: cached.items.len(), + recommended_count: rec_count, + cached_at: cached.cached_at, + is_offline: false, + etag: cached.etag, + items: cached.items, + }); + } + } + } + } + + // 2. Perform Network Fetch with ETag support + let stored_etag = fs::read_to_string(&etag_file).ok().map(|s| s.trim().to_string()); + let client = reqwest::blocking::Client::builder() + .connect_timeout(Duration::from_secs(CONNECT_TIMEOUT_SECS)) + .timeout(Duration::from_secs(READ_TIMEOUT_SECS)) + .redirect(reqwest::redirect::Policy::limited(5)) + .build() + .map_err(|e| Error::Invalid(format!("HTTP client initialization failed: {}", e)))?; + + let mut request = client.get(OFFICIAL_CATALOGUE_URL) + .header(reqwest::header::USER_AGENT, "CSM-Modules-Preview/0.7.1"); + + if let Some(ref etag) = stored_etag { + if !force_refresh && cache_file.is_file() { + request = request.header(reqwest::header::IF_NONE_MATCH, etag.as_str()); + } + } + + let fetch_result = request.send(); + + match fetch_result { + Ok(response) => { + let status = response.status(); + + // 2.1 Handle 304 Not Modified + if status == reqwest::StatusCode::NOT_MODIFIED { + if cache_file.is_file() { + if let Ok(content) = fs::read_to_string(&cache_file) { + if let Ok(mut cached) = serde_json::from_str::(&content) { + cached.timestamp = now_ts; + cached.cached_at = Utc::now().to_rfc3339(); + let _ = fsx::atomic_write_json(&cache_file, &cached); + let rec_count = cached.items.iter().filter(|i| i.is_coa_recommended).count(); + return Ok(PublicCatalogueResponse { + total_count: cached.items.len(), + recommended_count: rec_count, + cached_at: cached.cached_at, + is_offline: false, + etag: cached.etag, + items: cached.items, + }); + } + } + } + // If 304 received but cache file was missing or corrupt, re-fetch without etag + return fetch_or_load_catalogue(cache_dir, true); + } + + // 2.2 Handle 200 OK + if status.is_success() { + let new_etag = response + .headers() + .get(reqwest::header::ETAG) + .and_then(|h| h.to_str().ok()) + .map(|s| s.to_string()); + + let text = response.text().map_err(|e| { + Error::Invalid(format!("Failed to read catalogue response body: {}", e)) + })?; + + let raw: OfficialCatalogueRaw = serde_json::from_str(&text).map_err(|e| { + Error::Invalid(format!("Failed to parse AzerothCore official catalogue JSON: {}", e)) + })?; + + let items = normalize_catalogue(&raw, &overlay); + let rec_count = items.iter().filter(|i| i.is_coa_recommended).count(); + let now_str = Utc::now().to_rfc3339(); + + let cached_doc = CachedDocument { + schema_version: 1, + cached_at: now_str.clone(), + timestamp: now_ts, + etag: new_etag.clone(), + items: items.clone(), + }; + + // Persist cache and etag atomically + let _ = fsx::atomic_write_json(&cache_file, &cached_doc); + if let Some(ref tag) = new_etag { + let _ = fs::write(&etag_file, tag); + } + + return Ok(PublicCatalogueResponse { + total_count: items.len(), + recommended_count: rec_count, + cached_at: now_str, + is_offline: false, + etag: new_etag, + items, + }); + } + + // If non-200 and non-304 HTTP status, fallback to existing cache if available + try_offline_fallback(&cache_file, stored_etag, &overlay) + } + Err(_net_err) => { + // Network unreachable, DNS timeout, connection reset -> offline fallback + try_offline_fallback(&cache_file, stored_etag, &overlay) + } + } +} + +fn try_offline_fallback( + cache_file: &Path, + stored_etag: Option, + overlay: &CoaOverlayDocument, +) -> Result { + if cache_file.is_file() { + if let Ok(content) = fs::read_to_string(cache_file) { + if let Ok(cached) = serde_json::from_str::(&content) { + let rec_count = cached.items.iter().filter(|i| i.is_coa_recommended).count(); + return Ok(PublicCatalogueResponse { + total_count: cached.items.len(), + recommended_count: rec_count, + cached_at: cached.cached_at, + is_offline: true, + etag: cached.etag.or(stored_etag), + items: cached.items, + }); + } + } + } + + // If completely offline and no cache file exists, build minimal catalog from overlay rules + let empty_raw = OfficialCatalogueRaw { + global_search: false, + organizations: BTreeMap::new(), + }; + let items = normalize_catalogue(&empty_raw, overlay); + let rec_count = items.iter().filter(|i| i.is_coa_recommended).count(); + + Ok(PublicCatalogueResponse { + total_count: items.len(), + recommended_count: rec_count, + cached_at: Utc::now().to_rfc3339(), + is_offline: true, + etag: None, + items, + }) +} + +// --------------------------------------------------------------------------- +// Unit & Integration Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn sample_official_json() -> &'static str { + r#"{ + "global_search": true, + "organizations": { + "azerothcore": { + "azerothcore-module": [ + { + "id": 101, + "name": "mod-npc-buffer", + "full_name": "azerothcore/mod-npc-buffer", + "description": "NPC buffer master", + "stargazers_count": 55, + "default_branch": "master", + "html_url": "https://github.com/azerothcore/mod-npc-buffer", + "forks_count": 12, + "watchers_count": 55, + "pushed_at": "2026-10-10T12:00:00Z", + "updated_at": "2026-10-10T12:00:00Z", + "license": { "spdx_id": "AGPL-3.0" }, + "topics": ["azerothcore-module", "utility"] + }, + { + "id": 102, + "name": "mod-random-community", + "full_name": "author/mod-random-community", + "description": "A community module", + "stargazers_count": 120, + "default_branch": "main", + "html_url": "https://github.com/author/mod-random-community", + "forks_count": 5, + "watchers_count": 120, + "pushed_at": "2026-09-01T12:00:00Z", + "updated_at": "2026-09-01T12:00:00Z", + "license": null, + "topics": ["azerothcore-module"] + }, + { + "id": 103, + "name": "mod-learn-spells", + "full_name": "azerothcore/mod-learn-spells", + "description": "Auto teach spells", + "stargazers_count": 48, + "default_branch": "master", + "html_url": "https://github.com/azerothcore/mod-learn-spells", + "forks_count": 8, + "watchers_count": 48, + "pushed_at": "2026-08-01T12:00:00Z", + "updated_at": "2026-08-01T12:00:00Z", + "license": { "spdx_id": "AGPL-3.0" }, + "topics": ["azerothcore-module"] + }, + { + "id": 104, + "name": "mod-ahbot", + "full_name": "azerothcore/mod-ahbot", + "description": "Upstream ahbot", + "stargazers_count": 80, + "default_branch": "master", + "html_url": "https://github.com/azerothcore/mod-ahbot", + "forks_count": 20, + "watchers_count": 80, + "pushed_at": "2026-07-01T12:00:00Z", + "updated_at": "2026-07-01T12:00:00Z", + "license": null, + "topics": ["azerothcore-module"] + }, + { + "id": 101, + "name": "mod-npc-buffer-duplicate", + "full_name": "azerothcore/mod-npc-buffer", + "description": "Duplicate entry with same id and slug", + "stargazers_count": 55, + "default_branch": "master", + "html_url": "https://github.com/azerothcore/mod-npc-buffer", + "forks_count": 12, + "watchers_count": 55, + "pushed_at": "2026-10-10T12:00:00Z", + "updated_at": "2026-10-10T12:00:00Z", + "license": { "spdx_id": "AGPL-3.0" }, + "topics": ["azerothcore-module"] + } + ], + "azerothcore-lua": [ + { + "id": 201, + "name": "pure-lua-script", + "full_name": "some/pure-lua-script", + "stargazers_count": 10, + "default_branch": "main", + "html_url": "https://github.com/some/pure-lua-script" + } + ] + } + } + }"# + } + + #[test] + fn test_slug_normalization() { + assert_eq!(normalize_repo_slug("https://github.com/azerothcore/mod-npc-buffer.git"), "azerothcore/mod-npc-buffer"); + assert_eq!(normalize_repo_slug("https://github.com/AzerothCore/Mod-NPC-Buffer/"), "azerothcore/mod-npc-buffer"); + assert_eq!(normalize_repo_slug("git@github.com:Corfirean/mod-coa-playerbots.git"), "corfirean/mod-coa-playerbots"); + assert_eq!(normalize_repo_slug("author/repo"), "author/repo"); + } + + #[test] + fn test_overlay_loading_and_blocked_detection() { + let overlay = load_coa_overlay(None); + assert!(!overlay.rules.is_empty(), "Embedded overlay must contain rules"); + + // Verify mod-ahbot is blocked + assert!(is_blocked_repo("https://github.com/azerothcore/mod-ahbot").is_some()); + assert!(is_blocked_repo("azerothcore/mod-ahbot").is_some()); + + // Verify clean repo is not blocked + assert!(is_blocked_repo("https://github.com/azerothcore/mod-npc-buffer").is_none()); + assert!(is_blocked_repo("random-author/clean-module").is_none()); + } + + #[test] + fn test_official_json_parsing_and_normalization() { + let raw: OfficialCatalogueRaw = serde_json::from_str(sample_official_json()).expect("Must parse sample JSON"); + let overlay = load_coa_overlay(None); + let items = normalize_catalogue(&raw, &overlay); + + // Deduplication: id 101 was duplicated, should only appear once + let buffer_count = items.iter().filter(|i| i.full_name == "azerothcore/mod-npc-buffer").count(); + assert_eq!(buffer_count, 1, "Duplicate repo with ID 101 must be deduplicated"); + + // Check statuses assigned via overlay + let buffer = items.iter().find(|i| i.full_name == "azerothcore/mod-npc-buffer").expect("mod-npc-buffer must exist"); + assert_eq!(buffer.coa_status, CoaCompatibilityStatus::Verified); + assert!(buffer.is_coa_recommended); + + let learn_spells = items.iter().find(|i| i.full_name == "azerothcore/mod-learn-spells").expect("mod-learn-spells must exist"); + assert_eq!(learn_spells.coa_status, CoaCompatibilityStatus::Incompatible); + assert!(!learn_spells.is_coa_recommended); + assert!(learn_spells.conflict_reason.is_some()); + + let ahbot = items.iter().find(|i| i.full_name == "azerothcore/mod-ahbot").expect("mod-ahbot must exist"); + assert_eq!(ahbot.coa_status, CoaCompatibilityStatus::Blocked); + + let community = items.iter().find(|i| i.full_name == "author/mod-random-community").expect("community module must exist"); + assert_eq!(community.coa_status, CoaCompatibilityStatus::Unverified); + assert_eq!(community.coa_status_source, "AutoPublic"); + assert!(!community.is_coa_recommended); + + // Check priority sorting: Recommended modules must come first + let first_non_rec_idx = items.iter().position(|i| !i.is_coa_recommended).unwrap_or(items.len()); + for (idx, item) in items.iter().enumerate() { + if idx < first_non_rec_idx { + assert!(item.is_coa_recommended, "Items before boundary must be recommended"); + } else { + assert!(!item.is_coa_recommended, "Items after boundary must not be recommended"); + } + } + } + + #[test] + fn test_disk_caching_and_offline_fallback() { + let d = tempdir().expect("tempdir"); + let cache_dir = d.path(); + + // 1. Manually write a cached document + let items = vec![ + PublicCatalogueItem { + id: 999, + name: "cached-mod".into(), + full_name: "test/cached-mod".into(), + owner: "test".into(), + html_url: "https://github.com/test/cached-mod".into(), + description: "Test description".into(), + stars: 42, + forks: 2, + default_branch: "master".into(), + pushed_at: Some("2026-10-01T00:00:00Z".into()), + updated_at: Some("2026-10-01T00:00:00Z".into()), + license: Some("MIT".into()), + topics: vec!["azerothcore-module".into()], + coa_status: CoaCompatibilityStatus::Unverified, + coa_status_source: "CacheTest".into(), + conflict_reason: None, + is_coa_recommended: false, + } + ]; + + let cached_doc = CachedDocument { + schema_version: 1, + cached_at: Utc::now().to_rfc3339(), + timestamp: Utc::now().timestamp(), + etag: Some("\"sample-etag\"".into()), + items: items.clone(), + }; + + let cache_file = cache_dir.join("catalogue_cache.json"); + fsx::atomic_write_json(&cache_file, &cached_doc).expect("write cache"); + + // 2. Read without force refresh -> should return cached doc instantly without network + let resp = fetch_or_load_catalogue(cache_dir, false).expect("load from cache"); + assert_eq!(resp.total_count, 1); + assert_eq!(resp.items[0].name, "cached-mod"); + assert!(!resp.is_offline); + + // 3. Corrupt the cache file -> test resilience + fs::write(&cache_file, b"NOT_A_VALID_JSON").expect("write corrupt cache"); + let overlay = load_coa_overlay(None); + let fallback_resp = try_offline_fallback(&cache_file, None, &overlay).expect("fallback must succeed"); + assert!(fallback_resp.is_offline); + assert!(!fallback_resp.items.is_empty(), "Fallback must supply overlay items even on corrupt cache"); + } +} diff --git a/crates/coa-core/src/coa-modules-overlay.json b/crates/coa-core/src/coa-modules-overlay.json new file mode 100644 index 0000000..b58bec3 --- /dev/null +++ b/crates/coa-core/src/coa-modules-overlay.json @@ -0,0 +1,132 @@ +{ + "version": 1, + "description": "Curated CoA compatibility and recommendation overlay for AzerothCore modules", + "rules": [ + { + "repo_slug": "corfirean/mod-coa-playerbots", + "status": "Recommended", + "is_recommended": true, + "reason": "Official companion bots for groups, dungeons and guild in Conquest of Azeroth." + }, + { + "repo_slug": "corfirean/mod-coa-content-scaling", + "status": "Recommended", + "is_recommended": true, + "reason": "Official dynamic dungeon and raid scaling system with solo modes." + }, + { + "repo_slug": "zyth45/mod-playerbots", + "status": "Recommended", + "is_recommended": true, + "reason": "SQUID's Playerbots with full autonomous AI. Mutually exclusive with CoA Companions." + }, + { + "repo_slug": "corfirean/azerothcore-wotlk-coa", + "status": "Recommended", + "is_recommended": true, + "reason": "Official Conquest of Azeroth modular core features." + }, + { + "repo_slug": "azerothcore/mod-npc-buffer", + "status": "Verified", + "is_recommended": true, + "reason": "NPC buff master offering spells, sickness removal, and player restoration. Verified dynamic DLL on CoA Plugin SDK." + }, + { + "repo_slug": "azerothcore/mod-congrats-on-level", + "status": "Verified", + "is_recommended": true, + "reason": "Level-up celebration animations, fireworks and announcements. Verified dynamic DLL on CoA Plugin SDK." + }, + { + "repo_slug": "azerothcore/mod-npc-enchanter", + "status": "Recommended", + "is_recommended": true, + "reason": "Official NPC equipment enchanting module." + }, + { + "repo_slug": "azerothcore/mod-reward-played-time", + "status": "Verified", + "is_recommended": true, + "reason": "Periodic rewards and tokens granted based on active playtime. Verified dynamic DLL on CoA Plugin SDK." + }, + { + "repo_slug": "azerothcore/mod-solocraft", + "status": "RequiresAdapter", + "is_recommended": true, + "reason": "Dynamically boosts player stats in group content. Verified DLL build; requires calibration with CoA Content Scaling." + }, + { + "repo_slug": "azerothcore/mod-cfbg", + "status": "Verified", + "is_recommended": false, + "reason": "Cross-faction battlegrounds system. Built into CoA core fork." + }, + { + "repo_slug": "azerothcore/mod-duel-reset", + "status": "Verified", + "is_recommended": false, + "reason": "Resets health, mana, cooldowns and auras after duels. Verified dynamic DLL." + }, + { + "repo_slug": "azerothcore/mod-pvp-titles", + "status": "Verified", + "is_recommended": false, + "reason": "Classic PvP rank titles awarded based on honorable kills." + }, + { + "repo_slug": "azerothcore/mod-starter-guild", + "status": "Verified", + "is_recommended": false, + "reason": "Automatically invites newly created characters into a designated starter guild." + }, + { + "repo_slug": "azerothcore/mod-skip-dk-starting-area", + "status": "Verified", + "is_recommended": false, + "reason": "Allows Death Knights to skip the Scarlet Enclave starter zone." + }, + { + "repo_slug": "azerothcore/mod-learn-spells", + "status": "Incompatible", + "is_recommended": false, + "reason": "Directly conflicts with CoA classless progression & spell draft: automatically grants standard class spells, breaking player builds." + }, + { + "repo_slug": "azerothcore/mod-autobalance", + "status": "Incompatible", + "is_recommended": false, + "reason": "Direct gameplay authority collision with mod-coa-content-scaling: both systems attempt to dynamically scale creature health and damage." + }, + { + "repo_slug": "zhengpeiru21/mod-individual-progression", + "status": "Incompatible", + "is_recommended": false, + "reason": "Conflicts with CoA progression curve, dungeon level brackets, and content scaling gating." + }, + { + "repo_slug": "azerothcore/mod-arac", + "status": "Incompatible", + "is_recommended": false, + "reason": "Incompatible with CoA custom races DBC table (Esteria, etc.) and expanded race limits." + }, + { + "repo_slug": "azerothcore/mod-ahbot", + "status": "Blocked", + "is_recommended": false, + "reason": "Upstream repository has missing script loader entrypoint (Addmod_ahbotScripts) causing compilation and linking failures. Use official mod-ah-bot-plus instead." + }, + { + "repo_slug": "azerothcore/mod-eluna", + "status": "RequiresAdapter", + "is_recommended": false, + "reason": "Requires external LuaJIT runtime and custom CoA spell system exports." + }, + { + "repo_slug": "azerothcore/mod-transmog", + "status": "RequiresAdapter", + "is_recommended": false, + "reason": "Redundant: CoA already provides a built-in wardrobe and transmog system configured in coa.conf." + } + ] +} diff --git a/crates/coa-core/src/lib.rs b/crates/coa-core/src/lib.rs index e4733af..d65a4d7 100644 --- a/crates/coa-core/src/lib.rs +++ b/crates/coa-core/src/lib.rs @@ -12,6 +12,7 @@ pub mod allsettings; pub mod analyzer; pub mod backup; pub mod build_engine; +pub mod catalogue; pub mod channels; pub mod cleanbase; pub mod client;