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:
parent
83fd1b74d1
commit
0b771bf8bb
|
|
@ -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`.
|
||||||
|
|
|
||||||
|
|
@ -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 ошибок`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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` — каталог карт с разбором состава сетов.
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.100",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<LangVersion>latest</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<InvariantGlobalization>true</InvariantGlobalization>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
@ -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; }
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -0,0 +1,8 @@
|
||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>netstandard2.1</TargetFramework>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
</Project>
|
||||||
|
|
@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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()
|
||||||
Loading…
Reference in New Issue