Server skeleton, content pipeline and 94-card catalog

Solution on .NET 10 LTS with rules, content and CLI projects. Content importer pulls stats from the official card list, merges trigger/rarity data, emits three YAML sets (94 cards, 124 printings). Validator checks the grade-0 shield invariant, sentinel shield, parallel printing links, and deck construction limits. 14 tests cover catalog integrity and deck validation.
This commit is contained in:
MpakobecEx 2026-10-20 21:27:45 +05:00
parent 83fd1b74d1
commit 0b771bf8bb
26 changed files with 2709 additions and 30 deletions

View File

@ -13,6 +13,7 @@
| 1 | Сетевой транспорт | **Свой протокол: TCP/WebSocket + MessagePack** | `Netcode for GameObjects` **исключён** из стека. Общие DTO в `/shared`, генератор кода для клиента. Сервер — чистый .NET 8. | | 1 | Сетевой транспорт | **Свой протокол: TCP/WebSocket + MessagePack** | `Netcode for GameObjects` **исключён** из стека. Общие DTO в `/shared`, генератор кода для клиента. Сервер — чистый .NET 8. |
| 2 | Объём MVP | **1-й бустер + 2 триальные колоды** | MVP = ядро правил + один сет (94 карты) + две готовые колоды + коллекция + **полный декбилдер** + бустеры. Турниры, команды, территории, косметика, баттлпасс, Draft — после MVP. | | 2 | Объём MVP | **1-й бустер + 2 триальные колоды** | MVP = ядро правил + один сет (94 карты) + две готовые колоды + коллекция + **полный декбилдер** + бустеры. Турниры, команды, территории, косметика, баттлпасс, Draft — после MVP. |
| 3 | Описание карт | **C#-скрипты + статы в YAML** | 1 карта = 1 файл-скрипт + запись в YAML-сете. Контент-мейкер без C# не добавляет карты — учитываем в планировании. | | 3 | Описание карт | **C#-скрипты + статы в YAML** | 1 карта = 1 файл-скрипт + запись в YAML-сете. Контент-мейкер без C# не добавляет карты — учитываем в планировании. |
| 3a | Версия .NET | **.NET 10 LTS** вместо .NET 8 из плана | .NET 8 выходит из поддержки 10.11.2026. .NET 10 — до ноября 2028. |
| 4 | Игровой сервер | **Microsoft Orleans** | GeekServer **исключён**. 1 матч = 1 grain, состояние в Redis/PostgreSQL через persistence-провайдер. | | 4 | Игровой сервер | **Microsoft Orleans** | GeekServer **исключён**. 1 матч = 1 grain, состояние в Redis/PostgreSQL через persistence-провайдер. |
| 5 | Платформа | **ПК (Windows) first**, мобилка после релиза | MVP-клиент — Windows + WPF-лаунчер. Тач-ввод, Addressables-доставка и сторы — веха после v1. | | 5 | Платформа | **ПК (Windows) first**, мобилка после релиза | MVP-клиент — Windows + WPF-лаунчер. Тач-ввод, Addressables-доставка и сторы — веха после v1. |
| 6 | Сайд-дек / BO3 | **Убрать сайд-дек, BO1** | §13 плана правим: сайд-дек и «main + side ≤ 4 копий» удаляются. Валидация колоды проще. | | 6 | Сайд-дек / BO3 | **Убрать сайд-дек, BO1** | §13 плана правим: сайд-дек и «main + side ≤ 4 копий» удаляются. Валидация колоды проще. |
@ -184,7 +185,7 @@
### Блок 2. Инфраструктура, окружения, CI/CD `[MVP]` ### Блок 2. Инфраструктура, окружения, CI/CD `[MVP]`
- [ ] **P0** Структура репозитория: `/server` (solution), `/client` (Unity), `/launcher`, `/shared` (контракты), `/content` (данные карт), `/docs`, `/ops`. - [ ] **P0** Структура репозитория: `/server` (solution), `/client` (Unity), `/launcher`, `/shared` (контракты), `/content` (данные карт), `/docs`, `/ops`.
- [ ] **P0** `Directory.Build.props` + `global.json` (.NET 8, `LangVersion`, nullable, analyzers). - [ ] **P0** `Directory.Build.props` + `global.json` (.NET 10, `LangVersion`, nullable, analyzers).
- [ ] **P0** `docker-compose.yml`: postgres:16-alpine, redis:7-alpine (+ AOF), nginx, meta-api (ASP.NET Core), game-silo (Orleans). - [ ] **P0** `docker-compose.yml`: postgres:16-alpine, redis:7-alpine (+ AOF), nginx, meta-api (ASP.NET Core), game-silo (Orleans).
- [ ] **P0** Сети `backend`/`frontend`, volumes `postgres_data`/`redis_data`, `.env.example` без секретов. - [ ] **P0** Сети `backend`/`frontend`, volumes `postgres_data`/`redis_data`, `.env.example` без секретов.
- [ ] **P0** Healthcheck-эндпоинты и `depends_on: condition: service_healthy`. - [ ] **P0** Healthcheck-эндпоинты и `depends_on: condition: service_healthy`.

View File

