Card text tokenization, keyword catalog and RU translation workflow

Card texts are stored as templates with icon tokens ({auto}, {cb:3}, {vcrc}, {clan:kagero}). Keyword catalog maps tokens to icons and captions. translations build generates content/translations/ru.yaml preserving existing work; translations check verifies completeness and token integrity. 21 tests.
This commit is contained in:
MpakobecEx 2026-10-20 21:35:55 +05:00
parent 0b771bf8bb
commit 625ed36f94
11 changed files with 1434 additions and 9 deletions

View File

@ -239,7 +239,8 @@
- [ ] **P0** Unity Localization: String Tables `en`/`ru`, ключи по схеме `ui.menu.play`, runtime-переключение без перезагрузки сцены.
- [ ] **P0** Сервер: параметр `lang` в запросах карт/глоссария, fallback `en`.
- [ ] **P1** Текстовый шаблонизатор карт: `{cb}`, `{sb}`, `{cc}`, `{sc}` → локализованный термин из глоссария; поддержка `<b>`, `<i>`, переносов.
- [x] **P1** Токенизация текста карт: `content/keywords.yaml` (иконки + подписи), шаблоны с токенами `{cb:3}`, `{vcrc}`, `{clan:kagero}`; проверка целостности токенов при переводе.
- [ ] **P1** Рендер шаблона в клиенте: подстановка иконок по токенам, rich-text, авто-фит текста на карте.
- [ ] **P1** Проверка шрифтов TMP: кириллица, fallback-атлас, авто-фит текста на карте.
- [ ] **P1** Псевдолокализация (длинные строки) для проверки вёрстки.
- [ ] **P1** Лаунчер: RESX `ru`/`en`, общий глоссарий с сервером (единый CSV как источник).

View File

@ -88,6 +88,7 @@
- [x] Уникальность `printingId`, наличие `nameId`.
- [x] Ссылка SP-принта на существующий базовый номер.
- [x] Загрузчик: YAML → in-memory каталог, используется и сервером, и тестами.
- [x] Ключевые слова и шаблоны: `content/keywords.yaml`, `content/translations/ru.yaml`, команды `translations build|check`.
- [ ] Сид в PostgreSQL: `cards`, `printings`, `sets`, `card_legality` (пустая).
- [ ] Экспорт каталога для клиента (JSON + список Addressables-ключей).

View File

