feat(core): implement AzerothCore module catalogue engine, overlay and caching

This commit is contained in:
Дмитрий committed 2026-11-03 10:48:56 +05:00
1 parent 3dbfffba76
commit 4e661e6986
4 files changed
+1448

No files matched your search

+477
View File
@@ -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/<id>/<version>/` и линкуются в `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)`<br>`analyze_github_repository(&url, cache_dir)`<br>`parse_and_validate_github_url(&url)` |
| **Controlled Build Engine** | `crates/coa-core/src/build_engine.rs` | `detect_toolchain()`<br>`build_module_from_source(&source_dir, &realm_root, ...)`<br>`generate_adapter(&module_dir)` |
| **Lifecycle & Orchestration** | `crates/coa-core/src/preview.rs` | `install_qualified_module(&realm_root, &id, &ver, apply_sql, ...)`<br>`list_installed_preview_plugins(&realm_root)`<br>`toggle_preview_module(&realm_root, &id, enable)`<br>`remove_preview_module(&realm_root, &id)`<br>`plan_sql_review(&module_id, &report)` |
| **Plugin Store & Registry** | `crates/coa-core/src/plugins.rs` | `load_registry(&root)` / `save_registry(&root, &reg)`<br>`install(...)` / `remove(...)`<br>`require_server_stopped(&root)` |
| **Tauri IPC Bridge** | `src-tauri/src/lib.rs` | `analyze_github_module(url, realm_id)` (L2800)<br>`preview_build_module(realm_id, source)` (L2849)<br>`preview_install_module(realm_id, id, ver, sql)` (L2895)<br>`preview_toggle_module(realm_id, id, enable)` (L2919)<br>`preview_remove_module(realm_id, id)` (L2934)<br>`preview_list_installed_plugins(realm_id)` (L2948) |
| **Frontend API Layer** | `src/lib/api.ts` | `api.analyzeGithubModule(url, realmId)` (L733)<br>`api.previewBuildModule(realmId, source)`<br>`api.previewInstallModule(...)`<br>`api.previewToggleModule(...)` |
| **Modules V2 UI View** | `src/screens/ModulesPage.tsx` | Вкладка `installed` (L2151)<br>Вкладка `catalog` (L2813)<br>Вкладка `builds` (L3431)<br>Вкладка `toolchain` (L3500)<br>Функция интейка: `runAnalysis(url)` (L1328)<br>Функция сборки: `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<String>,
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<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub license: Option<RawLicense>,
#[serde(default)]
pub topics: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawLicense {
pub spdx_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RawCatalogue {
pub global_search: bool,
pub organizations: BTreeMap<String, BTreeMap<String, Vec<RawRepoEntry>>>,
}
#[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<String>,
pub coa_status: String, // "verified", "unverified", "incompatible", "blocked"
pub coa_note: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CatalogueResponse {
pub modules: Vec<PublicModuleCard>,
pub cached_at: String,
pub is_offline: bool,
}
pub fn get_catalogue(cache_dir: &Path, force_refresh: bool) -> Result<CatalogueResponse> {
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<coa_core::catalogue::CatalogueResponse, UiError> {
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<CatalogueResponse>("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. Проверяется канонический загрузчик `Add<module_name>Scripts()`.
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<id>.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 остаются нетронутыми.*
+838
View File
@@ -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<String, BTreeMap<String, Vec<OfficialRepoRaw>>>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OfficialRepoRaw {
pub id: u64,
pub name: String,
pub full_name: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub stargazers_count: u32,
#[serde(default)]
pub created_at: Option<String>,
#[serde(default = "default_branch_val")]
pub default_branch: String,
#[serde(default)]
pub owner: Option<OfficialOwnerRaw>,
pub html_url: String,
#[serde(default)]
pub forks_count: u32,
#[serde(default)]
pub watchers_count: u32,
#[serde(default)]
pub pushed_at: Option<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub license: Option<OfficialLicenseRaw>,
#[serde(default)]
pub topics: Vec<String>,
}
fn default_branch_val() -> String {
"master".into()
}
#[derive(Debug, Clone, Deserialize)]
pub struct OfficialOwnerRaw {
pub login: String,
#[serde(default)]
pub avatar_url: Option<String>,
#[serde(default)]
pub html_url: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct OfficialLicenseRaw {
#[serde(default)]
pub spdx_id: Option<String>,
}
// ---------------------------------------------------------------------------
// CoA Overlay Models
// ---------------------------------------------------------------------------
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoaOverlayDocument {
pub version: u32,
#[serde(default)]
pub description: Option<String>,
pub rules: Vec<CoaOverlayRule>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CoaOverlayRule {
#[serde(default)]
pub repo_id: Option<u64>,
pub repo_slug: String,
pub status: CoaCompatibilityStatus,
#[serde(default)]
pub is_recommended: bool,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub tested_version: Option<String>,
#[serde(default)]
pub tested_abi: Option<String>,
}
#[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<String>,
#[serde(default)]
pub updated_at: Option<String>,
#[serde(default)]
pub license: Option<String>,
#[serde(default)]
pub topics: Vec<String>,
pub coa_status: CoaCompatibilityStatus,
pub coa_status_source: String, // "Overlay", "VerifiedCatalog", "AutoPublic"
pub conflict_reason: Option<String>,
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<String>,
pub items: Vec<PublicCatalogueItem>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PublicCatalogueResponse {
pub items: Vec<PublicCatalogueItem>,
pub cached_at: String,
pub is_offline: bool,
#[serde(default)]
pub etag: Option<String>,
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::<CoaOverlayDocument>(&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<String> {
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<PublicCatalogueItem> {
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<String, &CoaOverlayRule> = 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<PublicCatalogueResponse> {
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::<CachedDocument>(&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::<CachedDocument>(&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<String>,
overlay: &CoaOverlayDocument,
) -> Result<PublicCatalogueResponse> {
if cache_file.is_file() {
if let Ok(content) = fs::read_to_string(cache_file) {
if let Ok(cached) = serde_json::from_str::<CachedDocument>(&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");
}
}
@@ -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."
}
]
}
+1
View File
@@ -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;