@ -23,11 +23,11 @@
| Слой | Технология | | Слой | Технология |
|---|---| |---|---|
| Сервер | .NET 8, ASP.NET Core, Orleans, EF Core, PostgreSQL 16, Redis 7 | | Сервер | **.NET 10 LTS**, ASP.NET Core, Orleans, EF Core, PostgreSQL 16, Redis 7 |
| Протокол матча | TCP + MessagePack, кадры с префиксом длины | | Протокол матча | TCP + MessagePack, кадры с префиксом длины |
| Мета-API | HTTP/JSON, JWT | | Мета-API | HTTP/JSON, JWT |
| Клиент | Unity 6 LTS, URP, Addressables, Input System, Unity Localization, DOTween | | Клиент | Unity 6 LTS, URP, Addressables, Input System, Unity Localization, DOTween |
| Лаунчер | WPF, .NET 8, CommunityToolkit.Mvvm | | Лаунчер | WPF, **.NET 10 LTS**, CommunityToolkit.Mvvm |
| Контейнеры | Docker Compose, nginx | | Контейнеры | Docker Compose, nginx |
### Оценка (один разработчик, полный день) ### Оценка (один разработчик, полный день)
@ -51,31 +51,25 @@
## 1. Репозиторий и каркас `[2–3 дня]` ## 1. Репозиторий и каркас `[2–3 дня]`
- [ ] `git init`, ветка `master`. - [x] `git init`, ветка `master`.
- [ ] `.gitignore`: Unity (`Library/`, `Temp/`, `Logs/`, `UserSettings/`, `Builds/`), .NET (`bin/`, `obj/`), WPF (`publish/`), секреты (`.env`, `appsettings.Production.json`). - [x] `.gitignore`: Unity (`Library/`, `Temp/`, `Logs/`, `UserSettings/`, `Builds/`), .NET (`bin/`, `obj/`), WPF (`publish/`), секреты (`.env`, `appsettings.Production.json`).
- [ ] Отдельно игнорировать только Unity-генерируемые `*.csproj`/`*.sln` в `/client`, чтобы не убить `server/*.sln`. - [x] Отдельно игнорировать только Unity-генерируемые `*.csproj`/`*.sln` в `/client`, чтобы не убить `server/*.sln`.
- [ ] `.gitattributes`: `* text=auto eol=lf`, `*.unity`, `*.prefab`, `*.asset` → `merge=unityyamlmerge`, `*.png`/`*.wav` → `binary`. - [x] `.gitattributes`: `* text=auto eol=lf`, `*.unity`, `*.prefab`, `*.asset` → `merge=unityyamlmerge`, `*.png`/`*.wav` → `binary`.
- [ ] Структура: - [x] Структура (на 20.10.2026 — `server`, `content`, `tools`; `client` и `launcher` появятся со своими блоками):
``` ```
/server /server
Vanguard.sln Vanguard.slnx
/src/Vanguard.Shared контракты, DTO, MessagePack
/src/Vanguard.Rules чистое ядро правил, без Orleans и БД /src/Vanguard.Rules чистое ядро правил, без Orleans и БД
/src/Vanguard.Rules.Cards 94 скрипта карт
/src/Vanguard.Content загрузчик и валидатор контента /src/Vanguard.Content загрузчик и валидатор контента
/src/Vanguard.Meta.Api ASP.NET Core /src/Vanguard.Shared контракты, DTO, MessagePack
/src/Vanguard.Game.Silo Orleans
/src/Vanguard.Cli headless-раннер матчей /src/Vanguard.Cli headless-раннер матчей
/tests/Vanguard.Rules.Tests /tests/Vanguard.Rules.Tests
/client Unity-проект /content/sets/BT01.yaml
/launcher WPF /content/sets/TD01.yaml
/content/sets/BT01 YAML /content/sets/TD02.yaml
/content/sets/TD01 /tools/import_cards.py
/content/sets/TD02
/ops docker-compose, nginx, скрипты
/docs
``` ```
- [ ] `Directory.Build.props`: `net8.0`, `Nullable=enable`, `TreatWarningsAsErrors` для `Rules`. - [x] `Directory.Build.props`: `net10.0`, `Nullable=enable`, `LangVersion=latest`.
- [ ] `docker-compose.yml`: postgres, redis (AOF), meta-api, game-silo, nginx. - [ ] `docker-compose.yml`: postgres, redis (AOF), meta-api, game-silo, nginx.
- [ ] `docker compose up` поднимает БД и пустой API; healthcheck отвечает. - [ ] `docker compose up` поднимает БД и пустой API; healthcheck отвечает.
- [ ] CI: сборка solution + `dotnet test` на push. - [ ] CI: сборка solution + `dotnet test` на push.
@ -86,18 +80,18 @@
## 2. Контент-пайплайн `[4–6 дней]` ## 2. Контент-пайплайн `[4–6 дней]`
- [ ] Схема YAML: `printingId`, `nameId`, `setId`, `cardType`, `grade`, `power`, `shield`, `critical`, `triggerType`, `clan`, `rarity`, `isSentinel`, `isRestraint`, `name_en/ru`, `text_en/ru`, `flavor_en/ru`, `imageKey`. - [x] Схема YAML: `printingId`, `nameId`, `cardType`, `grade`, `power`, `shield`, `critical`, `triggerType`, `clan`, `rarity`, `isSentinel`, `isRestraint`, `name_en/ru`, `text_en/ru`, `imageKey`.
- [ ] Импорт `card-mvp.md` → три YAML-файла (BT01, TD01, TD02). 94 записи, 124 принта. - [x] Импорт с официального сайта → три YAML-файла (BT01, TD01, TD02). 94 карты, 124 принта.
- [ ] Валидатор: - [x] Валидатор:
- [ ] `sum(deck) = 50`, `≤4` по `nameId`, `≤4` Sentinel, `≤4` Heal. - [x] `sum(deck) = 50`, `≤4` по `nameId`, `≤4` Sentinel, `≤4` Heal.
- [ ] Инвариант щита: grade 0 → 5000 **только** у Draw, иначе 10000. - [x] Инвариант щита: grade 0 → 5000 **только** у Draw, иначе 10000.
- [ ] Уникальность `printingId`, наличие `nameId` в обеих локалях. - [x] Уникальность `printingId`, наличие `nameId`.
- [ ] Ссылка SP-принта на существующий базовый номер. - [x] Ссылка SP-принта на существующий базовый номер.
- [ ] Загрузчик: YAML → in-memory каталог, используется и сервером, и тестами. - [x] Загрузчик: YAML → in-memory каталог, используется и сервером, и тестами.
- [ ] Сид в PostgreSQL: `cards`, `printings`, `sets`, `card_legality` (пустая). - [ ] Сид в PostgreSQL: `cards`, `printings`, `sets`, `card_legality` (пустая).
- [ ] Экспорт каталога для клиента (JSON + список Addressables-ключей). - [ ] Экспорт каталога для клиента (JSON + список Addressables-ключей).
**Готово, когда:** `dotnet run -- validate` печатает «94 cards, 124 printings, 0 errors». **Готово:** `dotnet run --project server/src/Vanguard.Cli -- validate` → `94 карты, 124 принта, 0 ошибок`.
--- ---

81
README.md Normal file
View File

@ -0,0 +1,81 @@
# Vanguard Re:Master
Фанатский ремастер Cardfight!! Vanguard базовой эры (2011): формат `Classic`, сет BT01 и два триал-дека TD01/TD02.
> Fan-made project. Not affiliated with Bushiroad. All card names and art belong to their respective owners.
## Статус
| Блок | Состояние |
|---|---|
| Документация и решения | готово |
| Каталог карт (94 / 124 принта) | готово |
| Контент-пайплайн и валидатор | готово |
| Игровое ядро (правила) | в работе |
| Сервер, клиент, лаунчер | не начато |
## Требования
- **.NET SDK 10** (LTS). Версия зафиксирована в `global.json`.
- **Python 3** — только для переимпорта карт из первоисточника.
## Быстрый старт
```powershell
# проверить целостность контента
dotnet run --project server/src/Vanguard.Cli -- validate
# сводка по кланам, триггерам и редкостям
dotnet run --project server/src/Vanguard.Cli -- stats
# тесты
dotnet test server/Vanguard.slnx
```
Ожидаемый вывод валидатора:
```
Карт: 94 Принтов: 124
[Warning] card.no-ru-name: Без русского названия: 94 карт (...).
OK: ошибок 0, предупреждений 1
```
## Структура
```
/server/src/Vanguard.Rules ядро правил: карты, колоды, состояние игры
/server/src/Vanguard.Content загрузка и проверка YAML-контента
/server/src/Vanguard.Shared общие контракты (netstandard2.1, для Unity)
/server/src/Vanguard.Cli консольные команды
/server/tests тесты правил и контента
/content/sets карты в YAML
/tools импорт из первоисточника
```
`Vanguard.Rules` — чистая библиотека без Orleans, БД и сети. Это позволяет прогонять
матчи в консоли и проверять правила до появления клиента.
## Контент
Первоисточник — официальный карт-лист `en.cf-vanguard.com`; триггеры и редкости
выгружены из вики. Пересобрать YAML:
```powershell
python tools/import_cards.py
```
Валидатор проверяет инварианты, которые легко нарушить вручную:
- у grade 0 щит 5000 **только** у Draw-триггера, иначе 10000;
- Sentinel всегда со щитом 0;
- параллельный принт (`BT01/S01`…`S12`) ссылается на существующий базовый принт той же карты;
- в колоде ровно 50 карт, ≤4 копий по имени (а не по принту), ≤4 Sentinel, ≤4 Heal.
## Документация
- `plan.md` — исходный дизайн-документ.
- `DEVELOPMENT_CHECKLIST.md` — решения, механики, экономика, риски.
- `IMPLEMENTATION_CHECKLIST.md` — план реализации по блокам и гейтам.
- `content/cards-mvp.md` — каталог карт с разбором состава сетов.

