diff --git a/DEVELOPMENT_CHECKLIST.md b/DEVELOPMENT_CHECKLIST.md index 8c74b69..5eecc9b 100644 --- a/DEVELOPMENT_CHECKLIST.md +++ b/DEVELOPMENT_CHECKLIST.md @@ -239,7 +239,8 @@ - [ ] **P0** Unity Localization: String Tables `en`/`ru`, ключи по схеме `ui.menu.play`, runtime-переключение без перезагрузки сцены. - [ ] **P0** Сервер: параметр `lang` в запросах карт/глоссария, fallback `en`. -- [ ] **P1** Текстовый шаблонизатор карт: `{cb}`, `{sb}`, `{cc}`, `{sc}` → локализованный термин из глоссария; поддержка ``, ``, переносов. +- [x] **P1** Токенизация текста карт: `content/keywords.yaml` (иконки + подписи), шаблоны с токенами `{cb:3}`, `{vcrc}`, `{clan:kagero}`; проверка целостности токенов при переводе. +- [ ] **P1** Рендер шаблона в клиенте: подстановка иконок по токенам, rich-text, авто-фит текста на карте. - [ ] **P1** Проверка шрифтов TMP: кириллица, fallback-атлас, авто-фит текста на карте. - [ ] **P1** Псевдолокализация (длинные строки) для проверки вёрстки. - [ ] **P1** Лаунчер: RESX `ru`/`en`, общий глоссарий с сервером (единый CSV как источник). diff --git a/IMPLEMENTATION_CHECKLIST.md b/IMPLEMENTATION_CHECKLIST.md index c91c1af..b57ce68 100644 --- a/IMPLEMENTATION_CHECKLIST.md +++ b/IMPLEMENTATION_CHECKLIST.md @@ -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-ключей). diff --git a/README.md b/README.md index ceafdd0..62b5831 100644 --- a/README.md +++ b/README.md @@ -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)`, ``) заменяются +на иконки при отрисовке. Чтобы перевод не ломал разметку, тексты хранятся в виде +**шаблонов с токенами**: + +``` +{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` — исходный дизайн-документ. diff --git a/content/keywords.yaml b/content/keywords.yaml new file mode 100644 index 0000000..e68af15 --- /dev/null +++ b/content/keywords.yaml @@ -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" diff --git a/content/translations/ru.yaml b/content/translations/ru.yaml new file mode 100644 index 0000000..8ef5b89 --- /dev/null +++ b/content/translations/ru.yaml @@ -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: "" diff --git a/server/src/Vanguard.Cli/Program.cs b/server/src/Vanguard.Cli/Program.cs index 94c30a4..9b1bc56 100644 --- a/server/src/Vanguard.Cli/Program.cs +++ b/server/src/Vanguard.Cli/Program.cs @@ -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 [путь к 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; } diff --git a/server/src/Vanguard.Content/CardText/CardTextTokenizer.cs b/server/src/Vanguard.Content/CardText/CardTextTokenizer.cs new file mode 100644 index 0000000..28842ac --- /dev/null +++ b/server/src/Vanguard.Content/CardText/CardTextTokenizer.cs @@ -0,0 +1,112 @@ +using System.Text.RegularExpressions; + +namespace Vanguard.Content.CardText; + +/// +/// Превращает официальный текст карты в шаблон с токенами. +/// Токен — это иконка, которую клиент подставит при отрисовке. +/// +/// Квадратные скобки в тексте означают две разные вещи: +/// * маркер/стоимость — [AUTO], [Counter-Blast 2], [Rest this unit]; +/// * произвольный текст стоимости — [Choose a card from your hand, and discard it]. +/// Поэтому скобочная группа разбирается целиком: если внутри остались только токены, +/// разделители и пробелы, скобки убираются; иначе скобки сохраняются как контейнер, +/// а известные слова внутри заменяются токенами. +/// +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(@"<>?", RegexOptions.Compiled); + private static readonly Regex CostRegex = new( + @"(Counter-Blast|Soul-Blast|Soul-Charge|Soul-Chage) (\d+)", + RegexOptions.Compiled); + + private static readonly Dictionary 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 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 == ':'); + } + + /// Токены в виде «имя» или «имя:значение» с количеством вхождений. + public static IReadOnlyDictionary ExtractTokens(string? template) + { + var result = new Dictionary(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 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); + } +} diff --git a/server/src/Vanguard.Content/CardText/KeywordCatalog.cs b/server/src/Vanguard.Content/CardText/KeywordCatalog.cs new file mode 100644 index 0000000..1e0eae1 --- /dev/null +++ b/server/src/Vanguard.Content/CardText/KeywordCatalog.cs @@ -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 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; } +} + +/// Справочник ключевых слов: токен → иконка и подпись. +public sealed class KeywordCatalog +{ + private readonly Dictionary _byId; + + private KeywordCatalog(IEnumerable keywords) + { + _byId = keywords.ToDictionary(k => k.Id, StringComparer.Ordinal); + All = _byId.Values.ToList(); + } + + public IReadOnlyCollection All { get; } + + public bool Contains(string id) => _byId.ContainsKey(id); + + /// + /// Проверяет токен. Для клана токен выглядит как {clan:kagero} и ищется + /// по id clan-kagero, остальные — по имени токена. + /// + 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(File.ReadAllText(path)) + ?? throw new InvalidOperationException($"Пустой файл ключевых слов: {path}"); + + return new KeywordCatalog(file.Keywords); + } +} diff --git a/server/src/Vanguard.Content/Translations/TranslationService.cs b/server/src/Vanguard.Content/Translations/TranslationService.cs new file mode 100644 index 0000000..d193e6a --- /dev/null +++ b/server/src/Vanguard.Content/Translations/TranslationService.cs @@ -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 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; } + + /// Английский текст с токенами вместо иконок. Источник для перевода. + 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(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 Issues) +{ + public bool IsComplete => Issues.Count == 0; +} + +public static class TranslationService +{ + /// + /// Собирает шаблоны из каталога, сохраняя уже введённые переводы. + /// + public static TranslationFile Build(CardCatalog catalog, TranslationFile? existing) + { + var previous = existing?.Cards.ToDictionary(c => c.NameId, StringComparer.Ordinal) + ?? new Dictionary(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(); + var translated = 0; + var unknown = new HashSet(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); + } +} diff --git a/server/tests/Vanguard.Rules.Tests/CardTextTests.cs b/server/tests/Vanguard.Rules.Tests/CardTextTests.cs new file mode 100644 index 0000000..cb04fee --- /dev/null +++ b/server/tests/Vanguard.Rules.Tests/CardTextTests.cs @@ -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("")); + Assert.Equal("{clan:royal-paladin}", CardTextTokenizer.ToTemplate("<")); + Assert.Equal("{clan:spike-brothers}", CardTextTokenizer.ToTemplate("<")); + } + + [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); + } +} diff --git a/server/tests/Vanguard.Rules.Tests/TestPaths.cs b/server/tests/Vanguard.Rules.Tests/TestPaths.cs new file mode 100644 index 0000000..31bee66 --- /dev/null +++ b/server/tests/Vanguard.Rules.Tests/TestPaths.cs @@ -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("Корень репозитория не найден."); + } +}