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? Template { get; set; } public string? TextRu { get; set; } } public static class TranslationYaml { private const string Header = """ # Русские тексты карт. # # Имена карт остаются английскими — переводим только текст эффекта. # Заполняйте только 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'); 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, 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 hasText = string.IsNullOrWhiteSpace(card.Template) || !string.IsNullOrWhiteSpace(card.TextRu); if (hasText) translated++; else 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); } }