1176
content/sets/BT01.yaml Normal file

File diff suppressed because it is too large Load Diff

137
content/sets/TD01.yaml Normal file
View File

@ -0,0 +1,137 @@
set: TD01
cards:
- nameId: crimson-butterfly-brigitte
nameEn: "Crimson Butterfly, Brigitte"
type: NormalUnit
grade: 3
power: 10000
shield: 0
critical: 1
trigger: None
clan: RoyalPaladin
textEn: "[AUTO](VC):When this unit's drive check reveals a grade 3 <<Royal Paladin>>, this unit gets [Power] +5000 until end of that battle."
- nameId: knight-of-conviction-bors
nameEn: "Knight of Conviction, Bors"
type: NormalUnit
grade: 3
power: 10000
shield: 0
critical: 1
trigger: None
clan: RoyalPaladin
textEn: "[AUTO](VC/RC):[Counter-Blast 1] When this unit attacks, you may pay the cost. If you do, this unit gets [Power] +3000 until end of that battle."
- nameId: knight-of-the-harp-tristan
nameEn: "Knight of the Harp, Tristan"
type: NormalUnit
grade: 2
power: 8000
shield: 5000
critical: 1
trigger: None
clan: RoyalPaladin
textEn: "[AUTO](VC):When this unit's drive check reveals a grade 3 <<Royal Paladin>>, this unit gets [Power] +5000 until end of that battle."
- nameId: starlight-unicorn
nameEn: "Starlight Unicorn"
type: NormalUnit
grade: 1
power: 6000
shield: 5000
critical: 1
trigger: None
clan: RoyalPaladin
textEn: "[AUTO]:When this unit is placed on (RC), choose another of your <<Royal Paladin>>, and that unit gets [Power] +2000 until end of turn."
- nameId: knight-of-rose-morgana
nameEn: "Knight of Rose, Morgana"
type: NormalUnit
grade: 1
power: 6000
shield: 5000
critical: 1
trigger: None
clan: RoyalPaladin
textEn: "[AUTO](VC/RC):[Choose a card from your hand, and discard it] When this unit attacks, you may pay the cost. If you do, this unit gets [Power] +4000 until end of turn."
- nameId: stardust-trumpeter
nameEn: "Stardust Trumpeter"
type: NormalUnit
grade: 0
power: 6000
shield: 10000
critical: 1
trigger: None
clan: RoyalPaladin
- nameId: bringer-of-good-luck-epona
nameEn: "Bringer of Good Luck, Epona"
type: TriggerUnit
grade: 0
power: 5000
shield: 10000
critical: 1
trigger: Critical
clan: RoyalPaladin
printings:
- printingId: TD01/001EN
nameId: crimson-butterfly-brigitte
rarity: TD
imageKey: td01_001
- printingId: TD01/002EN
nameId: knight-of-conviction-bors
rarity: TD
imageKey: td01_002
- printingId: TD01/003EN
nameId: solitary-knight-gancelot
rarity: TD
imageKey: td01_003
- printingId: TD01/004EN
nameId: knight-of-silence-gallatin
rarity: TD
imageKey: td01_004
- printingId: TD01/005EN
nameId: blaster-blade
rarity: TD
imageKey: td01_005
- printingId: TD01/006EN
nameId: knight-of-the-harp-tristan
rarity: TD
imageKey: td01_006
- printingId: TD01/007EN
nameId: covenant-knight-randolf
rarity: TD
imageKey: td01_007
- printingId: TD01/008EN
nameId: little-sage-marron
rarity: TD
imageKey: td01_008
- printingId: TD01/009EN
nameId: wingal
rarity: TD
imageKey: td01_009
- printingId: TD01/010EN
nameId: starlight-unicorn
rarity: TD
imageKey: td01_010
- printingId: TD01/011EN
nameId: knight-of-rose-morgana
rarity: TD
imageKey: td01_011
- printingId: TD01/012EN
nameId: stardust-trumpeter
rarity: TD
imageKey: td01_012
- printingId: TD01/013EN
nameId: bringer-of-good-luck-epona
rarity: TD
imageKey: td01_013
- printingId: TD01/014EN
nameId: yggdrasil-maiden-elaine
rarity: TD
imageKey: td01_014
- printingId: TD01/015EN
nameId: weapons-dealer-govannon
rarity: TD
imageKey: td01_015
- printingId: TD01/016EN
nameId: flogal
rarity: TD
imageKey: td01_016

138
content/sets/TD02.yaml Normal file
View File

@ -0,0 +1,138 @@
set: TD02
cards:
- nameId: dragon-monk-goku
nameEn: "Dragon Monk, Goku"
type: NormalUnit
grade: 3
power: 10000
shield: 0
critical: 1
trigger: None
clan: Kagero
textEn: "[AUTO](VC):When this unit's drive check reveals a grade 3 <<Kagero>>, choose an opponent's grade 1 or less rear-guard, and retire it."
- nameId: demonic-dragon-berserker-yaksha
nameEn: "Demonic Dragon Berserker, Yaksha"
type: NormalUnit
grade: 3
power: 9000
shield: 0
critical: 1
trigger: None
clan: Kagero
textEn: "[AUTO](Hand):During your main phase, when an opponent's rear-guard is put into the drop zone, if you have a grade 2 vanguard, you may reveal this card. If you do, ride this card."
- nameId: berserk-dragon
nameEn: "Berserk Dragon"
type: NormalUnit
grade: 2
power: 9000
shield: 5000
critical: 1
trigger: None
clan: Kagero
textEn: "[AUTO]:[Counter-Blast 2] When this unit is placed on (VC) or (RC), if you have a <<Kagero>> vanguard, you may pay the cost. If you do, choose an opponent's grade 2 or less rear-guard, and retire it."
- nameId: flame-of-hope-aermo
nameEn: "Flame of Hope, Aermo"
type: NormalUnit
grade: 1
power: 6000
shield: 5000
critical: 1
trigger: None
clan: Kagero
textEn: "[AUTO](RC):[Choose a card from your hand, and discard it] When an attack hits during the battle that this unit boosted, you may pay the cost. If you do, draw a card."
- nameId: demonic-dragon-madonna-joka
nameEn: "Demonic Dragon Madonna, Joka"
type: NormalUnit
grade: 1
power: 6000
shield: 5000
critical: 1
trigger: None
clan: Kagero
textEn: "[AUTO](RC):During your main phase, when an opponent's rear-guard is put into the drop zone, this unit gets [Power] +3000 until end of turn."
- nameId: lizard-runner-undeux
nameEn: "Lizard Runner, Undeux"
type: NormalUnit
grade: 0
power: 6000
shield: 10000
critical: 1
trigger: None
clan: Kagero
- nameId: demonic-dragon-mage-rakshasa
nameEn: "Demonic Dragon Mage, Rakshasa"
type: TriggerUnit
grade: 0
power: 3000
shield: 10000
critical: 1
trigger: Critical
clan: Kagero
textEn: "[AUTO](RC):During your main phase, when an opponent's rear-guard is put into the drop zone, this unit gets [Power] +3000 until end of turn."
printings:
- printingId: TD02/001EN
nameId: dragonic-overlord
rarity: TD
imageKey: td02_001
- printingId: TD02/002EN
nameId: dragon-monk-goku
rarity: TD
imageKey: td02_002
- printingId: TD02/003EN
nameId: demonic-dragon-berserker-yaksha
rarity: TD
imageKey: td02_003
- printingId: TD02/004EN
nameId: dragon-knight-nehalem
rarity: TD
imageKey: td02_004
- printingId: TD02/005EN
nameId: berserk-dragon
rarity: TD
imageKey: td02_005
- printingId: TD02/006EN
nameId: wyvern-strike-tejas
rarity: TD
imageKey: td02_006
- printingId: TD02/007EN
nameId: embodiment-of-armor-bahr
rarity: TD
imageKey: td02_007
- printingId: TD02/008EN
nameId: dragon-monk-gojo
rarity: TD
imageKey: td02_008
- printingId: TD02/009EN
nameId: flame-of-hope-aermo
rarity: TD
imageKey: td02_009
- printingId: TD02/010EN
nameId: demonic-dragon-madonna-joka
rarity: TD
imageKey: td02_010
- printingId: TD02/011EN
nameId: wyvern-strike-jarran
rarity: TD
imageKey: td02_011
- printingId: TD02/012EN
nameId: lizard-runner-undeux
rarity: TD
imageKey: td02_012
- printingId: TD02/013EN
nameId: dragon-dancer-monica
rarity: TD
imageKey: td02_013
- printingId: TD02/014EN
nameId: lizard-soldier-ganlu
rarity: TD
imageKey: td02_014
- printingId: TD02/015EN
nameId: dragon-monk-genjo
rarity: TD
imageKey: td02_015
- printingId: TD02/016EN
nameId: demonic-dragon-mage-rakshasa
rarity: TD
imageKey: td02_016