@ -51,6 +51,8 @@ OK: ошибок 0, предупреждений 1
/server/src/Vanguard.Cli консольные команды
/server/tests тесты правил и контента
/content/sets карты в YAML
/content/keywords.yaml ключевые слова и иконки
/content/translations/ru.yaml русские названия и тексты
/tools импорт из первоисточника
```
@ -73,6 +75,31 @@ python tools/import_cards.py
- параллельный принт (`BT01/S01`…`S12`) ссылается на существующий базовый принт той же карты;
- в колоде ровно 50 карт, ≤4 копий по имени (а не по принту), ≤4 Sentinel, ≤4 Heal.
## Тексты карт и иконки
Ключевые слова (`[AUTO]`, `[Counter-Blast 3]`, `(VC/RC)`, `<Kagero>`) заменяются
на иконки при отрисовке. Чтобы перевод не ломал разметку, тексты хранятся в виде
**шаблонов с токенами**:
```
{auto}:{cb:2} When this unit is placed on {vc}, you may pay the cost...
```
Список токенов и их иконок — `content/keywords.yaml`.
Русские тексты — `content/translations/ru.yaml`: заполняются `nameRu` и `textRu`,
токены в `textRu` нужно сохранить в том же количестве.
```powershell
# создать файл переводов или обновить шаблоны, сохранив введённые переводы
dotnet run --project server/src/Vanguard.Cli -- translations build
# проверить заполнение и целостность токенов
dotnet run --project server/src/Vanguard.Cli -- translations check
```
Проверка ловит: незаполненные имена и тексты, потерянные или лишние токены,
токены, которых нет в справочнике.
## Документация
- `plan.md` — исходный дизайн-документ.

224
content/keywords.yaml Normal file
View File

@ -0,0 +1,224 @@
# Ключевые слова карт и их иконки.
#
# token — как слово выглядит в шаблоне текста карты (в фигурных скобках).
# icon — ключ ассета в клиенте (Addressables).
# ru — подпись для тултипа и для озвучивания.
# numeric — у токена есть число, оно передаётся через двоеточие: {cb:3}
#
# Всё, что не попало в этот файл, остаётся обычным текстом и переводится как есть.
version: 1
keywords:
# --- Тип способности ---
- id: cont
kind: ability
token: "{cont}"
en: "[CONT]"
ru: "Непрерывный"
icon: "kw/cont"
- id: auto
kind: ability
token: "{auto}"
en: "[AUTO]"
ru: "Авто"
icon: "kw/auto"
- id: act
kind: ability
token: "{act}"
en: "[ACT]"
ru: "Активируемый"
icon: "kw/act"
# --- Зоны ---
- id: vc
kind: zone
token: "{vc}"
en: "(VC)"
ru: "Круг Вэнгарда"
icon: "kw/vc"
- id: rc
kind: zone
token: "{rc}"
en: "(RC)"
ru: "Круг Рира"
icon: "kw/rc"
- id: gc
kind: zone
token: "{gc}"
en: "(GC)"
ru: "Круг Стража"
icon: "kw/gc"
- id: vcrc
kind: zone
token: "{vcrc}"
en: "(VC/RC)"
ru: "Круг Вэнгарда или Рира"
icon: "kw/vcrc"
- id: hand
kind: zone
token: "{hand}"
en: "(Hand)"
ru: "Рука"
icon: "kw/hand"
- id: drop
kind: zone
token: "{drop}"
en: "[Drop zone]"
ru: "Зона сброса"
icon: "kw/drop"
# --- Стоимости ---
- id: cb
kind: cost
token: "{cb:N}"
en: "[Counter-Blast N]"
ru: "Контр-Бласт"
icon: "kw/cb"
numeric: true
- id: sb
kind: cost
token: "{sb:N}"
en: "[Soul-Blast N]"
ru: "Соул-Бласт"
icon: "kw/sb"
numeric: true
- id: sc
kind: cost
token: "{sc:N}"
en: "[Soul-Charge N]"
ru: "Соул-Чардж"
icon: "kw/sc"
numeric: true
# --- Характеристики и действия ---
- id: power
kind: stat
token: "{power}"
en: "[Power]"
ru: "Сила"
icon: "kw/power"
- id: critical
kind: stat
token: "{critical}"
en: "[Critical]"
ru: "Критический"
icon: "kw/critical"
- id: shield
kind: stat
token: "{shield}"
en: "Shield"
ru: "Щит"
icon: "kw/shield"
- id: heal
kind: trigger
token: "{heal}"
en: "[Heal]"
ru: "Лечение"
icon: "trigger/heal"
- id: draw
kind: trigger
token: "{draw}"
en: "[Draw]"
ru: "Взять карту"
icon: "trigger/draw"
- id: stand
kind: action
token: "{stand}"
en: "[Stand]"
ru: "Встать"
icon: "kw/stand"
- id: rest
kind: action
token: "{rest}"
en: "[Rest this unit]"
ru: "Повернуть этого юнита"
icon: "kw/rest"
# --- Ключевые слова ---
- id: sentinel
kind: keyword
token: "{sentinel}"
en: "Sentinel"
ru: "Стражник"
icon: "kw/sentinel"
- id: twin-drive
kind: keyword
token: "{twin-drive}"
en: "Twin Drive!!"
ru: "Двойной Драйв!!"
icon: "kw/twin-drive"
- id: boost
kind: keyword
token: "{boost}"
en: "Boost"
ru: "Усиление"
icon: "kw/boost"
- id: intercept
kind: keyword
token: "{intercept}"
en: "Intercept"
ru: "Перехват"
icon: "kw/intercept"
# --- Кланы ---
- id: clan-royal-paladin
kind: clan
token: "{clan:royal-paladin}"
en: "Royal Paladin"
ru: "Королевский Паладин"
icon: "clan/royal-paladin"
- id: clan-oracle-think-tank
kind: clan
token: "{clan:oracle-think-tank}"
en: "Oracle Think Tank"
ru: "Оракул Мысли"
icon: "clan/oracle-think-tank"
- id: clan-kagero
kind: clan
token: "{clan:kagero}"
en: "Kagero"
ru: "Кагеро"
icon: "clan/kagero"
- id: clan-nubatama
kind: clan
token: "{clan:nubatama}"
en: "Nubatama"
ru: "Нубатама"
icon: "clan/nubatama"
- id: clan-tachikaze
kind: clan
token: "{clan:tachikaze}"
en: "Tachikaze"
ru: "Тачиказе"
icon: "clan/tachikaze"
- id: clan-nova-grappler
kind: clan
token: "{clan:nova-grappler}"
en: "Nova Grappler"
ru: "Нова Грэпплер"
icon: "clan/nova-grappler"
- id: clan-spike-brothers
kind: clan
token: "{clan:spike-brothers}"
en: "Spike Brothers"
ru: "Спайк Бразерс"
icon: "clan/spike-brothers"
- id: clan-dark-irregulars
kind: clan
token: "{clan:dark-irregulars}"
en: "Dark Irregulars"
ru: "Тёмные Иррегуляры"
icon: "clan/dark-irregulars"
- id: clan-granblue
kind: clan
token: "{clan:granblue}"
en: "Granblue"
ru: "Гранблю"
icon: "clan/granblue"
- id: clan-megacolony
kind: clan
token: "{clan:megacolony}"
en: "Megacolony"
ru: "Мегаколония"
icon: "clan/megacolony"

View File

@ -0,0 +1,572 @@
# Русские тексты карт.
#
# Заполняйте только nameRu и textRu. Остальные поля не меняйте.
# Токены в фигурных скобках — это иконки: {auto}, {cb:3}, {vcrc}, {clan:kagero}.
# Их нужно сохранить в textRu ровно в таком же количестве, иначе проверка не пройдёт.
# Список всех токенов — content/keywords.yaml.
#
# Проверка: dotnet run --project server/src/Vanguard.Cli -- translations check
version: 1
cards:
- nameId: assault-dragon-blightops
nameEn: "Assault Dragon, Blightops"
nameRu: ""
template: |-
{auto}:{cb: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.
textRu: ""
- nameId: asura-kaiser
nameEn: "Asura Kaiser"
nameRu: ""
template: |-
{cont}{vcrc}:If you do not have another {clan:nova-grappler} vanguard or rear-guard, this unit gets {power} -2000.
{auto}{vc}:When this unit's drive check reveals a grade 3 {clan:nova-grappler}, choose one of your rear-guards, and {stand} it.
textRu: ""
- nameId: barcgal
nameEn: "Barcgal"
nameRu: ""
template: |-
{auto}:When another {clan:royal-paladin} rides this unit, you may call this card to {rc}.
{act}{rc}:{rest} Search your deck for up to one card named "Future Knight, Llew" or "Flogal", call it to {rc}, and shuffle your deck.
textRu: ""
- nameId: battle-sister-chocolat
nameEn: "Battle Sister, Chocolat"
nameRu: ""
template: |-
{cont}:{sentinel} (You may only have up to four cards with "{cont}:{sentinel}" in a deck.){auto}:[Choose a {clan:oracle-think-tank} 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 {clan:oracle-think-tank} that is being attacked, and that unit cannot be hit until end of that battle.
textRu: ""
- nameId: battle-sister-cocoa
nameEn: "Battle Sister, Cocoa"
nameRu: ""
template: |-
{auto}:When this unit is placed on {vc} or {rc}, if you have an {clan:oracle-think-tank} vanguard, look at the top card of your deck, and put that card on the top or the bottom of your deck.
textRu: ""
- nameId: battle-sister-mocha
nameEn: "Battle Sister, Mocha"
nameRu: ""
template: |-
{auto}{vcrc}: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.
textRu: ""
- nameId: battleraizer
nameEn: "Battleraizer"
nameRu: ""
template: |-
{auto}:When another {clan:nova-grappler} rides this unit, you may call this card to {rc}.
{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.
textRu: ""
- nameId: berserk-dragon
nameEn: "Berserk Dragon"
nameRu: ""
template: |-
{auto}:{cb:2} When this unit is placed on {vc} or {rc}, if you have a {clan:kagero} vanguard, you may pay the cost. If you do, choose an opponent's grade 2 or less rear-guard, and retire it.
textRu: ""
- nameId: blaster-blade
nameEn: "Blaster Blade"
nameRu: ""
template: |-
{auto}:{cb: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.
{auto}:{cb:2} When this unit is placed on {rc}, if you have a {clan:royal-paladin} vanguard, you may pay the cost. If you do, choose an opponent's grade 2 or greater rear-guard, and retire it.
textRu: ""
- nameId: bloody-hercules
nameEn: "Bloody Hercules"
nameRu: ""
template: ""
textRu: ""
- nameId: blue-dust
nameEn: "Blue Dust"
nameRu: ""
template: |-
{auto}{vcrc}:When this unit's attack hits, if you have a {clan:dark-irregulars} vanguard, you may {sc:1}.
textRu: ""
- nameId: bringer-of-good-luck-epona
nameEn: "Bringer of Good Luck, Epona"
nameRu: ""
template: ""
textRu: ""
- nameId: brutal-jack
nameEn: "Brutal Jack"
nameRu: ""
template: |-
{cont}{vcrc}:Restraint (This unit cannnot attack.){act}{vcrc}:{cb:1} Until end of turn, this unit loses "Restraint".
{auto}{vc}:When this unit is boosted by a {clan:nova-grappler}, this unit gets {power} +5000 until end of that battle.
textRu: ""
- nameId: ceo-amaterasu
nameEn: "CEO Amaterasu"
nameRu: ""
template: |-
{cont}{vc}:During your turn, if the number of cards in your hand is four or greater, this unit gets {power} +4000.
{auto}{vc}:At the beginning of your main phase, {sc:1}, look at the top card of your deck, and put that card on the top or the bottom of your deck.
{auto}{vcrc}:{sb:8} & {cb:5} When this unit's attack hits, you may pay the cost. If you do, draw up to five cards.
textRu: ""
- nameId: clay-doll-mechanic
nameEn: "Clay-doll Mechanic"
nameRu: ""
template: |-
{auto}:When this unit is placed on {rc}, if you have a {clan:nova-grappler} vanguard, choose a card from your damage zone, and turn it face up.
textRu: ""
- nameId: commodore-blueblood
nameEn: "Commodore Blueblood"
nameRu: ""
template: ""
textRu: ""
- nameId: covenant-knight-randolf
nameEn: "Covenant Knight, Randolf"
nameRu: ""
template: |-
{auto}{vcrc}: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.
textRu: ""
- nameId: crimson-butterfly-brigitte
nameEn: "Crimson Butterfly, Brigitte"
nameRu: ""
template: |-
{auto}{vc}:When this unit's drive check reveals a grade 3 {clan:royal-paladin}, this unit gets {power} +5000 until end of that battle.
textRu: ""
- nameId: dandy-guy-romario
nameEn: "Dandy Guy, Romario"
nameRu: ""
template: ""
textRu: ""
- nameId: demon-eater
nameEn: "Demon Eater"
nameRu: ""
template: |-
{auto}{vc}:At the beginning of your main phase, {sc:1}, and this unit gets {power} +2000 until end of turn.
{auto}{vcrc}:{sb:8} & {cb:5} When this unit's attack hits, you may pay the cost. If you do, retire all of your opponent's rear-guards.
textRu: ""
- nameId: demon-slaying-knight-lohengrin
nameEn: "Demon Slaying Knight, Lohengrin"
nameRu: ""
template: |-
{auto}{vc}:At the beginning of your main phase, {sc:1}, and this unit gets {power} +2000 until end of turn.
{auto}{vcrc}:{sb:8} & {cb:5} When this unit's attack hits, you may pay the cost. If you do, retire all of your opponent's rear-guards.
textRu: ""
- nameId: demonic-dragon-berserker-yaksha
nameEn: "Demonic Dragon Berserker, Yaksha"
nameRu: ""
template: |-
{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.
textRu: ""
- nameId: demonic-dragon-madonna-joka
nameEn: "Demonic Dragon Madonna, Joka"
nameRu: ""
template: |-
{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.
textRu: ""
- nameId: demonic-dragon-mage-rakshasa
nameEn: "Demonic Dragon Mage, Rakshasa"
nameRu: ""
template: |-
{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.
textRu: ""
- nameId: dragon-dancer-monica
nameEn: "Dragon Dancer, Monica"
nameRu: ""
template: ""
textRu: ""
- nameId: dragon-knight-aleph
nameEn: "Dragon Knight, Aleph"
nameRu: ""
template: |-
{act}{vc}:[{cb: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.
textRu: ""
- nameId: dragon-knight-nehalem
nameEn: "Dragon Knight, Nehalem"
nameRu: ""
template: ""
textRu: ""
- nameId: dragon-monk-genjo
nameEn: "Dragon Monk, Genjo"
nameRu: ""
template: |-
(You may only have up to four cards with "{heal} HEAL" in a deck.)
textRu: ""
- nameId: dragon-monk-gojo
nameEn: "Dragon Monk, Gojo"
nameRu: ""
template: |-
{act}{vcrc}:[{rest} & Choose a card from your hand, and discard it] Draw a card.
textRu: ""
- nameId: dragon-monk-goku
nameEn: "Dragon Monk, Goku"
nameRu: ""
template: |-
{auto}{vc}:When this unit's drive check reveals a grade 3 {clan:kagero}, choose an opponent's grade 1 or less rear-guard, and retire it.
textRu: ""
- nameId: dragonic-overlord
nameEn: "Dragonic Overlord"
nameRu: ""
template: |-
{cont}{vcrc}:If you do not have another {clan:kagero} vanguard or rear-guard, this unit gets {power} -2000.
{act}{vcrc}:{cb:3} Until end of turn, this unit gets {power} +5000, gets "Own{vcrc}:When this unit's attack hits an opponent's rear-guard, {stand} this unit," and loses "{twin-drive}".
textRu: ""
- nameId: dream-eater
nameEn: "Dream Eater"
nameRu: ""
template: ""
textRu: ""
- nameId: embodiment-of-armor-bahr
nameEn: "Embodiment of Armor, Bahr"
nameRu: ""
template: ""
textRu: ""
- nameId: embodiment-of-spear-tahr
nameEn: "Embodiment of Spear, Tahr"
nameRu: ""
template: ""
textRu: ""
- nameId: embodiment-of-victory-aleph
nameEn: "Embodiment of Victory, Aleph"
nameRu: ""
template: |-
{act}{vcrc}:{cb:4} This unit gets {power} +3000/{critical} +1 until end of turn.
{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.
textRu: ""
- nameId: flame-of-hope-aermo
nameEn: "Flame of Hope, Aermo"
nameRu: ""
template: |-
{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.
textRu: ""
- nameId: flash-shield-iseult
nameEn: "Flash Shield, Iseult"
nameRu: ""
template: |-
{cont}:{sentinel} (You may only have up to four cards with "{cont}:{sentinel}" in a deck.)
{auto}:[Choose a {clan:royal-paladin} 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 {clan:royal-paladin} that is being attacked, and that unit cannot be hit until end of that battle.
textRu: ""
- nameId: flogal
nameEn: "Flogal"
nameRu: ""
template: ""
textRu: ""
- nameId: future-knight-llew
nameEn: "Future Knight, Llew"
nameRu: ""
template: |-
{act}{rc}:[{cb: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.
textRu: ""
- nameId: guiding-zombie
nameEn: "Guiding Zombie"
nameRu: ""
template: |-
{auto}:When another {clan:granblue} rides this unit, you may call this card to {rc}.
{act}{rc}:[Put this unit into your soul] Put three cards from the top of your deck into your drop zone.
textRu: ""
- nameId: hell-spider
nameEn: "Hell Spider"
nameRu: ""
template: |-
{cont}{vc}:During your turn, if all of your opponent's vanguard and rear-guards are Rest , this unit gets {power} +3000.
{auto}:{cb: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.
textRu: ""
- nameId: highspeed-brakki
nameEn: "Highspeed, Brakki"
nameRu: ""
template: |-
{auto}{rc}:{sb: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.
textRu: ""
- nameId: hungry-dumpty
nameEn: "Hungry Dumpty"
nameRu: ""
template: |-
{auto}:When this unit is placed on {rc}, if you have a {clan:nova-grappler} vanguard, choose a card from your damage zone, and turn it face up.
textRu: ""
- nameId: ironclad-dragon-shieldon
nameEn: "Ironclad Dragon, Shieldon"
nameRu: ""
template: ""
textRu: ""
- nameId: juggernaut-maximum
nameEn: "Juggernaut Maximum"
nameRu: ""
template: |-
{cont}{vcrc}:If you do not have another {clan:spike-brothers} vanguard or rear-guard, this unit gets {power} -2000.
{auto}{rc}:{sb: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.
textRu: ""
- nameId: karma-queen
nameEn: "Karma Queen"
nameRu: ""
template: |-
{auto}:{cb: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.
textRu: ""
- nameId: king-of-knights-alfred
nameEn: "King of Knights, Alfred"
nameRu: ""
template: |-
{cont}{vc}:Your units cannot boost this unit.
{cont}{vc}:During your turn, this unit gets {power} +2000 for each of your {clan:royal-paladin} rear-guards.
{act}{vcrc}:{cb:3} Search your deck for up to one grade 2 or less {clan:royal-paladin}, call it to {rc}, and shuffle your deck.
textRu: ""
- nameId: king-of-sword
nameEn: "King of Sword"
nameRu: ""
template: ""
textRu: ""
- nameId: knight-of-conviction-bors
nameEn: "Knight of Conviction, Bors"
nameRu: ""
template: |-
{auto}{vcrc}:{cb:1} When this unit attacks, you may pay the cost. If you do, this unit gets {power} +3000 until end of that battle.
textRu: ""
- nameId: knight-of-rose-morgana
nameEn: "Knight of Rose, Morgana"
nameRu: ""
template: |-
{auto}{vcrc}:[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.
textRu: ""
- nameId: knight-of-silence-gallatin
nameEn: "Knight of Silence, Gallatin"
nameRu: ""
template: ""
textRu: ""
- nameId: knight-of-the-harp-tristan
nameEn: "Knight of the Harp, Tristan"
nameRu: ""
template: |-
{auto}{vc}:When this unit's drive check reveals a grade 3 {clan:royal-paladin}, this unit gets {power} +5000 until end of that battle.
textRu: ""
- nameId: lake-maiden-lien
nameEn: "Lake Maiden, Lien"
nameRu: ""
template: |-
{act}{vcrc}:[{rest} & Choose a card from your hand, and discard it] Draw a card.
textRu: ""
- nameId: little-sage-marron
nameEn: "Little Sage, Marron"
nameRu: ""
template: ""
textRu: ""
- nameId: lizard-runner-undeux
nameEn: "Lizard Runner, Undeux"
nameRu: ""
template: ""
textRu: ""
- nameId: lizard-soldier-conroe
nameEn: "Lizard Soldier, Conroe"
nameRu: ""
template: |-
{auto}:When another {clan:kagero} rides this unit, you may call this card to a {rc}.
{act}{rc}:[{cb:1} & Retire this unit] Search your deck for up to one grade 1 or less {clan:kagero}, reveal it to your opponent, put it into your hand, and shuffle your deck.
textRu: ""
- nameId: lizard-soldier-ganlu
nameEn: "Lizard Soldier, Ganlu"
nameRu: ""
template: ""
textRu: ""
- nameId: lozenge-magus
nameEn: "Lozenge Magus"
nameRu: ""
template: |-
(You may only have up to four cards with "{heal} HEAL" in a deck.)
{auto}:When another {clan:oracle-think-tank} rides this unit, you may call this card to {rc}.
{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.
textRu: ""
- nameId: lucky-girl
nameEn: "Lucky Girl"
nameRu: ""
template: ""
textRu: ""
- nameId: madame-mirage
nameEn: "Madame Mirage"
nameRu: ""
template: ""
textRu: ""
- nameId: maiden-of-libra
nameEn: "Maiden of Libra"
nameRu: ""
template: |-
{auto}{vcrc}:{cb:2} When this unit's attack hits, if you have an {clan:oracle-think-tank} vanguard, you may pay the cost. If you do, draw a card.
textRu: ""
- nameId: miracle-kid
nameEn: "Miracle Kid"
nameRu: ""
template: ""
textRu: ""
- nameId: monster-frank
nameEn: "Monster Frank"
nameRu: ""
template: |-
{act}{drop}:{cb:3} If you have a grade 2 vanguard, ride this card.
textRu: ""
- nameId: mr-invincible
nameEn: "Mr. Invincible"
nameRu: ""
template: |-
{auto}{vc}:At the beginning of your main phase, {sc:1}, choose a card from your damage zone, and turn it face up.
{auto}{vcrc}:{sb:8} & {cb:5} When this unit's attack hits, you may pay the cost. If you do, {stand} all of your units.
textRu: ""
- nameId: nightmare-baby
nameEn: "Nightmare Baby"
nameRu: ""
template: |-
{auto}{rc}:When this unit boosts a unit named "Blue Dust", the boosted unit gets {power} +4000 until end of that battle.
textRu: ""
- nameId: oracle-guardian-apollon
nameEn: "Oracle Guardian, Apollon"
nameRu: ""
template: |-
{auto}{vc}:{cb: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.
{auto}{rc}:{cb:2} When this unit's attack hits, you may pay the cost. If you do, draw a card.
textRu: ""
- nameId: oracle-guardian-gemini
nameEn: "Oracle Guardian, Gemini"
nameRu: ""
template: ""
textRu: ""
- nameId: oracle-guardian-nike
nameEn: "Oracle Guardian, Nike"
nameRu: ""
template: ""
textRu: ""
- nameId: oracle-guardian-wiseman
nameEn: "Oracle Guardian, Wiseman"
nameRu: ""
template: ""
textRu: ""
- nameId: queen-of-heart
nameEn: "Queen of Heart"
nameRu: ""
template: |-
{auto}{rc}:When this unit boosts a unit named "King of Sword", the boosted unit gets {power} +4000 until end of that battle.
textRu: ""
- nameId: redshoe-milly
nameEn: "Redshoe, Milly"
nameRu: ""
template: |-
{auto}:When this unit is put into the drop zone from {gc}, put this card into your soul.
textRu: ""
- nameId: ring-girl-clara
nameEn: "Ring Girl, Clara"
nameRu: ""
template: |-
(You may only have up to four cards with "{heal} HEAL" in a deck.)
textRu: ""
- nameId: rock-the-wall
nameEn: "Rock the Wall"
nameRu: ""
template: |-
{auto}:When this unit is put into the drop zone from {gc}, put this card into your soul.
textRu: ""
- nameId: screamin-and-dancin-announcer-shout
nameEn: "Screamin' and Dancin' Announcer, Shout"
nameRu: ""
template: |-
{act}{vcrc}:[{rest} & Choose a card from your hand, and discard it] Draw a card.
textRu: ""
- nameId: shining-lady
nameEn: "Shining Lady"
nameRu: ""
template: ""
textRu: ""
- nameId: solitary-knight-gancelot
nameEn: "Solitary Knight, Gancelot"
nameRu: ""
template: |-
{act}{vc}:{cb:2} If you have a card named "Blaster Blade" in your soul, this unit gets {power} +5000/{critical} +1 until end of turn.
{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.
textRu: ""
- nameId: sonic-noa
nameEn: "Sonic Noa"
nameRu: ""
template: ""
textRu: ""
- nameId: stardust-trumpeter
nameEn: "Stardust Trumpeter"
nameRu: ""
template: ""
textRu: ""
- nameId: starlight-unicorn
nameEn: "Starlight Unicorn"
nameRu: ""
template: |-
{auto}:When this unit is placed on {rc}, choose another of your {clan:royal-paladin}, and that unit gets {power} +2000 until end of turn.
textRu: ""
- nameId: stealth-beast-chigasumi
nameEn: "Stealth Beast, Chigasumi"
nameRu: ""
template: |-
{auto}{vcrc}: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.
textRu: ""
- nameId: stealth-beast-hagakure
nameEn: "Stealth Beast, Hagakure"
nameRu: ""
template: |-
{auto}:{cb:1} When this unit is placed on {gc}, if you have a {clan:nubatama} 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.
textRu: ""
- nameId: stealth-dragon-dreadmaster
nameEn: "Stealth Dragon, Dreadmaster"
nameRu: ""
template: |-
{auto}{rc}:{cb:1} When an attack hits during the battle that this unit boosted a {clan:nubatama}, 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.
textRu: ""
- nameId: stealth-dragon-voidmaster
nameEn: "Stealth Dragon, Voidmaster"
nameRu: ""
template: |-
{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.
{auto}{vcrc}:{cb: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.
textRu: ""
- nameId: tough-boy
nameEn: "Tough Boy"
nameRu: ""
template: ""
textRu: ""
- nameId: tyrant-deathrex
nameEn: "Tyrant, Deathrex"
nameRu: ""
template: |-
{auto}{vc}:When this unit attacks, this unit gets {power} +5000 until end of that battle.
{auto}{vc}:When this unit's attack hits, choose one of your rear-guards, and retire it.
textRu: ""
- nameId: vortex-dragon
nameEn: "Vortex Dragon"
nameRu: ""
template: |-
{auto}{vc}:At the begininng of your main phase, {sc:1}, and this unit gets {power} +2000 until end of turn.
{act}{vcrc}:{sb:8} & {cb:5} Choose up to three of your opponent's rear-guards, and retire them.
textRu: ""
- nameId: weapons-dealer-govannon
nameEn: "Weapons Dealer, Govannon"
nameRu: ""
template: ""
textRu: ""
- nameId: weather-girl-milk
nameEn: "Weather Girl, Milk"
nameRu: ""
template: |-
{auto}{rc}:When this unit boosts an {clan:oracle-think-tank} vanguard, if the number of cards in your hand is four or greater, the boosted unit gets {power} +4000 until end of that battle.
textRu: ""
- nameId: wingal
nameEn: "Wingal"
nameRu: ""
template: |-
{auto}{rc}:When this unit boosts a card named "Blaster Blade", the boosted unit gets {power} +4000 until end of that battle.
textRu: ""
- nameId: wonder-boy
nameEn: "Wonder Boy"
nameRu: ""
template: ""
textRu: ""
- nameId: wyvern-guard-barri
nameEn: "Wyvern Guard, Barri"
nameRu: ""
template: |-
{cont}:{sentinel} (You may only have up to four cards with "{cont}:{sentinel}" in a deck.){auto}:[Choose a {clan:kagero} 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 {clan:kagero} that is being attacked, and that unit cannot be hit until end of that battle.
textRu: ""
- nameId: wyvern-strike-jarran
nameEn: "Wyvern Strike, Jarran"
nameRu: ""
template: |-
{auto}{rc}:When this unit boosts a card named "Wyvern Strike, Tejas", the boosted unit gets {power} +4000 until end of that battle.
textRu: ""
- nameId: wyvern-strike-tejas
nameEn: "Wyvern Strike, Tejas"
nameRu: ""
template: |-
{cont}{vcrc}:If this unit would attack, it may instead attack an opponent's unit in the back row of the same column as this unit.
textRu: ""
- nameId: yggdrasil-maiden-elaine
nameEn: "Yggdrasil Maiden, Elaine"
nameRu: ""
template: |-
(You may only have up to four cards with "{heal} HEAL" in a deck.)
textRu: ""

View File

@ -1,18 +1,22 @@
using Vanguard.Content;
using Vanguard.Content.CardText;
using Vanguard.Content.Translations;
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");
var root = FindRepoRoot() ?? Directory.GetCurrentDirectory();
return command switch
{
"validate" => RunValidate(setsDirectory),
"stats" => RunStats(setsDirectory),
"validate" => RunValidate(PickPath(1) ?? Path.Combine(root, "content", "sets")),
"stats" => RunStats(PickPath(1) ?? Path.Combine(root, "content", "sets")),
"translations" => RunTranslations(root),
_ => Usage(),
};
string? PickPath(int index) =>
args.Length > index ? Path.GetFullPath(args[index]) : null;
static int RunValidate(string setsDirectory)
{
CardCatalog catalog;
@ -67,13 +71,89 @@ static int RunStats(string setsDirectory)
return 0;
}
int RunTranslations(string repoRoot)
{
var action = (args.Length > 1 ? args[1] : "check").ToLowerInvariant();
var path = args.Length > 2
? Path.GetFullPath(args[2])
: Path.Combine(repoRoot, "content", "translations", "ru.yaml");
var catalog = CatalogLoader.LoadDirectory(Path.Combine(repoRoot, "content", "sets"));
var keywords = KeywordCatalog.Load(Path.Combine(repoRoot, "content", "keywords.yaml"));
switch (action)
{
case "build":
{
var existing = File.Exists(path) ? TranslationYaml.Read(path) : null;
var file = TranslationService.Build(catalog, existing);
TranslationYaml.Write(file, path);
Console.WriteLine($"Записано: {path}");
Console.WriteLine($"Карт: {file.Cards.Count} (существующие переводы сохранены)");
return 0;
}
case "check":
{
if (!File.Exists(path))
{
Console.Error.WriteLine($"Файл не найден: {path}");
Console.Error.WriteLine("Создайте его: dotnet run --project server/src/Vanguard.Cli -- translations build");
return 1;
}
var file = TranslationYaml.Read(path);
var report = TranslationService.Check(file, keywords);
var byCode = report.Issues
.GroupBy(i => i.Code)
.OrderBy(g => g.Key, StringComparer.Ordinal)
.ToList();
foreach (var group in byCode)
Console.WriteLine($" {group.Key,-14} {group.Count(),4}");
if (byCode.Count > 0 && report.Issues.Count > 0)
{
Console.WriteLine();
Console.WriteLine("Примеры:");
foreach (var issue in report.Issues.Take(10))
Console.WriteLine($" {issue.NameId}: {issue.Message}");
}
Console.WriteLine();
Console.WriteLine($"Переведено: {report.Translated}/{report.Total}");
if (report.Issues.Count == 0)
{
Console.WriteLine("OK: перевод полный, токены не нарушены.");
return 0;
}
Console.WriteLine($"Замечаний: {report.Issues.Count}");
return 1;
}
default:
Console.WriteLine("""
vanguard translations <build|check> [путь к ru.yaml]
build создать или обновить шаблоны, сохранив переводы
check проверить заполнение и целостность токенов
""");
return 2;
}
}
static int Usage()
{
Console.WriteLine("""
vanguard <команда> [путь к content/sets]
vanguard <команда> [параметры]
validate проверить целостность контента (по умолчанию)
stats сводка по кланам, триггерам и редкостям
validate [путь] проверить контент (по умолчанию)
stats [путь] сводка по кланам, триггерам и редкостям
translations build [путь] собрать файл переводов
translations check [путь] проверить переводы
""");
return 2;
}

View File

@ -0,0 +1,112 @@
using System.Text.RegularExpressions;
namespace Vanguard.Content.CardText;
/// <summary>
/// Превращает официальный текст карты в шаблон с токенами.
/// Токен — это иконка, которую клиент подставит при отрисовке.
///
/// Квадратные скобки в тексте означают две разные вещи:
/// * маркер/стоимость — [AUTO], [Counter-Blast 2], [Rest this unit];
/// * произвольный текст стоимости — [Choose a card from your hand, and discard it].
/// Поэтому скобочная группа разбирается целиком: если внутри остались только токены,
/// разделители и пробелы, скобки убираются; иначе скобки сохраняются как контейнер,
/// а известные слова внутри заменяются токенами.
/// </summary>
public static class CardTextTokenizer
{
private static readonly Regex TokenRegex = new(@"\{([a-z-]+)(?::([a-z0-9-]+))?\}", RegexOptions.Compiled);
private static readonly Regex BracketRegex = new(@"\[([^\]]*)\]", RegexOptions.Compiled);
private static readonly Regex ZoneRegex = new(@"\((VC/RC|VC|RC|GC|Hand)\)", RegexOptions.Compiled);
private static readonly Regex ClanRegex = new(@"<<?([A-Za-z ]+?)>>?", RegexOptions.Compiled);
private static readonly Regex CostRegex = new(
@"(Counter-Blast|Soul-Blast|Soul-Charge|Soul-Chage) (\d+)",
RegexOptions.Compiled);
private static readonly Dictionary<string, string> WholeBracketTokens = new(StringComparer.Ordinal)
{
["AUTO"] = "{auto}",
["ACT"] = "{act}",
["CONT"] = "{cont}",
["Power"] = "{power}",
["Critical"] = "{critical}",
["Heal"] = "{heal}",
["Draw"] = "{draw}",
["Stand"] = "{stand}",
["Rest this unit"] = "{rest}",
["Drop zone"] = "{drop}",
};
private static readonly Dictionary<string, string> CostTokens = new(StringComparer.Ordinal)
{
["Counter-Blast"] = "cb",
["Soul-Blast"] = "sb",
["Soul-Charge"] = "sc",
["Soul-Chage"] = "sc", // опечатка в официальном тексте
};
public static string ToTemplate(string? source)
{
if (string.IsNullOrWhiteSpace(source))
return string.Empty;
var text = BracketRegex.Replace(source, m => FormatBracket(m.Groups[1].Value));
text = ZoneRegex.Replace(text, m =>
m.Groups[1].Value == "VC/RC" ? "{vcrc}" : "{" + m.Groups[1].Value.ToLowerInvariant() + "}");
text = ClanRegex.Replace(text, m => "{clan:" + Slug(m.Groups[1].Value) + "}");
text = text.Replace("Sentinel", "{sentinel}", StringComparison.Ordinal);
text = text.Replace("Twin Drive!!", "{twin-drive}", StringComparison.Ordinal);
return text;
}
private static string FormatBracket(string content)
{
var inner = WholeBracketTokens.TryGetValue(content.Trim(), out var whole)
? whole
: ReplaceInnerKeywords(content);
return IsOnlyTokens(inner) ? inner : $"[{inner}]";
}
private static string ReplaceInnerKeywords(string content)
{
var result = CostRegex.Replace(content, m => $"{{{CostTokens[m.Groups[1].Value]}:{m.Groups[2].Value}}}");
return result.Replace("Rest this unit", "{rest}", StringComparison.Ordinal);
}
private static bool IsOnlyTokens(string value)
{
var rest = TokenRegex.Replace(value, string.Empty);
return rest.All(c => char.IsWhiteSpace(c) || c == '&' || c == ':');
}
/// <summary>Токены в виде «имя» или «имя:значение» с количеством вхождений.</summary>
public static IReadOnlyDictionary<string, int> ExtractTokens(string? template)
{
var result = new Dictionary<string, int>(StringComparer.Ordinal);
if (string.IsNullOrEmpty(template))
return result;
foreach (Match match in TokenRegex.Matches(template))
{
var key = match.Groups[2].Success
? $"{match.Groups[1].Value}:{match.Groups[2].Value}"
: match.Groups[1].Value;
result[key] = result.GetValueOrDefault(key) + 1;
}
return result;
}
public static IEnumerable<string> ExtractTokenNames(string? template) =>
ExtractTokens(template).Keys.Select(k => k.Split(':')[0]).Distinct(StringComparer.Ordinal);
internal static string Slug(string value)
{
var slug = value.Trim().ToLowerInvariant().Replace(' ', '-');
return Regex.Replace(slug, "[^a-z0-9-]", string.Empty);
}
}

View File

@ -0,0 +1,64 @@
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Vanguard.Content.CardText;
public sealed class KeywordFile
{
public int Version { get; set; } = 1;
public List<KeywordEntry> Keywords { get; set; } = new();
}
public sealed class KeywordEntry
{
public string Id { get; set; } = string.Empty;
public string Kind { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
public string En { get; set; } = string.Empty;
public string Ru { get; set; } = string.Empty;
public string Icon { get; set; } = string.Empty;
public bool Numeric { get; set; }
}
/// <summary>Справочник ключевых слов: токен → иконка и подпись.</summary>
public sealed class KeywordCatalog
{
private readonly Dictionary<string, KeywordEntry> _byId;
private KeywordCatalog(IEnumerable<KeywordEntry> keywords)
{
_byId = keywords.ToDictionary(k => k.Id, StringComparer.Ordinal);
All = _byId.Values.ToList();
}
public IReadOnlyCollection<KeywordEntry> All { get; }
public bool Contains(string id) => _byId.ContainsKey(id);
/// <summary>
/// Проверяет токен. Для клана токен выглядит как {clan:kagero} и ищется
/// по id clan-kagero, остальные — по имени токена.
/// </summary>
public bool ContainsToken(string name, string? value) =>
name == "clan" && !string.IsNullOrEmpty(value)
? Contains($"clan-{value}")
: Contains(name);
public KeywordEntry Get(string id) =>
_byId.TryGetValue(id, out var entry)
? entry
: throw new KeyNotFoundException($"Ключевое слово не найдено: {id}");
public static KeywordCatalog Load(string path)
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
var file = deserializer.Deserialize<KeywordFile>(File.ReadAllText(path))
?? throw new InvalidOperationException($"Пустой файл ключевых слов: {path}");
return new KeywordCatalog(file.Keywords);
}
}

View File

@ -0,0 +1,208 @@
using System.Text;
using Vanguard.Rules.Cards;
using YamlDotNet.Serialization;
using YamlDotNet.Serialization.NamingConventions;
namespace Vanguard.Content.Translations;
public sealed class TranslationFile
{
public int Version { get; set; } = 1;
public List<TranslationEntry> Cards { get; set; } = new();
}
public sealed class TranslationEntry
{
public string NameId { get; set; } = string.Empty;
public string NameEn { get; set; } = string.Empty;
public string? NameRu { get; set; }
/// <summary>Английский текст с токенами вместо иконок. Источник для перевода.</summary>
public string? Template { get; set; }
public string? TextRu { get; set; }
}
public static class TranslationYaml
{
private const string Header =
"""
# Русские тексты карт.
#
# Заполняйте только nameRu и textRu. Остальные поля не меняйте.
# Токены в фигурных скобках — это иконки: {auto}, {cb:3}, {vcrc}, {clan:kagero}.
# Их нужно сохранить в textRu ровно в таком же количестве, иначе проверка не пройдёт.
# Список всех токенов — content/keywords.yaml.
#
# Проверка: dotnet run --project server/src/Vanguard.Cli -- translations check
""";
public static void Write(TranslationFile file, string path)
{
var dir = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(dir))
Directory.CreateDirectory(dir);
var sb = new StringBuilder();
sb.Append(Header.ReplaceLineEndings("\n"));
sb.Append("version: ").Append(file.Version).Append('\n');
sb.Append("cards:\n");
foreach (var card in file.Cards)
{
sb.Append(" - nameId: ").Append(card.NameId).Append('\n');
sb.Append(" nameEn: ").Append(Quote(card.NameEn)).Append('\n');
sb.Append(" nameRu: ").Append(Quote(card.NameRu ?? string.Empty)).Append('\n');
AppendBlock(sb, "template", card.Template);
sb.Append(" textRu: ").Append(Quote(card.TextRu ?? string.Empty)).Append('\n');
}
File.WriteAllText(path, sb.ToString(), new UTF8Encoding(encoderShouldEmitUTF8Identifier: false));
}
public static TranslationFile Read(string path)
{
var deserializer = new DeserializerBuilder()
.WithNamingConvention(CamelCaseNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
return deserializer.Deserialize<TranslationFile>(File.ReadAllText(path)) ?? new TranslationFile();
}
private static void AppendBlock(StringBuilder sb, string key, string? value)
{
if (string.IsNullOrEmpty(value))
{
sb.Append(" ").Append(key).Append(": \"\"\n");
return;
}
sb.Append(" ").Append(key).Append(": |-\n");
foreach (var line in value.ReplaceLineEndings("\n").Split('\n'))
sb.Append(" ").Append(line.TrimEnd()).Append('\n');
}
private static string Quote(string value) =>
"\"" + value
.Replace("\\", "\\\\")
.Replace("\"", "\\\"")
.Replace("\r", string.Empty)
.Replace("\n", "\\n") + "\"";
}
public sealed record TranslationIssue(string NameId, string Code, string Message);
public sealed record TranslationReport(
int Total,
int Translated,
IReadOnlyList<TranslationIssue> Issues)
{
public bool IsComplete => Issues.Count == 0;
}
public static class TranslationService
{
/// <summary>
/// Собирает шаблоны из каталога, сохраняя уже введённые переводы.
/// </summary>
public static TranslationFile Build(CardCatalog catalog, TranslationFile? existing)
{
var previous = existing?.Cards.ToDictionary(c => c.NameId, StringComparer.Ordinal)
?? new Dictionary<string, TranslationEntry>(StringComparer.Ordinal);
var cards = catalog.Cards
.OrderBy(c => c.NameId, StringComparer.Ordinal)
.Select(card =>
{
previous.TryGetValue(card.NameId, out var old);
return new TranslationEntry
{
NameId = card.NameId,
NameEn = card.NameEn,
NameRu = old?.NameRu ?? string.Empty,
Template = CardText.CardTextTokenizer.ToTemplate(card.TextEn),
TextRu = old?.TextRu ?? string.Empty,
};
})
.ToList();
return new TranslationFile { Version = 1, Cards = cards };
}
public static TranslationReport Check(TranslationFile file, CardText.KeywordCatalog? keywords = null)
{
var issues = new List<TranslationIssue>();
var translated = 0;
var unknown = new HashSet<string>(StringComparer.Ordinal);
foreach (var card in file.Cards)
{
var hasName = !string.IsNullOrWhiteSpace(card.NameRu);
var hasText = string.IsNullOrWhiteSpace(card.Template) || !string.IsNullOrWhiteSpace(card.TextRu);
if (hasName && hasText)
translated++;
else
{
if (!hasName)
issues.Add(new TranslationIssue(card.NameId, "name.missing", "Не заполнено nameRu."));
if (!hasText)
issues.Add(new TranslationIssue(card.NameId, "text.missing", "Не заполнено textRu."));
}
var source = CardText.CardTextTokenizer.ExtractTokens(card.Template);
var target = CardText.CardTextTokenizer.ExtractTokens(card.TextRu);
if (!string.IsNullOrWhiteSpace(card.TextRu))
{
foreach (var (token, count) in source)
{
var actual = target.GetValueOrDefault(token);
if (actual != count)
{
issues.Add(new TranslationIssue(
card.NameId,
"token.mismatch",
$"Токен {{{token}}}: в шаблоне {count}, в переводе {actual}."));
}
}
foreach (var (token, count) in target)
{
if (source.ContainsKey(token))
continue;
issues.Add(new TranslationIssue(
card.NameId,
"token.extra",
$"В переводе лишний токен {{{token}}} ({count})."));
}
}
if (keywords is not null)
{
var tokens = CardText.CardTextTokenizer.ExtractTokens(card.Template).Keys
.Concat(CardText.CardTextTokenizer.ExtractTokens(card.TextRu).Keys);
foreach (var token in tokens)
{
var parts = token.Split(':', 2);
var name = parts[0];
var value = parts.Length > 1 ? parts[1] : null;
if (!keywords.ContainsToken(name, value) && unknown.Add(token))
{
issues.Add(new TranslationIssue(
card.NameId,
"token.unknown",
$"Токен {{{token}}} отсутствует в content/keywords.yaml."));
}
}
}
}
return new TranslationReport(file.Cards.Count, translated, issues);
}
}

View File

@ -0,0 +1,115 @@
using Vanguard.Content.CardText;
using Vanguard.Content.Translations;
using Xunit;
namespace Vanguard.Rules.Tests;
public class CardTextTests
{
[Fact]
public void Tokenizer_Replaces_Ability_And_Zone_Markers()
{
var template = CardTextTokenizer.ToTemplate(
"[AUTO](VC/RC):[Counter-Blast 2] When this unit is placed on (RC), retire a rear-guard.");
Assert.Equal(
"{auto}{vcrc}:{cb:2} When this unit is placed on {rc}, retire a rear-guard.",
template);
}
[Fact]
public void Tokenizer_Handles_Numeric_Costs()
{
var template = CardTextTokenizer.ToTemplate("[ACT](VC):[Soul-Blast 8 & Counter-Blast 5] Draw up to five cards.");
Assert.Contains("{sb:8}", template);
Assert.Contains("{cb:5}", template);
Assert.DoesNotContain("[", template);
}
[Fact]
public void Tokenizer_Handles_Clan_Brackets_And_Source_Typo()
{
Assert.Equal("{clan:royal-paladin}", CardTextTokenizer.ToTemplate("<Royal Paladin>"));
Assert.Equal("{clan:royal-paladin}", CardTextTokenizer.ToTemplate("<<Royal Paladin>"));
Assert.Equal("{clan:spike-brothers}", CardTextTokenizer.ToTemplate("<<Spike Brothers>"));
}
[Fact]
public void Tokenizer_Keeps_Free_Text_Costs_Untouched()
{
var template = CardTextTokenizer.ToTemplate(
"[ACT](RC):[Rest this unit & Choose a card from your hand, and discard it] Draw a card.");
Assert.Equal(
"{act}{rc}:[{rest} & Choose a card from your hand, and discard it] Draw a card.",
template);
}
[Fact]
public void Every_Template_Token_Exists_In_Keywords()
{
var keywords = KeywordCatalog.Load(TestPaths.Keywords);
var catalog = TestCatalog.Instance;
foreach (var card in catalog.Cards)
{
var template = CardTextTokenizer.ToTemplate(card.TextEn);
foreach (var token in CardTextTokenizer.ExtractTokens(template).Keys)
{
var parts = token.Split(':', 2);
var value = parts.Length > 1 ? parts[1] : null;
Assert.True(
keywords.ContainsToken(parts[0], value),
$"{card.NameId}: токен {{{token}}} отсутствует в keywords.yaml");
}
}
}
[Fact]
public void Translation_Check_Flags_Broken_Tokens()
{
var file = new TranslationFile
{
Cards =
{
new TranslationEntry
{
NameId = "test",
NameEn = "Test",
NameRu = "Тест",
Template = "{auto}{vc}:Draw a card.",
TextRu = "Взять карту.",
},
},
};
var report = TranslationService.Check(file);
Assert.Contains(report.Issues, i => i.Code == "token.mismatch");
}
[Fact]
public void Translation_Check_Passes_On_Complete_Entry()
{
var file = new TranslationFile
{
Cards =
{
new TranslationEntry
{
NameId = "test",
NameEn = "Test",
NameRu = "Тест",
Template = "{auto}{vc}:Draw a card.",
TextRu = "{auto}{vc}:Взять карту.",
},
},
};
var report = TranslationService.Check(file);
Assert.True(report.IsComplete);
}
}

View File

@ -0,0 +1,21 @@
using Vanguard.Content.CardText;
namespace Vanguard.Rules.Tests;
public static class TestPaths
{
public static string Root { get; } = Find();
public static string ContentSets => Path.Combine(Root, "content", "sets");
public static string Keywords => Path.Combine(Root, "content", "keywords.yaml");
public static string Translations => Path.Combine(Root, "content", "translations", "ru.yaml");
private static string Find()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir is not null && !Directory.Exists(Path.Combine(dir.FullName, "content", "sets")))
dir = dir.Parent;
return dir?.FullName ?? throw new InvalidOperationException("Корень репозитория не найден.");
}
}