mirror of
https://github.com/Corfirean/coa-server-manager.git
synced 2026-11-13 04:21:02 +00:00
feat(core): implement AzerothCore module catalogue engine, overlay and caching
This commit is contained in:
1 parent
3dbfffba76
commit
4e661e6986
4 files changed
+1448
No files matched your search
@@ -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, ®)`<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 остаются нетронутыми.*
|
||||
@@ -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."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user