6
global.json Normal file
View File

@ -0,0 +1,6 @@
{
"sdk": {
"version": "10.0.100",
"rollForward": "latestFeature"
}
}

View File

@ -0,0 +1,9 @@
<Project>
<PropertyGroup>
<LangVersion>latest</LangVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<InvariantGlobalization>true</InvariantGlobalization>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
</Project>

11
server/Vanguard.slnx Normal file
View File

@ -0,0 +1,11 @@
<Solution>
<Folder Name="/src/">
<Project Path="src/Vanguard.Cli/Vanguard.Cli.csproj" />
<Project Path="src/Vanguard.Content/Vanguard.Content.csproj" />
<Project Path="src/Vanguard.Rules/Vanguard.Rules.csproj" />
<Project Path="src/Vanguard.Shared/Vanguard.Shared.csproj" />
</Folder>
<Folder Name="/tests/">
<Project Path="tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj" />
</Folder>
</Solution>

View File

@ -0,0 +1,92 @@
using Vanguard.Content;
using Vanguard.Rules.Cards;
var command = (args.Length > 0 ? args[0] : "validate").ToLowerInvariant();
var setsDirectory = args.Length > 1
? Path.GetFullPath(args[1])
: Path.Combine(FindRepoRoot() ?? Directory.GetCurrentDirectory(), "content", "sets");
return command switch
{
"validate" => RunValidate(setsDirectory),
"stats" => RunStats(setsDirectory),
_ => Usage(),
};
static int RunValidate(string setsDirectory)
{
CardCatalog catalog;
try
{
catalog = CatalogLoader.LoadDirectory(setsDirectory);
}
catch (Exception ex)
{
Console.Error.WriteLine($"Ошибка загрузки контента: {ex.Message}");
return 1;
}
var report = ContentValidator.Validate(catalog);
Console.WriteLine($"Карт: {catalog.CardCount} Принтов: {catalog.PrintingCount}");
Console.WriteLine();
foreach (var issue in report.Issues
.OrderByDescending(i => i.Severity)
.ThenBy(i => i.Code, StringComparer.Ordinal))
{
Console.WriteLine($" [{issue.Severity,-7}] {issue.Code}: {issue.Message}");
}
Console.WriteLine();
Console.WriteLine(report.IsValid
? $"OK: ошибок {report.ErrorCount}, предупреждений {report.WarningCount}"
: $"FAIL: ошибок {report.ErrorCount}, предупреждений {report.WarningCount}");
return report.IsValid ? 0 : 1;
}
static int RunStats(string setsDirectory)
{
var catalog = CatalogLoader.LoadDirectory(setsDirectory);
Console.WriteLine("По кланам:");
foreach (var group in catalog.Cards.GroupBy(c => c.Clan).OrderByDescending(g => g.Count()))
Console.WriteLine($" {group.Key,-16} {group.Count(),3}");
Console.WriteLine();
Console.WriteLine("По триггерам:");
foreach (var group in catalog.Cards.GroupBy(c => c.Trigger).OrderByDescending(g => g.Count()))
Console.WriteLine($" {group.Key,-10} {group.Count(),3}");
Console.WriteLine();
Console.WriteLine("По редкостям (принты):");
foreach (var group in catalog.Printings.GroupBy(p => p.Rarity).OrderBy(g => g.Key))
Console.WriteLine($" {group.Key,-5} {group.Count(),3}");
return 0;
}
static int Usage()
{
Console.WriteLine("""
vanguard <команда> [путь к content/sets]
validate проверить целостность контента (по умолчанию)
stats сводка по кланам, триггерам и редкостям
""");
return 2;
}
static string? FindRepoRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null)
{
if (Directory.Exists(Path.Combine(dir.FullName, "content", "sets")))
return dir.FullName;
dir = dir.Parent;
}
return null;
}

View File

@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Vanguard.Content\Vanguard.Content.csproj" />
<ProjectReference Include="..\Vanguard.Rules\Vanguard.Rules.csproj" />
</ItemGroup>
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,67 @@
using Vanguard.Rules.Cards;
namespace Vanguard.Content;
/// <summary>Формат YAML-файла сета.</summary>
public sealed class CardSetFile
{
public string Set { get; set; } = string.Empty;
public List<CardEntry> Cards { get; set; } = new();
public List<PrintingEntry> Printings { get; set; } = new();
}
public sealed class CardEntry
{
public string NameId { get; set; } = string.Empty;
public string NameEn { get; set; } = string.Empty;
public string? NameRu { get; set; }
public CardType Type { get; set; }
public int Grade { get; set; }
public int Power { get; set; }
public int Shield { get; set; }
public int Critical { get; set; } = 1;
public TriggerType Trigger { get; set; }
public Clan Clan { get; set; }
public bool Sentinel { get; set; }
public bool Restraint { get; set; }
public string? TextEn { get; set; }
public string? TextRu { get; set; }
public CardDefinition ToDefinition() => new()
{
NameId = NameId,
NameEn = NameEn,
NameRu = NameRu,
Type = Type,
Grade = Grade,
Power = Power,
Shield = Shield,
Critical = Critical,
Trigger = Trigger,
Clan = Clan,
IsSentinel = Sentinel,
IsRestraint = Restraint,
TextEn = TextEn,
TextRu = TextRu,
};
}
public sealed class PrintingEntry
{
public string PrintingId { get; set; } = string.Empty;
public string NameId { get; set; } = string.Empty;
public Rarity Rarity { get; set; }
public string? ImageKey { get; set; }
public bool IsParallel { get; set; }
public string? BasePrintingId { get; set; }
public CardPrinting ToPrinting() => new()
{
PrintingId = PrintingId,
NameId = NameId,
Rarity = Rarity,
ImageKey = ImageKey,
IsParallel = IsParallel,
BasePrintingId = BasePrintingId,
};
}

