From 2631d3199c46fa87b94cc933c1017e9c07813a9d Mon Sep 17 00:00:00 2001 From: Moonbase Date: Tue, 6 Oct 2026 19:00:15 -0700 Subject: [PATCH] Implement Multiplayer Support Implement Custom Screen Name Support --- Controllers/CDLController.cs | 28 +++- Controllers/ConfigAndAssetsController.cs | 8 +- Core/Player.cs | 39 +++++ Core/Rooms/GameRoomAttribute.cs | 8 + Core/Rooms/GameRooms/WordRiotRoom.cs | 23 +++ Core/Rooms/IGameRoom.cs | 18 +++ Core/Rooms/Room.cs | 191 +++++++++++++++++++++++ Core/Rooms/RoomManager.cs | 47 ++++++ Core/Sessions.cs | 57 +++++++ FlashGateway.cs | 72 ++++++--- Program.cs | 10 ++ TCPGameServer.cs | 130 ++++++++------- 12 files changed, 543 insertions(+), 88 deletions(-) create mode 100644 Core/Player.cs create mode 100644 Core/Rooms/GameRoomAttribute.cs create mode 100644 Core/Rooms/GameRooms/WordRiotRoom.cs create mode 100644 Core/Rooms/IGameRoom.cs create mode 100644 Core/Rooms/Room.cs create mode 100644 Core/Rooms/RoomManager.cs create mode 100644 Core/Sessions.cs diff --git a/Controllers/CDLController.cs b/Controllers/CDLController.cs index 767b934..d578b57 100644 --- a/Controllers/CDLController.cs +++ b/Controllers/CDLController.cs @@ -1,4 +1,6 @@ using Microsoft.AspNetCore.Mvc; +using NotPogo.Core; +using NotPogo.Core.Rooms; namespace NotPogo.Controllers { @@ -8,18 +10,30 @@ namespace NotPogo.Controllers [Route("/cdl/cdl-info.jsp")] public IResult CDLInfo(string lkey) { - // this is just to satisfy download clients - if (string.IsNullOrEmpty(lkey)) - return Results.Empty; - else - return Results.Text("error none screenname 'NotPogoUser' site 'pogo' tokens 9999 enti 'oava 1 obad 1'"); + var acct = Sessions.Find(lkey); + if (acct is null) + return Results.Text("error notloggedin", "text/plain"); // see note below + + var enti = $"oava {(acct.OwnsPremiumAvatar ? 1 : 0)} obad {(acct.OwnsBadges ? 1 : 0)}"; + var body = string.Join(" ", + "error", "none", + "screenname", Q(acct.ScreenName), + "site", Q(acct.Site), + "tokens", acct.Tokens.ToString(), + "enti", Q(enti), + "avatar", Q(acct.Avatar)); + return Results.Text(body, "text/plain"); } + static string Q(string s) => "'" + s.Replace("\\", "\\\\").Replace("'", "\\'") + "'"; + [HttpGet("/cdl/1/login")] - public IResult CDLLogin() + public IResult CDLLogin(string name) { + var lkey = Sessions.Issue(name); + // more satisfying - return Results.Text($"\n{Guid.NewGuid()}", "text/xml"); + return Results.Text($"\n{lkey}", "text/xml"); } [HttpGet("/cdl/1/cdl-clientprops")] diff --git a/Controllers/ConfigAndAssetsController.cs b/Controllers/ConfigAndAssetsController.cs index 4bc6422..6371cc9 100644 --- a/Controllers/ConfigAndAssetsController.cs +++ b/Controllers/ConfigAndAssetsController.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Mvc; +using NotPogo.Core; using NotPogo.Model; using System.IO.Compression; using System.Net; @@ -27,6 +28,7 @@ namespace NotPogo.Controllers string site, string locale, string hostedGameClass, + string scrn = null!, // user will get a username assigned if screenName is not set bool isSingleApplet = true) { string baseUrl = $"{Request.Scheme}://{Request.Host.Value}"; @@ -40,6 +42,8 @@ namespace NotPogo.Controllers // stay one 776-wide tag with no second one alongside it. int gameWidth = isSingleApplet ? 776 : 564; + string lkey = Sessions.Issue(scrn); // since there isn't a database + var gameApplet = new AppletDefinition { Name = "game", @@ -59,10 +63,10 @@ namespace NotPogo.Controllers { "site", site }, { "rkey", "" }, { "unid", "" }, - { "scrn", "NotPogoUser" }, + { "scrn", scrn! }, { "sourceid", "" }, { "launchPage", "" }, - { "lkey", "" }, + { "lkey", lkey }, { "cnid", "" }, { "isSingleApplet", isSingleApplet ? "true" : "false" }, { "browserInfo", "" }, diff --git a/Core/Player.cs b/Core/Player.cs new file mode 100644 index 0000000..40f47fc --- /dev/null +++ b/Core/Player.cs @@ -0,0 +1,39 @@ +using NotPogo.Core.Rooms; +using System.Net.Sockets; + +namespace NotPogo.Core +{ + public sealed class Player + { + static int _nextId; + + public int Id { get; } = Interlocked.Increment(ref _nextId); + public Account? Account { get; set; } + public string ScreenName { get; set; } = "Guest"; + public string ProfileId { get; set; } = "100000000000"; + public string LoginKey { get; set; } = ""; + public Room? Room { get; set; } + public DateTime? LastSeen { get; set; } + + readonly NetworkStream _stream; + readonly SemaphoreSlim _writeLock = new(1, 1); + + public Player(NetworkStream stream) => _stream = stream; + + public async Task SendAsync(params(string key, string value)[] fields) + { + var msg = new Dictionary(); + foreach(var (k, v) in fields) msg[k] = v; + await SendAsync(msg); + } + + public async Task SendAsync(Dictionary msg) + { + await _writeLock.WaitAsync(); + try { await TCPProtocol.WriteMessageAsync(_stream, msg); } + catch(IOException) {} + catch(ObjectDisposedException) {} + finally { _writeLock.Release(); } + } + } +} diff --git a/Core/Rooms/GameRoomAttribute.cs b/Core/Rooms/GameRoomAttribute.cs new file mode 100644 index 0000000..3fcea62 --- /dev/null +++ b/Core/Rooms/GameRoomAttribute.cs @@ -0,0 +1,8 @@ +namespace NotPogo.Core.Rooms +{ + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true)] + public sealed class GameRoomAttribute(string gameCode) : Attribute + { + public string GameCode { get; } = gameCode; + } +} \ No newline at end of file diff --git a/Core/Rooms/GameRooms/WordRiotRoom.cs b/Core/Rooms/GameRooms/WordRiotRoom.cs new file mode 100644 index 0000000..3a1a0bd --- /dev/null +++ b/Core/Rooms/GameRooms/WordRiotRoom.cs @@ -0,0 +1,23 @@ +namespace NotPogo.Core.Rooms.GameRooms +{ + [GameRoom("wordriot_cdl")] + public class WordRiotRoom(string ruleId) : IGameRoom + { + public Task OnGameJoinAsync(Room room, Player player) => Task.CompletedTask; + + public Task OnMessageAsync(Room room, Player player, string op, Dictionary msg) => Task.FromResult(true); + + public async Task OnPlayerJoinedAsync(Room room, Player player) + { + await player.SendAsync( + ("msg.op", "say"), + ("chnl", "serv"), + ("scrn", ""), + ("type", "stnd"), + ("text", "Welcome To Word Riot! Please Wait For Players To Join") + ); + } + + public Task OnPlayerLeftAsync(Room room, Player player) => Task.CompletedTask; + } +} diff --git a/Core/Rooms/IGameRoom.cs b/Core/Rooms/IGameRoom.cs new file mode 100644 index 0000000..f7b34b6 --- /dev/null +++ b/Core/Rooms/IGameRoom.cs @@ -0,0 +1,18 @@ +namespace NotPogo.Core.Rooms +{ + public interface IGameRoom + { + Task OnPlayerJoinedAsync(Room room, Player player); + Task OnPlayerLeftAsync(Room room, Player player); + Task OnMessageAsync(Room room, Player player, string op, Dictionary msg); + Task OnGameJoinAsync(Room room, Player player); + } + + public sealed class ChatOnlyRoom : IGameRoom + { + public Task OnMessageAsync(Room room, Player player, string op, Dictionary msg) => Task.FromResult(false); + public Task OnPlayerJoinedAsync(Room room, Player player) => Task.CompletedTask; + public Task OnPlayerLeftAsync(Room room, Player player) => Task.CompletedTask; + public Task OnGameJoinAsync(Room room, Player player) => Task.CompletedTask; + } +} diff --git a/Core/Rooms/Room.cs b/Core/Rooms/Room.cs new file mode 100644 index 0000000..adfc3bf --- /dev/null +++ b/Core/Rooms/Room.cs @@ -0,0 +1,191 @@ +using Microsoft.AspNetCore.StaticFiles; +using System.Runtime.InteropServices.Marshalling; + +namespace NotPogo.Core.Rooms +{ + public sealed class Room + { + public string Key { get; } + public string HumanName { get; } + public string GameCode { get; } + public string RuleId { get; } + public int SoftCapacity { get; } + public int TabId { get; } + public bool Live { get; set; } = true; + public IGameRoom Game { get; } + + readonly List _players = new(); + readonly object _lock = new(); + + public Room(string key, string humanName, string gameCode, string ruleId, + int softCapacity, int tabId, bool live, IGameRoom game) + { + Key = key; HumanName = humanName; GameCode = gameCode; RuleId = ruleId; + SoftCapacity = softCapacity; TabId = tabId; Live = live; Game = game; + } + + public bool IsJoinable => Live && PlayerCount < SoftCapacity; + + public int PlayerCount { get { lock (_lock) return _players.Count; } } + public Player[] Snapshot() { lock (_lock) { return [.. _players]; } } + + public static (string, string)[] PresenceFields(Player p) => + [ + ("msg.op", "rm.join"), ("scrn", p.ScreenName), ("snme", p.ScreenName), ("pfid", p.ProfileId), + ("plid", p.Id.ToString()), ("entl", "ictsebaulovfFmMrRG"), ("priv", "0"), + ("aepc", ""), ("acvw", ""), ("agvs", ""), ("alrc", ""), + ]; + + public static (string, string)[][] PlayerState(Player p) => + [ + [("msg.op", "pl.rm.stte"), ("plid", p.Id.ToString()), ("gmpf", ""), ("glpf", "..."), ("frls", "")], + [("msg.op", "pl.rm.stte"), ("plid", p.Id.ToString()), ("tokb", "9999")], + [("msg.op", "pl.rm.stte"), ("plid", p.Id.ToString()), ("avtr", ""), ("icon", "avatarminighost")] + ]; + + public async Task JoinAsync(Player player) + { + Player[] others; + lock (_lock) + { + others = [.. _players]; + _players.Add(player); + player.Room = this; + } + + await player.SendAsync( + ("msg.op", "load"), + ("rmst", "start") + ); + + await player.SendAsync(PresenceFields(player)); + foreach (var other in others) + await player.SendAsync(PresenceFields(other)); + + await player.SendAsync( + ("msg.op", "gm.crea"), + ("launch", "y"), + ("live", "0"), + ("port", "0"), + ("iali", "86400000"), + ("gins", HumanName), + ("dead", "0"), + ("iaid", "15000"), + ("shut", ""), + ("gmid", "0") + ); + + await player.SendAsync( + ("msg.op", "load"), + ("rmst", "end") + ); + + await player.SendAsync( + ("msg.op", "say"), + ("chnl", "serv"), + ("scrn", ""), + ("type", "stnd"), + ("text", "NotPogo Is Alpha Software! Some Things May Not Function Correctly.") + ); + + foreach (var other in others) + foreach (var m in PlayerState(other)) await player.SendAsync(m); + await BroadcastAsync(PresenceFields(player), except: player); + foreach (var m in PlayerState(player)) await BroadcastAsync(m); + + await Game.OnPlayerJoinedAsync(this, player); + } + + public async Task LeaveAsync(Player player) + { + lock (_lock) + { + if (!_players.Remove(player)) return; + player.Room = null; + } + + await BroadcastAsync([("msg.op", "rm.left"), ("plid", player.Id.ToString()), ("scrn", player.ScreenName)]); + await Game.OnPlayerLeftAsync(this, player); + } + + public Task BroadcastAsync((string, string)[] values, Player? except = null) + { + var msg = new Dictionary(); + foreach (var (k, v) in values) msg[k] = v; + return BroadcastAsync(msg, except); + } + + public Task BroadcastAsync(Dictionary msg, Player? except = null) => + Task.WhenAll(Snapshot().Where(p => p != except).Select(p => p.SendAsync(msg))); + + public async Task HandleAsync(Player player, string op, Dictionary msg) + { + switch(op) + { + case "gm.join": + await GameJoinAsync(player); + return; + case "say": + msg.TryGetValue("text", out var text); + + await BroadcastAsync( + [("msg.op", "say"), + ("chnl", msg.GetValueOrDefault("chnl", "")), + ("plid", player.Id.ToString()), + ("scrn", player.ScreenName), + ("type", "stnd"), + ("text", text ?? "")] + ); + + return; + case "rm.left": + case "disc": + await LeaveAsync(player); + return; + } + + if (!await Game.OnMessageAsync(this, player, op, msg)) + Console.WriteLine($"[Room {Key}] Unhandled OP '{op}' From {player.ScreenName}"); + } + + public async Task GameJoinAsync(Player player) + { + string plid = player.Id.ToString(); + + await player.SendAsync( + ("msg.op", "gm.join"), + ("plid", plid), + ("snme", player.ScreenName), + ("gmid", "0"), + ("scrn", player.ScreenName), + ("join", "join") + ); + + await player.SendAsync( + ("msg.op", "gm.lnch"), + ("gmid", "0") + ); + + await player.SendAsync( + ("msg.op", "tokb"), + ("tokb", "9999") + ); + + await player.SendAsync( + ("msg.op", "tick"), + ("scrn", player.ScreenName), + ("text", "Welcome to Not Pogo!") + ); + + await player.SendAsync( + ("msg.op", "cnfg"), + ("impd", "40") + ); + + await player.SendAsync( + ("msg.op", "ext.pref"), + ("pref", "") + ); + } + } +} \ No newline at end of file diff --git a/Core/Rooms/RoomManager.cs b/Core/Rooms/RoomManager.cs new file mode 100644 index 0000000..200d1a9 --- /dev/null +++ b/Core/Rooms/RoomManager.cs @@ -0,0 +1,47 @@ +using NotPogo.Core.Rooms.GameRooms; +using System.Collections.Concurrent; +using System.Reflection; + +namespace NotPogo.Core.Rooms +{ + public static class RoomManager + { + static readonly ConcurrentDictionary Rooms = new(); + + static readonly Dictionary> Factories = BuildFactories(); + + static Dictionary> BuildFactories() + { + var map = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + foreach (var type in typeof(IGameRoom).Assembly.GetTypes() + .Where(t => typeof(IGameRoom).IsAssignableFrom(t) && !t.IsAbstract)) + { + foreach (var attr in type.GetCustomAttributes()) + { + // constructor convention: (string ruleId) if the game has modes, otherwise parameterless + Func factory = + type.GetConstructor([typeof(string)]) is not null ? rule => (IGameRoom)Activator.CreateInstance(type, rule)! + : type.GetConstructor(Type.EmptyTypes) is not null ? _ => (IGameRoom)Activator.CreateInstance(type)! + : throw new InvalidOperationException($"{type.Name} needs a (string ruleId) or parameterless constructor"); + + if (!map.TryAdd(attr.GameCode, factory)) + throw new InvalidOperationException($"Game code '{attr.GameCode}' is claimed by more than one room class"); + } + } + return map; + } + + static IGameRoom CreateGame(string gameCode, string ruleId) => + Factories.TryGetValue(gameCode, out var create) ? create(ruleId) : new ChatOnlyRoom(); + + public static Room Define(string key, string humanName, string gameCode, string ruleId, bool isLive, int softCapacity = 50, int tabId = 1) => + Rooms.GetOrAdd(key, _ => new Room(key, humanName, gameCode, ruleId, softCapacity, tabId, isLive, CreateGame(gameCode, ruleId))); + + public static Room GetOrCreate(string key, string gameCode, string ruleId = "") => + Rooms.GetOrAdd(key, _ => new Room(key, key, gameCode, ruleId, 50, 1, false, CreateGame(gameCode, ruleId))); + + public static Room? Find(string key) => Rooms.TryGetValue(key, out var r) ? r : null; + public static IEnumerable ForGame(string gameCode) => Rooms.Values.Where(r => r.GameCode == gameCode); + } +} diff --git a/Core/Sessions.cs b/Core/Sessions.cs new file mode 100644 index 0000000..b5947f6 --- /dev/null +++ b/Core/Sessions.cs @@ -0,0 +1,57 @@ +using Microsoft.AspNetCore.StaticFiles; +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; + +namespace NotPogo.Core +{ + public sealed class Account + { + public required string ScreenName { get; init; } + public string ProfileId { get; init; } = "1000000000"; // pfid + public string Site { get; init; } = "pogo"; + public int Tokens { get; set; } = 9999; + public string Avatar { get; set; } = ""; + public bool OwnsPremiumAvatar { get; set; } = true; // enti.oava + public bool OwnsBadges { get; set; } = true; // enti.obad + } + + public static class Sessions + { + static byte[] _secret = []; // set once at startup + static readonly ConcurrentDictionary Live = new(StringComparer.OrdinalIgnoreCase); + + public static void Init(string secret) => _secret = Encoding.UTF8.GetBytes(secret); + + // lkey = base64url(screenName) + "." + base64url(HMAC(screenName)) + public static string Issue(string screenName) + { + var name = Encoding.UTF8.GetBytes(screenName); + return B64(name) + "." + B64(HMACSHA256.HashData(_secret, name)); + } + + public static Account? Find(string? lkey) + { + if (string.IsNullOrEmpty(lkey)) return null; + var parts = lkey.Split('.'); + if (parts.Length != 2) return null; + try + { + var name = UnB64(parts[0]); + if (!CryptographicOperations.FixedTimeEquals(UnB64(parts[1]), HMACSHA256.HashData(_secret, name))) + return null; // forged or from another secret + var screenName = Encoding.UTF8.GetString(name); + // in-memory state for this run; recreated with defaults after a restart + return Live.GetOrAdd(screenName, n => new Account { ScreenName = n }); + } + catch (FormatException) { return null; } + } + + static string B64(byte[] b) => Convert.ToBase64String(b).TrimEnd('=').Replace('+', '-').Replace('/', '_'); + static byte[] UnB64(string s) + { + s = s.Replace('-', '+').Replace('_', '/'); + return Convert.FromBase64String(s + new string('=', (4 - s.Length % 4) % 4)); + } + } +} diff --git a/FlashGateway.cs b/FlashGateway.cs index 3e4c63c..61eafc0 100644 --- a/FlashGateway.cs +++ b/FlashGateway.cs @@ -3,11 +3,21 @@ using NotPogo.Amf; using NotPogo; +using NotPogo.Core.Rooms; namespace NotPogo; public static class FlashGateway { + static string _gameHost = "127.0.0.1"; + static int _gamePort = 11200; + + static string[] RuleOrder(string gameCode) => + [.. RoomManager.ForGame(gameCode) // all Word Riot rooms + .Select(r => r.RuleId) // "classic", "rally", "advanced", "classic", ... + .Distinct() // each mode once + .OrderBy(r => r, StringComparer.Ordinal)]; + // "Service.method" -> handler(args). Args arrive as AMF0 values: strings, doubles, etc. static readonly Dictionary, object?>> Handlers = new() { @@ -15,46 +25,66 @@ public static class FlashGateway ["com.pogo.flash.FlashRoomsFeed.generateFeed"] = args => { string gameCode = Str(args, 0), siteCode = Str(args, 1); + var rooms = RoomManager.ForGame(gameCode).ToArray(); return new { errorMsg = (string?)null, // non-null = SWF shows the error - tabs = new[] { new { tabId = 1, index = 0 } }, - rooms = new[] + tabs = rooms.Select(r => r.TabId).Distinct() + .OrderBy(t => t) + .Select((tabId, i) => new { tabId, index = i }) + .ToArray(), + rooms = rooms.Select(r => new { - // shown only if tabId == selected tab AND ruleId == gRuleId (omit ruleId if noruleid) - // clickable only if numPeople < softCapacity && live - new { key = "wr_classic_1", humanName = "NotPogo WR Classic", numPeople = 0, softCapacity = 50, live = true, tabId = 1, ruleId = "classic" }, - new { key = "wr_rally_1", humanName = "NotPogo WR Rally", numPeople = 0, softCapacity = 50, live = true, tabId = 1, ruleId = "rally" }, - new { key = "wr_advanced_1", humanName = "NotPogo WR Expert", numPeople = 0, softCapacity = 50, live = true, tabId = 1, ruleId = "advanced" }, - }, + key = r.Key, + humanName = r.HumanName, + ruleId = r.RuleId, + tabId = r.TabId, + numPeople = r.PlayerCount, + softCapacity = r.SoftCapacity, + live = r.Live, + }).ToArray(), rules = Array.Empty(), // stored, never read by the SWF - ruleTabs = new[] - { - new { id = "classic", index = 0 }, - new { id = "rally", index = 1 }, - new { id = "advanced", index = 2 }, - }, // [{ id = , index = n }] -> ruleIdx for Play Now + ruleTabs = RuleOrder(gameCode).Select((id, i) => new { id, index = i }) }; }, // RoomTabs.goRoom -> connectRoom(roomKey, gameCode, siteCode, lkey) ["com.pogo.flash.FlashRoomConnect.connectRoom"] = args => - RoomInfo(Str(args, 0)), + RoomInfo(RoomManager.Find(Str(args, 0))), // RoomTabs.playNow -> playNow(gameCode, siteCode, lkey, tabIdx, ruleIdx) ["com.pogo.flash.FlashRoomConnect.playNow"] = args => - RoomInfo("wr_room_1"), // pick a room server-side + { + string gameCode = Str(args, 0); + int ruleIdx = args.Count > 4 && args[4] is double d ? (int)d : -1; + var rules = RuleOrder(gameCode); + string? ruleId = ruleIdx >= 0 && ruleIdx < rules.Length ? rules[ruleIdx] : null; + + var pick = RoomManager.ForGame(gameCode) + .Where(r => r.IsJoinable && (ruleId is null || r.RuleId == ruleId)) + .OrderByDescending(r => r.PlayerCount) // fill busy rooms first + .FirstOrDefault(); + return RoomInfo(pick, notFound: "No rooms are available right now."); + } }; // roomInfo becomes FSCommand goRoom "humanName '..' key '..' host '..' port '..'"; the exe connects TCP to host:port - static object RoomInfo(string roomKey) => new + static object RoomInfo(Room? room, string notFound = "That room no longer exists.") { - errorMsg = (string?)null, // null or "NO ERROR" = success - roomInfo = new { humanName = "NotPogo Room", key = roomKey, host = "downld.pogo.com", port = 11200 }, - }; + if (room is null) return new { errorMsg = notFound }; + if (!room.IsJoinable) return new { errorMsg = "That room is full." }; + return new + { + errorMsg = (string?)null, + roomInfo = new { humanName = room.HumanName, key = room.Key, host = _gameHost, port = _gamePort }, + }; + } - public static void MapFlashGateway(this IEndpointRouteBuilder app) + public static void MapFlashGateway(this WebApplication app) { + _gameHost = app.Configuration.GetValue("GameServerHost", "127.0.0.1")!; + _gamePort = app.Configuration.GetValue("GameServerPort", 11200); + app.MapPost("/flashservices/gateway", async (HttpContext ctx, ILoggerFactory lf) => { var log = lf.CreateLogger("FlashGateway"); diff --git a/Program.cs b/Program.cs index 7c960c7..4c936d5 100644 --- a/Program.cs +++ b/Program.cs @@ -1,5 +1,7 @@ using Microsoft.AspNetCore.StaticFiles; using Microsoft.Extensions.FileProviders; +using NotPogo.Core; +using NotPogo.Core.Rooms; namespace NotPogo { @@ -7,8 +9,11 @@ namespace NotPogo { public static void Main(string[] args) { + var builder = WebApplication.CreateBuilder(args); + Sessions.Init(builder.Configuration["SessionSecret"] ?? "change-me"); + // Add services to the container. builder.Services.AddControllersWithViews(); @@ -47,6 +52,11 @@ namespace NotPogo app.MapControllers(); app.MapFlashGateway(); + // Define Rooms For Word Riot Deluxe + RoomManager.Define("npwr_classic_1", "NotPogo Classic Room 1", "wordriot_cdl", "classic", true); + RoomManager.Define("npwr_rally_1", "NotPogo Rally Room 1", "wordriot_cdl", "rally", true); + RoomManager.Define("npwr_advanced_1", "NotPogo Advanced Room 1", "wordriot_cdl", "advanced", true); + app.Run(); } } diff --git a/TCPGameServer.cs b/TCPGameServer.cs index ce42f38..b7d5f38 100644 --- a/TCPGameServer.cs +++ b/TCPGameServer.cs @@ -1,6 +1,7 @@ using System.Net.Sockets; using System.Net; using NotPogo.Core; +using NotPogo.Core.Rooms; namespace NotPogo { @@ -16,9 +17,12 @@ namespace NotPogo public static List Clients { get; } = []; public static BowlingGame? SingleplayerBowlingGame; + static readonly HashSet RoomOps = ["say", "rm.left", "disc", "gm.join"]; + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _tcpPort = _configuration.GetValue("GameServerPort"); + Listener = new(IPAddress.Any, _tcpPort); Listener.Start(); _logger.LogInformation("Game Server Listening On Port {Port}", _tcpPort); @@ -33,9 +37,12 @@ namespace NotPogo private async Task HandleClientAsync(TcpClient client) { + Player? player = null; + using (client) await using (var stream = client.GetStream()) { + player = new(stream); try { while (true) @@ -47,64 +54,30 @@ namespace NotPogo } catch (EndOfStreamException) { break; } - _logger.LogInformation("Received:"); - foreach (var kv in received) - _logger.LogInformation(" {Key} = {Value}", kv.Key, kv.Value); - if (!received.TryGetValue("msg.op", out var op)) continue; + _logger.LogInformation("[plid {Plid}] <- {Op} {Fields}", + player.Id, + op, + string.Join(" ", received.Where(kv => kv.Key != "msg.op").Select(kv => $"{kv.Key}='{kv.Value}'"))); + if (op == "logn") // Login Request { // we know this is a compatible client now so let's add it to the client list Clients.Add(client); - // send logn response chain - await SendAsync(stream, ("msg.op", "logn"), ("flags", "65535"), ("time", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())); - await SendAsync(stream, ("msg.op", "init"), ("mxpl", "70"), ("plid", "1"), ("mxtb", "70"), ("mxst", "4"), ("cnid", "1623")); - await SendAsync(stream, ("msg.op", "load"), ("rmst", "start")); - await SendAsync(stream, ("msg.op", "rm.join"), ("scrn", "NotPogoUser"), ("pfid", "1000000000"), ("plid", "1"), ("entl", "ictsebaulovfFmMrRG"), - ("snme", "NotPogoUser"), ("priv", "0"), ("aepc", ""), ("acvw", ""), ("agvs", ""), ("alrc", "")); - await SendAsync(stream, ("msg.op", "gm.crea"), ("launch", "y"), ("live", "0"), ("port", "0"), ("iali", "86400000"), ("gins", "NotPogoTempRoom"), - ("dead", "0"), ("iaid", "15000"), ("shut", ""), ("gmid", "0")); - await SendAsync(stream, ("msg.op", "load"), ("rmst", "end")); - await SendAsync(stream, ("msg.op", "say"), ("chnl", "serv"), ("scrn", "NotPogoUser"), ("type", "stnd"), ("text", - "Welcome to Not Pogo, the best place for games, fun and friends!")); - await SendAsync(stream, ("msg.op", "say"), ("chnl", "serv"), ("scrn", "NotPogoUser"), ("type", "stnd"), ("text", - "Caution: This emulator is in Alpha Stages. Some things may not work, even with proper game properties.")); - await SendAsync(stream, ("msg.op", "pl.rm.stte"), ("plid", "1"), ("gmpf", ""), ("glpf", "..."), ("frls", "")); - await SendAsync(stream, ("msg.op", "pl.rm.stte"), ("plid", "1"), ("tokb", "9999")); - await SendAsync(stream, ("msg.op", "pl.rm.stte"), ("plid", "1"), ("avtr", ""), ("icon", "avatarminighost")); - _logger.LogInformation("Sent 'logn' Response Sequence"); + // handle logn + await HandleLoginAsync(player, received); } - else if (op == "gm.join") // Room Join Request + else if (op == "ping") { - // send gm.join response chain - await SendAsync(stream, ("msg.op", "gm.join"), ("plid", "1"), ("snme", "NotPogoUser"), ("gmid", "0"), ("scrn", "NotPogoUser"), ("join", "join")); - await SendAsync(stream, ("msg.op", "gm.lnch"), ("gmid", "0")); - await SendAsync(stream, ("msg.op", "tokb"), ("tokb", "9999")); - await SendAsync(stream, ("msg.op", "tick"), ("scrn", "NotPogoUser"), ("text", "Welcome to Not Pogo!")); - await SendAsync(stream, ("msg.op", "cnfg"), ("impd", "40")); - await SendAsync(stream, ("msg.op", "ext.pref"), ("pref", "")); - - // send lottso config, more than likely will be ignored by other clients - // this is mostly to get lottso express to work - await SendAsync(stream, - ("msg.op", "lb.cfg"), - ("lb.cmn.rack.ballnum", "6"), - ("lb.cmn.ball.range", "1,75"), - ("lb.cmn.ball.starting", "30"), - ("lb.cmn.ball.extra", "1"), - ("lb.cmn.jackpot.goal", "50"), - ("lb.cmn.score.to.token", "100"), - ("lb.cmn.rank.points", "10"), - ("lb.cmn.scoring.goal", "100,200,300,400,500"), - ("lb.cmn.cheat", "false"), - ("rank.maxOrd", "10") - ); - - _logger.LogInformation("Sent 'gm.join' Response Sequence (Launches Game)"); + player.LastSeen = DateTime.UtcNow; } - else if (op == "ext.over") + else if (player.Room is not null && (RoomOps.Contains(op) || op.StartsWith(player.Room.GameCode + "."))) + { + await player.Room.HandleAsync(player, op, received); + } + else if (op == "ext.over") // TODO: move to rooms extending IGameRoom { // because we aren't actually keeping track of anything going on in the games, we can just send the correct placeholders @@ -126,24 +99,65 @@ namespace NotPogo ); _logger.LogInformation("Results Sent"); - } else if (op == "co.redy") - { - // when client is ready, start game loop - SingleplayerBowlingGame ??= new(); - _ = SingleplayerBowlingGame.StartGameLoop(stream); - } else if (op == "co.turn") - { - // increment score - SingleplayerBowlingGame?.RecordThrow(int.Parse(received["pins"])); } } - } catch (Exception e) { _logger.LogError(e, "Error Handling Client"); } + } catch (Exception e) when (e is EndOfStreamException or IOException or SocketException) { _logger.LogInformation("plid {Plid} disconnected ({Reason})", player.Id, e.GetBaseException().Message); } + catch(Exception e) + { + _logger.LogError(e, "Error Handling plid {Plid}", player.Id); + } + finally + { + try + { + if (player.Room is { } room) await room.LeaveAsync(player); + } + catch(Exception e) { _logger.LogError(e, "Cleanup Failed For plid {Plid}", player.Id); } + } } Clients.Remove(client); _logger.LogInformation("Client Disconnected"); } + private async Task HandleLoginAsync(Player player, Dictionary logn) + { + var accnt = Sessions.Find(logn.GetValueOrDefault("lkey")); + + // populate player information + player.Account = accnt; + player.ScreenName = accnt?.ScreenName ?? $"Guest_{player.Id}"; + + // get game code and rule id + string game = logn.GetValueOrDefault("game", ""); + string rkey = logn.GetValueOrDefault("rkey", ""); + + // pick the room + var room = !string.IsNullOrEmpty(rkey) ? + RoomManager.Find(rkey) ?? + RoomManager.GetOrCreate(rkey, game) : + RoomManager.GetOrCreate($"{(game == "" ? "default" : game)}_lobby", game); + + string plid = player.Id.ToString(); + + await player.SendAsync( + ("msg.op", "logn"), + ("flags", "65535"), + ("time", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString()) + ); + + await player.SendAsync( + ("msg.op", "init"), + ("mxpl", "70"), + ("plid", plid), + ("mxtb", "70"), + ("mxst", "4"), + ("cnid", "1623") + ); + + await room.JoinAsync(player); + } + public static Task SendAsync(NetworkStream stream, params (string key, string value)[] fields) { var msg = new Dictionary();