From cda31cd0701ec67662d054080610e89befdfbfc2 Mon Sep 17 00:00:00 2001 From: MrMaho Date: Wed, 28 Oct 2026 17:26:24 +0100 Subject: [PATCH] Erste Version Co-Authored-By: Claude Opus 5.5 --- .gitignore | 6 ++ BSO.Server.csproj | 22 ++++ GameServer.cs | 237 +++++++++++++++++++++++++++++++++++++++++ Program.cs | 116 ++++++++++++++++++++ README.md | 40 +++++++ Shared/Connection.cs | 194 +++++++++++++++++++++++++++++++++ Shared/PacketReader.cs | 36 +++++++ Shared/Protocol.cs | 88 +++++++++++++++ TestBot.cs | 62 +++++++++++ logo.ico | Bin 0 -> 45987 bytes release.ps1 | 45 ++++++++ 11 files changed, 846 insertions(+) create mode 100644 .gitignore create mode 100644 BSO.Server.csproj create mode 100644 GameServer.cs create mode 100644 Program.cs create mode 100644 README.md create mode 100644 Shared/Connection.cs create mode 100644 Shared/PacketReader.cs create mode 100644 Shared/Protocol.cs create mode 100644 TestBot.cs create mode 100644 logo.ico create mode 100644 release.ps1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08a4571 --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +bin/ +obj/ +dist/ +publish/ +.vs/ +*.user diff --git a/BSO.Server.csproj b/BSO.Server.csproj new file mode 100644 index 0000000..5ba3ea5 --- /dev/null +++ b/BSO.Server.csproj @@ -0,0 +1,22 @@ + + + + Exe + net10.0 + disable + enable + BSO.Server + BSO.Server + 0.2.0 + BSO Dedicated Server + true + + logo.ico + false + + + + + + + diff --git a/GameServer.cs b/GameServer.cs new file mode 100644 index 0000000..d7e0cbd --- /dev/null +++ b/GameServer.cs @@ -0,0 +1,237 @@ +using System.Net; +using System.Net.Sockets; +using BSO.Net; + +namespace BSO.Server; + +/// +/// Dedizierter Server: nimmt Spieler an (Hello -> Welcome), merkt sich den letzten Zustand jedes Spielers und reicht +/// ihn an alle anderen weiter. Laeuft in einer einzigen Schleife (Tick) - alle Verbindungen werden dort abgefragt, +/// es braucht keine Sperren. +/// +public class GameServer +{ + public const string Version = "0.2.0"; + static readonly TimeSpan HelloTimeout = TimeSpan.FromSeconds(10); + static readonly TimeSpan SilenceTimeout = TimeSpan.FromSeconds(30); // Mod pingt alle 2 s + + class Client + { + public int Id; + public string Name; + public string Address; + public Connection Conn; + public bool Welcomed; + public DateTime Connected, LastMessage; + public PlayerState State; + public bool HasState; + } + + readonly TcpListener listener; + readonly Dictionary clients = new(); + int nextId = 1; + + public string Name { get; } + public int MaxPlayers { get; } + public TestBot Bot { get; private set; } + + public GameServer(int port, string name, int maxPlayers) + { + Name = name; + MaxPlayers = maxPlayers; + listener = new TcpListener(IPAddress.Any, port); + listener.Start(); + Log($"BSO Server {Version} (Protokoll {Protocol.Version}) auf Port {port} - \"{name}\", max. {maxPlayers} Spieler"); + } + + public static void Log(string line) => Console.WriteLine(DateTime.Now.ToString("HH:mm:ss") + " " + line); + + public void Tick() + { + while (listener.Pending()) + { + TcpClient tcp = listener.AcceptTcpClient(); + var c = new Client + { + Id = nextId++, + Address = (tcp.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "?", + Conn = new Connection(tcp), + Connected = DateTime.UtcNow, + LastMessage = DateTime.UtcNow + }; + clients[c.Id] = c; + } + + DateTime now = DateTime.UtcNow; + foreach (Client c in clients.Values.ToList()) + { + foreach (Message m in c.Conn.Poll()) + { + c.LastMessage = now; + try { Handle(c, m); } + catch (Exception e) { c.Conn.Close("Ungueltige Nachricht " + m.Type + ": " + e.Message); } + } + if (!c.Welcomed && now - c.Connected > HelloTimeout) c.Conn.Close("Kein Hello"); + if (now - c.LastMessage > SilenceTimeout) c.Conn.Close("Zeitueberschreitung"); + if (c.Conn.IsClosed) Remove(c); + } + + if (Bot != null && Bot.Tick(Players().Where(p => p.HasState).Select(p => p.State))) + Broadcast(MsgType.PlayerState, StatePayload(Bot.Id, Bot.State), except: 0); + } + + void Handle(Client c, Message m) + { + BinaryReader r = m.Reader(); + switch (m.Type) + { + case MsgType.Info: + c.Conn.Send(MsgType.InfoReply, Wire.Build(w => + { + w.Write(Name); + w.Write(Version); + w.Write(Players().Count()); + w.Write(MaxPlayers); + })); + break; + + case MsgType.Hello: + if (c.Welcomed) return; + int protocol = r.ReadInt32(); + string modVersion = r.ReadString(); + string name = Clean(r.ReadString(), c.Id); + if (protocol != Protocol.Version) + { + Reject(c, $"Versionen passen nicht: Server Protokoll {Protocol.Version}, Mod {modVersion} hat Protokoll {protocol}"); + return; + } + if (Players().Count() >= MaxPlayers) + { + Reject(c, $"Server ist voll ({MaxPlayers} Spieler)"); + return; + } + c.Name = name; + c.Welcomed = true; + var others = Others(c.Id).ToList(); + c.Conn.Send(MsgType.Welcome, Wire.Build(w => + { + w.Write(c.Id); + w.Write(Name); + w.Write(others.Count); + foreach ((int id, string n, _, _) in others) { w.Write(id); w.Write(n); } + })); + // Letzten Stand der anderen gleich mitschicken, damit sie nicht erst beim naechsten Paket auftauchen + foreach ((int id, _, bool has, PlayerState s) in others) + if (has) c.Conn.Send(MsgType.PlayerState, StatePayload(id, s)); + Broadcast(MsgType.PlayerJoined, Wire.Build(w => { w.Write(c.Id); w.Write(c.Name); }), except: c.Id); + Log($"{c.Name} (#{c.Id}, {c.Address}, Mod {modVersion}) ist beigetreten - {Players().Count()} Spieler"); + break; + + case MsgType.PlayerState: + if (!c.Welcomed) return; + c.State = PlayerState.Read(r); + c.HasState = true; + Broadcast(MsgType.PlayerState, StatePayload(c.Id, c.State), except: c.Id); + break; + + case MsgType.Ping: + c.Conn.Send(MsgType.Pong, m.Payload); + break; + } + } + + void Reject(Client c, string reason) + { + Log($"{c.Address} abgelehnt: {reason}"); + c.Conn.Send(MsgType.Reject, Wire.Build(w => w.Write(reason))); + c.Conn.Close(reason); + } + + void Remove(Client c) + { + clients.Remove(c.Id); + if (!c.Welcomed) return; + Broadcast(MsgType.PlayerLeft, Wire.Build(w => w.Write(c.Id)), except: c.Id); + Log($"{c.Name} (#{c.Id}) ist gegangen: {c.Conn.CloseReason} - {Players().Count()} Spieler"); + } + + IEnumerable Players() => clients.Values.Where(c => c.Welcomed && !c.Conn.IsClosed); + + /// Alle anderen Spieler inkl. Testbot: (ID, Name, hat Zustand, Zustand). + IEnumerable<(int, string, bool, PlayerState)> Others(int self) + { + foreach (Client p in Players()) + if (p.Id != self) yield return (p.Id, p.Name, p.HasState, p.State); + if (Bot != null) yield return (Bot.Id, Bot.Name, Bot.HasState, Bot.State); + } + + void Broadcast(MsgType type, byte[] payload, int except) + { + foreach (Client p in Players()) + if (p.Id != except) p.Conn.Send(type, payload); + } + + static byte[] StatePayload(int id, PlayerState s) => Wire.Build(w => { w.Write(id); s.Write(w); }); + + static string Clean(string name, int id) + { + name = new string((name ?? "").Where(ch => !char.IsControl(ch)).ToArray()).Trim(); + if (name.Length > 24) name = name.Substring(0, 24); + return name.Length > 0 ? name : "Spieler" + id; + } + + // --------------------------------------------------------------------------------------------------------------- + // Konsole + + public void Command(string line) + { + string[] parts = line.Trim().Split(' ', 2, StringSplitOptions.RemoveEmptyEntries); + if (parts.Length == 0) return; + switch (parts[0].ToLowerInvariant()) + { + case "help": + Log("Befehle: status, bot, kick [grund], stop"); + break; + case "status": + case "players": + Log($"{Players().Count()}/{MaxPlayers} Spieler" + (Bot != null ? " + Testbot" : "")); + foreach (Client p in Players()) + Log($" #{p.Id} {p.Name} ({p.Address}) " + (p.HasState ? $"{p.State.Mode} bei {p.State.X / 100:0} / {p.State.Y / 100:0} m, {p.State.Speed * 0.036f:0} km/h" : "noch keine Position")); + break; + case "bot": + if (Bot == null) AddBot(); + else + { + Broadcast(MsgType.PlayerLeft, Wire.Build(w => w.Write(Bot.Id)), except: 0); + Bot = null; + Log("Testbot entfernt"); + } + break; + case "kick": + string[] args = parts.Length > 1 ? parts[1].Split(' ', 2) : Array.Empty(); + if (args.Length > 0 && int.TryParse(args[0], out int kickId) && clients.TryGetValue(kickId, out Client k)) + { + string reason = args.Length > 1 ? args[1] : "Vom Server entfernt"; + Reject(k, reason); + } + else Log("kick [grund] - IDs siehe status"); + break; + default: + Log("Unbekannter Befehl - help"); + break; + } + } + + public void AddBot() + { + Bot = new TestBot(nextId++); + Broadcast(MsgType.PlayerJoined, Wire.Build(w => { w.Write(Bot.Id); w.Write(Bot.Name); }), except: 0); + Log($"Testbot #{Bot.Id} da - faehrt Kreise um den ersten Spieler, der im Spiel ist"); + } + + public void Stop() + { + foreach (Client c in clients.Values) c.Conn.Close("Server beendet"); + listener.Stop(); + } +} diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..f931485 --- /dev/null +++ b/Program.cs @@ -0,0 +1,116 @@ +using System.Collections.Concurrent; +using System.Diagnostics; +using System.Net.Sockets; +using BSO.Net; +using BSO.Server; + +// BSO.Server [--port 7777] [--name "Mein Server"] [--max 8] [--bot] +// BSO.Server --probe host[:port] (Test: verbindet sich wie die Mod und zeigt, was ankommt) + +int port = Protocol.DefaultPort, max = 8; +string name = "BSO Server", probe = null; +bool bot = false; +for (int i = 0; i < args.Length; i++) +{ + switch (args[i]) + { + case "--port": port = int.Parse(args[++i]); break; + case "--name": name = args[++i]; break; + case "--max": max = int.Parse(args[++i]); break; + case "--bot": bot = true; break; + case "--probe": probe = args[++i]; break; + default: + Console.WriteLine("BSO.Server [--port 7777] [--name \"Name\"] [--max 8] [--bot] | --probe host[:port]"); + return 1; + } +} + +if (probe != null) return Probe(probe); + +var server = new GameServer(port, name, max); +if (bot) server.AddBot(); +GameServer.Log("Befehle: help, status, bot, kick , stop"); + +var commands = new ConcurrentQueue(); +new Thread(() => +{ + string line; + while ((line = Console.ReadLine()) != null) commands.Enqueue(line); +}) { IsBackground = true, Name = "Konsole" }.Start(); + +bool running = true; +Console.CancelKeyPress += (_, e) => { e.Cancel = true; running = false; }; +while (running) +{ + server.Tick(); + while (commands.TryDequeue(out string cmd)) + { + if (cmd.Trim().Equals("stop", StringComparison.OrdinalIgnoreCase)) running = false; + else server.Command(cmd); + } + Thread.Sleep(5); +} +server.Stop(); +GameServer.Log("Server beendet"); +return 0; + +/// Verbindet sich wie die Mod (Info, dann Hello als "Probe") und zeigt 5 s lang alles, was ankommt. +static int Probe(string target) +{ + string[] hp = target.Split(':'); + int p = hp.Length > 1 ? int.Parse(hp[1]) : Protocol.DefaultPort; + var tcp = new TcpClient(); + if (!tcp.ConnectAsync(hp[0], p).Wait(5000)) { Console.WriteLine("Keine Verbindung zu " + target); return 2; } + var conn = new Connection(tcp); + conn.Send(MsgType.Info, null); + conn.Send(MsgType.Hello, Wire.Build(w => { w.Write(Protocol.Version); w.Write("probe"); w.Write("Probe"); })); + conn.Send(MsgType.Ping, Wire.Build(w => w.Write(Stopwatch.GetTimestamp()))); + // Eigene Position, damit der Testbot losfaehrt + var me = new PlayerState { Mode = PlayerMode.InBus, X = 70000, Y = 30000, Z = 760 }; + + var watch = Stopwatch.StartNew(); + int states = 0; + while (watch.Elapsed.TotalSeconds < 5 && !conn.IsClosed) + { + conn.Send(MsgType.PlayerState, Wire.Build(w => me.Write(w))); + foreach (Message m in conn.Poll()) + { + BinaryReader r = m.Reader(); + switch (m.Type) + { + case MsgType.InfoReply: + Console.WriteLine($"Info: \"{r.ReadString()}\" Version {r.ReadString()}, {r.ReadInt32()}/{r.ReadInt32()} Spieler"); + break; + case MsgType.Welcome: + int id = r.ReadInt32(); + string server = r.ReadString(); + int n = r.ReadInt32(); + var list = Enumerable.Range(0, n).Select(_ => $"#{r.ReadInt32()} {r.ReadString()}").ToList(); + Console.WriteLine($"Welcome: ich bin #{id} auf \"{server}\", andere: {(n == 0 ? "-" : string.Join(", ", list))}"); + break; + case MsgType.Reject: + Console.WriteLine("Abgelehnt: " + r.ReadString()); + break; + case MsgType.Pong: + Console.WriteLine($"Ping: {Stopwatch.GetElapsedTime(r.ReadInt64()).TotalMilliseconds:0.0} ms"); + break; + case MsgType.PlayerJoined: + Console.WriteLine($"Beigetreten: #{r.ReadInt32()} {r.ReadString()}"); + break; + case MsgType.PlayerLeft: + Console.WriteLine($"Gegangen: #{r.ReadInt32()}"); + break; + case MsgType.PlayerState: + int who = r.ReadInt32(); + PlayerState s = PlayerState.Read(r); + if (states++ % 10 == 0) + Console.WriteLine($"Zustand #{who}: {s.Mode} {s.X / 100:0.0} / {s.Y / 100:0.0} / {s.Z / 100:0.0} m, {s.Speed * 0.036f:0} km/h, {s.DistanceTo(me) / 100:0.0} m entfernt"); + break; + } + } + Thread.Sleep(100); + } + Console.WriteLine($"{states} Zustaende in 5 s" + (conn.IsClosed ? " - getrennt: " + conn.CloseReason : "")); + conn.Close("Probe fertig"); + return 0; +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..6fd1fde --- /dev/null +++ b/README.md @@ -0,0 +1,40 @@ +# BSO Server + +Dedizierter Server für **BSO – Bus Simulator Online** (Multiplayer-Mod für Bus-Simulator 2012). + +- Mod: [BSO-Mod](https://git.mbm.wtf/MrMaho/BSO-Mod) +- Launcher: [BSO-Launcher](https://git.mbm.wtf/MrMaho/BSO-Launcher) +- Website, Konten und Releases: [BSO-Website](https://git.mbm.wtf/MrMaho/BSO-Website) + +Nimmt Spieler an (Hello → Welcome), merkt sich den letzten Zustand jedes Spielers und reicht ihn an alle anderen +weiter. Eine einzige Schleife fragt alle Verbindungen ab – ohne Sperren. Protokoll wie bei MSCO: +`[int Länge][byte Typ][Daten]` über TCP. + +`Shared\*.cs` (Protokoll, `PacketReader`, `Connection`) sind **dieselben Dateien wie in BSO-Mod** – bei Änderungen in +beiden Repos gleich halten und bei inkompatiblen Änderungen `Protocol.Version` erhöhen. + +## Starten + +Voraussetzung: .NET 10 Runtime. + +```powershell +BSO.Server.exe --bot # Port 7777, mit Testbot +BSO.Server.exe --port 7777 --name "Mein Server" --max 8 +BSO.Server.exe --probe host[:port] # Test: verbindet sich wie die Mod, zeigt was ankommt +``` + +Für Spieler von außen Port **7777/TCP** im Router/Firewall freigeben. + +| Konsole | | +|---|---| +| `status` | Spieler mit Position, Modus und Tempo | +| `bot` | Testbot an/aus – fährt als „Bus“ 30 km/h Kreise (20 m) um den ersten Spieler | +| `kick [grund]` | Spieler entfernen | +| `stop` | Server beenden | + +## Bauen + +```powershell +dotnet publish -c Release -o publish +.\release.ps1 -Product server -Version 0.2.0 -File BSO-Server.zip -Changelog "..." +``` diff --git a/Shared/Connection.cs b/Shared/Connection.cs new file mode 100644 index 0000000..23ea1af --- /dev/null +++ b/Shared/Connection.cs @@ -0,0 +1,194 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Sockets; +using System.Threading; + +namespace BSO.Net +{ + /// Setzt aus einem Byte-Strom Nachrichten zusammen: [int Laenge][byte Typ][Payload]. + public class FrameParser + { + const int MaxFrame = 1024 * 1024; + + byte[] buffer = new byte[64 * 1024]; + int count; + + public void Append(byte[] data, int length) + { + EnsureCapacity(count + length); + Buffer.BlockCopy(data, 0, buffer, count, length); + count += length; + } + + public void Parse(List messages) + { + int offset = 0; + while (count - offset >= 5) + { + int length = BitConverter.ToInt32(buffer, offset); + if (length < 1 || length > MaxFrame) throw new IOException("Invalid packet"); + if (count - offset - 4 < length) break; + + var payload = new byte[length - 1]; + Buffer.BlockCopy(buffer, offset + 5, payload, 0, payload.Length); + messages.Add(new Message { Type = (MsgType)buffer[offset + 4], Payload = payload }); + offset += 4 + length; + } + if (offset > 0) + { + Buffer.BlockCopy(buffer, offset, buffer, 0, count - offset); + count -= offset; + } + } + + public static byte[] Frame(MsgType type, byte[] payload) + { + payload ??= Array.Empty(); + var frame = new byte[5 + payload.Length]; + int length = payload.Length + 1; // Little Endian wie BitConverter + frame[0] = (byte)length; frame[1] = (byte)(length >> 8); frame[2] = (byte)(length >> 16); frame[3] = (byte)(length >> 24); + frame[4] = (byte)type; + Buffer.BlockCopy(payload, 0, frame, 5, payload.Length); + return frame; + } + + void EnsureCapacity(int needed) + { + if (needed <= buffer.Length) return; + var bigger = new byte[Math.Max(needed, buffer.Length * 2)]; + Buffer.BlockCopy(buffer, 0, bigger, 0, count); + buffer = bigger; + } + } + + /// + /// TCP-Verbindung wie bei MSCO: Lesen ist nicht-blockierend und wird ueber Poll() abgefragt (im Spiel im Hauptthread), + /// Senden laeuft ueber eine Warteschlange und einen eigenen Thread - eine kurz volle Leitung haelt das Spiel nicht an. + /// Kleine Pakete werden dabei zusammengefasst. + /// + public class Connection + { + volatile bool closed; + volatile string closeReason; + public bool IsClosed => closed; + public string CloseReason => closeReason; + + // Mehr als das unterwegs = die Gegenseite nimmt nichts mehr an + const long MaxQueuedBytes = 16L * 1024 * 1024; + const int MaxBatchBytes = 256 * 1024; + + readonly TcpClient client; + readonly NetworkStream stream; + readonly FrameParser parser = new(); + readonly byte[] readBuffer = new byte[64 * 1024]; + readonly Queue outgoing = new(); + readonly object gate = new(); + long queuedBytes; + readonly Thread writer; + + public Connection(TcpClient client) + { + this.client = client; + client.NoDelay = true; + stream = client.GetStream(); + writer = new Thread(WriteLoop) { IsBackground = true, Name = "BSO send" }; + writer.Start(); + } + + /// Sende-Thread: wartet auf Pakete und schreibt sie (gebuendelt) in die Verbindung. + void WriteLoop() + { + var batch = new MemoryStream(MaxBatchBytes); + try + { + while (true) + { + batch.SetLength(0); + lock (gate) + { + while (outgoing.Count == 0 && !closed) Monitor.Wait(gate); + if (outgoing.Count == 0) return; // geschlossen und nichts mehr zu senden + do + { + byte[] frame = outgoing.Dequeue(); + queuedBytes -= frame.Length; + batch.Write(frame, 0, frame.Length); + } while (outgoing.Count > 0 && batch.Length + outgoing.Peek().Length <= MaxBatchBytes); + Monitor.PulseAll(gate); // Close() wartet evtl. aufs Leerwerden + } + stream.Write(batch.GetBuffer(), 0, (int)batch.Length); + } + } + catch (Exception e) + { + if (!closed) Close("Netzwerkfehler: " + e.Message); + } + } + + readonly List polled = new(); // wiederverwendet: der Aufrufer geht sie sofort durch + public List Poll() + { + var messages = polled; + messages.Clear(); + if (IsClosed) return messages; + try + { + // Verbindung von der Gegenseite geschlossen? + if (client.Client.Poll(0, SelectMode.SelectRead) && client.Available == 0) + { + Close("Verbindung getrennt"); + return messages; + } + while (stream.DataAvailable) + { + int read = stream.Read(readBuffer, 0, readBuffer.Length); + if (read <= 0) break; + parser.Append(readBuffer, read); + } + parser.Parse(messages); + } + catch (Exception e) + { + Close("Netzwerkfehler: " + e.Message); + } + return messages; + } + + public void Send(MsgType type, byte[] payload) + { + if (closed) return; + byte[] frame = FrameParser.Frame(type, payload); + bool tooMuch; + lock (gate) + { + tooMuch = queuedBytes + frame.Length > MaxQueuedBytes; + if (!tooMuch) + { + outgoing.Enqueue(frame); + queuedBytes += frame.Length; + Monitor.Pulse(gate); + } + } + if (tooMuch) Close("Verbindung zu langsam - zu viele Daten warten aufs Senden"); + } + + public void Close(string reason) + { + lock (gate) + { + if (closed) return; + // Was schon in der Warteschlange liegt (z.B. eine Abschiedsnachricht), kurz noch rausgehen lassen + var until = DateTime.UtcNow.AddMilliseconds(300); + while (outgoing.Count > 0 && DateTime.UtcNow < until && Thread.CurrentThread != writer) + Monitor.Wait(gate, 50); + closed = true; + closeReason = reason; + outgoing.Clear(); + queuedBytes = 0; + Monitor.PulseAll(gate); + } + try { client.Close(); } catch { } + } + } +} diff --git a/Shared/PacketReader.cs b/Shared/PacketReader.cs new file mode 100644 index 0000000..9753108 --- /dev/null +++ b/Shared/PacketReader.cs @@ -0,0 +1,36 @@ +using System.IO; + +namespace BSO.Net +{ + /// + /// BinaryReader fuer Daten aus dem Netz (wie bei MSCO): jede Laengenangabe wird gegen die tatsaechlich vorhandenen + /// Bytes geprueft - eine gefaelschte Laenge darf nicht zu einer riesigen Speicheranforderung fuehren. + /// + public sealed class PacketReader : BinaryReader + { + public PacketReader(byte[] data) : base(new MemoryStream(data ?? new byte[0], false)) { } + + long Remaining => BaseStream.Length - BaseStream.Position; + + public override byte[] ReadBytes(int count) + { + if (count < 0 || count > Remaining) throw new EndOfStreamException("Invalid length " + count); + return base.ReadBytes(count); + } + + public override char[] ReadChars(int count) + { + if (count < 0 || count > Remaining) throw new EndOfStreamException("Invalid length " + count); + return base.ReadChars(count); + } + + public override string ReadString() + { + long start = BaseStream.Position; + int length = Read7BitEncodedInt(); + if (length < 0 || length > Remaining) throw new EndOfStreamException("Invalid string length " + length); + BaseStream.Position = start; + return base.ReadString(); + } + } +} diff --git a/Shared/Protocol.cs b/Shared/Protocol.cs new file mode 100644 index 0000000..f47b0c0 --- /dev/null +++ b/Shared/Protocol.cs @@ -0,0 +1,88 @@ +using System; +using System.IO; + +namespace BSO.Net +{ + /// + /// Netzwerkprotokoll von Mod und Server - beide binden diese Dateien ein (Shared\*.cs), es gibt also nur eine + /// Fassung. Aufbau wie bei MSCO: [int Laenge][byte Typ][Daten], Daten mit BinaryWriter, Positionen in cm (Engine). + /// + public static class Protocol + { + /// Bei jeder inkompatiblen Aenderung erhoehen - der Server lehnt andere Versionen ab. + public const int Version = 1; + public const int DefaultPort = 7777; + } + + public enum MsgType : byte + { + Hello = 1, // Client -> Server: int Protokoll, string Mod-Version, string Name + Welcome = 2, // Server -> Client: int eigene ID, string Servername, int n, je [int ID, string Name] + Reject = 3, // Server -> Client: string Grund + PlayerState = 4, // Client -> Server: PlayerState; Server -> Client: int ID + PlayerState + PlayerJoined = 5, // Server -> Client: int ID, string Name + PlayerLeft = 6, // Server -> Client: int ID + Ping = 7, // long Zeitstempel (Stopwatch-Ticks des Senders) + Pong = 8, // Antwort auf Ping mit demselben Zeitstempel (Laufzeit messen) + Info = 9, // Abfrage ohne Hello (spaeter Server-Browser, jetzt --probe) + InfoReply = 10 // string Servername, string Version, int Spieler, int max. Spieler + } + + public enum PlayerMode : byte + { + Menu = 0, // noch keine Szene / Ladebildschirm + OnFoot = 1, // Spielfigur (KeyControlledCharacter) + InBus = 2 // sitzt im eigenen Bus (CarVehicle) + } + + public struct Message + { + public MsgType Type; + public byte[] Payload; + + public BinaryReader Reader() => new PacketReader(Payload); + } + + /// Was jeder Spieler ~10x pro Sekunde von sich schickt. Drehung wie die Engine: Grad (Gier, Nick, Roll). + public struct PlayerState + { + public PlayerMode Mode; + public float X, Y, Z; + public float Yaw, Pitch, Roll; + public float Speed; // cm/s + + public void Write(BinaryWriter w) + { + w.Write((byte)Mode); + w.Write(X); w.Write(Y); w.Write(Z); + w.Write(Yaw); w.Write(Pitch); w.Write(Roll); + w.Write(Speed); + } + + public static PlayerState Read(BinaryReader r) => new PlayerState + { + Mode = (PlayerMode)r.ReadByte(), + X = r.ReadSingle(), Y = r.ReadSingle(), Z = r.ReadSingle(), + Yaw = r.ReadSingle(), Pitch = r.ReadSingle(), Roll = r.ReadSingle(), + Speed = r.ReadSingle() + }; + + public float DistanceTo(PlayerState o) + { + float dx = X - o.X, dy = Y - o.Y, dz = Z - o.Z; + return MathF.Sqrt(dx * dx + dy * dy + dz * dz); + } + } + + public static class Wire + { + public static byte[] Build(Action write) + { + using var ms = new MemoryStream(); + using var w = new BinaryWriter(ms); + write(w); + w.Flush(); + return ms.ToArray(); + } + } +} diff --git a/TestBot.cs b/TestBot.cs new file mode 100644 index 0000000..7993a7c --- /dev/null +++ b/TestBot.cs @@ -0,0 +1,62 @@ +using System.Diagnostics; +using BSO.Net; + +namespace BSO.Server; + +/// +/// Fake-Mitspieler zum Alleine-Testen: faehrt als "Bus" Kreise (20 m Radius, ~30 km/h) um die Stelle, an der der erste +/// Spieler im Spiel ist. Entfernt sich der Spieler mehr als 300 m, zieht der Kreis zu ihm um. +/// +public class TestBot +{ + const float Radius = 2000; // cm + const float SpeedCm = 30 / 0.036f; // 30 km/h in cm/s + const float Follow = 30000; // cm + static readonly TimeSpan SendInterval = TimeSpan.FromMilliseconds(100); + + public int Id { get; } + public string Name => "Testbot"; + public PlayerState State { get; private set; } + public bool HasState { get; private set; } + + readonly Stopwatch clock = Stopwatch.StartNew(); + TimeSpan lastSend; + float centerX, centerY, angle; + bool hasCenter; + + public TestBot(int id) => Id = id; + + /// Bewegt den Bot weiter; true = neuer Zustand ist faellig (10x pro Sekunde). + public bool Tick(IEnumerable players) + { + TimeSpan now = clock.Elapsed; + if (now - lastSend < SendInterval) return false; + float dt = (float)(now - lastSend).TotalSeconds; + lastSend = now; + + PlayerState? target = players.Where(p => p.Mode != PlayerMode.Menu).Cast().FirstOrDefault(); + if (target == null) return false; + PlayerState t = target.Value; + float dx = t.X - centerX, dy = t.Y - centerY; + if (!hasCenter || dx * dx + dy * dy > Follow * Follow) + { + centerX = t.X; + centerY = t.Y; + hasCenter = true; + GameServer.Log($"Testbot faehrt jetzt Kreise um {centerX / 100:0} / {centerY / 100:0} m"); + } + + angle += SpeedCm / Radius * Math.Min(dt, 1); + State = new PlayerState + { + Mode = PlayerMode.InBus, + X = centerX + Radius * MathF.Cos(angle), + Y = centerY + Radius * MathF.Sin(angle), + Z = t.Z, + Yaw = (angle * 180 / MathF.PI + 90) % 360, // Fahrtrichtung = Tangente + Speed = SpeedCm + }; + HasState = true; + return true; + } +} diff --git a/logo.ico b/logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..b531e3d4040c9915707bffe1b845496f750245a9 GIT binary patch literal 45987 zcmagEQ<$eg&nNtE+jjS~ZClfJPusR_W7@WD+qP}ncK6QnKKosJ@?E?4!CgrzsiR6# z`6U1V5C8&zhzR(%5(4-@000*N0Dy`4UtR?a0BHOtBP9GUpAG>4Ktlll3=IF}X<-3? z!hf~`0{`VZ|Iu@}0Dzz0e|bkz03eF?-+}+W|D0c-_m5Bl0K(;E#o(Z^q5pAk65=9? z|JHw&07OXee}969AAJA-_(4KMP}wchNy$=NSrGO6y36rpZ!5I|@ zcM-*^e;TLb@nqVKmnl3@(s0;WgwJKq@J6A=G!d8#9s>(XG0>@dV3n2Jm4yLOx?E9v z=Z5U&P1V7ErZXoS!>v!}*HHGW9zPQ^5>0qjZCF(~Q;$`{ht%$RGeNhoX(&vKDn3d; z(w`PGE)AStSyi)&P{$-3q;9Ut0dDL96p#Wmuyf370U*3tB4j+k!t8SHP# zFs#K$`GtoSx(g+buLy`^*m?sv{79vf!Rg6N%VYU8vly0Nps{7Qg^7O{2FdF7EoOv5 zX7a?Sy(qc8C=+orgO`0mR!QRSx(&nCk->;cZ2i$l_ds+-yv7?LoP};}#s?=y8~Eb` zoLK<5N4}zu`OC^b%dU|5(ihQkTq%B01b+O5ev-nP7n(RJR3dzG=r}e5E5xQ1%V?p| z(AF8HxZ^m6u6eW9(5U@258Ne_x9CscK$)$l~v%@dSn#TA6X*5 zbWup4f?;I^-2IlenMJ3V9?(Oe?H4!2P@I()b-TE{U|T8lW`}`0LaQpsZwDnJL&2EA zuUt;ufa$s156>YJC?HaNlCL1`(ny4n1BPThkun9$AL+z2d~$Mxzmn{|3suJz0w<-5 z9UyKd$+Q&w$K~T4+AKmcM&9S(P4$+e1n2e`h3Q@*ZFu&Zo1=oA4KJjslI)VI%G>}7 z&o}FKDCFJdKEER!Wvj2J%Br5LL8aI#12t1tH*X4cDM7ME+2wvahCBO};NRu{`62vX zrT#~N|L6LH0BY5}5&D&=_Wnr#5~8vq)k1m!|3BXI&tCzU|KmNouDq`R02s`Fy{A&e z+X____2+7PTeU{Zlid31sa8Nr6DSyXKA$KK_&1Bl;Gh94p#=+O7&Zy!m)I_XfdQky zr??1mq@Qk3Y$~KABDBAXqGC~R5_-C2Te6PksrHSB=Lh4#DZBa zUZYH(8?*IQ(kghq*+vb)x>UZu6uvXX_|s;HK4fv1vS;J=zS97!`*2q0&;?EHDqm^M zlMfqkZTTM`RrpZXOkc+$@74~Kw88W%HVantDUOkB-NEwor2UjRd4RZy> z1lvsYj!CvfALB{U?KT+3JS%u7b+ElXebVLS$kirC!)e!dYWeoFkwTj!Nl~!%i#$Uy znzYE1Nex)i;(~_~`n=~Bp&mSen>%m$5GbqfV8d#OO<29!GmHpPB;B}0waR1J2~ij( z@ka}g%Qk$T`YQ&5ENyud(C)uatxsRS5#1>km zVs=7&#|{vk;kJ5o{dPw|@Y<8?v#_BW1~5Kx36t>CM9VJ3ClK^IKr7 zxmSK+!DAC-nyKs)IDi`UCNh%C0MV4? zd&h}kC9=w9M%04~TRPQ$8YJWU~i>s1YPM7R(53(m#lmvXJNt+IF^Jz{Z52$Zga4 z7s16+lmKWMveJ7}L>i~OqKQco#w^i*Q@>Lxfti^Oh0aFgA-O;G)cWj$l@LT6FogBL zRv+MOgT4(%sa^B*))^N4=~^)=LmLdhl)>$ar=lzH>UBV$o@Gi;x*3GAyFtgy z*^e`%P2A=to&eN!eh3d2&-VfcF)!PGUKJm@p!uiVHv^Yd+=B5;{ZC@*kUa z)78b~@Z#56^ux4sL`LZZc({J7KMGlr)yzUudm@aa;g+D3?V)?3H&MUta8dpR$WYr6N zv-H&Gc#k1`508r;@w_Qj*l4q^2@h53l5+U8KA#oF?IiaA{U@q+naZsHpQxIF`{ecw z-9h>Ph^mDD_6h$dsycdjJOTg^a{rC0jjHZe$|G1mU4{sruGS1X1h3_A`HY}}2V`Y| zaK#ph1%;%}=ja&7iBy7r6q`QdLdwezA)%V0<|V5%sKkN){1w_%QIOV5Z(I~BMUqG9 z1&6o|2VS+rxjF0F&N!RA>^{*ZXM+rud+vUj%6L2G_I-%@%h~9ix^4&>gA%3eFIfRO zw?&8ynFshFZod<`mk@YEBrq+s{G=px`_?W%xpeoJchGU<_?fFy_*-~QUc@v7(ino= zl?111m)AE(#Ji8BM6sb|C4`)j-g##exiH9G#KMVCOEbP=ea$2qdGU_+i&6$pj%&t8 z(!Xlp9o}T=W+2<%<8FoaZ^;K{qdXu7f)Y#CNYz?o%+;ryI+_$Y_|Z#MX&I*&STn>d zN#rr6x@(6lXQKgY5sTjbQuPAgK;bsH6daSM%ma4#v zPY{rGLsZcT+tkAyR5=cT1aqFs;SCJ(p}EWN4zN(z3c%k*#Xi^H#z9cmaBVhBluf4@zDdZabF{xgWs;rIBWW;((D>Hd7^QkKHS5!qFe z1?q%qz$KhaI99a-2;uV>k=m6s{0-SDYXp*PW;*Q9;F~zjR1n)Xh_x;HA>qdxgV?s) zNF@wtr`-Q4b3CIbCe;_bz?Tz}7TcJCvfvd*&hTCtLr4`OxCFhX+E{Hjq@+Tovcae$ zuA>`?{o4oNLGvr9ot=f)%Ny|hoglB>1{=~d4iAQ*mGlIubt&2IcOj$(ZwC_vDUL4v zk~L7&D~B6j=z;Tfl(7Hw($F~ynOY25n$OfRit-@3=^3H!b6(zo$< zMbBslhGO*-MF}Vlga(vJKbC6Ke^5cP@zE<6TV?>QJ9d)Nkym!!jad5lC6yxuOHx*_ z*|g{ofQHZD=x1v)LqD>A?h)%2nZq?o(S_mul(9#^LC%xZWkSqt0JDcHeVZ%_R(n+A zXL;n&Ox6!7HA+!tV)vE9Ld@|t-3c*LK7x4aKElF93n89qo|qKs>HAEMWNSnDqjG~wLF9pIfxMYcFhXDtbPk+V2|@o#zFOF)PYn^uDXKS>&>iu4$s+kQpk zNJY+@Bifq{uEe-t+TvpV4jGT+pV(~p5^NSoJO64{6{d%>38E% zYUcGy!Jcs!{gRXiM_7kM{Z#N~6#>JgB7G8`B^jX{=Hp`xZ;%^ zYiECrq)74da5TCEGqbUG#gBW)ohR!L>Q|w2%YM%+6!M!_Cy{zqZOYQX+iZ;UShJb| zWUJxpQvS+N%VM~5XeX;Ql!~weSm;5isYrx7ZvzB+Ru}1vr#`FvAGyu=qDe1pXEf^Y z=y3SDc}r|BYx9`0O)YC2czI9I?GY?#6~?`aRC#v(uTpnX@_Zlj@jqLqcEL6t`3rrQ znNY*ej8G@eo{y*EQts(7sBHeSI$;(ymWw(Mah1z~0X1pn81=O6w6VECWR ztP1=V3IM?D|2H#FYCt(DAEAA3Pi9ypNTUm(BQFO9hy&JI3RFP&ycN{Q^!O6iM0Q$vCCWgmH+km(&s}u1)9NOk-K7& z8^D1Ku0xk-`ol2%w`Il^Uxc3UX|b*`Xsb{8DJGIAJPaZgR3p)65%mVq`t)YfO_gZa z+%46S=D=y6_J+m@et1? z?Tpv4n=W#kYhnmHK0==RX5$7Y?AqBajsWGv|u3g?vdH#HYQwG1pOO&ge zIp5}v7x284;`@72&bMxm}MJI1l$%Dnn1^`Mank^Q=s zxC7|tRFqi_RbWpUe)lj}R9mXZY8I5;AOeZE3(@Rc^ztX(tqQ)duKwT+pOBjacuTlk zxc}OG7FcDDuF6Fi5ar7mZldklY6;lEunx#d67y?#Zqp$JHiGwj!ItuZ=7V$TUA>$Ndc3T}=1|*1x1g>dl=!J#OWxV6idzMY zJ_3T+@%={}gAJ1b({JKk!$uoAZ1@|oHBVpBjk_5A8fwAU-L*nRwnwyeNd!x}P@tLs751u)Jb`m|xN0mV2CG@P zQa5sl?Q-Bo)<$0TcajO!Tu5Mpnb%esN-95=jAxRSn*`v~p7v27PD z)mJcJFWIV7EXAEfw*3MqQUPVx&|}6EwL4~5zj-Q#D0g*gb73fB7qp1prFpWSN$)*9!?XK$zW^e zL*(Bdpu{XEP7~5v1`G%G@b;>ag3ARnzxXWk%XG@K7fUS|&RPjOiQGjuRF`trJ4m*- zAe!@Ek93_UKy^b$1lfX&M8Ksz_GkySIe{$v?rxK8G!M%D!nF@UIC%+-KnUIXRLSL$ z6L+4UTnG$Mb7j{YtgI~DRog$rw(oY^l!m%v)~6wz!HPVV$a4^>(KDYh-z1d2-e?{&wWLD)l3)U-duRQ%z$E{8Zi{4Q zJ-_7lEQ$X&&y_!L*w9nqsZZ*ia-H(r%MbIB-i&)F9UHV&{L=JXi_%H|oq02sP7Pz>{p8*qT zNH7`GIocq)ZLHR7R|qOscdB+>dOc@CgU<4`AhVXXJ;xP79!h2ou@pcH1`dUq5fSok zR=G`SJ#+I!$R`Wl+*kXq`!70$APVk-j>q63l7Zt(E(jNhRNOUI=u;>0>#vO6^v*@5 z!AgTt?4{*5peh|^{VSEK-zbVLNKN?tGCNL+CpM$?uA$ zaD<(_zkra0$uHjNh#XEQ5%;6Wd&0p)744Ad(g7ZiJ6Y^EQ#-ED59QYK*7PFK2CLGm1h z%bj&v?)l0HwCsTwEY%-{fAx9=Jjce1s(prhw)EF9(Ccl8@!!2qfFLgxs=lJRAr z%V~#0;c3W)k>n|T4fE%b%j$zMNqKl0fk=h5>u(?A$`3QQPJb!{KE@zB8oGQQ2gVuy zJJj#*fc~JY1|&8UQsf14OR@1wI}+10<@*HSL>pWnum&+OUR^o`{e5|@B zIyz|{v~^a7w9aO;{k->~rQY}BU^XAp`9HK@++B_1DhMUca)aPHqST|YL_kcTjwT65L?WaHXsv1((_dlVZM7=c=4=g z`(S>WR(s+Tk{e^S_%uoX_8I)mMcVPMM1mFSSE2x0Ki6bk5I=eZpfWZs$KTn=`qGDq zm~%8Z3$tnwvsOiw1Mqm@rw14FR5_sU@T0kW9QFAL>AIg=vXqU5t77W^MaJrtb>Gk$ zW4`d7@hz2Zp)lT-lL6m!{*#gf$`ZTVY0!}hXyxX6?<<$KWXei@+Vf=I`V8k zjNGa$@~>66Pr`3{rtg(LRb2_`#QcKt*zSjBP-bdAfAzphLi|4I8MoSMHz_}Go%Ha) zEXb3=jr{n)pTNG_Hr~IqHCmj#_ukDT8Ak6JQwt&xJm9lF)cv)RK!3p2X=>~y6DiTS*=kw1R%_6^>cv7*j-2L#0 zuqn&cn;80aqZ=X>I;Qh({j$-@e;p}}nL6H`C$2Zlz3~5g@&Ei-+SC81)$zZJ1)&d= zK=kOfi%k9M&LD8p)cw7u8uPI*S+qViiS`lPFGO393W zO2HL8tDw)nr^i98tQk^`OI(yTit2!JPdFwlTM>vewSZK5%hD_IYDGVP;9y2-+B_)#=#HO~mc)yHIc~bx;nY#x?3?GQ`hf5?F z6#V{IFVaZmK8%V&vP9tgt;peP!Np`jZkQ`J8P*wrq<#ch``yRrK0%iy0tsqT#$e4; zZAg9X*qI3NMp)hbKH^vWVT%p1CS)?JHOGzpQ!`FiPP(l6)I zzM}Eg=kOEaj*_7nU!^-OXj4TW(-DMT-l2xG!j^>8;lFW-c<<&eAIXPa8+~1IPrNB} zDT>$R=D{0$=ZIguwxFGokQ*H4K0?ZFi_csrQjOmKSepxu>ehJPAF_z+ZcpXgjYGT45Y%{W#2+ctc=)E8xNUVD+sZ+{!*CA^cC4LLX zvEOx&7$68$!P20H{G5)Mm2pJtIdc|mXg!gpX9ZOXxj8}*mf4|3#U1}16FE0_%{}V{ z4=Rtzr&j*jlQfb`74^yMS=M9<|H5xqCyY=G$&g{CQEyAErfL`3ZnQGe_aR5!PI?XTjTyT&JbaEvm%K^OLl8i{-dQACG0wPfG2gDTVEZ5K*E7|VJT&Kr)(iK8XhvPmk!&<@B} z)eVhADu`pfcQA;}Gr8HJY+a8y&mzb@6RG>NlHm)M_<8rsz-p}rwzCRIn~0PaLpbNN z0?pqe?@(ePCWL%JU}t_)aB3TG)qMvw6XD+Ei6|6Zs5MWq5T(I^;c^6K_Cj+f5c`*y z6hG_eP?w~To<7qMTY3?~O1{CCLA@vMYP^z%Wj1PMnUZCui+Qh{j<7>d%|*1DF*~Jv zoYb~%sqaBtAcrn$Ic>wWWA!U~2;VDCy(0lOb%4C4FseHK?}a;|H9;#zgPmw!w4n*dp-`XzwW_uuljO>2r9Ao?pC>i|!Iz zfo7{MU}zNt;rEyI^f+fLKgg0Z)Z)~gx9D4xpmyEiO>9^&R8#b^0E$Z)7FB&3R3TUS zH%iqCQ#_g(h=y9KV zyxBxe{=S;OV8oL^Fc9wpV5D39m`b;w6ed4x$RTtDZty8I%ql4Z!wa=2)>}^Pm9J*A zw2qdiTIG%Uo!*Tm5c6t>KEW0N%gZ5389RL`f@J+gjNe6Xe)LJWe7&=P1&B`=$i5>G zaR8{7B-e8&1&a7}QjEd_2C?a7QY>f|q)?PF z9IM%C589EhMx^{#+VgA6xn-f>Saezo>8`}{Ub(St9OC=wDIF~$ZRt8A1v#)KC+10R zmh4FB&F{1}7|9za&IZ#(;aA;b_U?$FP9O>$j-yQM+%F?=N3qZgOS?c1?}rWXYjp}l zTx$TfL%~!rdPhWK>$$rSN1{o7X4`mL!>06zncS6tyOC%9W@%0Oo0H5nGqU+WjUZ1w zDgWO5C!Ij+Q#|eJu#z&P{QRr6)?=1`Tw>7w^gOq&xJ*PEjH5ar#NP%w? z_*ih@?cG32pKd3+1w+}uu>K(Za{k*P67J^^Y|s}HWbCEjchw8A?>)%B0g#}5-6=wm zF?=b>Id%rOZ7Ss(wa|N2-)Nl{p0LUall;5g(6D3N9d+8~Gg)gbl%dYw`TZ~JEDs=4Mx*eVtrCJ)n}P;Rve0B$3oV6A=++B@9RrMn)y#jJn~fxL+pgiZHF< z);1cb(%(Q=`;Uk?=+hV>;KnC9yy{LmPCv-NtUC)x#*X=|HXZ5*wt*uVL4y2mzMzsX zVpo{tFL;7{BnVgsWH7IrgCJF@+A6UwXN9doe~t0Kf=vbEOiUf%rG3ke3 zyTrKslGT^A_YxTQtBCVGagZX6XP80s0wM4H8EdEbZnQs-BYc$Iq|)2S^qvpInNAq! zLbyQ;Dd=Y2K|>m*Z234yc0B4I-c=j^6#^a$@a8O(;f%IFow0nA?WQ5<6(t-L0X56+ zWGHJYhA!FBwS2U2wX$U$n6^veo*d)Uvq4~SY0HCBNwt{dX<7P1oaDEpR+v%oF&JJH z-pCF1!a(B_j8Gd0Qi9Tmmy!TAeFgh(IN+4nbc_Jh87K%6iZj*J0XH!=JEGIF`-W@bKr0K3Dmw3M6n{?tJhx8#loOy}>WV8U zHo5dG#@mEK?T^s3L(Hh$IWss^qpn#s*M;j4xU@{Db@Ku_Z&*eMTLB;8!OI3I#Lp&n z2?-+61FfF;TKVYJOUFX<*HDMO!;@0um^)?}NQB7S*dLftD0tDn#hkp6-U+=N{DzeB z_eq8V-oeHzfiauRwOiN#(}=3(3N{`nTUjIr8@I$D#`dk1Dk+fDoyItyP0nV8#I6Q5 zr03#vrPFlqQ!+zN{0c#n-#U{Xr7kO;6mL5}0zH%s3=UTt@@8X|3KWRF-N7;Ys|^ct zKi$US9Y*;T2`=`ahR2Oqma9ETNj2Xk+s_3T1?zlm25cU(A6?C|F~{2(m6iZ28Sm#n z*iPNd$aWJf@|ng~Q}kx6)e1p;Bq)n`<+P@51oq|qhohLhnW+}T$pop*uMaQGk4Hzn zMh+^?CrEJqPMyx5Dq%#%ZS>^WGnEa}a*u~Q$7b*ItQ;cSW2!>m2mN&l>t?SQcH35b z5ViDiL=vBGmGsgBFh*}bl5^@~D47Wbb?n@VBzK>h?_OPV#3{@>-F-bU+0HxY=AmKQ^o*nm1VS^Ev6KZG7;TGIGSk%9yQJuC$AdayN0p#1YxF zNoE}rD|JG6VUELB5+v5Ul3fj^pgU~HOSw0qMP1xzpTXS=u0aqn;*qm(y7wQPE99Gf zXy?J721Ug6_o;wxSdCDU7YmKiG2!uPBR^Bz_>ezc7CudNP_oJj%PY}i z#k6bRXdo1s9QGgF?HZ6Id5KS9eKcwq>{LYl$(?=0BaNuX9@OyDtAjfX8a5B+t;sn+ z_Urk+6s-c1y^mUq*$-nUtQG870o%Q!NeDbAPRv>?ICU_J?m}|*Wcxdw4$ZlAYEV=t z$Ud8IUf4!ueN_IL??SxqN4s;Elpy>Xg+=_*bzn|ngt40w%wz5y%ckFrV*Z|UF!PMe zP}gL=4+VxXU!;@U+n9*Qb7O3O{9-`P$%&($@FN%43OgIRLUBu1KR%W5Qewgy>ofUF zMlT%^`Qcsv^xz`yzNzNGfYf%oXf(SWTrtKguDDl@bh{Yi$F;ht1N&({t998oX7CM0 z_OSSVre`26N~+Hg*YSrc{!l_>+?Zb|hLJz=l}_PXaBN?PzU_S2VmDnC=O+K0o|EWO zPn)!yt=li8%eR;#4_;SvzME1U+lg3lcoUA36oC#kL&_CTchl4TkohV^8%5=#hN!Kp z%$w1|_pYoaUqjX_pgG+k2xod0dP=8t6|n^rML#^K{;H@YPk$4f{L!H!`;g*f(~LDg z`%uPzBn5_(H7E8gE*dM8^WjO^+2L&e)4J|86312Nt)5>ldC9bNs)Gb>tsYS;5bAo{ zKU`YOcf)c;(1@2~ZePtDM;kmsdPB(ZQBI!ZCvfm^L^@{294OW4iQwXQS^1-3`KNWf znLo_VN!-pmS6a$1HeMk+43U4pfboB@fPL?W3+bE=ok~1HZ9_G%r=M)-=Z$&M;GFD; z7TFq};h#Kv3e|zYvJS0yS9oQKk(xB0XY}Zqt*eC7X@@nc#@_YOEbUHjDBzYTW!{oD5YAM9d) zPk-Y7U>BF#P^oA`P2V5e+^#0qmmjbf48Y_0h(JIJn+ExyLvW&-@u?Ux+YxfQ(k$30 zGLKS`WY{PPGnhSQ()so<*N_y1VT1vf5xuYz43oo`7g?SiI!`)(w!B-gjKEKRFyCEN zv&zrQ9c8n)ni?SjUlj*J4I?clB;rt$YY2PQMn^+Mpu=d;W^=uO6#)Ut#B@aCzOz3G zr@V7FX?Db`o#f|#4EnELg0?Tt+JY+NQtkM1%=YN+p6b`O{oz6vC2lTGV?5p);p>&f z2v-E-pGgFLjXN$eHY`zs!vvA=`BJxFhUsSCR|WT$uBA?fHMc$G2f^%#NHYfvdH$ zd*RSN;ysc|_K62mE}lmf-gjAYET}1vO2>+ujz#n1d46(`?hx@Y5-_Cu!x-K)c`yN| z-9o<~+`Z6GxdD3zZ8HOi=AVw#2vOby*I$jX$u(~*B-@Fw>gOc6+9=+l({o$m@hT3f z<7v6Y+Zzt4RXO7iJH%aB##*+dV3jPNT2j6{PXgVs=nGO#(Ll5`1*h{Q&+Ro}5~55= z(v46ex__SEyny!Oykyh&W=|3@`=3}c7Yr_7a)gp?b$yJF#l#Vz7<=0$!fi((-=L`0 zb`CzAf`$8}zV&i$e!ab4a4*YQJwZt&@Wl~G{`92OB=?7**faM=C6UmhlGMyansPrX z*quKF%7`g^%rP}yZ$wqUTR5$(l&@5`TLf`$+!=?kllm2A8oWCGjZM@B__`ng6ORyE z_N(6-A9F62RG6p%*`D5`rJS%3!4D1rV5m#WQ;ygv$q^1XahmyhY`!cOtK12ma=gNl zdAS+FUgK1_kgyI+etFKjk8&7u6^}{ROLwK{qh*3;q~Ib7FX*Pq(kv%qiB= zaIqa|7P}TaNPfZ;*&*_tM2+mc6D)+sv*dGZ%q!LP|8Dmf`kZVrkYFbRZ>V0yof;J-$q3;}tk!8Ge5W9y0@VTaJe9e<=nHX6*ksSoWF|eXI9b5YM@lqK*HM1 zKr7^+n9!sQJ>q_TV;R|!;|<`Q;RzA+>dUoeJe=Q9_>8()*ughJ?KcCK;1UYhrmV0L z=iX0`)a?BfCSffLtt_(kN@wM03gOIaRPgjm~b`BC1h{{jc2Ogg(;ltaVq}} za{i~RQC^H&=*k+BdL^3ev9T%lVN?(nwGf^g%+U7}nTHC6<6TDQAG+$;jZ#GnLPLYF z1=xSWh$ZiF>Tj1qh=WfaHJ0gsun)tD+y5MSQM#uul~T$k^& zaZ*k$IGELGBv@a#`9e!OnEAbikew%V?w^-8RV7vP?K_sfn4`(Z>Z*JV_1%W6&-A0M%ck8Zhh5Z zG}fwLqSzB9_kuJiVn_MD>a9k$sWQJLDZ|nCK(Y zJyOuj3{ZAySNpc{_-vjWvH(~-mxBs9`cl4+7_|2>wPsD$<;yA`CVWRxl=_`(x4dFD zkV05*lo78d(M+3tT@cDPVLGUE4fJ5q)E#LP)J(i#S4JU+_o2XSY@NE*!OCzpJxP`+y z%wLv8;JPq)V=NVSUH&xhDS}`3kMy)S-<$gFTf{Ky!fh*s{TYm)YYR2PC;m~1NXhv< zeveKEqZu)VuTMS88#7Fhlu8(;uB=~v+n>u}Gv@!i?U0HJiZtK0r^*09MaPqFb#M1B z$Y2*u*9|WILGqO^Bf$u`tpWru^d(>Et%3>yYG}AOo*ru~xJaB18_pMqmGBE_AtcsV ze>rA5G(9@kdBT^_^#w&Z4m*JS=TW;5HL0(A+h5tsOUWe-Yw6YAnv+u|{%A_}VJ>I0 z8Q^@OlFv_AWQX~et~G>BlFGJ=diLWZ`?$bt?Hh@8wfQC6+?+pEiwWPqKJB*fSc46$ zH;PKU7UgO~SFE(n*9j^OWm@#CO2gGQqdb}SfbU!7nC_@$4NP3$y_$1^r0aF)9z=<>#np*v1suB7#jxw)-&_Ui5$jFV3LPF;O`e-w znc5%8@+0yv@gdpf%PD!BN7dku7>!oye?K3(tG=5vg)9VT((jdpmqRoiGX8)=k_Pt> zjl~x-h8fE{FRkR8fBgQZu4?sr{gnAko*XP>q1b(4L-|+M3O~j5b^`i#q=M>>h<4uq z>p*O~pm=KOZ*4h@E-D)&Uq067`@IRgs76L+Mu$D^MKhkDarPYHC2rr6f*d-{b>2Tt z=_ISrzGo}?9ul5hh~+mANgGfFT?WK>cPw+&%$i-MEFCK+FFMk;I*V3-Uovc@IcZN7 zXJYd;0(JWrsytFS=IDiq(oUD!f!W|4GusC*2Za~5eWy4*2)10_?(3^tRr*_kg{)`dvXRZ5<=b25YTl6|Di{n`I)P7Q;GlVhjk0P8!cRuD(n5q-F$FEk_mV(FWi|7D3UO zYwy%$h>WuX_F8C!mU_Blt_Lz0Af%roV3Ld-C7c zIAb?r&XT+MgiD+7Wu;aqnQO5HsCHd$Pw{)NL;UaJp+mHu`60o+7Kd;Ymj~?Qw}izI zoT;QD1v37`fIp6qKmOelX9Sn!pWKK8UARu6S)hbWpA3%!) z=HsF(00v_93&8$1i<1_St84D(-9()Cvab@WPC)UJT>i&wN%h@BaaQ=H5?OgqhKu#U z+><5lf%DB9LQ=jkq;z+Z4)t8y8zmKH7N)o!Z>N=OZf_^GP&$0Uca`Ta zv3obW;~ZUqxk|^j5ivfkkft9;5akLv>6Uk;Mw)MUaH>J=$zd}A|79@9cz#42ew9QW z_pYitw8Y8pa%`7hLWy3E50I&2gi80uhFpl`B!8#*`15N0;Xf?&ui6-)0X+kUgG&Ic zf?*O=wpjfa5VflcOF#&-^smfVv(n@$tF*7Z(Z*_)j|i$-^@>2EwQOk zL|5T~aDWZ1-tfO6a2K`jnyR}K0pi%(8pyw2N6GKyZzmvIo}cRc?QT)w_Cfv5z*THW zv{B5iF7kn7txRWAkZi6jr@>ZZIB*8Tf#+&nZ$WOAf|0qgF$bZ5wtF#n{7rIhb}6w3 zp#c51Xb=T^-tsQoHt5iEvqJ?<=Ng{KnIBcUdh_@yUog&+x)8=hjVWkogs&ebJ zNPu195R_=p@T(-<5EN*(dK<-Z1h31KSfk0XO{q!hE7`MsY{hFQhZ0&nqqA>cez#gp zh{Sj+#-4znm2p zDHSwOr`ZuGH<#}@sz>OoFiEH$&NJ$opz%1eJ06ly56ja|a${Yj3<0wY4&_y30l zB+D6Cc!6mzC6&IO5X`E34km^T%Mcul#;g8E?qCWLU9BCkjus&eLXDej-rf_@c8BJ4 zxf34v<0E~e*!WWB{v*m_qlT_@Q1RBxx_f%QGFxv*TheBc!iQ1!Pjy9kcN8baF})Yl z8}tGp|2S)>z~*h|k4!B0g~lLQ=re1tJFpBq5Mm!_?Yq^jMYFls=ffFv!BUmFMfu2j z%(n0^vsb6!HPhzH^|0*W_0|P>vvn#LGiiaJ^p;AIUuK)FsFhRm>TmASPS7ZJY)O9a zXftJ#S-eMzPhpv<`j2n%&iQ})w2moyT_VK`A2BVHj+~A@cZ2lJkkd$h!y9h1(9N4dU`2cfsyz}Wfy&@i3VQ}s$~Rh)$M z<|7)~TSdf!#v?ebyWsz8!Mfv z->!Ecgy8&sr*#+9;f%c2;j<`~rHd5~K_9!`9D8B(cs8f!pqxR)&h!;G8Qj#(L_`}W znrwg)p#6t`AS=08zg`wkmqZAqYW`^#S$#Var@zzdyAzp>+DCr6%W$ET0TZ~j58z*X z-vCH4Q86;rIebF|5^06a#-i{CG&9MCH?FW@+4%y6CRR+aOHss6p8#G?9?$3Yzn zipQSc5JLi>K7cZLoWlDtT@&~W^371x<523b>Gg30N?2Kpy>NS1m61-<*=ZCPX0p+@ zFr6%PL9#!ls&k$|#1*UoM6gF4yV#hlgX_Q zMgCP4zjP?BR~zF>(r*&H-$lhi*L2^&Rq5~imMY`KlbE40*t_y9{$4Uy$HW&aeONF| zL)Mr1a^}Mkl9jK48nbT&FA^D>t+fgACk1la3V7k=<9%kb8^Xue!%QWyrj){K=p&zN@6pO%!quish%CpZ<{UB_7n}zXPEJ~E|h|CsKPHM@s7K?I-E)4 zbhr`USaH$sJ``BLj6k|JZJdg{&O5~N?eTz)7n|P@|1ZA2GAOPm`f_l$0KuK032wn% zf(H`Z3GVJru;2uD5ANp^Y*60o6Y)v;E&#Q?ojEtIK!W3%h4YN0zne|-skA1l7< zF2;kUJ#hs{V>`?+{cLEDiGx1V9{oDyS%SieDKfw#*fyfbp7pCqJ^7PQk?7sqiZh?( z2kwLWQj3D^F2_HCcA`#KS=wSB%VOH$nJ95B4%7({D?N*z$5hskeii-loDm)HBeEwL0;7>k#B4q}uvc_06+rkvFS?qRJ&t;|RQFDMdn|A#Hh$4By zlNvrkTjde|s5lUtmgMd}!3k%wJOtOm8=@=wUS#KkY*7S)$VVTuqw)Dc9~^$4x_8$5 zQrdzsTXxb66!n=t@RbAiCm-?s)SB#37lz zF^AjhIQTY~kkh5?Qw?9oJG1wR^Smc%xGKrcz6ep&^$rO?40i?4XqA9$U)VH03yXZ5 zk{FWZPHt2nC0-9+78dRAhqkR5txu|-79{KAKcuCmuQJG%T@XDw;^FRNSB`IiQ{GSXyM5{h3My(wGTA^ib~O zAX@Q<`z0Ofl!=%~uR4!u+7k~tPMMh+LeTRzFW8Xuu6p`4Vy)yaunwr|XcibkQ`Qr0 z+4?q#;B$Z<<=?Gr1v(l9|D z!y#xplsHN*1&UnoynmQZ<(?t@fDTT zK7^r;lXh@C%$I(jZoZ$z5m8XDwO`iw1Rco6#B^>wRy1uCuL^b2cbRk$TLy{Va)XPj46T{X#K%J{@x1t z7*t`7DD^c=6NxNFy#PA^AbqZ3p4BLe!%>oZV0_+Aj$IJ&kM$&EyhVhJ2L&0yksANF zsEpS425C$L{6(HJFH^LV&|ISMQ{~oJUK)X%txhpxhhFf?@o(RfjVUrisr9&rf3;zM z4WJ!zs$Y=5%4WyiYOv*NG^R=1JPG*ly`@v{J8p6NejwV^j;e<&!L7tn80z1&(g>Vo zmQq8Myb;O7_su;8ytjWj^V-V)E#M$s>J1&N75#a!_EK}qj5*hJfN)pqbG^XSev56c znp5;XwN<_9X4P}2Vb79L+4w;&GP4gC_cOWd>C$ky(LJJ19Hmn&%3s(ys8;ihR7)&0 z8|)zUcV4xD*N(` zKS=8JOq0!-W2nsy(MJ}aX=f_0J1SG=Vffoc?oWhrgIoNB3v6?x9@>i${+OfuKbXqe z3nx;l+LB3Ws?Wwn?2?(O`+Db9&u}fhN`Zs}Y&wa;+E1`3@NPQ07kbjtxgf^T|tW5sqGY@JSW9tQKy z&l)sYT5c;&)m>g?R&K6l)FAhjC*Z$C7g8=e3aFTQV^+jlM*Tf0%o0Nlg)(r;d>X+btA+DX1m!ccZyP5LS z^5~zijr%qncT>-jj43!m)h`m@3R;CL{1Qa0nB0zU?-uxJz_? za=*gKAV@T{hW^TU`*dcAykf8bay>do%eZ24#dbKK$bQElK|avNU{Rx8&oZ|y8-8*0 z)H+vh#H|27C~G=EA=X$z^KhGAqQB1d+k1Ze>hV$3ZWkvoF7q2z{@#^@jlW~5|Bb1+ z%99{@&PNr0p(3=%{MX1>!)M#gC$^5tmOGvHP`Q?T|K)dxBLP@47;o0@8nW%by6!3= z`gL$hSznBGotOS$Rqe-M1R!BiuX1@RCP)k5k~u5LCuw)LUr?@->RT;YHl0 zVm8>4=XVRy+^o6NqPE>j!#mY5-v>VYbuHiNYP2xiqs;VxmC%I~<95c#)N`teYZnHk z(!gZ9-)*ncm-8lh&3oS=)xwp!BY}84m$%%LmrqUpJzrkjvT}|Q-ErF7@S(1WhRmLa zTi;;jYF?I_E zOefu{7ugr*iCjr7oAYlu;!H3teD7kh{;7Ln@85GePH&<(|69wskp%O}N$8WOr(RxK zmRsw5Q~cTuk(S0fFPyV6sg!FQ`S%@k-#xMi%Dq3EINR91I{-5|tkWHCmVR;He>8uo zFN7j}Gf8K4G_ZPTM>Bh+D5gU82h{1(PYF#0S>-!%xG8&j*jjJ8&01X=NGIB}IkW4{ zSA^Ljk5KApbrOp{VXQxH&f#$<(|>XvHa6Y4W}qPz4iLqDc8<7_J50BJ;EnsyZkw0q zIr*1@)oVP>3#*Uc1A3u&wa<|0;kXy_H6|L5Sa9GsPtF%Z7Zu8fD(g{;(WYl|gys#- zAk-vB)OPp)0`Xrf?-Ho-wOx0`Cu;0lt`r_R0$n|C{BhQEdCxa0e&c*Eh(|)UX*UZN z_{Sba3c$1++C_;Zq=>*Nu$cOCp>utP!Erv<+m~gERo@jH^i*ZM_L1!(@wejKx1zw_ zHli=ghM0>kof0X2-v<@JMzmW?XVVD>>&weXv(~fC%dsjPQk7!L%q;y{K>bj9U7e4b z5r2C&ZE{txe^Uw$=?Z!x=MY!amv~oks7+8{NjU=1%i1$%0(CyNKf?Kx!7YZ93!btj z)xFdkt?YUwB`wyw+>hhi{Vq2?bquihM%$DAg}nSA4tX7sxE|!w#BWS*R7d=TNXQOV zR=!*mqRMCC`Cz)?q(#&s`s7OPb>L0z2>aZa$R^jnw+L2516%j%rKBm2H=m5Ic$>bT zxtCb4HY&yr2K2@^YxknUs>;Df&Gy)!_4c$CWg$Nm)E0BHm~1LDE@X65-l9KACl;Xp z23ku+(AhsSna7coiK?^Iog04^tYDd9;YRn}&^!|V)Cyo`4obSLHlqigU95`*meU*P z;Q$IhGLrp>MQ*ATDi`j7Zt4Q@IJRm7)2~-ZIT7Sid39| zm&LMN{bMMNf>FgL5iYCjEnmgXOXY=0CQ+J&cdZzKaF<9pf9@1P528P4iJFC^#G2s%X?AbYwmB zV#wFSG>iDsdbao>cs)N7G12{3`s8SgYnQp zV*~y27u&x+KpiZ;!vvpj|Kl+vCP=oqHetxk@?GOSw}`( zC(kA#*S*FAxN5;0H4k!Px=k&Ps36iIV@H-%k$zGza-Nb_mx>l79U{Ug!f*-=93yzV z8F5Lf6>op|+an_@?4ab@Pgb3`z_~-rTXRkIvK(NH;<1s}$DAK3ppuZ#yUpw6`nO51 z^x8V~a^=}6>T?&^$xYg4-o;JZU(R^nYk#T>GnANEUgSsa+GFAVX=h%Z`JM~rtY*$V zf(>q+ETuIW`|U~NI=uh(J^E=b#Odk0=~#?FbwhJBk=dFUTog!Ww(${j_i;eX4*90$5ry})>EOgJ zuhwXbU6r+cAy#yE12mbI>H3m%$esDC<&*4wofC0{QGon&={a?eQ?YUf-ZA&@txUZ& zG>gqIpA(Z**#iYyS3kv;C$Bde*$9108q=+SgWuiaGE4nqGF2&XJLCJO8+U}d2jJB; zCU}`9Pa#CKTVn*SvP|G3F}E3Z{CN?VqN!9#X3@Lh6D~bg`5kBqblfuN#*uq35)Jw^ z@n3MAz~-P#^$o6TwS+I$ZWEu@yV&u}w{3sZ>Ug-T>mZvb3$&p5R!~J5=XUblcbI|P z`+V7o9T+5WO97QCrQMsP8qngPREZFQO{53GZhKpDUku|uY_=`&=YUPYJQ?zib7$y~ zNt5>@j|Em?srD29dgG#FLpPnM-ETFqeakUbml7O8Xf$gR&?y>hs4NS0h&tmyGpn$J=LO$3y7=@6ne6~Dz`;~mzA zN14-EiB&lTu$y-6eAk&gI5Qrwd+_n0#v6>)m^riNl-jZKhRX)hFFoP$GWVve%M-;t z-1eXDG+iJ)UsC-G7nrO3P-FShXf2u};TQQzjvuR$Gg#eibAL%lpBgtVG>OglYHP04 zW-g|)cWq9fCay=4lSnDk6`T6>e+~yTq`Q%VDB)a+Qz{ih) zK)ED#o)G3XK5P1Wjtj$fJbECfsB_}&(Shk;k?qLJe8I!FTTrn7vtlkDqvx(n-Y1tl z$b5n3AJ)A$E1Ezh{3*9Rzx(thtE5YhXm8E&IR0#iX{1jJ4(oB@{=`U(&H-eX-pD33 z3hGqH2=zKJ8+;z}f>B;MI1c_)h=2XE_L-8_NLsFlDD;@phaD0*tJ(-_83+HKFo*sxa?#uyenugiZnTFYx?96Li!}#Px8TItLrm)3Uu9Xc-XinlBsW%>I&jsu zeoqZZ4NB-oym>utC!D9?yPmGVd&ulwvPz~Z#Z+ysXqncZIMJFSxaacPm%Hv>lJ8Mw zk!I6?&?DeuAdENl_Qnt+5eIwtKj~r_=6+a1GiT4RlK&6o3MLl?)2jR*%9XxLxyAp5 zS-rB){875dzpcJZnQJJ!&rE zq|oNeCzD3f)6?VHNWPo*?QZYBoQC9Ore?DM3$o`G=f|4RFQP$+MjotC;c44`CbzgVPNmBfs`8@cD0c zzOIDYroTUcTH&pvbBtYDB0|I0uNKt!k%0kh6Aj~x@l=+F+5s@PkI4?PgDS4}_6B^LP-0mS@uQ_>91| z{Qapl#n4DT&%Y7%1tV9avV&xhcu)nPU-wg!Fk$FWyeT=VltN0+I>dW0!g^Eve3+@? ziqFK0yV$wL6_W=5dLZs%0a@0T55_zo44~NXc!=tQWC064zx5c0qz1p_o>JZ^`P~ef zr&)T$9E)TC+a0wc{HYwCQNkIYbbZY5M6wo=k9=y21P9Bkb@Yyp3uTW5)43xB%Fkxhj^nZ zz~?Tnsm}*21pSk$VJGTamQ!TU8BnfX!7l#_t8IFp<2y??ddjNv8n63$jAl_xXH082 zaeK&%yK--^$4^CTOQtmXo?rlovFFzS&Q^f~7=^*}SoyiEdLBfMbW+$t=A$t)vJyi;r%z-HjJ|3BS>{1;jQt&`+G z4=_fidyA&N*-M*Y_C=Axl==eD?}(e6#otG9o6MP3bjbaqEZ|SC#}{am8LASJE;J1F z!Zb`m11mn@twEVNd!1`KA@#f9R>-&WKl9`%FFTO1)W@BT#>%Ze-2Mby08ur=9@8FB z#`c&T9J0FVH9i%LDwd<-VB|)YxPs$6d68Rri66!%R*XOJ!k=OnAk^hbGj(N{DRpB) zyDw+J=$G;M`oV)(m{9Jv*HEa{4Ih~#lm)=1{RJH(`p(fB?_8(}Pq8aQkJ2uKOKkmp z;ioJ?snQWBLNjF0JAiO-tv@1rD(|ZosEbT@za`PXq?cUDawi`xC`KzenKu?1{F;sV z;jRuIAMPRJuP1VpoRd2IQl%KlJjF_NN(U7+j+x!+8?{MpCvS%L9l$L3y|Mviw;pcI4H$ zCMMY<4nMR;s}FbwAg0A&@@M6p8;vzjQQ_vE<~TnG|Ccg+czcW5re=iy)KZ9{N(Imf{tumI3_owjnk?Ocxs(v$8z_1wQGFaDR)6Y;>)I>TWSTE)oU%hbAG ze_A<{(B9VO7b1T!dyr8Wq$gNbm3H1UmsVu`J*oP2-m2@FpDfS47Xj})v%Qg(Qi3wU z70wAF_;sW9^kM$1A#7-U09ukL7a?>YR8IiqCgxk`Zgzw~Ml;j)unAO;iCjJYW;~+M zZk}d*FAM>XnT7kZoitsS_jXCRKy$JRBT&+JIB=?4@>BvlQ3FmKCD1)j#jy0<1!`ax zR<2D;nYr>%{b_9b%I&_?3g_Dgu&{KbpG1tTy8p@_V)udYa={}+ADX1psaoTJk;8o} zbVXd{Y{7@F%=i1KpYZ{1SIZChre1sxW)wOM1bkE|v`}-qrVIP5y6bV2ddP8ak>!I& z7%JxqQ`MirF&!e~MMlwxfi>AWQTpld-C<}PPn{}B1KryEE3t|WN2e*HOHejy5PKEP zp1X3MVo~1FC6@9g$no{cpvw0uu-<-p0d!K0OY@VoNuGdL&Y$!yuLrs0nLNE>sy6*M zo~1z@o60U@Pry!O>=;)A8-Ptms#KEB)p{rvp%sGW(Ibjt`{u!(xn&pxdj|$f>5TZx z@pAsjwrWt8#B~z{@<;Er2ja*|yFau%U8@`Zk+u?kJH~dbN#GWu84~+t3Y1nRYd5~! z6F|82c0FbFy8Q{}o4&f}0jg5hlLtZE8Y5B`(6&3A+t4F1z#n9rW}%8|t_;v`O5~B< z-Nz!`5on&e9EgYD`*&-An8;0bqE)rHq7F zq^P4gS2ku~@JDJ^_`aKa_QD_Oe7IS?wqWG0yV!~2?3V<8e?jTUU+RJmVoDts4g|4V zjC~-jY5})0(qph<^T1G*-6S@qHXt1g4eOE(q~yiT3Ot=q-RY*k`rz@Fatu71`%D@YLg#VJPwI3qhZPevL9KZcrT=KOA@nFkA(xfIx!{!AR=}? zCnjO2*UtT8d+bAPiUEM?e5N3XJs}D7jCc|yw|1Fj+UHF2Q!plH{w4gcRk=5qT+k*n z#&YGKba;hVdCDi<%=)WL*MWYjU9*TyFxBwiyg=-z8V`pJ0av)`wt9iJj{Y8EWQsD! z_|`Xg1o08lz7MCnhgS4AwMzD{xduXYS8L$ec54dBu*bH-HWT#HEhxbBTak!;fYp|x zQPRF?k?Kru@rk#2&@aS3nbWbba+R$z1ub(^u&4ccSh$fO(WX!ft#o9(5V{VmUw0L$`o2ulL`<_6mE| zf4xQxA0)DABD7^bmHNLpG8K{rcN>LUfqgGgOty@qn}bN?@vSj}6qo2PiX&=&aLoXl ze;^q4-h8~}yOfPQEA-M7`7-)TZnX`Kse&TB5U(ivC$2xc{xfdSI(v|~PrPCLLs4%v zQn#;1f$xNmn0Q}EvxpF%MnMmDj;)wtqsfX`OZp7Nu_;2 z{%{mR2AGXPvvV%DDkJP}e*Fnf+>~GIR2sdn9JIqFhJ3xQYLA%Tc?dKq(@z$S|H5zY z@wgq~ij$K7ZN?OT*s0AwO;&eScTqP#K!Ba+Ag5e0{MmlZ&+dVXlmXNavt@kq~hXdj3kulWXbu-e%p7>LGKlhHtQ*A zzK&ja;}rQlDQQv#bRmtn70A7|%n*E+_(hw3+ip5!FDar-Hddvad)N~D#wPZTn?+eB}Ng!Lus?=MDpyY{jy3W*`?%Pj?mACc3!7e zd09KzS57l`=6)A*uiO`>pv2ha&t;ItJvvE0*~h|;abowIZ{>pfJm8Xa-2Pc#!TRf0 zN1Z{%WaM76)ZmP}`Wh?U+uqRKhkt;{P5=Dbrd^A7gp>r+{32ja@Kg_OKT{q@Y{P4N zBmtrC`k67k?|VBBMw^h^^v&sCUo^zgGXbtZZM(&%`-R1iW9#o92T7mxUs*M&-1!3M zLLr;?#Wz9)ssVe^nRkZQ$X0+S&m@24#`$@2N6yD4a?qu%<8Xwh9~BK0FN#oY=?#-q z^zO4Xdb9z9DsI}UJM4F?|6uOHp=4)^{;}xP|K*7L{r%ZmNUlNF9B}KWv?JB%5sQVY z4Y8UPIZLdJmKBGCpXTlyw!bI~wlJ11{RgeZQBpn1UM!#1&LzolsP0X)5NoJBPlf*s zyP+!9HuzLQinhjakyy?I7fkj5>^*0&^AThR@({N$k^T$%sWBs0Rj6yM7HFY5ZV|zG zwr$ihBj(}-1~?8Lpa3#Tgl@h$TG@2`Y8uY@1oe*LHz##%Vyttffq>hz}$ zO0i_Xdu}OVrWd@=KktS~icXA=wEAM%SQ<)OA_b6-L7{fR@8tr4qP0f6U)tekG;Zhn z-@e)Gjn1#O_Wn3da=Jpvw;OO<%;O>(5xrrMLJ!?sCaYxz9ei^1Gg|O)b2Lys+}4@{ zX4JMQslg?xp+^N~8NL)gjWg9`g=imGc~YRJ6WR#k8N%V{>Xg%c{TlkWNHjhUCs!6Y zU3KBzu2pb+C&yt53TL7S#Qo~U-`7JWRduC!3#zUV5aA6{{C=8cRcsau449nxasx@r zi(5LlqzNPCjhGyYq*M9oWg9L-!Mw6UPbv3s?|!t^koj6ISt!(ty)5ti+5JRWV(y5U zshk4mlP)!9XNGDEW)h!x9}C7vd`-Ne%uJ~hoi=-lvgo^HM;`$~kmd8j8d92;>i6L? z0sv7h&8UXDD0`0obJmt&WVlgoEH3)&6Nx+2QjPP#_^~=)YCBq}g68(2!0aH)O7HdO zu{;?%hetDWjuX!p26BZDQo-J_tSHZH@LFumxM%B6l!V(jrNaLg3qWLtxuMYuQBCsFp2mK6I3KbIxeRkGWgqB1rAd=P2&kBC@`csccnJ}-{JDNypbcu$SzUd;@h zMMnjpLrtYr|D)^b0b98kVo5!hTea7S7a<;v*O}k7;hF25NOtLPyq3g2E~*7F)weXn zpkg?`<4<2_)s;PF!3T-CIN zRDOUPaT-YdT#X#RZ6?|BI@J1Glh$<$I+t=j#sWMM?>GI9dR@#$lQG zIV|1LS(8kFoCb0BuZ_>SL>h#K4c;ZsY_ck|FeH_GsGQbiV@)Rj3~ecHDG9m$wpGn8 z5{1jf@*ozMMZ_oy-aQlcEo>#~1d09 zGY~UY@_DS%{`D^$UOXJcpKLbm{ur#6zsHnRmxeO?^vfy_Z!ZxM5W5$qX&0cUy!it1 zyA2nD%15SZsy+8g_n!&xMUD1=C{`fzd9$6i2>0)YvX+a$IViY}zz?7JLQb;(uvh0N zz5b>}jQ4tWIKkI7fBn7+?F#+(2qf=q9P$!i+(yb8L%luHIG69T3s{ofycrO~lf;tT z_IfC|@`%~Gx=@~&ZP>Rq$0tlKD=^*FB#5t(Q!qdf;mbF7%klnLt=MBc-{?x#(PGc* zX>+mF6HD1z*@f3t#1pds!(g zVfJQefho%c(=*onwXu;y!BsrV-$ms~%E%zM0n5RA)3aH&=i5?c|K?v6Oj4oXY=_1WEAW{48DE?q+>Un@4(Ra)8?GJ_M_)@3NsunWFuS*EPKxc@&jgo9L)%ayV( zp)C`${>5PY(D>Lgj9MUDSV<_dzC5c_I-WbpZgbUSpc<402(qVpT!B`ttu>j$NqQCe zLxiO#rkt53V;(M6LzX_g6&ivkg`pobV}X}XdK|B37zHv#spI`z0?uh<_;>E3 zN}Pd>hm(o8had6_=aOfgo}?FZp1jYz4mj<+_ElKATIdXpDD-B#&)^7@MadEyd@>-% z-lj8hN5B?Tny$I_CoFjZRpu@+B=k!P2k3TU*DGFfe?kFa^st$qhs{r4A6y;(O%i^O13Yf9aDA4%{Z~f^$4xXQ zZNt~;{#>$fkE0yKgZrtH_uRN!s1e70=r0S$UG&?)P)sc!aHa9wE$t5thftDzsa|nK z#G*lE;-%9+>o%*yqEzx=JaMWe0E?_05o@mq5SdC|DY)bA#} zZ}on1EyxGcn|hXU20vb7Y}fW%YpM)wH(Zs1P@`12ERbYCL?0s0@sBn-ILV>bv~wAd z@7g0C+j1{YwME>O&ZSkTG*6y8s6S$&r{%!hl6?$MUQ@ zX7ZasUp!_e$-;P!$?26_a-nAqpO;JRSWx5YV96L{P^^oA&0<+;j zT-t}D&E`1tAmq9|t!x`UlUOsBQ^r*j1IGNg@e#Mmys4QUGT1rtljJ^Sa*r2Xss3wh zai%g#Z?4lnaEjeM;^n3mcV{y#4!VVEKO^-F7eUcZlJDH@JjDw4Wjt!4-YrSYNGTcqo56!5!g-z8sAoN<6UZSIQ^YeF~^cn z)eRSJw7tsaH4YPJxn4i~#<&r=GWJq;`bV$awSkJpC3MT+l*Z>}s{f)!RFQJSRRDBQ&kM6#m9%t@G9-pY~W{KmI&Jp=a5O4gNc6D-h)uDTMS^P z6t|GAz$vRHB#hL5d8o3089H?rqsL9EksHR7tIHp{p&yg+3tLw4FY=p60uL1*GM-28 zemqL57Z#zNUy7K$k}wf-!W~2yME<2?-?Jb|B8|lMzL!`e4=Q}=B}NY-m$*Cyr5l=n z2OD;)!I5B(-<5FbUxQ8j`auzU?`E^Tfw-%3bLI83U*vSR0y5M;XBil;fn-#TN4a|o zD_*e8@n3wGVBrS#*_KB*O-f&pFZVB)ds5WVtB4ZZ1+!m9sJ{xL`)HMawjlGV<$V7m z3rI0@mcg0e{u^2R?~7f4VTjdYE+&#Vo3Y)oJ@#u@h`I@P&s5jA#8S^0q2N_u)K*UW z%b0ATzfh(Tlx+_{gq0tRLR4v??!MUHIGIUWukG9x0~4K7cImm+#qki-LkW54)oWlk~p)lh&WOnKgw zjdo)dgq=7G7@h6C7qlX46O^&|rMUo{@-Jv%mucp}|i7&rGE4x-@E^3Oi| zRvBd$)icgeE5qq#N+eP&ZCXp1fJ5sc_8`k_I2xC*9Cj#n^<{4bIdr7n>`rgxvD0;~ z(;o?$?0R!z_I^<&^3n$g&ghA)Ej$eErIvP(5VjA9fmFK=`~ni=uc1w@LAz9gyd8z4 zRq7$H>_wnQTQ%>=ACakX9QJRoB4o2qFdc)J&5vFSba<|<)ClLcqAq2GTtQonDE4nX zm@?0~@w*5;kygV3V8Mdr&NR`W+#e5g(hfVn^qA}eJipHfw$fanPzyk*ImP&SiD}EM za^K{@wfP=g@`3z0ms8MWBfb&8Cw-;PCuG;GIxCX`?@x2f5%=zub;6H z-v%@$g{-crjZ`?p`*ls}LM& z$e=ovaJtwc;3rkw>s{XOP@|zC4EDjf-Yy)PpHyC1R9`@rmS~uDjQ|_R-cRawpw`I{ z+(8ulqx>4N*W#4CTCp7hZly>QM7bBsF4(^mYx1vn(86~6OX`b#z=Vf!m{jCWu(g&^ zyj!9|I&LVYSQc=Vlsp@!j}QRwy3+FkCB|;f znRx+Jj$-VXl2m<817k&TQl-T5E4YwwBCt@L_{ekZEga$+v-kC?I5>DWnwo&(i4o6_3rhPM@!!lHo25{#E{us?1_pkm5I|a>8qGIm=Sh!;H@dq#9;Rm%zu2BPo045m4&qdJ2P}a+TOYP5f+yF5Y97?^ z$%D4H-eJP>IHr#_nu2W*fKrj&7-xEc_bEMl^JbU$TNt?bg)qF^9<yuO$C@?#? z(z=tE1#pjCVvpVqPnOFn+bc)!=R*Fr>lKQ0NKi_?7=#KQF^q)G^ zR#n71UkQ#YPqK6p|1i(HPP_ke|MoQL&mdxe*J}`H@uURx@cHT|ajbZbuSpqyd;WCI zjMJH&W6wTw3#-ciR`EL5>B|iR%A`i+919O#+K<*a9<$1rPQRVSTlj})z_q;q;Ozs- z)Dw9ZDFV^4p`ds*Q4=n1`3*_u!|`7(ISk&3Z8P{<0~xWfpfu}%&1Cmi+7j@0%jcSBFz6S zBb$1vTF!1j>}L0(Ls<2B=kb0Qg%KE_A{zD-da-F(;rQ)~@3zhz%Gj|)xmXY7%8B`3 z`(Yz(q4zSkwKsh_R@wgC9eiw~_lK`RB96l0{gMe&GwANAz2WYFd*ej=fZ%lGU)nlI zaqZxM$;Zu5CZJ`*y7k#4dd|AFa1Gc>(hT9_!kQej=q_KyM>k>+?Y5x5@ssiVpLJ&?=s3j`s|7wl^JUZDk8v2)|(+i7*H(*}EScvJHs!@4drCuaGn2FkWs( zwSZmi1fTT~ITHYI&<+VMu!GWPKaRzei(PA|FdP&O>q{q-{xcyh#cS_(bmOAsO6zcS zIX*EKEFp4jhufI0kO#)JQ@gqB8U#d!enXcd*m-%Q7vgvk^V^^5$YBl8@60Zdma8yCN zC@5Z~KW7zIZjnIq?rfjiU0v?_(n;)|?7T#%{c9SzAVjCC=HfKM+6AVga@#cI1ewTq zysU^3W@{LdzRuTZJ80@w@?okojos}&%jQ$jzJ@im|HU<6!I>yF0`?H2 zKUH3|*R)Nv%jfhfXjFXc6jmv z=+h*X-Vsk=X3%%~%)+4By8DpS6|^y?;8;aGJsHhZk0!(v);|~qZW!;jXOa$cyFy7x{!-hy*4S$DIxDsg6V`ep1_&@YTk1@dVyDe#AKdk_xmd`jxkz9x=!NpkzEAZ zw;)g8Gj_?xxiZ7>LiN2w9N1E3sIiI1Nn3`i#@SohQxY>bniICWHg~g4h!SH($C{z& zprkX;xVIpqP#bd`s+v~~h|EczefJ*fFYtYohIYLn=4K)< zw=0N0dH7Kt3&rDm*^uI%#;;^%()yQ5E;cf|K%yGD({Dw1WDKeT{~LdSR{DXVvxdkg zGt=?!jZ;TQL9H1;lEC*~aN^JmDs;3YmXkPGZcn>3nJVe8@mq*QrHwg|Z{Qe>fFI>rY#JnSv60+_(dop5UmSFJLHEMKsx2Fzlesc6^HV(6AAd zK5~pxa~@>1q{@D!i6A;WKe!#DQxseRlhkN%2E+^8`|XoD_zvVj>}iZ8v{9%zYW*Wn zCq7Z&B@0H|oaURVXq{_uq^-1nYbiL2{T9CbEs1iQcrf(y2W2Vpuy=b_4fd*rd+qJ zsQ`n-jE6>Znq>JefeY7-{0X#_Nu2Uj)lRJOk4PRUVsNb3kpho7&&LH;(k|fyLh>3q z>pj;-fx}OuZy^`eWC=$Xk}bHbwbBq#4bD4OpxjijoJ-C!Ov>RBeYS^C)%Y$iFwOv+ z2Sd-5sjmafF_3tR)HtmJl{#O`m?(Ku@ka#l!nq{^Em6L|T|Ufq*m3%JIF{t3QIkI?Elg z949=|8uLlF#pw=$e-Sy@pOW^!??DFe%+y5fkp;iANlzQc+m{9ud-A70HJVsMq_bW; zdN1zjBnqW0mb65F=X!#xhS!M+bMd$kLqLgP*HI@wdZE~SVlTKZXrI0Z9rM2;?oD?g z7Lxu@T--_(dE6c~k^pzs(xow#m-oK{dr){$X7+0u@8+AWu~J|6S==0TV)P4(o!{L* zFHs#ka}_~?ZF^I3Kzl_(wQ^lWB<<3}0i&iTT9=Ydeu(;+iubBQLOxwYR2wenFhqt% zFa7brSIz&(TCKahc_-Mh020fXvQrT+fU(E^{F}KcX$HTk2Q177g&^;vYXmlJK&F!T zgyPOSxK)7N*2SOiOGMyyOu+af9%Dl&`AvyY5wY@b9@D;@hoL~FcrM6a;AV83^6BO+ zoJ#E0?f%<=-xb-BqvOb5;8uvhKerSMz)S1N!&?rx-!b3!RM+kiCc5`54!bB|Ge;Zz z?05*PZe`SiD|Gm}3C%g9%G?jK^V!R&!VW+XGJKSB;fQJ!o!u@&YW?Vqte5vnxUw1% z9jYQzRfXIR+jv~|pd#101JE6N=^<6pTvu>)EfMCdXndv4`fu^Q)JC(u9d!pl9El$= z?7RObOslJBw$Y!?gWU(MDmKFoC*O6CrKyXb3L?2MU^&aT zN%aNWhqnJP)Vem;zB1AN)~@VFM1(3s-`U#Tjek>j>gSziE6jg#c@g^km%pt$-{eJk zo#6W-AQaY?+#XM;x%`%4a5B#oC1Z?hTI?FrqHbS*>s_8oh|bktNX2{2VNm(kC zh~1r`*RgGxy0kNrbCVeV7TZ<{9PXJsjk6@~2C|8Cw|vl}w%8(gFyxfQSBvQqF}%2X z_u86AsD$(Y{{jH$FpvS-08k*|16MA$*v@la*8K}u=QmLdz{KAFQ`@*F<#P_hSA3j1CGcsde>(tLO-` zjMR>>IG*9W)}bE6pGe5K;PL3o*;GE=9AjbgSjuGVRTA@x-*DVkdw2<2!E)+aLLe5L93>|>U@bb2shs;wb z&rx<0lCu45INilLW+G#Zz!-&|J3x+q+W2B%W8Bkqoz zp1TPeP`Mfm%M)t33TKwbj)pgfl4zrjA?OO!UJC3qBxFxz@RJy zu0;x5*vnXkSM3!&N)a-Uh@Ze)H;KazdYA{mM)dkS88bQse#fND8GXi}TbXQ$5Fg8| z{;|js8Z@C(Ocle8+hiL7=9souBayv#=19{k_m%tYH~)ILFog9a-wi(-_deh2a=jsH@QFc^ekoey3qFaK`13qbHBng7wG8@%x^tg3F;j@$>*75E^@Tc& z@`sgTa(vKyZZl;%OYZn_PZvT?ubwiAVsswLb3!~Nzk=X6dn2*oC%}5^@(0l=bEJ<) zY7eQP#WS~QS9wIkd%rIySc0#2{t#IauyGO>U<5j}tK%u4-2e4pq)j6ruswzMAd>(c zR1U-qr_EwpHe91dr}MbtX1z@yV=AjCz+&o79gY_;x0Tis+_4&}AmhX>o`CYvWA(*D zcFM&v2!MB@e>?wlgxAd|j;ctC!j^l=q|hYn*M@qHsVOdSX4;5?$`jun@%dLZA!LZG z^9%jKMXLa(8yHlE>yHQF?x`&V!h7b@C1{i8!fcJhr>-T;C znKkQPbLKv0pWgeru6=rSr8x=xU~!HXJ*l%iO3%Yg#t5Q9GxC`(Z&CK!eNbbD@QZDG zv38r9q!d3*KNg}#D(?mg72awqAkzm(xL&ICkZ^IUjJ2Z|-7Z=qFKkkPxcoZfZ0J*U z*!wqLht8pmrK64&Kw|Mr7bn}og=vZ?XgRJ*vc#`R;uK`vgebWy`gt6~zn^0bWTUkg z<9xLw8?7w}fwQ?>5?(GPS<8RHrrvY=KD0?zT`_<=`aB|~#jeB|^|0rv=P`0PK85vv zz5oqwfxQ+n7K@MKnXL-$&z8(ZcfP(B$5>GN;L2v|X8y@)^ttbpRTmv}u~K*-r1-wu zesap<_<@0!KBW1eg2^*lv^Vn2;)?nmT3-{=WbLPH%T)NqmLUTKfA-FG%}0fWR$vPs zCNImsy9!~w&NsqQ*wOVoJ*$*CdGUvaRC0OP0_EbClX|L>(`nEXT^#v4$iK%662^a9 zI>8Ewp8jUE^UrirdY@+4F2~luj#FglJ@iRq7$jG5A{oQw@A}kV)G218Y(n(__9-Uw z*d7Po-jXtA-#vdcWD1T)Voo^8npFE*v)xN1kUNZFB;S+bP)C-L7v7y%e@t6+{lyf0 z;AJH3^ff)^(C$YEOpReJRgt&%EVEDCL>X?bdlMcjU>8ys?{De9y|f75Fv~sY4%a1} z{6n3RwqHISeutUyowKOcLgLb`9#ze^ty~O*(&njvg9L@-Qdd}y%$k`MUg`V5yH1~) z#&WbWGGc{;EC=Zwhfqo9} zN`&rJrHs|ECr7ODd7^I!I1?FuQ2YC>`woMfSZ2uqo;o(d@=g{nCecitQFbG+2yOX` z8)mT5Wf#WQub?SXR)?@V>zH*PYdBNb;_%;2A@I2>Q2oUqnQ@(kNkhpQHucIWN)>%hHztlyNH!C9!d zgCmCBkgZ(tr?zQHo06uMx8r=M)64xUVs+#~#EmCAy7VeDE$)|wATsb&uAZ*U;vt-d zJE@&y>am0NAe2p^*AG+SG!S%RNy3WtN-vd%xq2!1e9d29^6D3pkg?1a39I5mDSVu# zYXy~3g>|LE_A*b$=WNaU_YY$3+;|C&!vLP6shS6J*^Kl&-BBLdtY&0qm#G<8m^7CpAI@| zS-JQ(w=`yxN#c9grRe&9eKxM#|&;wuwXV3z4HK?B7vv3nk*=b)lD56l&E zy5D-Q`4C5MzC4s9j~(3zr={JgjxA0WxV8 z+A?8g{)%la?zBOt=#$atUeB`{hX@sdYF=_JG1f@?f2VQ^Tt+UDUviW~c*O714jNeO z%)-V8rV)&I;SwHu&+OObldkn3Y9R(asjq})TO{s@8{F=dz9B{0x>vMD+73bUQ~6cVDz0>?nb{s?t8ga)Xo&A7;MZB z%lI{R$mK{WkAvu;TTbzY)l#P7yw$)y#$ZV|>d@>UO+TCpcK&TU&d|1fJ1s_a-2wzR z3qkW)LA4$inl#xKJG&pU7FKGtt36i_+-Vyz4b%5=FUzVw$@{&j*9D(m`8$oUwqNiO z3dcDZP^u(>Utp&F9;iSQ%ln&Vd8o5nX;4nwzPmK94k0zSl#;NDpv>)YBnf_HK`9$> z-*lfgySpNI9s?FF@!O`;^h(RV_ReCB+rs= zKGG=b5iv{u)7~4)lijLw%L$d}KGgZ}66bigv_Zp2FUY$4$63eLhZgOW90qa0f^IGRi4=!L(nf>_bW1<`gFJ@Y7($XXrdFs;MbWJ#m-$|mnCpNqP=5dwAB#asc z8#C&b!^f3d=bacXN6H&UA@<_%bM%L;J{+_U?ca$t3ylc*}gFKSTtWj@?rcMG53X!tsjpAu~o z0%X+4WqdB$nyjbd7hd;)Q?lE_Pq%D@F2+BEua|R7gzI^6wQDH(;j{OC+8rHuAD;1K z_q_QXe35q>Sqv_{=p!JqJN94_QxNteGP; z7(Ift@zBe==T56`xXK?aT>{nZ^72V)9H=cfFx|StKQa66Ep4h6ynH{#xv`mK%|@0O zqFlc8XZjCmLso&exTwq>k!wlEWEmRTK~sLe!4#WTPvIf!`6hz4>;091Hu3vA1O;0< zY$~D6&Gy_zh~=6(*J|$xw4M94T8a$p2{A#qt@hpM*OB^D;ial&mraq3JO$M{JC7Yb zf!_IPNA)wW1|AJ}cyrAdB33o-x0ui7W2&5fjY3bzLmYE|In3(!I*CiGki*{GRLJKDnk?-~4~W@=bQ zkb8H`^gfINFCfT!UK4#=x|bNyvi=qD6A*b&AmYM=Z5mU_f*r@BjegW!D39?B+QTwa z5j0ZUgfE{bSTWTYRL`Zwu?b*E4{(j^^rf%SlRJxVr!YL3+tJ+5DDa<_3XO0)f0a~c z|9pePV>r6hr12P@&){$RCEAYo#XQ+wsL=9f$Y*XvAA!8PVD>tLS8|Yk>0TWkD5$-Eye_^<8+^VGtc1o5kFA71#*qMzjiZMy6k(MTDzYlyw9L0!k_EB53vp1Ord(u>wiVd#r*4F(omrSP-@ z``b-=Kj(mI;0|iDU@;?^5U|TkJCO*-ofhI>@9^VdawfQ5bjhGD?(;3(|0J@n2rC5wt zuyX0{xPEW-Iv;XCu0>qQ-@?+01NjUty*JEz8*|m0fv+2y$;1B7YqJNdnFCn zfYol#U0@T+M^YpT_1n;9@OK5O}tw%$yRScl(2hr4{o zXY_qCF(&nPpxG3mYQkT3`Z{IPDb5=*Y3!YH@ER?Za991Gqo1O+<7!^+;5+#ohKmy-Sb(a=xZdYJ z+)C-24{zxGo?4f^{d8N)dYz2YedWiMSHo2)oL@*0I(~heG5Ie|mNaXz0sQ75ZqhbG zY-2V&kpdt&oxk;}%OUP=n{Vs+UM!fdogLBaz)>8q`a)U5iTqwkk0C}Pg_gwVhD(IJ zURLHXyWr9NN|dfmC&^Xc#p~4KLYIC~go4?H^Jr|U!k_UU;5cAo8Xy4Hik)J1^;Y4n z-d6h9lD!|TW(XX6&Gy#axW-G1UX{C8v1B3wGytpIJ0*v{d&dxWD^#zt`!IrdN+~!W zW}|^Q5U1wOD`Pb=A^O}s3(|ni3|8E%Yq}B25yrrnG==Gb)NrB_b1Xh zw4x`)o-2qwn6{s}v=x@4omoQj*ht4g#haiGbkiVbKl4@@oCaq4H%s*5H-v8}kJ=9_ zi2|-W2zn{CP|gp3D)&d9hJt{Q{`wH6n{z9{IhGvldYl9j^)X zP6LY{fOc1`Ef1oYPg2_X=UVIqLH}8;8ZudhxU9mztZL;F8RO`4q$!Y1Cl7ey2nmb) zwb&YVT{L5Jy=S|835U#w^ir?LLvO_z6CN_O@g-$!Ax~dB&H01xqKFCw$tE zqwQ+t7=dZzH3bp3bI~($TZ|DIrP9VE@*6*T92Ok`;)ROuX7 zP9X5_O-LE=`I0nOxJMV8!S<$qi_IG!a+JY!a7{0p$C zY69=ryjIlYc1D9f|0R!vbl~n5!*~ll!Tu!=W&%EkVCHi(C(*)Q zU!GRfu_OY&4OtCOEQ1!hE;;@(^WwwfRt=liT_)Jai2cG6&^=%y+Q*Q<<4u{RVa^)S z)a~VOH9X`V$wRgQrdXj73nl9#2Td@P&`f^R+l~|Ek-C|O7LCdid6x0Jp6srlyhps0 z?cCvJm0ZxW=7)Z(aqTOOYml#Hy{YNM{I-*Lf6=iAIolc*A>N338&pp4Ymr@Mt=m_^ z@F75y{XLj!oSmMC4-$MLM7!zuM0%&Qu97ITI)S0q=Ia z6M+L#*EX1~4+o0z1z>EzWjVZLqzwjDUy%v3Mx_g%{0@I*TasH3sSUx_Wwx?VQv zDx&VS&<%-~N4W$xJuYv0W3~pKfn)jJG^a76pH7xp`#pm1MYPY^JJ*^X9^k%24-QKK z2@mVhp>vb66)s+z;DqUb*$iWMIA7E4gMXR*wpXG*0_HlDp3A86tx?=0G5zXtAL^n; zHql<~>u-Q=lMSoVA%3l7FO2_M8%j}actJ8}zsfVKv~oLUDuW#)d$@LEXFiYLR`d`@ z{QRBnKS7szFevsa)y2{Lqg1@p0*rB^vL6Dg9sHIuED4gbu6ZxqFY-F#<4L@H@#!1V z4+tq5X>lR*CdOdTpW`fB;lVEKrT7&>q~&l@m0gc~qc;|H{E~Hz{`D1(j~-n4>Q&Pzn_^_!|JEwb|1Z_WhZ<3?D8+S|OZBMTOf~(%@pjGk3hH?(^~Gr{3zu;rQ*OuiAa;Nug}_LHK=2TeBX*dDMGJ$9&#VurB=UArEQDlX|
c?nbhtNx`9lI1AMfN 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 VDGK81s$yEDDwTjI|9^^t{|}rsLWKYT literal 0 HcmV?d00001 diff --git a/release.ps1 b/release.ps1 new file mode 100644 index 0000000..4a3273d --- /dev/null +++ b/release.ps1 @@ -0,0 +1,45 @@ +# Signiert ein Release und laedt es auf den BSO Master (wie release.ps1 bei MSCO). +# .\release.ps1 -Product mod|launcher|server -Version 0.2.0 -File dist\BSO-Mod.zip [-Changelog "..."] +# Braucht: Release-Schluessel (BSO-Launcher\new-release-key.ps1), BSO_RELEASE_TOKEN (= RELEASE_TOKEN des Masters), +# optional BSO_MASTER_URL (Standard https://bso.mbm.wtf). +# Signiert wird "BSO-RELEASE|produkt|version|sha256" - der Launcher prueft das mit dem eingebauten oeffentlichen Schluessel. +param( + [Parameter(Mandatory)][ValidateSet("mod", "launcher", "server")][string]$Product, + [Parameter(Mandatory)][string]$Version, + [Parameter(Mandatory)][string]$File, + [string]$Changelog = "" +) +$ErrorActionPreference = "Stop" +Add-Type -AssemblyName System.Security +Add-Type -AssemblyName System.Net.Http + +$master = ($env:BSO_MASTER_URL, "https://bso.mbm.wtf" | Where-Object { $_ } | Select-Object -First 1).TrimEnd('/') +$token = $env:BSO_RELEASE_TOKEN +if (-not $token) { Write-Host "BSO_RELEASE_TOKEN nicht gesetzt - nicht hochgeladen"; return } +$keyFile = Join-Path $env:APPDATA "BSO\release-key.dpapi" +if (-not (Test-Path $keyFile)) { throw "Kein Release-Schluessel ($keyFile) - erst new-release-key.ps1 im BSO-Launcher-Repo" } + +$data = [IO.File]::ReadAllBytes((Resolve-Path $File)) +$sha = ([System.Security.Cryptography.SHA256]::Create().ComputeHash($data) | ForEach-Object { $_.ToString("x2") }) -join "" +$private = [System.Security.Cryptography.ProtectedData]::Unprotect([IO.File]::ReadAllBytes($keyFile), $null, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser) +$rsa = [System.Security.Cryptography.RSA]::Create() +$rsa.FromXmlString([Text.Encoding]::UTF8.GetString($private)) +$message = [Text.Encoding]::UTF8.GetBytes("BSO-RELEASE|$Product|$Version|$sha") +$signature = [Convert]::ToBase64String($rsa.SignData($message, [System.Security.Cryptography.HashAlgorithmName]::SHA256, + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1)) + +$http = New-Object System.Net.Http.HttpClient +$http.Timeout = [TimeSpan]::FromMinutes(10) +$http.DefaultRequestHeaders.Authorization = New-Object System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", $token) +$form = New-Object System.Net.Http.MultipartFormDataContent +foreach ($pair in @(@("product", $Product), @("version", $Version), @("changelog", $Changelog), @("signature", $signature))) { + $form.Add((New-Object System.Net.Http.StringContent($pair[1])), "`"$($pair[0])`"") +} +$content = New-Object System.Net.Http.ByteArrayContent(, $data) +$form.Add($content, "`"file`"", "`"$(Split-Path $File -Leaf)`"") +$res = $http.PostAsync("$master/api/releases/upload", $form).Result +$body = $res.Content.ReadAsStringAsync().Result +if ($res.StatusCode -eq [Net.HttpStatusCode]::Conflict) { Write-Host "$Product $Version gibt es schon auf $master"; return } +if (-not $res.IsSuccessStatusCode) { throw "Hochladen fehlgeschlagen ($([int]$res.StatusCode)): $body" } +Write-Host "$Product $Version hochgeladen nach $master (sha256 $sha)"