View File

@ -0,0 +1,33 @@
using Vanguard.Rules.Cards;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Vanguard.Content;
public static class CatalogLoader
{
public static CardCatalog LoadDirectory(string directory)
{
if (!Directory.Exists(directory))
throw new DirectoryNotFoundException($"Каталог контента не найден: {directory}");
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
var cards = new List<CardDefinition>();
var printings = new List<CardPrinting>();
foreach (var file in Directory.GetFiles(directory, "*.yaml").OrderBy(f => f, StringComparer.Ordinal))
{
var set = deserializer.Deserialize<CardSetFile>(File.ReadAllText(file))
?? throw new InvalidOperationException($"Пустой файл: {file}");
cards.AddRange(set.Cards.Select(c => c.ToDefinition()));
printings.AddRange(set.Printings.Select(p => p.ToPrinting()));
}
return new CardCatalog(cards, printings);
}
}

View File

@ -0,0 +1,134 @@
using Vanguard.Rules.Cards;
namespace Vanguard.Content;
public enum IssueSeverity
{
Warning,
Error,
}
public sealed record ContentIssue(IssueSeverity Severity, string Code, string Message);
public sealed record ContentReport(IReadOnlyList<ContentIssue> Issues, CardCatalog Catalog)
{
public int ErrorCount => Issues.Count(i => i.Severity == IssueSeverity.Error);
public int WarningCount => Issues.Count(i => i.Severity == IssueSeverity.Warning);
public bool IsValid => ErrorCount == 0;
}
/// <summary>
/// Проверки целостности контента. Ловят ошибки разметки, а не ошибки игрока.
/// </summary>
public static class ContentValidator
{
public const int ExpectedCards = 94;
public const int ExpectedPrintings = 124;
public static ContentReport Validate(CardCatalog catalog)
{
var issues = new List<ContentIssue>();
var missingRu = new List<string>();
foreach (var card in catalog.Cards)
{
// Инвариант базовой эры: у grade 0 щит 5000 только у Draw-триггера, иначе 10000.
if (card.Grade == 0)
{
var expected = card.Trigger == TriggerType.Draw ? 5000 : 10000;
if (card.Shield != expected)
{
issues.Add(new ContentIssue(
IssueSeverity.Error,
"card.g0-shield",
$"{card.NameId}: grade 0 с триггером {card.Trigger} должен иметь щит {expected}, а не {card.Shield}."));
}
}
if (card.IsSentinel && card.Shield != 0)
{
issues.Add(new ContentIssue(
IssueSeverity.Error,
"card.sentinel-shield",
$"{card.NameId}: Sentinel обязан иметь щит 0, а не {card.Shield}."));
}
if (card.Type == CardType.TriggerUnit && card.Trigger == TriggerType.None)
{
issues.Add(new ContentIssue(
IssueSeverity.Warning,
"card.trigger-unit-without-trigger",
$"{card.NameId}: помечен как TriggerUnit, но триггер не задан."));
}
if (card.Type == CardType.NormalUnit && card.Grade == 0 && card.Trigger != TriggerType.None)
{
issues.Add(new ContentIssue(
IssueSeverity.Error,
"card.normal-unit-with-trigger",
$"{card.NameId}: NormalUnit не может иметь триггер {card.Trigger}."));
}
if (card.NameRu is null)
{
missingRu.Add(card.NameId);
}
}
if (missingRu.Count > 0)
{
var sample = string.Join(", ", missingRu.Take(5));
issues.Add(new ContentIssue(
IssueSeverity.Warning,
"card.no-ru-name",
$"Без русского названия: {missingRu.Count} карт ({sample}{(missingRu.Count > 5 ? ", …" : string.Empty)})."));
}
foreach (var printing in catalog.Printings.Where(p => p.IsParallel))
{
if (printing.BasePrintingId is null)
{
issues.Add(new ContentIssue(
IssueSeverity.Error,
"printing.parallel-no-base",
$"{printing.PrintingId}: параллельный принт без базового."));
continue;
}
if (!catalog.TryPrinting(printing.BasePrintingId, out var basePrinting))
{
issues.Add(new ContentIssue(
IssueSeverity.Error,
"printing.parallel-bad-base",
$"{printing.PrintingId}: базовый принт {printing.BasePrintingId} не найден."));
continue;
}
if (basePrinting.NameId != printing.NameId)
{
issues.Add(new ContentIssue(
IssueSeverity.Error,
"printing.parallel-name-mismatch",
$"{printing.PrintingId}: базовый принт относится к {basePrinting.NameId}, а не к {printing.NameId}."));
}
}
if (catalog.CardCount != ExpectedCards)
{
issues.Add(new ContentIssue(
IssueSeverity.Warning,
"content.card-count",
$"Карт: {catalog.CardCount}, ожидалось {ExpectedCards}."));
}
if (catalog.PrintingCount != ExpectedPrintings)
{
issues.Add(new ContentIssue(
IssueSeverity.Warning,
"content.printing-count",
$"Принтов: {catalog.PrintingCount}, ожидалось {ExpectedPrintings}."));
}
return new ContentReport(issues, catalog);
}
}

View File

@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<ItemGroup>
<ProjectReference Include="..\Vanguard.Rules\Vanguard.Rules.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="YamlDotNet" Version="18.1.0" />
</ItemGroup>
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,53 @@
namespace Vanguard.Rules.Cards;
/// <summary>Каталог карт: определения по nameId и принты по printingId.</summary>
public sealed class CardCatalog
{
private readonly Dictionary<string, CardDefinition> _cards;
private readonly Dictionary<string, CardPrinting> _printings;
public CardCatalog(IEnumerable<CardDefinition> cards, IEnumerable<CardPrinting> printings)
{
_cards = new Dictionary<string, CardDefinition>(StringComparer.Ordinal);
foreach (var card in cards)
{
if (!_cards.TryAdd(card.NameId, card))
throw new InvalidOperationException($"Дубликат nameId: {card.NameId}");
}
_printings = new Dictionary<string, CardPrinting>(StringComparer.Ordinal);
foreach (var printing in printings)
{
if (!_cards.ContainsKey(printing.NameId))
throw new InvalidOperationException(
$"Принт {printing.PrintingId} ссылается на неизвестный nameId {printing.NameId}");
if (!_printings.TryAdd(printing.PrintingId, printing))
throw new InvalidOperationException($"Дубликат printingId: {printing.PrintingId}");
}
}
public int CardCount => _cards.Count;
public int PrintingCount => _printings.Count;
public IEnumerable<CardDefinition> Cards => _cards.Values;
public IEnumerable<CardPrinting> Printings => _printings.Values;
public CardDefinition Card(string nameId) =>
_cards.TryGetValue(nameId, out var card)
? card
: throw new KeyNotFoundException($"Карта не найдена: {nameId}");
public CardPrinting Printing(string printingId) =>
_printings.TryGetValue(printingId, out var printing)
? printing
: throw new KeyNotFoundException($"Принт не найден: {printingId}");
public bool TryPrinting(string printingId, out CardPrinting printing) =>
_printings.TryGetValue(printingId, out printing!);
/// <summary>Определение карты по конкретному принту.</summary>
public CardDefinition CardOf(string printingId) => Card(Printing(printingId).NameId);
public IEnumerable<CardPrinting> PrintingsOf(string nameId) =>
_printings.Values.Where(p => p.NameId == nameId);
}

