diff --git a/DEVELOPMENT_CHECKLIST.md b/DEVELOPMENT_CHECKLIST.md index bba87f9..8c74b69 100644 --- a/DEVELOPMENT_CHECKLIST.md +++ b/DEVELOPMENT_CHECKLIST.md @@ -13,6 +13,7 @@ | 1 | Сетевой транспорт | **Свой протокол: TCP/WebSocket + MessagePack** | `Netcode for GameObjects` **исключён** из стека. Общие DTO в `/shared`, генератор кода для клиента. Сервер — чистый .NET 8. | | 2 | Объём MVP | **1-й бустер + 2 триальные колоды** | MVP = ядро правил + один сет (94 карты) + две готовые колоды + коллекция + **полный декбилдер** + бустеры. Турниры, команды, территории, косметика, баттлпасс, Draft — после MVP. | | 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-провайдер. | | 5 | Платформа | **ПК (Windows) first**, мобилка после релиза | MVP-клиент — Windows + WPF-лаунчер. Тач-ввод, Addressables-доставка и сторы — веха после v1. | | 6 | Сайд-дек / BO3 | **Убрать сайд-дек, BO1** | §13 плана правим: сайд-дек и «main + side ≤ 4 копий» удаляются. Валидация колоды проще. | @@ -184,7 +185,7 @@ ### Блок 2. Инфраструктура, окружения, CI/CD `[MVP]` - [ ] **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** Сети `backend`/`frontend`, volumes `postgres_data`/`redis_data`, `.env.example` без секретов. - [ ] **P0** Healthcheck-эндпоинты и `depends_on: condition: service_healthy`. diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index e1c8074..c91c1af 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -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, кадры с префиксом длины | | Мета-API | HTTP/JSON, JWT | | Клиент | Unity 6 LTS, URP, Addressables, Input System, Unity Localization, DOTween | -| Лаунчер | WPF, .NET 8, CommunityToolkit.Mvvm | +| Лаунчер | WPF, **.NET 10 LTS**, CommunityToolkit.Mvvm | | Контейнеры | Docker Compose, nginx | ### Оценка (один разработчик, полный день) @@ -51,31 +51,25 @@ ## 1. Репозиторий и каркас `[2–3 дня]` -- [ ] `git init`, ветка `master`. -- [ ] `.gitignore`: Unity (`Library/`, `Temp/`, `Logs/`, `UserSettings/`, `Builds/`), .NET (`bin/`, `obj/`), WPF (`publish/`), секреты (`.env`, `appsettings.Production.json`). -- [ ] Отдельно игнорировать только Unity-генерируемые `*.csproj`/`*.sln` в `/client`, чтобы не убить `server/*.sln`. -- [ ] `.gitattributes`: `* text=auto eol=lf`, `*.unity`, `*.prefab`, `*.asset` → `merge=unityyamlmerge`, `*.png`/`*.wav` → `binary`. -- [ ] Структура: +- [x] `git init`, ветка `master`. +- [x] `.gitignore`: Unity (`Library/`, `Temp/`, `Logs/`, `UserSettings/`, `Builds/`), .NET (`bin/`, `obj/`), WPF (`publish/`), секреты (`.env`, `appsettings.Production.json`). +- [x] Отдельно игнорировать только Unity-генерируемые `*.csproj`/`*.sln` в `/client`, чтобы не убить `server/*.sln`. +- [x] `.gitattributes`: `* text=auto eol=lf`, `*.unity`, `*.prefab`, `*.asset` → `merge=unityyamlmerge`, `*.png`/`*.wav` → `binary`. +- [x] Структура (на 20.10.2026 — `server`, `content`, `tools`; `client` и `launcher` появятся со своими блоками): ``` /server - Vanguard.sln - /src/Vanguard.Shared контракты, DTO, MessagePack + Vanguard.slnx /src/Vanguard.Rules чистое ядро правил, без Orleans и БД - /src/Vanguard.Rules.Cards 94 скрипта карт /src/Vanguard.Content загрузчик и валидатор контента - /src/Vanguard.Meta.Api ASP.NET Core - /src/Vanguard.Game.Silo Orleans + /src/Vanguard.Shared контракты, DTO, MessagePack /src/Vanguard.Cli headless-раннер матчей /tests/Vanguard.Rules.Tests - /client Unity-проект - /launcher WPF - /content/sets/BT01 YAML - /content/sets/TD01 - /content/sets/TD02 - /ops docker-compose, nginx, скрипты - /docs + /content/sets/BT01.yaml + /content/sets/TD01.yaml + /content/sets/TD02.yaml + /tools/import_cards.py ``` -- [ ] `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 up` поднимает БД и пустой API; healthcheck отвечает. - [ ] CI: сборка solution + `dotnet test` на push. @@ -86,18 +80,18 @@ ## 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`. -- [ ] Импорт `card-mvp.md` → три YAML-файла (BT01, TD01, TD02). 94 записи, 124 принта. -- [ ] Валидатор: - - [ ] `sum(deck) = 50`, `≤4` по `nameId`, `≤4` Sentinel, `≤4` Heal. - - [ ] Инвариант щита: grade 0 → 5000 **только** у Draw, иначе 10000. - - [ ] Уникальность `printingId`, наличие `nameId` в обеих локалях. - - [ ] Ссылка SP-принта на существующий базовый номер. -- [ ] Загрузчик: YAML → in-memory каталог, используется и сервером, и тестами. +- [x] Схема YAML: `printingId`, `nameId`, `cardType`, `grade`, `power`, `shield`, `critical`, `triggerType`, `clan`, `rarity`, `isSentinel`, `isRestraint`, `name_en/ru`, `text_en/ru`, `imageKey`. +- [x] Импорт с официального сайта → три YAML-файла (BT01, TD01, TD02). 94 карты, 124 принта. +- [x] Валидатор: + - [x] `sum(deck) = 50`, `≤4` по `nameId`, `≤4` Sentinel, `≤4` Heal. + - [x] Инвариант щита: grade 0 → 5000 **только** у Draw, иначе 10000. + - [x] Уникальность `printingId`, наличие `nameId`. + - [x] Ссылка SP-принта на существующий базовый номер. +- [x] Загрузчик: YAML → in-memory каталог, используется и сервером, и тестами. - [ ] Сид в PostgreSQL: `cards`, `printings`, `sets`, `card_legality` (пустая). - [ ] Экспорт каталога для клиента (JSON + список Addressables-ключей). -**Готово, когда:** `dotnet run -- validate` печатает «94 cards, 124 printings, 0 errors». +**Готово:** `dotnet run --project server/src/Vanguard.Cli -- validate` → `94 карты, 124 принта, 0 ошибок`. --- diff --git a/README.md b/README.md new file mode 100644 index 0000000..ceafdd0 --- /dev/null +++ b/README.md @@ -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` — каталог карт с разбором состава сетов. diff --git a/content/sets/BT01.yaml b/content/sets/BT01.yaml new file mode 100644 index 0000000..b3e82c6 --- /dev/null +++ b/content/sets/BT01.yaml @@ -0,0 +1,1176 @@ +set: BT01 + +cards: + - nameId: king-of-knights-alfred + nameEn: "King of Knights, Alfred" + type: NormalUnit + grade: 3 + power: 10000 + shield: 0 + critical: 1 + trigger: None + clan: RoyalPaladin + textEn: "[CONT](VC):Your units cannot boost this unit.\n[CONT](VC):During your turn, this unit gets [Power] +2000 for each of your rear-guards.\n[ACT](VC/RC):[Counter-Blast 3] Search your deck for up to one grade 2 or less , call it to (RC), and shuffle your deck." + - nameId: blaster-blade + nameEn: "Blaster Blade" + type: NormalUnit + grade: 2 + power: 9000 + shield: 5000 + critical: 1 + trigger: None + clan: RoyalPaladin + textEn: "[AUTO]:[Counter-Blast 2] When this unit is placed on (VC), you may pay the cost. If you do, choose an opponent's rear-guard, and retire it.\n[AUTO]:[Counter-Blast 2] When this unit is placed on (RC), if you have a > vanguard, you may pay the cost. If you do, choose an opponent's grade 2 or greater rear-guard, and retire it." + - nameId: barcgal + nameEn: "Barcgal" + type: NormalUnit + grade: 0 + power: 4000 + shield: 10000 + critical: 1 + trigger: None + clan: RoyalPaladin + textEn: "[AUTO]:When another rides this unit, you may call this card to (RC).\n[ACT](RC):[Rest this unit] Search your deck for up to one card named \"Future Knight, Llew\" or \"Flogal\", call it to (RC), and shuffle your deck." + - nameId: dragonic-overlord + nameEn: "Dragonic Overlord" + type: NormalUnit + grade: 3 + power: 11000 + shield: 0 + critical: 1 + trigger: None + clan: Kagero + textEn: "[CONT](VC/RC):If you do not have another vanguard or rear-guard, this unit gets [Power] -2000.\n[ACT](VC/RC):[Counter-Blast 3] Until end of turn, this unit gets [Power] +5000, gets \"Own(VC/RC):When this unit's attack hits an opponent's rear-guard, [Stand] this unit,\" and loses \"Twin Drive!!\"." + - nameId: embodiment-of-victory-aleph + nameEn: "Embodiment of Victory, Aleph" + type: NormalUnit + grade: 3 + power: 10000 + shield: 0 + critical: 1 + trigger: None + clan: Kagero + textEn: "[ACT](VC/RC):[Counter-Blast 4] This unit gets [Power] +3000/[Critical] +1 until end of turn.\n[ACT](VC):[Choose a card named \"Dragon Knight, Aleph\", a card named \"Embodiment of Armor, Bahr\", and a card named \"Embodiment of Spear, Tahr\" from your soul, and put them into your drop zone] Turn all cards in your damage zone face up." + - nameId: ceo-amaterasu + nameEn: "CEO Amaterasu" + type: NormalUnit + grade: 3 + power: 10000 + shield: 0 + critical: 1 + trigger: None + clan: OracleThinkTank + textEn: "[CONT](VC):During your turn, if the number of cards in your hand is four or greater, this unit gets [Power] +4000.\n[AUTO](VC):At the beginning of your main phase, [Soul-Charge 1], look at the top card of your deck, and put that card on the top or the bottom of your deck.\n[AUTO](VC/RC):[Soul-Blast 8 & Counter-Blast 5] When this unit's attack hits, you may pay the cost. If you do, draw up to five cards." + - nameId: battle-sister-cocoa + nameEn: "Battle Sister, Cocoa" + type: NormalUnit + grade: 1 + power: 6000 + shield: 5000 + critical: 1 + trigger: None + clan: OracleThinkTank + textEn: "[AUTO]:When this unit is placed on (VC) or (RC), if you have an vanguard, look at the top card of your deck, and put that card on the top or the bottom of your deck." + - nameId: asura-kaiser + nameEn: "Asura Kaiser" + type: NormalUnit + grade: 3 + power: 11000 + shield: 0 + critical: 1 + trigger: None + clan: NovaGrappler + textEn: "[CONT](VC/RC):If you do not have another vanguard or rear-guard, this unit gets [Power] -2000.\n[AUTO](VC):When this unit's drive check reveals a grade 3 , choose one of your rear-guards, and [Stand] it." + - nameId: demon-slaying-knight-lohengrin + nameEn: "Demon Slaying Knight, Lohengrin" + type: NormalUnit + grade: 3 + power: 10000 + shield: 0 + critical: 1 + trigger: None + clan: RoyalPaladin + textEn: "[AUTO](VC):At the beginning of your main phase, [Soul-Charge 1], and this unit gets [Power] +2000 until end of turn.\n[AUTO](VC/RC):[Soul-Blast 8 & Counter-Blast 5] When this unit's attack hits, you may pay the cost. If you do, retire all of your opponent's rear-guards." + - nameId: solitary-knight-gancelot + nameEn: "Solitary Knight, Gancelot" + type: NormalUnit + grade: 3 + power: 9000 + shield: 0 + critical: 1 + trigger: None + clan: RoyalPaladin + textEn: "[ACT](VC):[Counter-Blast 2] If you have a card named \"Blaster Blade\" in your soul, this unit gets [Power] +5000/[Critical] +1 until end of turn.\n[ACT](Hand):[Reveal this card to your opponent, and put it on top of your deck] Search your deck for up to one card named \"Blaster Blade\", reveal it to your opponent, put it into your hand, and shuffle your deck." + - nameId: flash-shield-iseult + nameEn: "Flash Shield, Iseult" + type: NormalUnit + grade: 1 + power: 6000 + shield: 0 + critical: 1 + trigger: None + clan: RoyalPaladin + textEn: "[CONT]:Sentinel (You may only have up to four cards with \"[CONT]:Sentinel\" in a deck.)\n[AUTO]:[Choose a from your hand, and discard it] When this unit is placed on (GC), you may pay the cost. If you do, choose one of your that is being attacked, and that unit cannot be hit until end of that battle." + sentinel: true + - nameId: future-knight-llew + nameEn: "Future Knight, Llew" + type: TriggerUnit + grade: 0 + power: 4000 + shield: 10000 + critical: 1 + trigger: Critical + clan: RoyalPaladin + textEn: "[ACT](RC):[Counter-Blast 1 & Choose a unit named \"Future Knight, Llew\", a unit named \"Barcgal\", and a unit named \"Flogal\" from your (RC), and put them into your soul] If you have a grade 1 vanguard, search your deck for up to one card named \"Blaster Blade\", ride it, and shuffle your deck." + - nameId: vortex-dragon + nameEn: "Vortex Dragon" + type: NormalUnit + grade: 3 + power: 10000 + shield: 0 + critical: 1 + trigger: None + clan: Kagero + textEn: "[AUTO](VC):At the begininng of your main phase, [Soul-Charge 1], and this unit gets [Power] +2000 until end of turn.\n[ACT](VC/RC):[Soul-Blast 8 & Counter-Blast 5] Choose up to three of your opponent's rear-guards, and retire them." + - nameId: dragon-knight-aleph + nameEn: "Dragon Knight, Aleph" + type: NormalUnit + grade: 2 + power: 9000 + shield: 5000 + critical: 1 + trigger: None + clan: Kagero + textEn: "[ACT](VC):[Counter-Blast 1 & Choose a unit named \"Embodiment of Armor, Bahr\" and a unit named \"Embodiment of Spear, Tahr\" from your (RC), and put them into your soul] Search your deck for up to one card named \"Embodiment of Victory, Aleph\", ride it, and shuffle your deck." + - nameId: wyvern-guard-barri + nameEn: "Wyvern Guard, Barri" + type: NormalUnit + grade: 1 + power: 6000 + shield: 0 + critical: 1 + trigger: None + clan: Kagero + textEn: "[CONT]:Sentinel (You may only have up to four cards with \"[CONT]:Sentinel\" in a deck.)[AUTO]:[Choose a from your hand, and discard it] When this unit is placed on (GC), you may pay the cost. If you do, choose one of your that is being attacked, and that unit cannot be hit until end of that battle." + sentinel: true + - nameId: lizard-soldier-conroe + nameEn: "Lizard Soldier, Conroe" + type: NormalUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: None + clan: Kagero + textEn: "[AUTO]:When another rides this unit, you may call this card to a (RC).\n[ACT](RC):[Counter-Blast 1 & Retire this unit] Search your deck for up to one grade 1 or less , reveal it to your opponent, put it into your hand, and shuffle your deck." + - nameId: maiden-of-libra + nameEn: "Maiden of Libra" + type: NormalUnit + grade: 2 + power: 9000 + shield: 5000 + critical: 1 + trigger: None + clan: OracleThinkTank + textEn: "[AUTO](VC/RC):[Counter-Blast 2] When this unit's attack hits, if you have an vanguard, you may pay the cost. If you do, draw a card." + - nameId: battle-sister-mocha + nameEn: "Battle Sister, Mocha" + type: NormalUnit + grade: 2 + power: 8000 + shield: 5000 + critical: 1 + trigger: None + clan: OracleThinkTank + textEn: "[AUTO](VC/RC):When this unit attacks, if the number of cards in your hand is four or greater, this unit gets [Power] +3000 until end of that battle." + - nameId: battle-sister-chocolat + nameEn: "Battle Sister, Chocolat" + type: NormalUnit + grade: 1 + power: 6000 + shield: 0 + critical: 1 + trigger: None + clan: OracleThinkTank + textEn: "[CONT]:Sentinel (You may only have up to four cards with \"[CONT]:Sentinel\" in a deck.)[AUTO]:[Choose a from your hand, and discard it] When this unit is placed on (GC), you may pay the cost. If you do, choose one of your that is being attacked, and that unit cannot be hit until end of that battle." + sentinel: true + - nameId: juggernaut-maximum + nameEn: "Juggernaut Maximum" + type: NormalUnit + grade: 3 + power: 11000 + shield: 0 + critical: 1 + trigger: None + clan: SpikeBrothers + textEn: "[CONT](VC/RC):If you do not have another < vanguard or rear-guard, this unit gets [Power] -2000.\n[AUTO](RC):[Soul-Blast 1] When this unit attacks, you may pay the cost. If you do, this unit gets [Power] +5000 until end of that battle, and at the beginning of the close step of that battle, return this unit to your deck, and shuffle your deck." + - nameId: knight-of-silence-gallatin + nameEn: "Knight of Silence, Gallatin" + type: NormalUnit + grade: 2 + power: 10000 + shield: 5000 + critical: 1 + trigger: None + clan: RoyalPaladin + - nameId: dragon-knight-nehalem + nameEn: "Dragon Knight, Nehalem" + type: NormalUnit + grade: 2 + power: 10000 + shield: 5000 + critical: 1 + trigger: None + clan: Kagero + - nameId: wyvern-strike-tejas + nameEn: "Wyvern Strike, Tejas" + type: NormalUnit + grade: 2 + power: 8000 + shield: 5000 + critical: 1 + trigger: None + clan: Kagero + textEn: "[CONT](VC/RC):If this unit would attack, it may instead attack an opponent's unit in the back row of the same column as this unit." + - nameId: embodiment-of-spear-tahr + nameEn: "Embodiment of Spear, Tahr" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: Critical + clan: Kagero + - nameId: oracle-guardian-apollon + nameEn: "Oracle Guardian, Apollon" + type: NormalUnit + grade: 3 + power: 10000 + shield: 0 + critical: 1 + trigger: None + clan: OracleThinkTank + textEn: "[AUTO](VC):[Counter-Blast 2] When this unit's attack hits, you may pay the cost. If you do, draw two cards, choose a card from your hand, return it to your deck, and shuffle your deck.\n[AUTO](RC):[Counter-Blast 2] When this unit's attack hits, you may pay the cost. If you do, draw a card." + - nameId: oracle-guardian-wiseman + nameEn: "Oracle Guardian, Wiseman" + type: NormalUnit + grade: 2 + power: 10000 + shield: 5000 + critical: 1 + trigger: None + clan: OracleThinkTank + - nameId: lozenge-magus + nameEn: "Lozenge Magus" + type: TriggerUnit + grade: 0 + power: 3000 + shield: 10000 + critical: 1 + trigger: Heal + clan: OracleThinkTank + textEn: "(You may only have up to four cards with \"[Heal] HEAL\" in a deck.)\n[AUTO]:When another rides this unit, you may call this card to (RC).\n[AUTO](RC):When this unit boosts, the boosted unit gets [Power] +3000 until end of that battle, and at the begininng of the end phase of that turn, return this unit to your deck, and shuffle your deck." + - nameId: mr-invincible + nameEn: "Mr. Invincible" + type: NormalUnit + grade: 3 + power: 10000 + shield: 0 + critical: 1 + trigger: None + clan: NovaGrappler + textEn: "[AUTO](VC):At the beginning of your main phase, [Soul-Chage 1], choose a card from your damage zone, and turn it face up.\n[AUTO](VC/RC):[Soul-Blast 8 & Counter-Blast 5] When this unit's attack hits, you may pay the cost. If you do, [Stand] all of your units." + - nameId: brutal-jack + nameEn: "Brutal Jack" + type: NormalUnit + grade: 2 + power: 11000 + shield: 5000 + critical: 1 + trigger: None + clan: NovaGrappler + textEn: "[CONT](VC/RC):Restraint (This unit cannnot attack.)[ACT](VC/RC):[Counter-Blast 1] Until end of turn, this unit loses \"Restraint\".\n[AUTO](VC):When this unit is boosted by a , this unit gets [Power] +5000 until end of that battle." + restraint: true + - nameId: king-of-sword + nameEn: "King of Sword" + type: NormalUnit + grade: 2 + power: 10000 + shield: 5000 + critical: 1 + trigger: None + clan: NovaGrappler + - nameId: queen-of-heart + nameEn: "Queen of Heart" + type: NormalUnit + grade: 1 + power: 6000 + shield: 5000 + critical: 1 + trigger: None + clan: NovaGrappler + textEn: "[AUTO](RC):When this unit boosts a unit named \"King of Sword\", the boosted unit gets [Power] +4000 until end of that battle." + - nameId: battleraizer + nameEn: "Battleraizer" + type: TriggerUnit + grade: 0 + power: 3000 + shield: 10000 + critical: 1 + trigger: Stand + clan: NovaGrappler + textEn: "[AUTO]:When another rides this unit, you may call this card to (RC).\n[AUTO](RC):When this unit boosts, the boosted unit gets [Power] +3000 until end of that battle, and at the begininng of the end phase of that turn, return this unit to your deck, and shuffle your deck." + - nameId: tyrant-deathrex + nameEn: "Tyrant, Deathrex" + type: NormalUnit + grade: 3 + power: 10000 + shield: 0 + critical: 1 + trigger: None + clan: Tachikaze + textEn: "[AUTO](VC):When this unit attacks, this unit gets [Power] +5000 until end of that battle.\n[AUTO](VC):When this unit's attack hits, choose one of your rear-guards, and retire it." + - nameId: assault-dragon-blightops + nameEn: "Assault Dragon, Blightops" + type: NormalUnit + grade: 2 + power: 9000 + shield: 5000 + critical: 1 + trigger: None + clan: Tachikaze + textEn: "[AUTO]:[Counter-Blast 1] When this unit is put into the drop zone from (RC), you may pay the cost. If you do, search your deck for up to one card named \"Ironclad Dragon, Shieldon\", reveal it to your opponent, put it into your hand, and shuffle your deck." + - nameId: stealth-dragon-voidmaster + nameEn: "Stealth Dragon, Voidmaster" + type: NormalUnit + grade: 3 + power: 9000 + shield: 0 + critical: 1 + trigger: None + clan: Nubatama + textEn: "[AUTO](VC):When this unit attacks, if the number of cards in your hand is greater than your opponent's, this unit gets [Power] +3000 until end of that battle.\n[AUTO](VC/RC):[Counter-Blast 1] When this unit's attack hits, if the number of cards in your hand is less than your opponent's, you may pay the cost. If you do, your opponent chooses a card from his or her hand, and discards it." + - nameId: demon-eater + nameEn: "Demon Eater" + type: NormalUnit + grade: 3 + power: 10000 + shield: 0 + critical: 1 + trigger: None + clan: DarkIrregulars + textEn: "[AUTO](VC):At the beginning of your main phase, [Soul-Charge 1], and this unit gets [Power] +2000 until end of turn.\n[AUTO](VC/RC):[Soul-Blast 8 & Counter-Blast 5] When this unit's attack hits, you may pay the cost. If you do, retire all of your opponent's rear-guards." + - nameId: monster-frank + nameEn: "Monster Frank" + type: NormalUnit + grade: 3 + power: 10000 + shield: 0 + critical: 1 + trigger: None + clan: Granblue + textEn: "[ACT][Drop zone]:[Counter-Blast 3] If you have a grade 2 vanguard, ride this card." + - nameId: commodore-blueblood + nameEn: "Commodore Blueblood" + type: NormalUnit + grade: 2 + power: 10000 + shield: 5000 + critical: 1 + trigger: None + clan: Granblue + - nameId: hell-spider + nameEn: "Hell Spider" + type: NormalUnit + grade: 3 + power: 10000 + shield: 0 + critical: 1 + trigger: None + clan: Megacolony + textEn: "[CONT](VC):During your turn, if all of your opponent's vanguard and rear-guards are Rest , this unit gets [Power] +3000.\n[AUTO]:[Counter-Blast 2] When this unit is placed on (VC) or (RC), you may pay the cost. If you do, choose one of your opponent's rear-guards, and the chosen unit cannot [Stand] during your opponent's next stand phase." + - nameId: bloody-hercules + nameEn: "Bloody Hercules" + type: NormalUnit + grade: 2 + power: 10000 + shield: 5000 + critical: 1 + trigger: None + clan: Megacolony + - nameId: covenant-knight-randolf + nameEn: "Covenant Knight, Randolf" + type: NormalUnit + grade: 2 + power: 8000 + shield: 5000 + critical: 1 + trigger: None + clan: RoyalPaladin + textEn: "[AUTO](VC/RC):When this unit attacks, if the number of cards in your hand is greater than your opponent's, this unit gets [Power] +3000 until end of that battle." + - nameId: little-sage-marron + nameEn: "Little Sage, Marron" + type: NormalUnit + grade: 1 + power: 8000 + shield: 5000 + critical: 1 + trigger: None + clan: RoyalPaladin + - nameId: lake-maiden-lien + nameEn: "Lake Maiden, Lien" + type: NormalUnit + grade: 1 + power: 7000 + shield: 5000 + critical: 1 + trigger: None + clan: RoyalPaladin + textEn: "[ACT](VC/RC):[Rest this unit & Choose a card from your hand, and discard it] Draw a card." + - nameId: wingal + nameEn: "Wingal" + type: NormalUnit + grade: 1 + power: 6000 + shield: 5000 + critical: 1 + trigger: None + clan: RoyalPaladin + textEn: "[AUTO](RC):When this unit boosts a card named \"Blaster Blade\", the boosted unit gets [Power] +4000 until end of that battle." + - nameId: weapons-dealer-govannon + nameEn: "Weapons Dealer, Govannon" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 5000 + critical: 1 + trigger: Draw + clan: RoyalPaladin + - nameId: flogal + nameEn: "Flogal" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: Stand + clan: RoyalPaladin + - nameId: yggdrasil-maiden-elaine + nameEn: "Yggdrasil Maiden, Elaine" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: Heal + clan: RoyalPaladin + textEn: "(You may only have up to four cards with \"[Heal] HEAL\" in a deck.)" + - nameId: embodiment-of-armor-bahr + nameEn: "Embodiment of Armor, Bahr" + type: NormalUnit + grade: 1 + power: 8000 + shield: 5000 + critical: 1 + trigger: None + clan: Kagero + - nameId: dragon-monk-gojo + nameEn: "Dragon Monk, Gojo" + type: NormalUnit + grade: 1 + power: 7000 + shield: 5000 + critical: 1 + trigger: None + clan: Kagero + textEn: "[ACT](VC/RC):[Rest this unit & Choose a card from your hand, and discard it] Draw a card." + - nameId: wyvern-strike-jarran + nameEn: "Wyvern Strike, Jarran" + type: NormalUnit + grade: 1 + power: 6000 + shield: 5000 + critical: 1 + trigger: None + clan: Kagero + textEn: "[AUTO](RC):When this unit boosts a card named \"Wyvern Strike, Tejas\", the boosted unit gets [Power] +4000 until end of that battle." + - nameId: dragon-dancer-monica + nameEn: "Dragon Dancer, Monica" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 5000 + critical: 1 + trigger: Draw + clan: Kagero + - nameId: lizard-soldier-ganlu + nameEn: "Lizard Soldier, Ganlu" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: Stand + clan: Kagero + - nameId: dragon-monk-genjo + nameEn: "Dragon Monk, Genjo" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: Heal + clan: Kagero + textEn: "(You may only have up to four cards with \"[Heal] HEAL\" in a deck.)" + - nameId: oracle-guardian-gemini + nameEn: "Oracle Guardian, Gemini" + type: NormalUnit + grade: 1 + power: 8000 + shield: 5000 + critical: 1 + trigger: None + clan: OracleThinkTank + - nameId: weather-girl-milk + nameEn: "Weather Girl, Milk" + type: NormalUnit + grade: 1 + power: 6000 + shield: 5000 + critical: 1 + trigger: None + clan: OracleThinkTank + textEn: "[AUTO](RC):When this unit boosts an vanguard, if the number of cards in your hand is four or greater, the boosted unit gets [Power] +4000 until end of that battle." + - nameId: oracle-guardian-nike + nameEn: "Oracle Guardian, Nike" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: Critical + clan: OracleThinkTank + - nameId: dream-eater + nameEn: "Dream Eater" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 5000 + critical: 1 + trigger: Draw + clan: OracleThinkTank + - nameId: miracle-kid + nameEn: "Miracle Kid" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 5000 + critical: 1 + trigger: Draw + clan: OracleThinkTank + - nameId: hungry-dumpty + nameEn: "Hungry Dumpty" + type: NormalUnit + grade: 2 + power: 9000 + shield: 5000 + critical: 1 + trigger: None + clan: NovaGrappler + textEn: "[AUTO]:When this unit is placed on (RC), if you have a vanguard, choose a card from your damage zone, and turn it face up." + - nameId: tough-boy + nameEn: "Tough Boy" + type: NormalUnit + grade: 1 + power: 8000 + shield: 5000 + critical: 1 + trigger: None + clan: NovaGrappler + - nameId: screamin-and-dancin-announcer-shout + nameEn: "Screamin' and Dancin' Announcer, Shout" + type: NormalUnit + grade: 1 + power: 7000 + shield: 5000 + critical: 1 + trigger: None + clan: NovaGrappler + textEn: "[ACT](VC/RC):[Rest this unit & Choose a card from your hand, and discard it] Draw a card." + - nameId: clay-doll-mechanic + nameEn: "Clay-doll Mechanic" + type: NormalUnit + grade: 1 + power: 7000 + shield: 5000 + critical: 1 + trigger: None + clan: NovaGrappler + textEn: "[AUTO]:When this unit is placed on (RC), if you have a vanguard, choose a card from your damage zone, and turn it face up." + - nameId: shining-lady + nameEn: "Shining Lady" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: Critical + clan: NovaGrappler + - nameId: lucky-girl + nameEn: "Lucky Girl" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: Stand + clan: NovaGrappler + - nameId: ring-girl-clara + nameEn: "Ring Girl, Clara" + type: TriggerUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: Heal + clan: NovaGrappler + textEn: "(You may only have up to four cards with \"[Heal] HEAL\" in a deck.)" + - nameId: sonic-noa + nameEn: "Sonic Noa" + type: NormalUnit + grade: 1 + power: 8000 + shield: 5000 + critical: 1 + trigger: None + clan: Tachikaze + - nameId: ironclad-dragon-shieldon + nameEn: "Ironclad Dragon, Shieldon" + type: NormalUnit + grade: 0 + power: 6000 + shield: 10000 + critical: 1 + trigger: None + clan: Tachikaze + - nameId: stealth-beast-chigasumi + nameEn: "Stealth Beast, Chigasumi" + type: NormalUnit + grade: 2 + power: 8000 + shield: 5000 + critical: 1 + trigger: None + clan: Nubatama + textEn: "[AUTO](VC/RC):When this unit attacks, if the number of cards in your hand is greater than your opponent's, this unit gets [Power] +3000 until end of that battle." + - nameId: stealth-dragon-dreadmaster + nameEn: "Stealth Dragon, Dreadmaster" + type: NormalUnit + grade: 1 + power: 7000 + shield: 5000 + critical: 1 + trigger: None + clan: Nubatama + textEn: "[AUTO](RC):[Counter-Blast 1] When an attack hits during the battle that this unit boosted a , if the number of cards in your hand is less than your opponent's, you may pay the cost. If you do, your opponent chooses a card from his or her hand, and discards it." + - nameId: stealth-beast-hagakure + nameEn: "Stealth Beast, Hagakure" + type: NormalUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: None + clan: Nubatama + textEn: "[AUTO]:[Counter-Blast 1] When this unit is placed on (GC), if you have a vanguard, and the number of cards in your hand is less than your opponent's, you may pay the cost. If you do, your opponent chooses a card from his or her hand, and discards it." + - nameId: blue-dust + nameEn: "Blue Dust" + type: NormalUnit + grade: 2 + power: 9000 + shield: 5000 + critical: 1 + trigger: None + clan: DarkIrregulars + textEn: "[AUTO](VC/RC):When this unit's attack hits, if you have a vanguard, you may [Soul-Charge 1]." + - nameId: nightmare-baby + nameEn: "Nightmare Baby" + type: NormalUnit + grade: 1 + power: 6000 + shield: 5000 + critical: 1 + trigger: None + clan: DarkIrregulars + textEn: "[AUTO](RC):When this unit boosts a unit named \"Blue Dust\", the boosted unit gets [Power] +4000 until end of that battle." + - nameId: rock-the-wall + nameEn: "Rock the Wall" + type: NormalUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: None + clan: DarkIrregulars + textEn: "[AUTO]:When this unit is put into the drop zone from (GC), put this card into your soul." + - nameId: highspeed-brakki + nameEn: "Highspeed, Brakki" + type: NormalUnit + grade: 2 + power: 9000 + shield: 5000 + critical: 1 + trigger: None + clan: SpikeBrothers + textEn: "[AUTO](RC):[Soul-Blast 1] When this unit attacks, you may pay the cost. If you do, this unit gets [Power] +5000 until end of that battle, and at the beginning of the close step of that battle, return this unit to your deck, and shuffle your deck." + - nameId: wonder-boy + nameEn: "Wonder Boy" + type: NormalUnit + grade: 1 + power: 8000 + shield: 5000 + critical: 1 + trigger: None + clan: SpikeBrothers + - nameId: redshoe-milly + nameEn: "Redshoe, Milly" + type: NormalUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: None + clan: SpikeBrothers + textEn: "[AUTO]:When this unit is put into the drop zone from (GC), put this card into your soul." + - nameId: dandy-guy-romario + nameEn: "Dandy Guy, Romario" + type: NormalUnit + grade: 1 + power: 8000 + shield: 5000 + critical: 1 + trigger: None + clan: Granblue + - nameId: guiding-zombie + nameEn: "Guiding Zombie" + type: NormalUnit + grade: 0 + power: 5000 + shield: 10000 + critical: 1 + trigger: None + clan: Granblue + textEn: "[AUTO]:When another rides this unit, you may call this card to (RC).\n[ACT](RC):[Put this unit into your soul] Put three cards from the top of your deck into your drop zone." + - nameId: karma-queen + nameEn: "Karma Queen" + type: NormalUnit + grade: 1 + power: 7000 + shield: 5000 + critical: 1 + trigger: None + clan: Megacolony + textEn: "[AUTO]:[Counter-Blast 2] When this unit is placed on (VC) or (RC), you may pay the cost. If you do, choose one of your opponent's rear-guards, and the chosen unit cannot [Stand] during your opponent's next stand phase." + - nameId: madame-mirage + nameEn: "Madame Mirage" + type: NormalUnit + grade: 0 + power: 6000 + shield: 10000 + critical: 1 + trigger: None + clan: Megacolony + +printings: + - printingId: BT01/001EN + nameId: king-of-knights-alfred + rarity: RRR + imageKey: bt01_001 + - printingId: BT01/002EN + nameId: blaster-blade + rarity: RRR + imageKey: bt01_002 + - printingId: BT01/003EN + nameId: barcgal + rarity: RRR + imageKey: bt01_003 + - printingId: BT01/004EN + nameId: dragonic-overlord + rarity: RRR + imageKey: bt01_004 + - printingId: BT01/005EN + nameId: embodiment-of-victory-aleph + rarity: RRR + imageKey: bt01_005 + - printingId: BT01/006EN + nameId: ceo-amaterasu + rarity: RRR + imageKey: bt01_006 + - printingId: BT01/007EN + nameId: battle-sister-cocoa + rarity: RRR + imageKey: bt01_007 + - printingId: BT01/008EN + nameId: asura-kaiser + rarity: RRR + imageKey: bt01_008 + - printingId: BT01/009EN + nameId: demon-slaying-knight-lohengrin + rarity: RR + imageKey: bt01_009 + - printingId: BT01/010EN + nameId: solitary-knight-gancelot + rarity: RR + imageKey: bt01_010 + - printingId: BT01/011EN + nameId: flash-shield-iseult + rarity: RR + imageKey: bt01_011 + - printingId: BT01/012EN + nameId: future-knight-llew + rarity: RR + imageKey: bt01_012 + - printingId: BT01/013EN + nameId: vortex-dragon + rarity: RR + imageKey: bt01_013 + - printingId: BT01/014EN + nameId: dragon-knight-aleph + rarity: RR + imageKey: bt01_014 + - printingId: BT01/015EN + nameId: wyvern-guard-barri + rarity: RR + imageKey: bt01_015 + - printingId: BT01/016EN + nameId: lizard-soldier-conroe + rarity: RR + imageKey: bt01_016 + - printingId: BT01/017EN + nameId: maiden-of-libra + rarity: RR + imageKey: bt01_017 + - printingId: BT01/018EN + nameId: battle-sister-mocha + rarity: RR + imageKey: bt01_018 + - printingId: BT01/019EN + nameId: battle-sister-chocolat + rarity: RR + imageKey: bt01_019 + - printingId: BT01/020EN + nameId: juggernaut-maximum + rarity: RR + imageKey: bt01_020 + - printingId: BT01/021EN + nameId: knight-of-silence-gallatin + rarity: R + imageKey: bt01_021 + - printingId: BT01/022EN + nameId: dragon-knight-nehalem + rarity: R + imageKey: bt01_022 + - printingId: BT01/023EN + nameId: wyvern-strike-tejas + rarity: R + imageKey: bt01_023 + - printingId: BT01/024EN + nameId: embodiment-of-spear-tahr + rarity: R + imageKey: bt01_024 + - printingId: BT01/025EN + nameId: oracle-guardian-apollon + rarity: R + imageKey: bt01_025 + - printingId: BT01/026EN + nameId: oracle-guardian-wiseman + rarity: R + imageKey: bt01_026 + - printingId: BT01/027EN + nameId: lozenge-magus + rarity: R + imageKey: bt01_027 + - printingId: BT01/028EN + nameId: mr-invincible + rarity: R + imageKey: bt01_028 + - printingId: BT01/029EN + nameId: brutal-jack + rarity: R + imageKey: bt01_029 + - printingId: BT01/030EN + nameId: king-of-sword + rarity: R + imageKey: bt01_030 + - printingId: BT01/031EN + nameId: queen-of-heart + rarity: R + imageKey: bt01_031 + - printingId: BT01/032EN + nameId: battleraizer + rarity: R + imageKey: bt01_032 + - printingId: BT01/033EN + nameId: tyrant-deathrex + rarity: R + imageKey: bt01_033 + - printingId: BT01/034EN + nameId: assault-dragon-blightops + rarity: R + imageKey: bt01_034 + - printingId: BT01/035EN + nameId: stealth-dragon-voidmaster + rarity: R + imageKey: bt01_035 + - printingId: BT01/036EN + nameId: demon-eater + rarity: R + imageKey: bt01_036 + - printingId: BT01/037EN + nameId: monster-frank + rarity: R + imageKey: bt01_037 + - printingId: BT01/038EN + nameId: commodore-blueblood + rarity: R + imageKey: bt01_038 + - printingId: BT01/039EN + nameId: hell-spider + rarity: R + imageKey: bt01_039 + - printingId: BT01/040EN + nameId: bloody-hercules + rarity: R + imageKey: bt01_040 + - printingId: BT01/041EN + nameId: covenant-knight-randolf + rarity: C + imageKey: bt01_041 + - printingId: BT01/042EN + nameId: little-sage-marron + rarity: C + imageKey: bt01_042 + - printingId: BT01/043EN + nameId: lake-maiden-lien + rarity: C + imageKey: bt01_043 + - printingId: BT01/044EN + nameId: wingal + rarity: C + imageKey: bt01_044 + - printingId: BT01/045EN + nameId: weapons-dealer-govannon + rarity: C + imageKey: bt01_045 + - printingId: BT01/046EN + nameId: flogal + rarity: C + imageKey: bt01_046 + - printingId: BT01/047EN + nameId: yggdrasil-maiden-elaine + rarity: C + imageKey: bt01_047 + - printingId: BT01/048EN + nameId: embodiment-of-armor-bahr + rarity: C + imageKey: bt01_048 + - printingId: BT01/049EN + nameId: dragon-monk-gojo + rarity: C + imageKey: bt01_049 + - printingId: BT01/050EN + nameId: wyvern-strike-jarran + rarity: C + imageKey: bt01_050 + - printingId: BT01/051EN + nameId: dragon-dancer-monica + rarity: C + imageKey: bt01_051 + - printingId: BT01/052EN + nameId: lizard-soldier-ganlu + rarity: C + imageKey: bt01_052 + - printingId: BT01/053EN + nameId: dragon-monk-genjo + rarity: C + imageKey: bt01_053 + - printingId: BT01/054EN + nameId: oracle-guardian-gemini + rarity: C + imageKey: bt01_054 + - printingId: BT01/055EN + nameId: weather-girl-milk + rarity: C + imageKey: bt01_055 + - printingId: BT01/056EN + nameId: oracle-guardian-nike + rarity: C + imageKey: bt01_056 + - printingId: BT01/057EN + nameId: dream-eater + rarity: C + imageKey: bt01_057 + - printingId: BT01/058EN + nameId: miracle-kid + rarity: C + imageKey: bt01_058 + - printingId: BT01/059EN + nameId: hungry-dumpty + rarity: C + imageKey: bt01_059 + - printingId: BT01/060EN + nameId: tough-boy + rarity: C + imageKey: bt01_060 + - printingId: BT01/061EN + nameId: screamin-and-dancin-announcer-shout + rarity: C + imageKey: bt01_061 + - printingId: BT01/062EN + nameId: clay-doll-mechanic + rarity: C + imageKey: bt01_062 + - printingId: BT01/063EN + nameId: shining-lady + rarity: C + imageKey: bt01_063 + - printingId: BT01/064EN + nameId: lucky-girl + rarity: C + imageKey: bt01_064 + - printingId: BT01/065EN + nameId: ring-girl-clara + rarity: C + imageKey: bt01_065 + - printingId: BT01/066EN + nameId: sonic-noa + rarity: C + imageKey: bt01_066 + - printingId: BT01/067EN + nameId: ironclad-dragon-shieldon + rarity: C + imageKey: bt01_067 + - printingId: BT01/068EN + nameId: stealth-beast-chigasumi + rarity: C + imageKey: bt01_068 + - printingId: BT01/069EN + nameId: stealth-dragon-dreadmaster + rarity: C + imageKey: bt01_069 + - printingId: BT01/070EN + nameId: stealth-beast-hagakure + rarity: C + imageKey: bt01_070 + - printingId: BT01/071EN + nameId: blue-dust + rarity: C + imageKey: bt01_071 + - printingId: BT01/072EN + nameId: nightmare-baby + rarity: C + imageKey: bt01_072 + - printingId: BT01/073EN + nameId: rock-the-wall + rarity: C + imageKey: bt01_073 + - printingId: BT01/074EN + nameId: highspeed-brakki + rarity: C + imageKey: bt01_074 + - printingId: BT01/075EN + nameId: wonder-boy + rarity: C + imageKey: bt01_075 + - printingId: BT01/076EN + nameId: redshoe-milly + rarity: C + imageKey: bt01_076 + - printingId: BT01/077EN + nameId: dandy-guy-romario + rarity: C + imageKey: bt01_077 + - printingId: BT01/078EN + nameId: guiding-zombie + rarity: C + imageKey: bt01_078 + - printingId: BT01/079EN + nameId: karma-queen + rarity: C + imageKey: bt01_079 + - printingId: BT01/080EN + nameId: madame-mirage + rarity: C + imageKey: bt01_080 + - printingId: BT01/S01EN + nameId: king-of-knights-alfred + rarity: SP + imageKey: bt01_s01 + isParallel: true + basePrintingId: BT01/001EN + - printingId: BT01/S02EN + nameId: blaster-blade + rarity: SP + imageKey: bt01_s02 + isParallel: true + basePrintingId: BT01/002EN + - printingId: BT01/S03EN + nameId: barcgal + rarity: SP + imageKey: bt01_s03 + isParallel: true + basePrintingId: BT01/003EN + - printingId: BT01/S04EN + nameId: dragonic-overlord + rarity: SP + imageKey: bt01_s04 + isParallel: true + basePrintingId: BT01/004EN + - printingId: BT01/S05EN + nameId: ceo-amaterasu + rarity: SP + imageKey: bt01_s05 + isParallel: true + basePrintingId: BT01/006EN + - printingId: BT01/S06EN + nameId: battle-sister-cocoa + rarity: SP + imageKey: bt01_s06 + isParallel: true + basePrintingId: BT01/007EN + - printingId: BT01/S07EN + nameId: asura-kaiser + rarity: SP + imageKey: bt01_s07 + isParallel: true + basePrintingId: BT01/008EN + - printingId: BT01/S08EN + nameId: solitary-knight-gancelot + rarity: SP + imageKey: bt01_s08 + isParallel: true + basePrintingId: BT01/010EN + - printingId: BT01/S09EN + nameId: vortex-dragon + rarity: SP + imageKey: bt01_s09 + isParallel: true + basePrintingId: BT01/013EN + - printingId: BT01/S10EN + nameId: maiden-of-libra + rarity: SP + imageKey: bt01_s10 + isParallel: true + basePrintingId: BT01/017EN + - printingId: BT01/S11EN + nameId: lozenge-magus + rarity: SP + imageKey: bt01_s11 + isParallel: true + basePrintingId: BT01/027EN + - printingId: BT01/S12EN + nameId: battleraizer + rarity: SP + imageKey: bt01_s12 + isParallel: true + basePrintingId: BT01/032EN diff --git a/content/sets/TD01.yaml b/content/sets/TD01.yaml new file mode 100644 index 0000000..1af2a1d --- /dev/null +++ b/content/sets/TD01.yaml @@ -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 <>, 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 <>, 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 <>, 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 diff --git a/content/sets/TD02.yaml b/content/sets/TD02.yaml new file mode 100644 index 0000000..f15b670 --- /dev/null +++ b/content/sets/TD02.yaml @@ -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 <>, 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 <> 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 diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/server/Directory.Build.props b/server/Directory.Build.props new file mode 100644 index 0000000..6258742 --- /dev/null +++ b/server/Directory.Build.props @@ -0,0 +1,9 @@ + + + latest + enable + enable + true + false + + diff --git a/server/Vanguard.slnx b/server/Vanguard.slnx new file mode 100644 index 0000000..6ab5b7b --- /dev/null +++ b/server/Vanguard.slnx @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/server/src/Vanguard.Cli/Program.cs b/server/src/Vanguard.Cli/Program.cs new file mode 100644 index 0000000..94c30a4 --- /dev/null +++ b/server/src/Vanguard.Cli/Program.cs @@ -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; +} diff --git a/server/src/Vanguard.Cli/Vanguard.Cli.csproj b/server/src/Vanguard.Cli/Vanguard.Cli.csproj new file mode 100644 index 0000000..e59f63b --- /dev/null +++ b/server/src/Vanguard.Cli/Vanguard.Cli.csproj @@ -0,0 +1,15 @@ + + + + + + + + + Exe + net10.0 + enable + enable + + + diff --git a/server/src/Vanguard.Content/CardSetFile.cs b/server/src/Vanguard.Content/CardSetFile.cs new file mode 100644 index 0000000..7622485 --- /dev/null +++ b/server/src/Vanguard.Content/CardSetFile.cs @@ -0,0 +1,67 @@ +using Vanguard.Rules.Cards; + +namespace Vanguard.Content; + +/// Формат YAML-файла сета. +public sealed class CardSetFile +{ + public string Set { get; set; } = string.Empty; + public List Cards { get; set; } = new(); + public List 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, + }; +} diff --git a/server/src/Vanguard.Content/CatalogLoader.cs b/server/src/Vanguard.Content/CatalogLoader.cs new file mode 100644 index 0000000..f73e100 --- /dev/null +++ b/server/src/Vanguard.Content/CatalogLoader.cs @@ -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(); + var printings = new List(); + + foreach (var file in Directory.GetFiles(directory, "*.yaml").OrderBy(f => f, StringComparer.Ordinal)) + { + var set = deserializer.Deserialize(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); + } +} diff --git a/server/src/Vanguard.Content/ContentValidator.cs b/server/src/Vanguard.Content/ContentValidator.cs new file mode 100644 index 0000000..ca184d8 --- /dev/null +++ b/server/src/Vanguard.Content/ContentValidator.cs @@ -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 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; +} + +/// +/// Проверки целостности контента. Ловят ошибки разметки, а не ошибки игрока. +/// +public static class ContentValidator +{ + public const int ExpectedCards = 94; + public const int ExpectedPrintings = 124; + + public static ContentReport Validate(CardCatalog catalog) + { + var issues = new List(); + var missingRu = new List(); + + 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); + } +} diff --git a/server/src/Vanguard.Content/Vanguard.Content.csproj b/server/src/Vanguard.Content/Vanguard.Content.csproj new file mode 100644 index 0000000..e69b143 --- /dev/null +++ b/server/src/Vanguard.Content/Vanguard.Content.csproj @@ -0,0 +1,17 @@ + + + + + + + + + + + + net10.0 + enable + enable + + + diff --git a/server/src/Vanguard.Rules/Cards/CardCatalog.cs b/server/src/Vanguard.Rules/Cards/CardCatalog.cs new file mode 100644 index 0000000..3149954 --- /dev/null +++ b/server/src/Vanguard.Rules/Cards/CardCatalog.cs @@ -0,0 +1,53 @@ +namespace Vanguard.Rules.Cards; + +/// Каталог карт: определения по nameId и принты по printingId. +public sealed class CardCatalog +{ + private readonly Dictionary _cards; + private readonly Dictionary _printings; + + public CardCatalog(IEnumerable cards, IEnumerable printings) + { + _cards = new Dictionary(StringComparer.Ordinal); + foreach (var card in cards) + { + if (!_cards.TryAdd(card.NameId, card)) + throw new InvalidOperationException($"Дубликат nameId: {card.NameId}"); + } + + _printings = new Dictionary(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 Cards => _cards.Values; + public IEnumerable 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!); + + /// Определение карты по конкретному принту. + public CardDefinition CardOf(string printingId) => Card(Printing(printingId).NameId); + + public IEnumerable PrintingsOf(string nameId) => + _printings.Values.Where(p => p.NameId == nameId); +} diff --git a/server/src/Vanguard.Rules/Cards/CardDefinition.cs b/server/src/Vanguard.Rules/Cards/CardDefinition.cs new file mode 100644 index 0000000..f94104c --- /dev/null +++ b/server/src/Vanguard.Rules/Cards/CardDefinition.cs @@ -0,0 +1,48 @@ +namespace Vanguard.Rules.Cards; + +/// +/// Логическая карта. Одна на имя: параллельные арты и переиздания ссылаются сюда через nameId. +/// Лимит копий в колоде считается по этому объекту, а не по принту. +/// +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; } + + /// Sentinel (Perfect Guard). Лимит — 4 карты в колоде. + public bool IsSentinel { get; init; } + + /// Restraint: не может атаковать. + public bool IsRestraint { get; init; } + + public string? TextEn { get; init; } + public string? TextRu { get; init; } + + /// Grade 0 без триггера — кандидат в стартовый вэнгард. + public bool IsStartingVanguardCandidate => Grade == 0 && Trigger == TriggerType.None; +} + +/// Конкретный принт: номер + арт. Единица коллекции и бустера. +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; } + + /// Параллельный арт (BT01/S01…S12). + public bool IsParallel { get; init; } + + /// Для параллельного арта — принт, к которому он привязан. + public string? BasePrintingId { get; init; } +} diff --git a/server/src/Vanguard.Rules/Cards/CardEnums.cs b/server/src/Vanguard.Rules/Cards/CardEnums.cs new file mode 100644 index 0000000..dfebdea --- /dev/null +++ b/server/src/Vanguard.Rules/Cards/CardEnums.cs @@ -0,0 +1,43 @@ +namespace Vanguard.Rules.Cards; + +/// Тип карты. В базовой эре их два. +public enum CardType +{ + NormalUnit = 0, + TriggerUnit = 1, +} + +/// Тип триггера. Front и Over появятся только в V/D-эре. +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, +} + +/// Редкость принта. TD — карта, существующая только в триал-деке. +public enum Rarity +{ + C = 0, + R = 1, + RR = 2, + RRR = 3, + SP = 4, + TD = 5, +} diff --git a/server/src/Vanguard.Rules/Decks/DeckValidator.cs b/server/src/Vanguard.Rules/Decks/DeckValidator.cs new file mode 100644 index 0000000..3dde7c9 --- /dev/null +++ b/server/src/Vanguard.Rules/Decks/DeckValidator.cs @@ -0,0 +1,74 @@ +using Vanguard.Rules.Cards; + +namespace Vanguard.Rules.Decks; + +public sealed record DeckViolation(string Code, string Message); + +/// +/// Правила конструирования колоды для формата Classic. +/// Лимиты считаются по nameId, а не по принту: 4 обычных Blaster Blade +/// и 4 параллельных — это 8 копий одной карты. +/// +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 Validate(CardCatalog catalog, IReadOnlyCollection printingIds) + { + var violations = new List(); + + if (printingIds.Count != RequiredSize) + { + violations.Add(new DeckViolation( + "deck.size", + $"В колоде {printingIds.Count} карт, требуется {RequiredSize}.")); + } + + var cards = new List(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 printingIds) => + Validate(catalog, printingIds).Count == 0; +} diff --git a/server/src/Vanguard.Rules/Vanguard.Rules.csproj b/server/src/Vanguard.Rules/Vanguard.Rules.csproj new file mode 100644 index 0000000..b760144 --- /dev/null +++ b/server/src/Vanguard.Rules/Vanguard.Rules.csproj @@ -0,0 +1,9 @@ + + + + net10.0 + enable + enable + + + diff --git a/server/src/Vanguard.Shared/Vanguard.Shared.csproj b/server/src/Vanguard.Shared/Vanguard.Shared.csproj new file mode 100644 index 0000000..b4b43f4 --- /dev/null +++ b/server/src/Vanguard.Shared/Vanguard.Shared.csproj @@ -0,0 +1,8 @@ + + + + netstandard2.1 + enable + + + diff --git a/server/tests/Vanguard.Rules.Tests/CatalogTests.cs b/server/tests/Vanguard.Rules.Tests/CatalogTests.cs new file mode 100644 index 0000000..0921cd1 --- /dev/null +++ b/server/tests/Vanguard.Rules.Tests/CatalogTests.cs @@ -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); + }); + } +} diff --git a/server/tests/Vanguard.Rules.Tests/DeckValidatorTests.cs b/server/tests/Vanguard.Rules.Tests/DeckValidatorTests.cs new file mode 100644 index 0000000..8070a97 --- /dev/null +++ b/server/tests/Vanguard.Rules.Tests/DeckValidatorTests.cs @@ -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 BuildLegalDeck() => DeckWith(); + + /// + /// Собирает колоду на 50 карт: сначала заданные принты, затем по 4 копии + /// каждого имени, кроме Sentinel и Heal (чтобы не упираться в их лимиты). + /// + private List DeckWith(params (string PrintingId, int Count)[] seed) + { + var deck = new List(); + var usedNames = new HashSet(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; + } +} diff --git a/server/tests/Vanguard.Rules.Tests/TestCatalog.cs b/server/tests/Vanguard.Rules.Tests/TestCatalog.cs new file mode 100644 index 0000000..8061d42 --- /dev/null +++ b/server/tests/Vanguard.Rules.Tests/TestCatalog.cs @@ -0,0 +1,24 @@ +using Vanguard.Content; +using Vanguard.Rules.Cards; + +namespace Vanguard.Rules.Tests; + +/// Общий каталог для тестов: грузится один раз из content/sets. +public static class TestCatalog +{ + private static readonly Lazy 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")); + } +} diff --git a/server/tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj b/server/tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj new file mode 100644 index 0000000..c99aa9d --- /dev/null +++ b/server/tests/Vanguard.Rules.Tests/Vanguard.Rules.Tests.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tools/import_cards.py b/tools/import_cards.py new file mode 100644 index 0000000..2dc779f --- /dev/null +++ b/tools/import_cards.py @@ -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'
  • \s*' + r'.*?
    \s*(?P[^<]*?)' + r'.*?
    \s*(?P.*?)
    ' + r'(?:\s*

    (?P.*?)

    )?\s*\s*
    \s*
  • ', + re.S, +) + + +def clean_text(raw: str | None) -> str | None: + if not raw: + return None + s = re.sub(r"", "\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".*?", 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()