From 59d7dfe14c250b5cca5e619d6c3da5f213819586 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 08:14:53 +0000 Subject: [PATCH 01/33] Pivot RedSync toward a CyberpunkMP SDK plugin. Document abandoning the competing host for CMP core sync, add RedSyncSystem plugin scaffold (join/leave logging + Discord hook), migration map, SDK stubs for CI, and a client redscript stub. Co-authored-by: XEROX710 --- README.md | 8 +- cyberpunkmp-plugin/MIGRATION.md | 49 +++++++ cyberpunkmp-plugin/README.md | 78 +++++++++++ cyberpunkmp-plugin/RedSyncSystem/Plugin.cs | 127 ++++++++++++++++++ .../RedSyncSystem/RedSyncSystem.csproj | 36 +++++ .../SdkStubs/CyberpunkSdk.Stubs.cs | 81 +++++++++++ .../SdkStubs/CyberpunkSdk.Stubs.csproj | 10 ++ .../client-redscript/RedSyncCmp.reds | 28 ++++ 8 files changed, 416 insertions(+), 1 deletion(-) create mode 100644 cyberpunkmp-plugin/MIGRATION.md create mode 100644 cyberpunkmp-plugin/README.md create mode 100644 cyberpunkmp-plugin/RedSyncSystem/Plugin.cs create mode 100644 cyberpunkmp-plugin/RedSyncSystem/RedSyncSystem.csproj create mode 100644 cyberpunkmp-plugin/SdkStubs/CyberpunkSdk.Stubs.cs create mode 100644 cyberpunkmp-plugin/SdkStubs/CyberpunkSdk.Stubs.csproj create mode 100644 cyberpunkmp-plugin/client-redscript/RedSyncCmp.reds diff --git a/README.md b/README.md index 76785e55..4b2f8f16 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,12 @@ # RedSync -Multiplayer framework for **Cyberpunk 2077** (v2.31). Rebrand of the Cyberverse rewrite — client plugin, redscript layer, and a .NET game server with a C++ networking core. +> **Direction change:** RedSync is pivoting to a **[CyberpunkMP](https://github.com/tiltedphoques/CyberpunkMP) plugin** +> (SDK-only). Core multiplayer sync (puppets, movement, animation, appearance, vehicles) is owned by CyberpunkMP. +> See [`cyberpunkmp-plugin/`](cyberpunkmp-plugin/) for the plugin scaffold and migration map. +> +> The standalone RedSync UDP server / RED4ext sync stack in this repo is **legacy** and not the path for full player anim. + +Multiplayer-related tooling for **Cyberpunk 2077** (v2.31). Originally a rebrand of the Cyberverse rewrite (client plugin, redscript layer, .NET game server). New work targets the CyberpunkMP plugin model. > [!NOTE] > Active development. A self-contained tester package lives in [`release/`](release/) with auto-detect installers. See [Quick start](#quick-start-for-testers). diff --git a/cyberpunkmp-plugin/MIGRATION.md b/cyberpunkmp-plugin/MIGRATION.md new file mode 100644 index 00000000..255a1ff7 --- /dev/null +++ b/cyberpunkmp-plugin/MIGRATION.md @@ -0,0 +1,49 @@ +# Migration map: RedSync host → CyberpunkMP plugin + +## Product decision + +| Before | After | +|--------|--------| +| RedSync = full multiplayer stack | CyberpunkMP = multiplayer stack | +| RedSync.Server + RED4ext netcode | Abandoned as competing host | +| Jackie/Judy + teleport puppets | CMP puppets / anim / appearance | +| Custom UDP protocol 0x7/0x8 | CMP protobuf + RPC | + +## Feature-by-feature + +| RedSync piece | Action | +|---------------|--------| +| `shared/protocol`, native GNS, `NetworkGameSystem` sync | **Drop** — CMP replaces | +| Player anim flags / TPP research | **Drop** — CMP core | +| `server/Managed` GameServer | **Freeze** — reference only | +| `server/Managed/Plugins` (.NET host we added) | **Superseded** by CMP plugin loader | +| Admin (`admins.json`, F7, kick/ban) | **Port** into `RedSyncSystem` + CMP admin/web if useful | +| Discord webhook | **Port** into `RedSyncSystem` | +| Spawn anchor | **Evaluate** — port only if CMP lacks equivalent | +| Tester `release/` RedSync client | **Retarget** docs toward CMP + this plugin | +| Redscript combat / nametags | **Keep ideas**; reimplement only as CMP client plugins via RPC | + +## Plugin conventions (CMP) + +From CMP’s loader: + +- Directory: `plugins/System/` +- DLL: `System.dll` +- Entry type: `System.Plugin` +- Static ctor runs at load; wire `Server.PlayerSystem` / `Server.World` events there +- RPC: redscript `ServerRpc` / `ClientRpc` + generated C# (`SdkGenerator`); implement `*_Impl` partials +- Optional: `IWebApiHook` + `assets/` for admin UI widgets + +## Suggested delivery order + +1. **Scaffold loads** — `RedSyncSystem` logs on load; `plugins` list shows it (CMP admin). +2. **Join/leave Discord (or console) announce** — uses `PlayerJoinEvent` / `PlayerLeftEvent`. +3. **Admin helpers** — thin RPCs for kick/say if CMP API exposes enough. +4. **Client redscript** — only for UX that CMP doesn’t ship. +5. **Retire** standalone RedSync server docs from the “how to play” path. + +## Explicit non-goals + +- Do not vendor CyberpunkMP native client, archives, or animation controllers. +- Do not reintroduce a second netcode stack beside CMP. +- Do not ship CMP binaries inside this repo. diff --git a/cyberpunkmp-plugin/README.md b/cyberpunkmp-plugin/README.md new file mode 100644 index 00000000..d06982a9 --- /dev/null +++ b/cyberpunkmp-plugin/README.md @@ -0,0 +1,78 @@ +# RedSync → CyberpunkMP plugin pivot + +RedSync is **no longer developing a competing multiplayer host**. +Player sync (movement, animation, appearance, vehicles) is handled by +**[CyberpunkMP](https://github.com/tiltedphoques/CyberpunkMP)**. + +This folder is the start of RedSync as a **CyberpunkMP plugin** that uses only +their published **.NET SDK** + **redscript RPC** surfaces. + +## License (read this) + +CyberpunkMP’s license allows plugins that: + +- use the provided SDK / interfaces +- do **not** modify CyberpunkMP source +- do **not** redistribute CyberpunkMP itself + +Example plugins under `code/scripting/EmoteSystem`, `JobSystem`, and +`code/assets/redscript/Plugins` are MIT. Study those patterns; do **not** copy +CMP core (animation controllers, archives, native client). + +## What CMP already owns (do not rebuild) + +| Area | Owner | +|------|--------| +| Connection / world | CyberpunkMP | +| Remote puppets + appearance | CyberpunkMP | +| Movement + basic animation | CyberpunkMP | +| Vehicles | CyberpunkMP | +| RPC transport | CyberpunkMP | + +## What RedSync plugin can add + +| Feature | Notes | +|---------|--------| +| Discord join/leave / server log webhook | Port from RedSync `DiscordWebhookTarget` | +| Admin allowlist extras | Kick/ban/announce UX on top of CMP | +| Spawn-anchor style conveniences | If still useful alongside CMP | +| RP / economy / jobs-style gameplay | Same shape as CMP `JobSystem` | +| Custom F10-style UI | Client redscript + `ServerRpc` / `ClientRpc` | + +## Layout + +``` +cyberpunkmp-plugin/ + README.md ← this file + MIGRATION.md ← feature migration map + RedSyncSystem/ ← server plugin (loads as plugins/RedSyncSystem/) + client-redscript/ ← optional client RPC / UI scripts for CMP +``` + +## Build / install (against a CMP server tree) + +1. Build or download a CyberpunkMP server that includes `CyberpunkSdk`. +2. Set `CYBERPUNKMP_SDK` to the folder containing `CyberpunkSdk.dll` + (or leave unset to compile against local **SdkStubs** for CI only — do not + deploy stub `CyberpunkSdk.dll` into a live CMP server). +3. Build: + +```bash +dotnet build cyberpunkmp-plugin/RedSyncSystem -c Release +``` + +4. Install **only** the plugin assembly: + +```text +/plugins/RedSyncSystem/RedSyncSystem.dll +``` + +Loader rules (from CMP): folder name `RedSyncSystem`, assembly +`RedSyncSystem.dll`, type `RedSyncSystem.Plugin`, static constructor runs on load. + +5. Optional: set `REDSYNC_DISCORD_WEBHOOK` on the CMP server process for join/leave announces. + +## Status + +Scaffold only. Standalone `server/Managed` RedSync host remains in the repo for +history but is **not** the product direction for multiplayer sync. diff --git a/cyberpunkmp-plugin/RedSyncSystem/Plugin.cs b/cyberpunkmp-plugin/RedSyncSystem/Plugin.cs new file mode 100644 index 00000000..b9e98d29 --- /dev/null +++ b/cyberpunkmp-plugin/RedSyncSystem/Plugin.cs @@ -0,0 +1,127 @@ +using CyberpunkSdk; +using CyberpunkSdk.Systems; + +namespace RedSyncSystem; + +/// +/// CyberpunkMP server plugin entry. Loaded from plugins/RedSyncSystem/RedSyncSystem.dll +/// as type RedSyncSystem.Plugin (CMP loader convention). +/// +/// Does not implement multiplayer sync — that is CyberpunkMP core. +/// This plugin adds RedSync-branded server extras (logging, future admin/RPCs). +/// +public class Plugin +{ + public static Plugin Instance { get; private set; } = null!; + + private readonly Logger _log = new("RedSync"); + private readonly object _gate = new(); + private bool _hooksAttached; + + static Plugin() + { + Instance = new Plugin(); + } + + private Plugin() + { + try + { + AttachHooks(); + _log.Info("RedSyncSystem plugin loaded (CyberpunkMP SDK). Sync is owned by CMP core."); + } + catch (Exception ex) + { + // Static ctor failures are easy to miss — always log. + try + { + _log.Error($"RedSyncSystem failed to initialize: {ex}"); + } + catch + { + Console.Error.WriteLine($"RedSyncSystem failed to initialize: {ex}"); + } + } + } + + private void AttachHooks() + { + lock (_gate) + { + if (_hooksAttached) + { + return; + } + + Server.PlayerSystem.PlayerJoinEvent += OnPlayerJoin; + Server.PlayerSystem.PlayerLeftEvent += OnPlayerLeft; + Server.World.UpdateEvent += OnWorldUpdate; + _hooksAttached = true; + } + } + + private void OnPlayerJoin(ulong id) + { + try + { + var player = Server.PlayerSystem.GetById(id); + _log.Info($"Player joined: {player.Username} (id={id}, puppet={player.PuppetId})"); + MaybeDiscord($"**{player.Username}** joined the server."); + } + catch (Exception ex) + { + _log.Error($"OnPlayerJoin failed: {ex.Message}"); + } + } + + private void OnPlayerLeft(ulong id) + { + try + { + // Player may already be gone from the manager; id is still useful. + _log.Info($"Player left: id={id}"); + MaybeDiscord($"Player `{id}` left the server."); + } + catch (Exception ex) + { + _log.Error($"OnPlayerLeft failed: {ex.Message}"); + } + } + + private void OnWorldUpdate(float delta) + { + // Reserved for lightweight RedSync extras (timers, anchor logic, etc.). + // Keep empty until needed — CMP already ticks gameplay. + } + + /// + /// Optional Discord announce. Set REDSYNC_DISCORD_WEBHOOK, or "off" to disable. + /// + private static void MaybeDiscord(string content) + { + var url = Environment.GetEnvironmentVariable("REDSYNC_DISCORD_WEBHOOK"); + if (string.IsNullOrWhiteSpace(url) + || string.Equals(url, "off", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + // Fire-and-forget; never block the CMP world tick. + _ = Task.Run(async () => + { + try + { + using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; + using var body = new StringContent( + $"{{\"content\":{System.Text.Json.JsonSerializer.Serialize(content)}}}", + System.Text.Encoding.UTF8, + "application/json"); + await client.PostAsync(url, body).ConfigureAwait(false); + } + catch + { + // Swallow — Discord must not take down the game server. + } + }); + } +} diff --git a/cyberpunkmp-plugin/RedSyncSystem/RedSyncSystem.csproj b/cyberpunkmp-plugin/RedSyncSystem/RedSyncSystem.csproj new file mode 100644 index 00000000..e2fbec64 --- /dev/null +++ b/cyberpunkmp-plugin/RedSyncSystem/RedSyncSystem.csproj @@ -0,0 +1,36 @@ + + + + net8.0 + enable + enable + RedSyncSystem + RedSyncSystem + 12 + + + + + $(CYBERPUNKMP_SDK) + + + + + + + + + $(CyberpunkMpSdkDir)\CyberpunkSdk.dll + false + + + + + + + + diff --git a/cyberpunkmp-plugin/SdkStubs/CyberpunkSdk.Stubs.cs b/cyberpunkmp-plugin/SdkStubs/CyberpunkSdk.Stubs.cs new file mode 100644 index 00000000..819b7473 --- /dev/null +++ b/cyberpunkmp-plugin/SdkStubs/CyberpunkSdk.Stubs.cs @@ -0,0 +1,81 @@ +// Compile-time stubs for CyberpunkSdk public surface. +// Production builds must reference the real CyberpunkSdk from a CyberpunkMP server. +// These stubs are NOT a redistribution of CyberpunkMP — only enough API for RedSyncSystem to compile in CI. + +using CyberpunkSdk.Game; +using CyberpunkSdk.Systems; + +namespace CyberpunkSdk +{ + public static class Server + { + public static PlayerSystem PlayerSystem { get; set; } = new StubPlayerSystem(); + public static World World { get; set; } = new StubWorld(); + public static IRpcManager RpcManager { get; set; } = new StubRpcManager(); + } + + file sealed class StubPlayerSystem : PlayerSystem + { + public override IEnumerable PlayerIds => Array.Empty(); + public override Player GetById(ulong Id) => throw new InvalidOperationException("Stub SDK — use CyberpunkMP CyberpunkSdk.dll at runtime."); + } + + file sealed class StubWorld : World + { + } + + file sealed class StubRpcManager : IRpcManager + { + public void Call(ulong playerId, ulong klass, ulong func, object args) + { + } + } +} + +namespace CyberpunkSdk.Systems +{ + public class Logger + { + private readonly string _name; + public Logger(string name) => _name = name; + public void Debug(string aMessage) => Console.WriteLine($"[DBG:{_name}] {aMessage}"); + public void Info(string aMessage) => Console.WriteLine($"[INF:{_name}] {aMessage}"); + public void Warn(string aMessage) => Console.WriteLine($"[WRN:{_name}] {aMessage}"); + public void Error(string aMessage) => Console.Error.WriteLine($"[ERR:{_name}] {aMessage}"); + } + + public interface IRpcManager + { + void Call(ulong playerId, ulong klass, ulong func, object args); + } +} + +namespace CyberpunkSdk.Game +{ + public abstract class PlayerSystem + { + public delegate void PlayerEvent(ulong Id); + public event PlayerEvent? PlayerJoinEvent; + public event PlayerEvent? PlayerLeftEvent; + public abstract IEnumerable PlayerIds { get; } + public abstract Player GetById(ulong Id); + protected void OnPlayerJoin(ulong Id) => PlayerJoinEvent?.Invoke(Id); + protected void OnPlayerLeft(ulong Id) => PlayerLeftEvent?.Invoke(Id); + } + + public abstract class World + { + public delegate void UpdateDelegate(float Delta); + public event UpdateDelegate? UpdateEvent; + protected void OnUpdate(float Delta) => UpdateEvent?.Invoke(Delta); + } + + public class Player + { + public ulong Id { get; init; } + public ulong PuppetId { get; init; } + public uint ConnectionId { get; init; } + public string Username { get; init; } = ""; + public void SendChat(string From, string Message) { } + } +} diff --git a/cyberpunkmp-plugin/SdkStubs/CyberpunkSdk.Stubs.csproj b/cyberpunkmp-plugin/SdkStubs/CyberpunkSdk.Stubs.csproj new file mode 100644 index 00000000..acb085ab --- /dev/null +++ b/cyberpunkmp-plugin/SdkStubs/CyberpunkSdk.Stubs.csproj @@ -0,0 +1,10 @@ + + + net8.0 + enable + enable + CyberpunkSdk + CyberpunkSdk + 12 + + diff --git a/cyberpunkmp-plugin/client-redscript/RedSyncCmp.reds b/cyberpunkmp-plugin/client-redscript/RedSyncCmp.reds new file mode 100644 index 00000000..444f493b --- /dev/null +++ b/cyberpunkmp-plugin/client-redscript/RedSyncCmp.reds @@ -0,0 +1,28 @@ +// RedSync client extras for CyberpunkMP. +// +// Place alongside CyberpunkMP redscript (or your CMP client plugin pack). +// RPC class/function hashes must be generated with CMP's SdkGenerator once +// ServerRpc natives are declared — until then this file is a stub. +// +// module CyberpunkMP.Plugins +// +// import CyberpunkMP.* +// +// // Example shape (uncomment after SdkGenerator run): +// // public native class RedSyncServer extends ServerRpc { +// // public static native func Ping() -> Void; +// // } +// // +// // public class RedSyncClient extends ClientRpc { +// // public func Notify(text: String) -> Void { +// // FTLog(s"[RedSync] \(text)"); +// // } +// // } + +module RedSync.CyberpunkMP + +public class RedSyncCmpClientStub { + public static func Describe() -> String { + return "RedSync runs as a CyberpunkMP plugin; core sync is CMP."; + } +} From ac311bef428de79438bf2b29c45e70f02f173b95 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 13 Jul 2026 08:16:25 +0000 Subject: [PATCH 02/33] Expand RedSyncSystem CMP plugin for local build and smoke test. Add config, batched Discord webhook, admin allowlist, join/leave tracking, build-local.sh staging, SdkStubs event simulation, and a smoke test that passes without a live CyberpunkMP server. Co-authored-by: XEROX710 --- cyberpunkmp-plugin/.gitignore | 6 + cyberpunkmp-plugin/README.md | 11 +- .../RedSyncSystem.Smoke/Program.cs | 20 +++ .../RedSyncSystem.Smoke/Program.csproj | 13 ++ .../RedSyncSystem/AdminAllowlist.cs | 131 ++++++++++++++++++ .../RedSyncSystem/DiscordWebhook.cs | 114 +++++++++++++++ cyberpunkmp-plugin/RedSyncSystem/Plugin.cs | 84 +++++------ .../RedSyncSystem/PluginConfig.cs | 125 +++++++++++++++++ .../RedSyncSystem/RedSyncSystem.csproj | 6 + cyberpunkmp-plugin/RedSyncSystem/redsync.json | 6 + .../SdkStubs/CyberpunkSdk.Stubs.cs | 40 +++++- cyberpunkmp-plugin/build-local.sh | 20 +++ 12 files changed, 525 insertions(+), 51 deletions(-) create mode 100644 cyberpunkmp-plugin/.gitignore create mode 100644 cyberpunkmp-plugin/RedSyncSystem.Smoke/Program.cs create mode 100644 cyberpunkmp-plugin/RedSyncSystem.Smoke/Program.csproj create mode 100644 cyberpunkmp-plugin/RedSyncSystem/AdminAllowlist.cs create mode 100644 cyberpunkmp-plugin/RedSyncSystem/DiscordWebhook.cs create mode 100644 cyberpunkmp-plugin/RedSyncSystem/PluginConfig.cs create mode 100644 cyberpunkmp-plugin/RedSyncSystem/redsync.json create mode 100755 cyberpunkmp-plugin/build-local.sh diff --git a/cyberpunkmp-plugin/.gitignore b/cyberpunkmp-plugin/.gitignore new file mode 100644 index 00000000..9451e554 --- /dev/null +++ b/cyberpunkmp-plugin/.gitignore @@ -0,0 +1,6 @@ +**/bin/ +**/obj/ +local-plugins/ +**/admins.json +**/redsync.json +!RedSyncSystem/redsync.json diff --git a/cyberpunkmp-plugin/README.md b/cyberpunkmp-plugin/README.md index d06982a9..e7f7a05c 100644 --- a/cyberpunkmp-plugin/README.md +++ b/cyberpunkmp-plugin/README.md @@ -72,7 +72,12 @@ Loader rules (from CMP): folder name `RedSyncSystem`, assembly 5. Optional: set `REDSYNC_DISCORD_WEBHOOK` on the CMP server process for join/leave announces. -## Status +## Local build (this machine) -Scaffold only. Standalone `server/Managed` RedSync host remains in the repo for -history but is **not** the product direction for multiplayer sync. +```bash +./cyberpunkmp-plugin/build-local.sh +dotnet run --project cyberpunkmp-plugin/RedSyncSystem.Smoke -c Release +``` + +Stages `cyberpunkmp-plugin/local-plugins/RedSyncSystem/` (DLL + `redsync.json`). +Copy that folder into a CyberpunkMP server’s `plugins/` directory to load it. diff --git a/cyberpunkmp-plugin/RedSyncSystem.Smoke/Program.cs b/cyberpunkmp-plugin/RedSyncSystem.Smoke/Program.cs new file mode 100644 index 00000000..2f46a7a9 --- /dev/null +++ b/cyberpunkmp-plugin/RedSyncSystem.Smoke/Program.cs @@ -0,0 +1,20 @@ +using CyberpunkSdk; +using RedSyncSystem; + +internal static class Program +{ + private static void Main() + { + Console.WriteLine("RedSyncSystem local smoke test"); + _ = Plugin.Instance; + Console.WriteLine($"Config: {PluginConfig.ConfigPath}"); + + StubPlayerSystem.Instance.SimulateJoin(1, "LocalTester"); + StubPlayerSystem.Instance.SimulateLeave(1); + StubWorld.Instance.SimulateTick(0.1f); + + Plugin.Instance.Admins.Add("LocalTester"); + Console.WriteLine($"IsAdmin(LocalTester)={Plugin.Instance.IsAdmin("LocalTester")}"); + Console.WriteLine("Smoke test OK"); + } +} diff --git a/cyberpunkmp-plugin/RedSyncSystem.Smoke/Program.csproj b/cyberpunkmp-plugin/RedSyncSystem.Smoke/Program.csproj new file mode 100644 index 00000000..403c2d83 --- /dev/null +++ b/cyberpunkmp-plugin/RedSyncSystem.Smoke/Program.csproj @@ -0,0 +1,13 @@ + + + Exe + net8.0 + enable + enable + 12 + + + + + + diff --git a/cyberpunkmp-plugin/RedSyncSystem/AdminAllowlist.cs b/cyberpunkmp-plugin/RedSyncSystem/AdminAllowlist.cs new file mode 100644 index 00000000..6aaae5f4 --- /dev/null +++ b/cyberpunkmp-plugin/RedSyncSystem/AdminAllowlist.cs @@ -0,0 +1,131 @@ +using System.Text.Json; + +namespace RedSyncSystem; + +/// +/// Optional RedSync-side admin allowlist (usernames). CMP may have its own admin; +/// this list is for RedSync plugin features only. +/// +public sealed class AdminAllowlist +{ + private readonly object _lock = new(); + private readonly string _path; + private readonly LoggerProxy _log; + private HashSet _admins = new(StringComparer.OrdinalIgnoreCase); + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true + }; + + public AdminAllowlist(string pluginDirectory, IEnumerable? seed, LoggerProxy log) + { + _log = log; + _path = Path.Combine(pluginDirectory, "admins.json"); + Load(seed); + } + + public bool IsAdmin(string? name) + { + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + lock (_lock) + { + return _admins.Contains(name.Trim()); + } + } + + public bool Add(string name) + { + name = name.Trim(); + if (string.IsNullOrWhiteSpace(name)) + { + return false; + } + + lock (_lock) + { + if (!_admins.Add(name)) + { + return false; + } + + SaveLocked(); + return true; + } + } + + public bool Remove(string name) + { + name = name.Trim(); + lock (_lock) + { + if (!_admins.Remove(name)) + { + return false; + } + + SaveLocked(); + return true; + } + } + + public IReadOnlyList List() + { + lock (_lock) + { + return _admins.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + } + } + + private void Load(IEnumerable? seed) + { + lock (_lock) + { + _admins = new HashSet(StringComparer.OrdinalIgnoreCase); + if (File.Exists(_path)) + { + try + { + var names = JsonSerializer.Deserialize>(File.ReadAllText(_path), JsonOptions) + ?? []; + foreach (var n in names) + { + if (!string.IsNullOrWhiteSpace(n)) + { + _admins.Add(n.Trim()); + } + } + } + catch (Exception ex) + { + _log.Warn($"admins.json load failed: {ex.Message}"); + } + } + + if (seed != null) + { + foreach (var n in seed) + { + if (!string.IsNullOrWhiteSpace(n)) + { + _admins.Add(n.Trim()); + } + } + } + + SaveLocked(); + _log.Info($"Admin allowlist: {_admins.Count} name(s) ({_path})"); + } + } + + private void SaveLocked() + { + var list = _admins.OrderBy(x => x, StringComparer.OrdinalIgnoreCase).ToList(); + File.WriteAllText(_path, JsonSerializer.Serialize(list, JsonOptions)); + } +} diff --git a/cyberpunkmp-plugin/RedSyncSystem/DiscordWebhook.cs b/cyberpunkmp-plugin/RedSyncSystem/DiscordWebhook.cs new file mode 100644 index 00000000..3c9ac387 --- /dev/null +++ b/cyberpunkmp-plugin/RedSyncSystem/DiscordWebhook.cs @@ -0,0 +1,114 @@ +using System.Collections.Concurrent; +using System.Text; +using System.Text.Json; + +namespace RedSyncSystem; + +/// +/// Batched Discord webhook sender (ported from RedSync server DiscordWebhookTarget ideas). +/// +public sealed class DiscordWebhook : IDisposable +{ + private const int FlushIntervalMs = 3000; + private const int MaxContentLength = 1900; + private const int MaxQueuedLines = 500; + + private readonly string _webhookUrl; + private readonly HttpClient _http = new() { Timeout = TimeSpan.FromSeconds(8) }; + private readonly ConcurrentQueue _pending = new(); + private readonly Timer _timer; + private readonly LoggerProxy _log; + private int _flushing; + private bool _disabled; + + public bool Enabled => !_disabled && !string.IsNullOrWhiteSpace(_webhookUrl); + + public DiscordWebhook(string webhookUrl, LoggerProxy log) + { + _log = log; + if (string.IsNullOrWhiteSpace(webhookUrl) + || string.Equals(webhookUrl, "off", StringComparison.OrdinalIgnoreCase)) + { + _disabled = true; + _webhookUrl = ""; + _timer = new Timer(_ => { }, null, Timeout.Infinite, Timeout.Infinite); + return; + } + + _webhookUrl = webhookUrl.Trim(); + _timer = new Timer(_ => _ = FlushAsync(), null, FlushIntervalMs, FlushIntervalMs); + _log.Info("Discord webhook enabled."); + } + + public void Enqueue(string line) + { + if (!Enabled || string.IsNullOrWhiteSpace(line)) + { + return; + } + + while (_pending.Count >= MaxQueuedLines && _pending.TryDequeue(out _)) + { + } + + _pending.Enqueue(line.Trim()); + } + + private async Task FlushAsync() + { + if (!Enabled || Interlocked.CompareExchange(ref _flushing, 1, 0) != 0) + { + return; + } + + try + { + while (!_pending.IsEmpty) + { + var batch = new StringBuilder(); + while (_pending.TryPeek(out var line)) + { + if (line.Length > MaxContentLength) + { + line = line[..MaxContentLength]; + } + + if (batch.Length + line.Length + 1 > MaxContentLength) + { + break; + } + + _pending.TryDequeue(out _); + batch.Append(line).Append('\n'); + } + + if (batch.Length == 0) + { + return; + } + + var payload = JsonSerializer.Serialize(new { content = batch.ToString() }); + using var body = new StringContent(payload, Encoding.UTF8, "application/json"); + var response = await _http.PostAsync(_webhookUrl, body).ConfigureAwait(false); + if (!response.IsSuccessStatusCode) + { + _log.Warn($"Discord webhook HTTP {(int)response.StatusCode}"); + } + } + } + catch (Exception ex) + { + _log.Warn($"Discord webhook flush failed: {ex.Message}"); + } + finally + { + Interlocked.Exchange(ref _flushing, 0); + } + } + + public void Dispose() + { + _timer.Dispose(); + _http.Dispose(); + } +} diff --git a/cyberpunkmp-plugin/RedSyncSystem/Plugin.cs b/cyberpunkmp-plugin/RedSyncSystem/Plugin.cs index b9e98d29..df372cc2 100644 --- a/cyberpunkmp-plugin/RedSyncSystem/Plugin.cs +++ b/cyberpunkmp-plugin/RedSyncSystem/Plugin.cs @@ -1,5 +1,4 @@ using CyberpunkSdk; -using CyberpunkSdk.Systems; namespace RedSyncSystem; @@ -8,16 +7,23 @@ namespace RedSyncSystem; /// as type RedSyncSystem.Plugin (CMP loader convention). /// /// Does not implement multiplayer sync — that is CyberpunkMP core. -/// This plugin adds RedSync-branded server extras (logging, future admin/RPCs). /// public class Plugin { public static Plugin Instance { get; private set; } = null!; - private readonly Logger _log = new("RedSync"); + private readonly LoggerProxy _log = new("RedSync"); private readonly object _gate = new(); private bool _hooksAttached; + private PluginConfig _config = new(); + private DiscordWebhook? _discord; + private AdminAllowlist? _admins; + private readonly Dictionary _names = new(); + + public AdminAllowlist Admins => _admins ?? throw new InvalidOperationException("Plugin not initialized"); + public PluginConfig Config => _config; + static Plugin() { Instance = new Plugin(); @@ -27,12 +33,19 @@ private Plugin() { try { + var pluginDir = AppContext.BaseDirectory; + Directory.CreateDirectory(pluginDir); + + _config = PluginConfig.LoadOrCreate(_log); + _discord = new DiscordWebhook(_config.DiscordWebhook, _log); + _admins = new AdminAllowlist(pluginDir, _config.Admins, _log); + AttachHooks(); - _log.Info("RedSyncSystem plugin loaded (CyberpunkMP SDK). Sync is owned by CMP core."); + _log.Info("RedSyncSystem loaded (CMP plugin). Sync owned by CyberpunkMP core."); + _discord.Enqueue("**RedSyncSystem** plugin online."); } catch (Exception ex) { - // Static ctor failures are easy to miss — always log. try { _log.Error($"RedSyncSystem failed to initialize: {ex}"); @@ -65,8 +78,16 @@ private void OnPlayerJoin(ulong id) try { var player = Server.PlayerSystem.GetById(id); + lock (_gate) + { + _names[id] = player.Username; + } + _log.Info($"Player joined: {player.Username} (id={id}, puppet={player.PuppetId})"); - MaybeDiscord($"**{player.Username}** joined the server."); + if (_config.AnnounceJoins) + { + _discord?.Enqueue($"**{player.Username}** joined the server."); + } } catch (Exception ex) { @@ -78,9 +99,20 @@ private void OnPlayerLeft(ulong id) { try { - // Player may already be gone from the manager; id is still useful. - _log.Info($"Player left: id={id}"); - MaybeDiscord($"Player `{id}` left the server."); + string name; + lock (_gate) + { + if (!_names.Remove(id, out name!)) + { + name = $"id:{id}"; + } + } + + _log.Info($"Player left: {name} (id={id})"); + if (_config.AnnounceLeaves) + { + _discord?.Enqueue($"**{name}** left the server."); + } } catch (Exception ex) { @@ -90,38 +122,8 @@ private void OnPlayerLeft(ulong id) private void OnWorldUpdate(float delta) { - // Reserved for lightweight RedSync extras (timers, anchor logic, etc.). - // Keep empty until needed — CMP already ticks gameplay. + // Reserved for lightweight RedSync extras. } - /// - /// Optional Discord announce. Set REDSYNC_DISCORD_WEBHOOK, or "off" to disable. - /// - private static void MaybeDiscord(string content) - { - var url = Environment.GetEnvironmentVariable("REDSYNC_DISCORD_WEBHOOK"); - if (string.IsNullOrWhiteSpace(url) - || string.Equals(url, "off", StringComparison.OrdinalIgnoreCase)) - { - return; - } - - // Fire-and-forget; never block the CMP world tick. - _ = Task.Run(async () => - { - try - { - using var client = new HttpClient { Timeout = TimeSpan.FromSeconds(5) }; - using var body = new StringContent( - $"{{\"content\":{System.Text.Json.JsonSerializer.Serialize(content)}}}", - System.Text.Encoding.UTF8, - "application/json"); - await client.PostAsync(url, body).ConfigureAwait(false); - } - catch - { - // Swallow — Discord must not take down the game server. - } - }); - } + public bool IsAdmin(string? username) => _admins?.IsAdmin(username) == true; } diff --git a/cyberpunkmp-plugin/RedSyncSystem/PluginConfig.cs b/cyberpunkmp-plugin/RedSyncSystem/PluginConfig.cs new file mode 100644 index 00000000..b47c10d9 --- /dev/null +++ b/cyberpunkmp-plugin/RedSyncSystem/PluginConfig.cs @@ -0,0 +1,125 @@ +using System.Text.Json; + +namespace RedSyncSystem; + +public sealed class PluginConfig +{ + public string DiscordWebhook { get; set; } = "off"; + public bool AnnounceJoins { get; set; } = true; + public bool AnnounceLeaves { get; set; } = true; + public string[] Admins { get; set; } = []; + + private static readonly JsonSerializerOptions JsonOptions = new() + { + WriteIndented = true, + PropertyNameCaseInsensitive = true, + ReadCommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + public static string ConfigPath => + Path.Combine(AppContext.BaseDirectory, "redsync.json"); + + public static PluginConfig LoadOrCreate(LoggerProxy log) + { + // Env wins over file for Discord (matches legacy RedSync server). + var envWebhook = Environment.GetEnvironmentVariable("REDSYNC_DISCORD_WEBHOOK"); + + try + { + if (!File.Exists(ConfigPath)) + { + var fresh = new PluginConfig(); + File.WriteAllText(ConfigPath, JsonSerializer.Serialize(fresh, JsonOptions)); + log.Info($"Wrote default config: {ConfigPath}"); + if (!string.IsNullOrWhiteSpace(envWebhook)) + { + fresh.DiscordWebhook = envWebhook; + } + + return fresh; + } + + var loaded = JsonSerializer.Deserialize(File.ReadAllText(ConfigPath), JsonOptions) + ?? new PluginConfig(); + if (!string.IsNullOrWhiteSpace(envWebhook)) + { + loaded.DiscordWebhook = envWebhook; + } + + return loaded; + } + catch (Exception ex) + { + log.Warn($"Failed to load redsync.json ({ex.Message}); using defaults."); + var fallback = new PluginConfig(); + if (!string.IsNullOrWhiteSpace(envWebhook)) + { + fallback.DiscordWebhook = envWebhook; + } + + return fallback; + } + } + + public void Save() + { + File.WriteAllText(ConfigPath, JsonSerializer.Serialize(this, JsonOptions)); + } +} + +/// Thin wrapper so config can log before CyberpunkSdk.Logger exists in odd load orders. +public sealed class LoggerProxy +{ + private readonly CyberpunkSdk.Systems.Logger? _sdk; + private readonly string _name; + + public LoggerProxy(string name) + { + _name = name; + try + { + _sdk = new CyberpunkSdk.Systems.Logger(name); + } + catch + { + _sdk = null; + } + } + + public void Info(string msg) + { + if (_sdk != null) + { + _sdk.Info(msg); + } + else + { + Console.WriteLine($"[INF:{_name}] {msg}"); + } + } + + public void Warn(string msg) + { + if (_sdk != null) + { + _sdk.Warn(msg); + } + else + { + Console.WriteLine($"[WRN:{_name}] {msg}"); + } + } + + public void Error(string msg) + { + if (_sdk != null) + { + _sdk.Error(msg); + } + else + { + Console.Error.WriteLine($"[ERR:{_name}] {msg}"); + } + } +} diff --git a/cyberpunkmp-plugin/RedSyncSystem/RedSyncSystem.csproj b/cyberpunkmp-plugin/RedSyncSystem/RedSyncSystem.csproj index e2fbec64..fe6b9cda 100644 --- a/cyberpunkmp-plugin/RedSyncSystem/RedSyncSystem.csproj +++ b/cyberpunkmp-plugin/RedSyncSystem/RedSyncSystem.csproj @@ -9,6 +9,12 @@ 12 + + + PreserveNewest + + +