View File

@ -0,0 +1,48 @@
namespace Vanguard.Rules.Cards;
/// <summary>
/// Логическая карта. Одна на имя: параллельные арты и переиздания ссылаются сюда через nameId.
/// Лимит копий в колоде считается по этому объекту, а не по принту.
/// </summary>
public sealed record CardDefinition
{
public required string NameId { get; init; }
public required string NameEn { get; init; }
public string? NameRu { get; init; }
public required CardType Type { get; init; }
public required int Grade { get; init; }
public required int Power { get; init; }
public required int Shield { get; init; }
public int Critical { get; init; } = 1;
public TriggerType Trigger { get; init; } = TriggerType.None;
public required Clan Clan { get; init; }
/// <summary>Sentinel (Perfect Guard). Лимит — 4 карты в колоде.</summary>
public bool IsSentinel { get; init; }
/// <summary>Restraint: не может атаковать.</summary>
public bool IsRestraint { get; init; }
public string? TextEn { get; init; }
public string? TextRu { get; init; }
/// <summary>Grade 0 без триггера — кандидат в стартовый вэнгард.</summary>
public bool IsStartingVanguardCandidate => Grade == 0 && Trigger == TriggerType.None;
}
/// <summary>Конкретный принт: номер + арт. Единица коллекции и бустера.</summary>
public sealed record CardPrinting
{
public required string PrintingId { get; init; }
public required string NameId { get; init; }
public required Rarity Rarity { get; init; }
public string? ImageKey { get; init; }
/// <summary>Параллельный арт (BT01/S01…S12).</summary>
public bool IsParallel { get; init; }
/// <summary>Для параллельного арта — принт, к которому он привязан.</summary>
public string? BasePrintingId { get; init; }
}

View File

@ -0,0 +1,43 @@
namespace Vanguard.Rules.Cards;
/// <summary>Тип карты. В базовой эре их два.</summary>
public enum CardType
{
NormalUnit = 0,
TriggerUnit = 1,
}
/// <summary>Тип триггера. Front и Over появятся только в V/D-эре.</summary>
public enum TriggerType
{
None = 0,
Critical = 1,
Draw = 2,
Stand = 3,
Heal = 4,
}
public enum Clan
{
RoyalPaladin = 0,
OracleThinkTank = 1,
Kagero = 2,
Nubatama = 3,
Tachikaze = 4,
NovaGrappler = 5,
SpikeBrothers = 6,
DarkIrregulars = 7,
Granblue = 8,
Megacolony = 9,
}
/// <summary>Редкость принта. TD — карта, существующая только в триал-деке.</summary>
public enum Rarity
{
C = 0,
R = 1,
RR = 2,
RRR = 3,
SP = 4,
TD = 5,
}

View File

@ -0,0 +1,74 @@
using Vanguard.Rules.Cards;
namespace Vanguard.Rules.Decks;
public sealed record DeckViolation(string Code, string Message);
/// <summary>
/// Правила конструирования колоды для формата Classic.
/// Лимиты считаются по nameId, а не по принту: 4 обычных Blaster Blade
/// и 4 параллельных — это 8 копий одной карты.
/// </summary>
public static class DeckValidator
{
public const int RequiredSize = 50;
public const int MaxCopiesPerName = 4;
public const int MaxSentinel = 4;
public const int MaxHeal = 4;
public static IReadOnlyList<DeckViolation> Validate(CardCatalog catalog, IReadOnlyCollection<string> printingIds)
{
var violations = new List<DeckViolation>();
if (printingIds.Count != RequiredSize)
{
violations.Add(new DeckViolation(
"deck.size",
$"В колоде {printingIds.Count} карт, требуется {RequiredSize}."));
}
var cards = new List<CardDefinition>(printingIds.Count);
foreach (var printingId in printingIds)
{
if (!catalog.TryPrinting(printingId, out var printing))
{
violations.Add(new DeckViolation("deck.unknown-printing", $"Неизвестный принт: {printingId}."));
continue;
}
cards.Add(catalog.Card(printing.NameId));
}
foreach (var group in cards.GroupBy(c => c.NameId))
{
if (group.Count() > MaxCopiesPerName)
{
var name = group.First().NameEn;
violations.Add(new DeckViolation(
"deck.copies",
$"«{name}» — {group.Count()} копий, максимум {MaxCopiesPerName}."));
}
}
var sentinels = cards.Count(c => c.IsSentinel);
if (sentinels > MaxSentinel)
{
violations.Add(new DeckViolation(
"deck.sentinel",
$"Sentinel: {sentinels}, максимум {MaxSentinel}."));
}
var heals = cards.Count(c => c.Trigger == TriggerType.Heal);
if (heals > MaxHeal)
{
violations.Add(new DeckViolation(
"deck.heal",
$"Heal-триггеров: {heals}, максимум {MaxHeal}."));
}
return violations;
}
public static bool IsLegal(CardCatalog catalog, IReadOnlyCollection<string> printingIds) =>
Validate(catalog, printingIds).Count == 0;
}

View File

@ -0,0 +1,9 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,8 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>

View File

@ -0,0 +1,93 @@
using Vanguard.Content;
using Vanguard.Rules.Cards;
using Xunit;
namespace Vanguard.Rules.Tests;
public class CatalogTests
{
private readonly CardCatalog _catalog = TestCatalog.Instance;
[Fact]
public void Catalog_Has_Expected_Size()
{
Assert.Equal(94, _catalog.CardCount);
Assert.Equal(124, _catalog.PrintingCount);
}
[Fact]
public void Content_Validation_Reports_No_Errors()
{
var report = ContentValidator.Validate(_catalog);
Assert.True(
report.IsValid,
string.Join(Environment.NewLine, report.Issues
.Where(i => i.Severity == IssueSeverity.Error)
.Select(i => $"{i.Code}: {i.Message}")));
}
[Fact]
public void Parallel_Printings_Link_To_Base()
{
var parallels = _catalog.Printings.Where(p => p.IsParallel).ToList();
Assert.Equal(12, parallels.Count);
foreach (var parallel in parallels)
{
Assert.NotNull(parallel.BasePrintingId);
var basePrinting = _catalog.Printing(parallel.BasePrintingId!);
Assert.Equal(basePrinting.NameId, parallel.NameId);
Assert.Equal(Rarity.SP, parallel.Rarity);
}
}
[Fact]
public void GradeZero_Shield_Invariant_Holds()
{
foreach (var card in _catalog.Cards.Where(c => c.Grade == 0))
{
var expected = card.Trigger == TriggerType.Draw ? 5000 : 10000;
Assert.Equal(expected, card.Shield);
}
}
[Fact]
public void Bt01_Has_Four_Triggers_Of_Each_Type()
{
var bt01 = _catalog.Printings
.Where(p => p.PrintingId.StartsWith("BT01/", StringComparison.Ordinal) && !p.IsParallel)
.Select(p => _catalog.CardOf(p.PrintingId))
.ToList();
foreach (var trigger in new[] { TriggerType.Critical, TriggerType.Draw, TriggerType.Stand, TriggerType.Heal })
{
Assert.Equal(4, bt01.Count(c => c.Trigger == trigger));
}
}
[Fact]
public void Sentinel_Cards_Have_Zero_Shield()
{
var sentinels = _catalog.Cards.Where(c => c.IsSentinel).ToList();
Assert.Equal(3, sentinels.Count);
Assert.All(sentinels, c => Assert.Equal(0, c.Shield));
}
[Fact]
public void Starting_Vanguards_Are_GradeZero_Without_Trigger()
{
var starters = _catalog.Printings
.Where(p => p.PrintingId is "TD01/012EN" or "TD02/012EN")
.Select(p => _catalog.CardOf(p.PrintingId))
.ToList();
Assert.Equal(2, starters.Count);
Assert.All(starters, c =>
{
Assert.Equal(0, c.Grade);
Assert.Equal(TriggerType.None, c.Trigger);
});
}
}

