Der Status des Projekts stand verstreut in elf Umsetzungsplaenen, drei Konzepten, der Linux-Analyse und dem Projektstand - teils widersprechend, teils wochenlang veraltet. Ab jetzt gibt es genau eine Statusquelle. docs/ROADMAP.md (neu): - Alle Vorhaben in vier Stufen A bis D, plus technische Schuld und Verlauf. Die Stufen sind eine Reihenfolge, keine Termine: jede schafft die Voraussetzung fuer die naechste. - Statuszeichen: erledigt / offen / blockiert (mit Ursache) / bewusst zurueckgestellt / Idee, nicht beschlossen. Damit ist das, was wir NICHT bauen wollen, sichtbar vorgehalten statt unauffindbar in einem Plan zu schlummern. - Inhaltlich getragen, nicht nur verlinkt: je Vorhaben Ziel, Phasen, Akzeptanzkriterien, offene Entscheidungen und Leitplanken aus den Quelldokumenten. - Sichtbar gemacht, was vorher zwischen den Dokumenten verborgen lag: CopyTrading Phase 1 ist der Engpass der gesamten Roadmap (MarketMaking und BundleArbitrage haben harte Voraussetzungen darauf), und die Sniper-Metriken aus Phase 3.2 sind ein Spezialfall des StrategieDrift-Fingerprints - zusammen bauen statt doppelt. Archiv (docs/archiv/): - 15 Dokumente verschoben (11 Umsetzungsplaene, 3 Konzepte, ANALYSE-Linux-Portierung). Sie bleiben die Bauanleitungen mit Code-Bezuegen, Risikotabellen und Begruendungen - eingefroren ist nur ihr Status. - archiv/README.md ordnet jedes Dokument seinem Roadmap-Punkt zu. Verweise nachgezogen - der eigentliche Aufwand: - 25 Markdown-Links repariert. 15 davon verschiebungsbedingt (eine Ebene tiefer), der Rest war schon vorher falsch: die Ideensammlung verlinkte Quellcode relativ zum Repo-Wurzelverzeichnis statt zu docs/. - 12 Dateien ausserhalb von docs/ verwiesen in Kommentaren auf die Plaene (csproj, props, setup.json, sechs Quelldateien) - alle auf archiv/ umgebogen. - Verweise auf Dateien, die der Fruehjahrsputz geloescht hat (Ui/, Program.cs, WindowMenuBar), zu Klartext entschaerft statt tote Links zu lassen. - Gegenprobe: 85 Links geprueft, 0 kaputt. Build gruen, 476 Tests gruen. PROJEKTSTAND.md entdoppelt: Abschnitt "Offen" verweist jetzt auf die Roadmap. Arbeitsteilung ist damit klar - Projektstand sagt was IST, Roadmap was KOMMT. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
399 lines
20 KiB
C#
399 lines
20 KiB
C#
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using System.Linq;
|
||
using Avalonia;
|
||
using Avalonia.Controls;
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Hosting;
|
||
using PolyTrader.App.Avalonia.Shell;
|
||
using PolyTrader.Core.Configuration;
|
||
using PolyTrader.Core.DependencyInjection;
|
||
using PolyTrader.Core.Modularity;
|
||
using PolyTrader.Core.Notifications;
|
||
using PolyTrader.Core.Security;
|
||
using PolyTrader.Modules.Accounting;
|
||
using PolyTrader.Modules.CopyTrading;
|
||
using PolyTrader.Modules.ResolutionFarming;
|
||
using PolyTrader.Modules.Supervisor;
|
||
using PolyTraderSharp;
|
||
using PolyTraderSharp.Models;
|
||
using PolyTraderSharp.Services;
|
||
|
||
namespace PolyTrader.App.Avalonia
|
||
{
|
||
/// <summary>
|
||
/// Einstiegspunkt der plattformneutralen PolyTrader-Anwendung.
|
||
///
|
||
/// <para>Der Aufbau ist bewusst zweigeteilt: <see cref="BuildHost"/> stellt den Generic Host mit
|
||
/// Persistenz, Diensten und Modulen zusammen – ohne jeden Bezug zur Oberfläche. Erst
|
||
/// <see cref="Main"/> hängt Avalonia daran. Damit lässt sich derselbe Host später ohne Umbau
|
||
/// für den kopflosen Linux-Betrieb starten (Schalter <c>--headless</c>, Stufe L2).</para>
|
||
/// </summary>
|
||
internal static class Program
|
||
{
|
||
private const string ServerSettingsPath = "server_settings.xml";
|
||
|
||
[STAThread]
|
||
public static int Main(string[] args)
|
||
{
|
||
bool headless = args.Any(a => string.Equals(a, "--headless", StringComparison.OrdinalIgnoreCase));
|
||
bool smoke = args.Any(a => string.Equals(a, "--smoke-ui", StringComparison.OrdinalIgnoreCase));
|
||
|
||
// D-2: Lizenz-CLI-Schalter (--license-status/--license-set-key/--license-deactivate)
|
||
// laufen VOR dem Host-Aufbau und beenden sich selbst - kein Sinn, dafür Trading-Dienste
|
||
// hochzufahren.
|
||
if (Licensing.LicenseCli.TryHandle(args, out int cliExitCode))
|
||
return cliExitCode;
|
||
|
||
var dcOptions = LoadDeploymentcenterOptions();
|
||
|
||
// Konstruktionsprüfung (--smoke-ui) testet Fensteraufbau, keine Lizenzlogik - dort
|
||
// bewusst ALLE Module registrieren, sonst würde die Prüfung auf einem Build-Server
|
||
// (nie lizenziert) still einen Großteil ihrer Abdeckung verlieren.
|
||
var host = BuildHost(dcOptions, enforceLicense: !smoke, out var bootLog);
|
||
|
||
// Konstruktionspruefung der Fenster: Host NICHT starten. Sonst laufen Trading-Engine,
|
||
// WSS-Clients und Jobs gegen die echten Boersen-Endpunkte an - fuer einen reinen
|
||
// UI-Test unerwuenscht (und auf einem Build-Server schlicht falsch).
|
||
if (smoke)
|
||
{
|
||
try { return RunSmokeUi(host, bootLog); }
|
||
finally { host.Dispose(); }
|
||
}
|
||
|
||
host.Start();
|
||
|
||
try
|
||
{
|
||
if (headless)
|
||
{
|
||
bootLog.Info("Kopfloser Betrieb: keine Oberfläche, Trading-Dienste laufen. Beenden mit Strg+C.");
|
||
host.WaitForShutdown();
|
||
return 0;
|
||
}
|
||
|
||
App.Services = host.Services;
|
||
BuildAvaloniaApp().StartWithClassicDesktopLifetime(args);
|
||
return 0;
|
||
}
|
||
finally
|
||
{
|
||
// Geordnetes Herunterfahren der Module – erst nach dem Ende der Oberfläche.
|
||
host.StopAsync(TimeSpan.FromSeconds(30)).GetAwaiter().GetResult();
|
||
host.Dispose();
|
||
}
|
||
}
|
||
|
||
/// <summary>Von Avalonia (auch vom XAML-Previewer) erwartete Fabrikmethode.</summary>
|
||
public static AppBuilder BuildAvaloniaApp() =>
|
||
AppBuilder.Configure<App>()
|
||
.UsePlatformDetect()
|
||
.WithInterFont()
|
||
.LogToTrace();
|
||
|
||
/// <summary>
|
||
/// Lädt NUR den Deploymentcenter-Konfigurationsblock, bevor der Generic Host existiert –
|
||
/// die Lizenzprüfung (D-2) muss VOR der Modul-Liste laufen, also bevor
|
||
/// <see cref="Host.CreateDefaultBuilder"/> seine eigene <see cref="IConfiguration"/> baut.
|
||
/// Liest dieselben Dateien wie der Host später (appsettings.json + appsettings.Local.json),
|
||
/// nur eigenständig und minimal. Von <see cref="Licensing.LicenseCli"/> mitbenutzt.
|
||
/// </summary>
|
||
internal static DeploymentcenterOptions LoadDeploymentcenterOptions()
|
||
{
|
||
var config = new ConfigurationBuilder()
|
||
.SetBasePath(AppContext.BaseDirectory)
|
||
.AddJsonFile("appsettings.json", optional: true)
|
||
.AddJsonFile("appsettings.Local.json", optional: true)
|
||
.Build();
|
||
|
||
var options = new DeploymentcenterOptions();
|
||
config.GetSection(DeploymentcenterOptions.SectionName).Bind(options);
|
||
return options;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Baut den Anwendungs-Host: Konfiguration, Master-Key, Persistenz, Core-Dienste und die
|
||
/// aktiven Module. Kennt die Oberfläche nicht.
|
||
/// </summary>
|
||
/// <param name="enforceLicense">
|
||
/// D-2: Wenn <c>false</c> (nur <c>--smoke-ui</c>), werden immer alle Module registriert –
|
||
/// die Konstruktionsprüfung testet Fensteraufbau, keine Lizenzlogik.
|
||
/// </param>
|
||
private static IHost BuildHost(DeploymentcenterOptions dcOptions, bool enforceLicense, out TerminalLogger bootLog)
|
||
{
|
||
var serverSettings = ServerSettings.Load(ServerSettingsPath);
|
||
var logger = new TerminalLogger();
|
||
bootLog = logger;
|
||
|
||
// Anzeige-Kultur festnageln: Auf Linux richtet sich die Kultur sonst nach LANG/LC_ALL,
|
||
// das unter systemd oft gar nicht gesetzt ist - dann faellt .NET auf Invariant zurueck
|
||
// und aus "1.234,56 USDC" wird "1,234.56 USDC". Maschinen-I/O (API, Logs, CSV) laeuft
|
||
// unabhaengig davon ohnehin invariant.
|
||
var uiCulture = new System.Globalization.CultureInfo("de-DE");
|
||
System.Globalization.CultureInfo.DefaultThreadCurrentCulture = uiCulture;
|
||
System.Globalization.CultureInfo.DefaultThreadCurrentUICulture = uiCulture;
|
||
|
||
ConfigureSecretProtection(logger);
|
||
|
||
// Anzeige-Zeitzone einmalig setzen (Logs, Auswertungen). Ein unbekannter Wert ist nicht
|
||
// fatal - es wird auf die Systemzeitzone ausgewichen und gewarnt.
|
||
AppTimeZone.Configure(serverSettings.ApplicationTimeZoneId, m => logger.Warning(m));
|
||
logger.Info($"Anzeige-Zeitzone: {AppTimeZone.CurrentId}");
|
||
|
||
var allModules = new List<IPolyTraderModule>
|
||
{
|
||
new CopyTradingModule(),
|
||
new ResolutionFarmingModule(),
|
||
new SupervisorModule(),
|
||
new AccountingModule()
|
||
};
|
||
var disabled = new HashSet<string>(serverSettings.DisabledModules, StringComparer.OrdinalIgnoreCase);
|
||
|
||
// D-2: ohne gültige Lizenz startet nur die Core-Shell (Terminal, Einstellungen) - alle
|
||
// Trading-/Analyse-Module bleiben unregistriert. Kein Environment.Exit (Entscheidung
|
||
// 31.07.2026): der Schlüssel lässt sich im Einstellungsfenster nachtragen.
|
||
bool licensed = !enforceLicense || Licensing.LicenseGate.CheckUsable(dcOptions, serverSettings, logger);
|
||
if (!licensed)
|
||
{
|
||
foreach (var m in allModules) disabled.Add(m.Name);
|
||
}
|
||
|
||
var modules = allModules.Where(m => !disabled.Contains(m.Name)).ToList();
|
||
|
||
return Host.CreateDefaultBuilder()
|
||
.UseContentRoot(AppContext.BaseDirectory)
|
||
.ConfigureAppConfiguration((_, config) =>
|
||
config.AddJsonFile("appsettings.Local.json", optional: true, reloadOnChange: false))
|
||
.ConfigureServices((context, services) =>
|
||
{
|
||
services.AddSingleton(dcOptions);
|
||
var databaseOptions = new DatabaseOptions
|
||
{
|
||
MySqlConnectionString = context.Configuration["Database:MySqlConnectionString"] ?? string.Empty
|
||
};
|
||
services.Configure<DatabaseOptions>(context.Configuration.GetSection(DatabaseOptions.SectionName));
|
||
services.AddCorePersistence(databaseOptions);
|
||
|
||
services.AddSingleton(serverSettings);
|
||
services.AddSingleton(logger);
|
||
services.AddSingleton<TradingState>();
|
||
services.AddSingleton<JobManager>();
|
||
services.AddSingleton<MullvadVpnService>();
|
||
services.AddHttpClient();
|
||
|
||
// Polymarket-Zugriffe teilen sich einen langlebigen Verbindungspool.
|
||
services.AddSingleton(sp => new PolymarketApiService(
|
||
sp.GetRequiredService<TerminalLogger>(), CreatePolymarketHttpClient()));
|
||
services.AddSingleton(sp => new PolymarketClobClient(
|
||
sp.GetRequiredService<TerminalLogger>(), CreatePolymarketHttpClient()));
|
||
|
||
// Benachrichtigungen: neutrale Senke, bis RocketChat/Telegram angebunden sind.
|
||
services.AddSingleton<INotificationSink>(
|
||
sp => new LogNotificationSink(sp.GetRequiredService<TerminalLogger>()));
|
||
|
||
services.AddSingleton(sp =>
|
||
{
|
||
var watchdog = new WatchdogHeartbeatService(sp.GetRequiredService<TerminalLogger>());
|
||
|
||
// D-1: erster "checks"-Eintrag - beweist die Mechanik mit einem real
|
||
// verfügbaren Signal. db/market_feed folgen, sobald die jeweiligen Dienste
|
||
// ein Gesundheitssignal nach außen geben (siehe
|
||
// docs/archiv/umsetzungsplaene/UMSETZUNGSPLAN-Deploymentcenter-Integration.md, D-1).
|
||
var vpn = sp.GetRequiredService<MullvadVpnService>();
|
||
var serverSettings = sp.GetRequiredService<ServerSettings>();
|
||
watchdog.RegisterCheck("vpn", () => serverSettings.VpnEnabled
|
||
? (vpn.IsConnected, vpn.IsConnected ? null : "Mullvad VPN nicht verbunden")
|
||
: (true, null)); // VPN bewusst aus -> kein Fehlalarm
|
||
|
||
return watchdog;
|
||
});
|
||
|
||
// MUSS vor den Modulen registriert werden: hydriert die Accounts, bevor die
|
||
// Trading-Dienste anlaufen (Reihenfolge = Registrierungsreihenfolge).
|
||
services.AddHostedService<CoreStateHydrationService>();
|
||
|
||
foreach (var module in modules)
|
||
{
|
||
services.AddSingleton(module);
|
||
module.RegisterServices(services, context.Configuration);
|
||
}
|
||
|
||
// D-3: Fehler-Reporting - rein ereignisgesteuert (AppDomain.UnhandledException,
|
||
// TaskScheduler.UnobservedTaskException, TerminalLogger-Fehler), kein eigener Takt.
|
||
services.AddSingleton(sp => new DeploymentcenterErrorReporter(
|
||
sp.GetRequiredService<TerminalLogger>(), sp.GetRequiredService<DeploymentcenterOptions>()));
|
||
|
||
services.AddHostedService<MarketSyncService>();
|
||
services.AddHostedService(sp => sp.GetRequiredService<MullvadVpnService>());
|
||
services.AddHostedService(sp => sp.GetRequiredService<WatchdogHeartbeatService>());
|
||
services.AddHostedService(sp => sp.GetRequiredService<DeploymentcenterErrorReporter>());
|
||
|
||
// Shell-Zustand (Fensterverwaltung). Auch im kopflosen Betrieb harmlos.
|
||
services.AddSingleton<AvaloniaUiHost>();
|
||
})
|
||
.Build();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Kopflose Konstruktionsprüfung aller registrierten Fenster – Nachfolger des
|
||
/// <c>--smoke-ui</c>-Laufs der WinForms-App, der Konstruktionsfehler zuverlässig gefangen hat.
|
||
/// Läuft ohne Fenster und ohne Message-Loop.
|
||
/// </summary>
|
||
private static int RunSmokeUi(IHost host, TerminalLogger logger)
|
||
{
|
||
// Avalonia muss initialisiert sein, damit Fenster konstruiert werden koennen.
|
||
BuildAvaloniaApp().SetupWithoutStarting();
|
||
App.Services = host.Services;
|
||
ThemeManager.Initialize(host.Services.GetRequiredService<ServerSettings>(), ServerSettingsPath, logger);
|
||
|
||
var uiHost = host.Services.GetRequiredService<AvaloniaUiHost>();
|
||
CoreViews.Register(uiHost, host.Services);
|
||
foreach (var module in host.Services.GetServices<IPolyTraderModule>())
|
||
module.RegisterUi(uiHost, host.Services);
|
||
ModuleViews.Register(uiHost, host.Services);
|
||
ViewIcons.AssignDefaults(uiHost);
|
||
|
||
int failures = 0;
|
||
Console.WriteLine("=== Smoke-UI: Seiten-Konstruktion (Avalonia) ===");
|
||
foreach (var view in uiHost.Views)
|
||
{
|
||
try
|
||
{
|
||
_ = view.CreateView();
|
||
Console.WriteLine($"[OK] {view.Id} ({view.Title})");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failures++;
|
||
Console.WriteLine($"[FEHLER] {view.Id}: {ex.GetType().Name}: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
// Die Startseite steckt seit der Einfenster-Shell in der Schleife oben (core.start);
|
||
// separat geprueft wird nur noch das Shell-Fenster selbst.
|
||
try
|
||
{
|
||
_ = new Views.ShellWindow(uiHost, host.Services);
|
||
Console.WriteLine("[OK] ShellWindow konstruiert");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failures++;
|
||
Console.WriteLine($"[FEHLER] ShellWindow: {ex.GetType().Name}: {ex.Message}");
|
||
}
|
||
|
||
try
|
||
{
|
||
_ = new Views.ShutdownConfirmWindow();
|
||
Console.WriteLine("[OK] ShutdownConfirmWindow konstruiert");
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failures++;
|
||
Console.WriteLine($"[FEHLER] ShutdownConfirmWindow: {ex.GetType().Name}: {ex.Message}");
|
||
}
|
||
|
||
// Der Einstellungs-Editor wird aus den Attributen des Modells aufgebaut. Ein Fenster kann
|
||
// fehlerfrei konstruieren und trotzdem leer sein, wenn die Attribute verlorengehen -
|
||
// deshalb hier gegen die tatsaechliche Feldzahl pruefen.
|
||
try
|
||
{
|
||
var sections = ViewModels.SettingsModelBuilder.Build(new ServerSettings());
|
||
int fieldCount = sections.Sum(x => x.Fields.Count);
|
||
if (sections.Count == 0 || fieldCount == 0)
|
||
{
|
||
failures++;
|
||
Console.WriteLine("[FEHLER] Einstellungs-Editor: keine Felder aus ServerSettings ermittelt " +
|
||
"(Category-/DisplayName-Attribute verloren?).");
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine($"[OK] Einstellungs-Editor: {sections.Count} Abschnitte, {fieldCount} Felder " +
|
||
$"({string.Join(", ", sections.Select(x => x.Title))})");
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
failures++;
|
||
Console.WriteLine($"[FEHLER] Einstellungs-Editor: {ex.GetType().Name}: {ex.Message}");
|
||
}
|
||
|
||
// Farbschema: jeder Token muss in BEIDEN Varianten aufloesen. Ein Tippfehler im
|
||
// Schluessel faellt sonst nicht auf - ThemeManager.Brush liefert dann still Grau.
|
||
string[] themeKeys =
|
||
{
|
||
"AppBgBrush", "AppSurfaceBrush", "AppSurfaceAltBrush", "AppCardBrush",
|
||
"AppBorderBrush", "AppDividerBrush",
|
||
"AppTextBrush", "AppMutedTextBrush", "AppCaptionTextBrush", "AppReadOnlyTextBrush",
|
||
"AppAccentBrush", "AppAccentAltBrush", "AppAccentStrongBrush", "AppAccentSoftBrush",
|
||
"AppPositiveBrush", "AppNegativeBrush", "AppWarningBrush",
|
||
"AppNotifyWarnBrush", "AppNotifyErrorBrush",
|
||
"AppTradeLossBrush", "AppTradeSmallWinBrush", "AppTradeBigWinBrush",
|
||
"AppToggleActiveBrush", "AppToggleSellOnlyBrush", "AppToggleInactiveBrush",
|
||
"AppChatUserBrush", "AppChatAgentBrush"
|
||
};
|
||
foreach (var variant in new[] { global::Avalonia.Styling.ThemeVariant.Light,
|
||
global::Avalonia.Styling.ThemeVariant.Dark })
|
||
{
|
||
var missing = themeKeys.Where(k =>
|
||
!(global::Avalonia.Application.Current!.TryFindResource(k, variant, out object? v) && v is global::Avalonia.Media.IBrush))
|
||
.ToList();
|
||
if (missing.Count > 0)
|
||
{
|
||
failures++;
|
||
Console.WriteLine($"[FEHLER] Farbschema „{variant}“: fehlende Farben: {string.Join(", ", missing)}");
|
||
}
|
||
else
|
||
{
|
||
Console.WriteLine($"[OK] Farbschema „{variant}“: alle {themeKeys.Length} Farben vorhanden");
|
||
}
|
||
}
|
||
|
||
Console.WriteLine(failures == 0 ? "=== Smoke-UI OK ===" : $"=== Smoke-UI: {failures} Fehler ===");
|
||
return failures == 0 ? 0 : 1;
|
||
}
|
||
|
||
/// <summary>
|
||
/// HttpClient für die Polymarket-APIs: langlebiger Verbindungspool und die Header, die die
|
||
/// Gegenstelle erwartet (identisch zur bisherigen WinForms-Zusammenstellung).
|
||
/// </summary>
|
||
private static System.Net.Http.HttpClient CreatePolymarketHttpClient() =>
|
||
new(new System.Net.Http.SocketsHttpHandler
|
||
{
|
||
PooledConnectionLifetime = TimeSpan.FromMinutes(2),
|
||
MaxConnectionsPerServer = 100
|
||
})
|
||
{
|
||
DefaultRequestHeaders = { { "User-Agent", "py_clob_client" }, { "Accept", "*/*" } }
|
||
};
|
||
|
||
/// <summary>
|
||
/// Lädt den Master-Key (Umgebungsvariable, sonst gitignorierte <c>master.key</c>, sonst wird
|
||
/// einer erzeugt – D-5/D-10) und aktiviert die at-rest-Verschlüsselung. Muss VOR jeder
|
||
/// Credential-Entschlüsselung laufen.
|
||
/// </summary>
|
||
private static void ConfigureSecretProtection(TerminalLogger logger)
|
||
{
|
||
string keyFile = Path.Combine(AppContext.BaseDirectory, "master.key");
|
||
string? masterKey = MasterKeyResolver.Resolve(
|
||
keyFile,
|
||
() => Environment.GetEnvironmentVariable("POLYTRADER_MASTER_KEY"),
|
||
onGenerated: _ => logger.Warning(
|
||
"🔐 Kein Master-Key gefunden – ein neuer wurde erzeugt und in \"master.key\" " +
|
||
"gespeichert (App-Ordner, gitignored). WICHTIG: Diese Datei SOFORT separat sichern " +
|
||
"(Passwort-Manager/Offline-Backup) – ihr Verlust macht jedes ab jetzt verschlüsselte " +
|
||
"Secret unwiederbringlich unlesbar."),
|
||
onWarning: msg => logger.Warning($"⚠️ {msg}"));
|
||
|
||
SecretProtection.Configure(masterKey);
|
||
|
||
if (SecretProtection.IsConfigured)
|
||
logger.Info("🔐 Secret-Verschlüsselung aktiv – Account-Credentials werden at-rest verschlüsselt (AES-256-GCM).");
|
||
else
|
||
logger.Warning("⚠️ SICHERHEIT: Kein POLYTRADER_MASTER_KEY gesetzt – Account-Credentials liegen UNVERSCHLÜSSELT in der DB.");
|
||
}
|
||
}
|
||
}
|