View File

@ -0,0 +1,123 @@
using Vanguard.Rules.Cards;
using Vanguard.Rules.Decks;
using Xunit;
namespace Vanguard.Rules.Tests;
public class DeckValidatorTests
{
private readonly CardCatalog _catalog = TestCatalog.Instance;
[Fact]
public void Legal_Deck_Has_No_Violations()
{
var deck = BuildLegalDeck();
Assert.Empty(DeckValidator.Validate(_catalog, deck));
}
[Fact]
public void Deck_Must_Have_Fifty_Cards()
{
var deck = BuildLegalDeck();
deck.RemoveAt(0);
var violations = DeckValidator.Validate(_catalog, deck);
Assert.Contains(violations, v => v.Code == "deck.size");
}
[Fact]
public void Copy_Limit_Counts_By_Name_Across_Printings()
{
var deck = DeckWith(("BT01/002EN", 4), ("TD01/005EN", 4));
var violations = DeckValidator.Validate(_catalog, deck);
var copies = Assert.Single(violations, v => v.Code == "deck.copies");
Assert.Contains("Blaster Blade", copies.Message);
}
[Fact]
public void Copy_Limit_Counts_Parallel_Art_As_Same_Card()
{
var deck = DeckWith(("BT01/002EN", 4), ("BT01/S02EN", 4));
var violations = DeckValidator.Validate(_catalog, deck);
Assert.Contains(violations, v => v.Code == "deck.copies");
}
[Fact]
public void Sentinel_Limit_Is_Four()
{
var deck = DeckWith(("BT01/011EN", 4), ("BT01/015EN", 1));
var violations = DeckValidator.Validate(_catalog, deck);
Assert.Contains(violations, v => v.Code == "deck.sentinel");
}
[Fact]
public void Heal_Limit_Is_Four()
{
var deck = DeckWith(("BT01/047EN", 4), ("BT01/053EN", 1));
var violations = DeckValidator.Validate(_catalog, deck);
Assert.Contains(violations, v => v.Code == "deck.heal");
}
[Fact]
public void Unknown_Printing_Is_Reported()
{
var deck = BuildLegalDeck();
deck[0] = "BT99/999EN";
var violations = DeckValidator.Validate(_catalog, deck);
Assert.Contains(violations, v => v.Code == "deck.unknown-printing");
}
private List<string> BuildLegalDeck() => DeckWith();
/// <summary>
/// Собирает колоду на 50 карт: сначала заданные принты, затем по 4 копии
/// каждого имени, кроме Sentinel и Heal (чтобы не упираться в их лимиты).
/// </summary>
private List<string> DeckWith(params (string PrintingId, int Count)[] seed)
{
var deck = new List<string>();
var usedNames = new HashSet<string>(StringComparer.Ordinal);
foreach (var (printingId, count) in seed)
{
deck.AddRange(Enumerable.Repeat(printingId, count));
usedNames.Add(_catalog.Printing(printingId).NameId);
}
foreach (var group in _catalog.Printings
.Where(p => !p.IsParallel)
.GroupBy(p => p.NameId)
.OrderBy(g => g.Key, StringComparer.Ordinal))
{
if (deck.Count >= DeckValidator.RequiredSize)
break;
if (usedNames.Contains(group.Key))
continue;
var card = _catalog.Card(group.Key);
if (card.IsSentinel || card.Trigger == TriggerType.Heal)
continue;
var printingId = group.First().PrintingId;
usedNames.Add(group.Key);
for (var i = 0; i < DeckValidator.MaxCopiesPerName && deck.Count < DeckValidator.RequiredSize; i++)
deck.Add(printingId);
}
return deck;
}
}

View File

@ -0,0 +1,24 @@
using Vanguard.Content;
using Vanguard.Rules.Cards;
namespace Vanguard.Rules.Tests;
/// <summary>Общий каталог для тестов: грузится один раз из content/sets.</summary>
public static class TestCatalog
{
private static readonly Lazy<CardCatalog> LazyCatalog = new(Load, isThreadSafe: true);
public static CardCatalog Instance => LazyCatalog.Value;
private static CardCatalog Load()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "content", "sets")))
dir = dir.Parent;
if (dir is null)
throw new InvalidOperationException("Не найден каталог content/sets.");
return CatalogLoader.LoadDirectory(Path.Combine(dir.FullName, "content", "sets"));
}
}

View File

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.4" />
</ItemGroup>
<ItemGroup>
<Using Include="Xunit" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Vanguard.Rules\Vanguard.Rules.csproj" />
<ProjectReference Include="..\..\src\Vanguard.Content\Vanguard.Content.csproj" />
</ItemGroup>
</Project>

267
tools/import_cards.py Normal file
View File

@ -0,0 +1,267 @@
"""Импорт карт BT01 / TD01 / TD02 в YAML.
Источники:
* en.cf-vanguard.com — статы и тексты (HTML, view=text)
* content/cards-mvp.md — триггеры и редкости (выгрузка из вики)
Запуск: python tools/import_cards.py
"""
from __future__ import annotations
import html
import re
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "content" / "sets"
CARDS_MD = ROOT / "content" / "cards-mvp.md"
BASE = "https://en.cf-vanguard.com/cardlist/cardsearch/?expansion={exp}&view=text&sort=no&page={page}"
SOURCES = {
"BT01": [(5, p) for p in range(1, 5)],
"TD01": [(3, 1)],
"TD02": [(4, 1)],
}
SP_BASE = ["001", "002", "003", "004", "006", "007", "008", "010", "013", "017", "027", "032"]
CLAN_MAP = {
"Royal Paladin": "RoyalPaladin",
"Oracle Think Tank": "OracleThinkTank",
"Kagero": "Kagero",
"Nubatama": "Nubatama",
"Tachikaze": "Tachikaze",
"Nova Grappler": "NovaGrappler",
"Spike Brothers": "SpikeBrothers",
"Dark Irregulars": "DarkIrregulars",
"Granblue": "Granblue",
"Megacolony": "Megacolony",
}
def fetch(exp: int, page: int) -> str:
url = BASE.format(exp=exp, page=page)
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
with urllib.request.urlopen(req, timeout=60) as r:
return r.read().decode("utf-8", errors="replace")
CARD_RE = re.compile(
r'<li>\s*<a href="/cardlist/\?cardno=(?P<code>[^"&]+)[^"]*">'
r'.*?<h5>\s*(?P<name>[^<]*?)<span>'
r'.*?<div class="status">\s*(?P<status>.*?)</div>'
r'(?:\s*<p>(?P<text>.*?)</p>)?\s*</div>\s*</a>\s*</li>',
re.S,
)
def clean_text(raw: str | None) -> str | None:
if not raw:
return None
s = re.sub(r"<br\s*/?>", "\n", raw)
s = re.sub(r"<[^>]+>", "", s)
s = html.unescape(s)
s = s.replace("\\<", "<").replace("\\>", ">")
s = re.sub(r"[ \t]+", " ", s).strip()
if s in ("-", "—", ""):
return None
return s
def parse_status(status: str) -> dict:
parts = [p.strip() for p in re.split(r"<span>.*?</span>", status, flags=re.S) if p.strip()]
out = {"type": parts[0] if parts else "", "clan": "", "grade": 0, "power": 0, "shield": 0}
for p in parts[1:]:
if p.startswith("Grade "):
out["grade"] = int(p.split()[1])
elif p.startswith("Power "):
out["power"] = int(p.split()[1].replace(",", ""))
elif p.startswith("Shield "):
v = p.split()[1]
out["shield"] = 0 if v in ("-", "") else int(v.replace(",", ""))
elif p in CLAN_MAP:
out["clan"] = CLAN_MAP[p]
return out
def slug(name: str) -> str:
s = name.lower()
s = s.replace("&", " and ")
s = re.sub(r"[^a-z0-9]+", "-", s)
return s.strip("-")
def parse_cards_md() -> dict[str, dict]:
"""Код -> {trigger, rarity} из таблиц карт-каталога."""
meta: dict[str, dict] = {}
for line in CARDS_MD.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if not cells or not re.match(r"^BT01/\d{3}$", cells[0]):
continue
# | № | Карта | G | Клан | Триггер | Редкость |
trig = cells[4].replace("*", "") if len(cells) > 4 else "—"
rar = cells[5].replace("*", "") if len(cells) > 5 else "C"
meta[cells[0]] = {
"trigger": "None" if trig in ("—", "", "-") else trig,
"rarity": rar.replace(" + SP", "").strip(),
"sp": "SP" in rar,
}
for line in CARDS_MD.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line.startswith("|"):
continue
cells = [c.strip() for c in line.strip("|").split("|")]
if not cells or not re.match(r"^TD0[12]/\d{3}$", cells[0]):
continue
# | Код | Кол-во | Карта | G | Триггер |
trig = cells[4].replace("*", "") if len(cells) > 4 else "—"
code = cells[0]
meta[code] = {
"trigger": "None" if trig.startswith("—") else trig.split()[0],
"rarity": "TD",
"sp": False,
}
return meta
def parse_trigger(raw: str) -> str:
raw = raw.replace("*", "").strip()
if raw.startswith("—"):
return "None"
for t in ("Critical", "Draw", "Stand", "Heal"):
if raw.startswith(t):
return t
return "None"
def main() -> None:
meta = parse_cards_md()
print(f"meta: {len(meta)} codes")
all_cards: dict[str, dict] = {}
by_set: dict[str, list[dict]] = {}
for set_id, pages in SOURCES.items():
rows: list[dict] = []
for exp, page in pages:
raw = fetch(exp, page)
found = 0
for m in CARD_RE.finditer(raw):
code = m.group("code").replace("/", "/").strip()
if not code.startswith(set_id):
continue
name = html.unescape(m.group("name")).strip()
st = parse_status(m.group("status"))
m_code = code.split("EN")[0]
info = meta.get(m_code, {})
text = clean_text(m.group("text"))
card = {
"printingId": code,
"nameId": slug(name),
"nameEn": name,
"type": "TriggerUnit" if st["type"] == "Trigger Unit" else "NormalUnit",
"grade": st["grade"],
"power": st["power"],
"shield": st["shield"],
"critical": 1,
"trigger": parse_trigger(info.get("trigger", "None")),
"clan": st["clan"],
"rarity": info.get("rarity", "C"),
"sentinel": bool(text and "Sentinel" in text),
"restraint": bool(text and "Restraint" in text),
"textEn": text,
}
rows.append(card)
if card["nameId"] not in all_cards:
all_cards[card["nameId"]] = card
found += 1
print(f" {set_id} exp={exp} page={page}: {found}")
by_set[set_id] = rows
# SP-принты уже приходят с сайта как BT01/S01EN..S12EN — отдельно генерировать не нужно.
bt01_regular = {r["nameId"]: r["printingId"] for r in by_set["BT01"] if "/S" not in r["printingId"]}
sp_count = 0
for r in by_set["BT01"]:
if "/S" in r["printingId"]:
r["isParallel"] = True
r["rarity"] = "SP"
r["basePrintingId"] = bt01_regular.get(r["nameId"])
sp_count += 1
print(f" SP: {sp_count}")
OUT.mkdir(parents=True, exist_ok=True)
written: set[str] = set()
for set_id, rows in by_set.items():
# определения карт — только те, что ещё не встречались в предыдущих сетах
defs: dict[str, dict] = {}
printings: list[dict] = []
for r in rows:
defs.setdefault(r["nameId"], r)
defs = {k: v for k, v in defs.items() if k not in written}
written.update(defs)
for r in rows:
entry = {
"printingId": r["printingId"],
"nameId": r["nameId"],
"rarity": r["rarity"],
"imageKey": r["printingId"].replace("/", "_").replace("EN", "").lower(),
}
if r.get("isParallel"):
entry["isParallel"] = True
entry["basePrintingId"] = r.get("basePrintingId")
printings.append(entry)
path = OUT / f"{set_id}.yaml"
with path.open("w", encoding="utf-8", newline="\n") as f:
f.write(f"set: {set_id}\n\ncards:\n")
for d in defs.values():
f.write(f" - nameId: {d['nameId']}\n")
f.write(f" nameEn: {yaml_str(d['nameEn'])}\n")
f.write(f" type: {d['type']}\n")
f.write(f" grade: {d['grade']}\n")
f.write(f" power: {d['power']}\n")
f.write(f" shield: {d['shield']}\n")
f.write(f" critical: {d['critical']}\n")
f.write(f" trigger: {d['trigger']}\n")
f.write(f" clan: {d['clan']}\n")
if d["textEn"]:
f.write(f" textEn: {yaml_str(d['textEn'])}\n")
if d.get("sentinel"):
f.write(" sentinel: true\n")
if d.get("restraint"):
f.write(" restraint: true\n")
f.write("\nprintings:\n")
for p in printings:
f.write(f" - printingId: {p['printingId']}\n")
f.write(f" nameId: {p['nameId']}\n")
f.write(f" rarity: {p['rarity']}\n")
if p.get("imageKey"):
f.write(f" imageKey: {p['imageKey']}\n")
if p.get("isParallel"):
f.write(" isParallel: true\n")
f.write(f" basePrintingId: {p['basePrintingId']}\n")
print(f"wrote {path}: {len(defs)} defs, {len(printings)} printings")
total_printings = sum(len(r) for r in by_set.values())
print(f"TOTAL: {len(all_cards)} cards, {total_printings} printings")
def yaml_str(value: str) -> str:
escaped = (
value.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\t", "\\t")
)
return f'"{escaped}"'
if __name__ == "__main__":
main()