Implement Multiplayer Support

Implement Custom Screen Name Support
This commit is contained in:
Alan Moon 2026-10-06 19:00:15 -07:00
parent 7e58c49ce6
commit 2631d3199c
12 changed files with 543 additions and 88 deletions

View File

@ -1,4 +1,6 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NotPogo.Core;
using NotPogo.Core.Rooms;
namespace NotPogo.Controllers namespace NotPogo.Controllers
{ {
@ -8,18 +10,30 @@ namespace NotPogo.Controllers
[Route("/cdl/cdl-info.jsp")] [Route("/cdl/cdl-info.jsp")]
public IResult CDLInfo(string lkey) public IResult CDLInfo(string lkey)
{ {
// this is just to satisfy download clients var acct = Sessions.Find(lkey);
if (string.IsNullOrEmpty(lkey)) if (acct is null)
return Results.Empty; return Results.Text("error notloggedin", "text/plain"); // see note below
else
return Results.Text("error none screenname 'NotPogoUser' site 'pogo' tokens 9999 enti 'oava 1 obad 1'"); 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")] [HttpGet("/cdl/1/login")]
public IResult CDLLogin() public IResult CDLLogin(string name)
{ {
var lkey = Sessions.Issue(name);
// more satisfying // more satisfying
return Results.Text($"<LoginResponse error=\"none\">\n<LoginSessionKey>{Guid.NewGuid()}</LoginSessionKey></LoginResponse>", "text/xml"); return Results.Text($"<LoginResponse error=\"none\">\n<LoginSessionKey>{lkey}</LoginSessionKey></LoginResponse>", "text/xml");
} }
[HttpGet("/cdl/1/cdl-clientprops")] [HttpGet("/cdl/1/cdl-clientprops")]

View File

@ -1,4 +1,5 @@
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc;
using NotPogo.Core;
using NotPogo.Model; using NotPogo.Model;
using System.IO.Compression; using System.IO.Compression;
using System.Net; using System.Net;
@ -27,6 +28,7 @@ namespace NotPogo.Controllers
string site, string site,
string locale, string locale,
string hostedGameClass, string hostedGameClass,
string scrn = null!, // user will get a username assigned if screenName is not set
bool isSingleApplet = true) bool isSingleApplet = true)
{ {
string baseUrl = $"{Request.Scheme}://{Request.Host.Value}"; string baseUrl = $"{Request.Scheme}://{Request.Host.Value}";
@ -40,6 +42,8 @@ namespace NotPogo.Controllers
// stay one 776-wide <applet> tag with no second one alongside it. // stay one 776-wide <applet> tag with no second one alongside it.
int gameWidth = isSingleApplet ? 776 : 564; int gameWidth = isSingleApplet ? 776 : 564;
string lkey = Sessions.Issue(scrn); // since there isn't a database
var gameApplet = new AppletDefinition var gameApplet = new AppletDefinition
{ {
Name = "game", Name = "game",
@ -59,10 +63,10 @@ namespace NotPogo.Controllers
{ "site", site }, { "site", site },
{ "rkey", "" }, { "rkey", "" },
{ "unid", "" }, { "unid", "" },
{ "scrn", "NotPogoUser" }, { "scrn", scrn! },
{ "sourceid", "" }, { "sourceid", "" },
{ "launchPage", "" }, { "launchPage", "" },
{ "lkey", "" }, { "lkey", lkey },
{ "cnid", "" }, { "cnid", "" },
{ "isSingleApplet", isSingleApplet ? "true" : "false" }, { "isSingleApplet", isSingleApplet ? "true" : "false" },
{ "browserInfo", "" }, { "browserInfo", "" },

39
Core/Player.cs Normal file
View File

@ -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<string, string>();
foreach(var (k, v) in fields) msg[k] = v;
await SendAsync(msg);
}
public async Task SendAsync(Dictionary<string, string> msg)
{
await _writeLock.WaitAsync();
try { await TCPProtocol.WriteMessageAsync(_stream, msg); }
catch(IOException) {}
catch(ObjectDisposedException) {}
finally { _writeLock.Release(); }
}
}
}

View File

@ -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;
}
}

View File

@ -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<bool> OnMessageAsync(Room room, Player player, string op, Dictionary<string, string> 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;
}
}

18
Core/Rooms/IGameRoom.cs Normal file
View File

@ -0,0 +1,18 @@
namespace NotPogo.Core.Rooms
{
public interface IGameRoom
{
Task OnPlayerJoinedAsync(Room room, Player player);
Task OnPlayerLeftAsync(Room room, Player player);
Task<bool> OnMessageAsync(Room room, Player player, string op, Dictionary<string, string> msg);
Task OnGameJoinAsync(Room room, Player player);
}
public sealed class ChatOnlyRoom : IGameRoom
{
public Task<bool> OnMessageAsync(Room room, Player player, string op, Dictionary<string, string> 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;
}
}

191
Core/Rooms/Room.cs Normal file
View File

@ -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<Player> _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<string, string>();
foreach (var (k, v) in values) msg[k] = v;
return BroadcastAsync(msg, except);
}
public Task BroadcastAsync(Dictionary<string, string> 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<string, string> 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", "")
);
}
}
}

47
Core/Rooms/RoomManager.cs Normal file
View File

@ -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<string, Room> Rooms = new();
static readonly Dictionary<string, Func<string, IGameRoom>> Factories = BuildFactories();
static Dictionary<string, Func<string, IGameRoom>> BuildFactories()
{
var map = new Dictionary<string, Func<string, IGameRoom>>(StringComparer.OrdinalIgnoreCase);
foreach (var type in typeof(IGameRoom).Assembly.GetTypes()
.Where(t => typeof(IGameRoom).IsAssignableFrom(t) && !t.IsAbstract))
{
foreach (var attr in type.GetCustomAttributes<GameRoomAttribute>())
{
// constructor convention: (string ruleId) if the game has modes, otherwise parameterless
Func<string, IGameRoom> 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<Room> ForGame(string gameCode) => Rooms.Values.Where(r => r.GameCode == gameCode);
}
}

57
Core/Sessions.cs Normal file
View File

@ -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<string, Account> 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));
}
}
}

View File

@ -3,11 +3,21 @@
using NotPogo.Amf; using NotPogo.Amf;
using NotPogo; using NotPogo;
using NotPogo.Core.Rooms;
namespace NotPogo; namespace NotPogo;
public static class FlashGateway 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. // "Service.method" -> handler(args). Args arrive as AMF0 values: strings, doubles, etc.
static readonly Dictionary<string, Func<List<object?>, object?>> Handlers = new() static readonly Dictionary<string, Func<List<object?>, object?>> Handlers = new()
{ {
@ -15,46 +25,66 @@ public static class FlashGateway
["com.pogo.flash.FlashRoomsFeed.generateFeed"] = args => ["com.pogo.flash.FlashRoomsFeed.generateFeed"] = args =>
{ {
string gameCode = Str(args, 0), siteCode = Str(args, 1); string gameCode = Str(args, 0), siteCode = Str(args, 1);
var rooms = RoomManager.ForGame(gameCode).ToArray();
return new return new
{ {
errorMsg = (string?)null, // non-null = SWF shows the error errorMsg = (string?)null, // non-null = SWF shows the error
tabs = new[] { new { tabId = 1, index = 0 } }, tabs = rooms.Select(r => r.TabId).Distinct()
rooms = new[] .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) key = r.Key,
// clickable only if numPeople < softCapacity && live humanName = r.HumanName,
new { key = "wr_classic_1", humanName = "NotPogo WR Classic", numPeople = 0, softCapacity = 50, live = true, tabId = 1, ruleId = "classic" }, ruleId = r.RuleId,
new { key = "wr_rally_1", humanName = "NotPogo WR Rally", numPeople = 0, softCapacity = 50, live = true, tabId = 1, ruleId = "rally" }, tabId = r.TabId,
new { key = "wr_advanced_1", humanName = "NotPogo WR Expert", numPeople = 0, softCapacity = 50, live = true, tabId = 1, ruleId = "advanced" }, numPeople = r.PlayerCount,
}, softCapacity = r.SoftCapacity,
live = r.Live,
}).ToArray(),
rules = Array.Empty<object>(), // stored, never read by the SWF rules = Array.Empty<object>(), // stored, never read by the SWF
ruleTabs = new[] ruleTabs = RuleOrder(gameCode).Select((id, i) => new { id, index = i })
{
new { id = "classic", index = 0 },
new { id = "rally", index = 1 },
new { id = "advanced", index = 2 },
}, // [{ id = <ruleId>, index = n }] -> ruleIdx for Play Now
}; };
}, },
// RoomTabs.goRoom -> connectRoom(roomKey, gameCode, siteCode, lkey) // RoomTabs.goRoom -> connectRoom(roomKey, gameCode, siteCode, lkey)
["com.pogo.flash.FlashRoomConnect.connectRoom"] = args => ["com.pogo.flash.FlashRoomConnect.connectRoom"] = args =>
RoomInfo(Str(args, 0)), RoomInfo(RoomManager.Find(Str(args, 0))),
// RoomTabs.playNow -> playNow(gameCode, siteCode, lkey, tabIdx, ruleIdx) // RoomTabs.playNow -> playNow(gameCode, siteCode, lkey, tabIdx, ruleIdx)
["com.pogo.flash.FlashRoomConnect.playNow"] = args => ["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 // 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 if (room is null) return new { errorMsg = notFound };
roomInfo = new { humanName = "NotPogo Room", key = roomKey, host = "downld.pogo.com", port = 11200 }, 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) => app.MapPost("/flashservices/gateway", async (HttpContext ctx, ILoggerFactory lf) =>
{ {
var log = lf.CreateLogger("FlashGateway"); var log = lf.CreateLogger("FlashGateway");

View File

@ -1,5 +1,7 @@
using Microsoft.AspNetCore.StaticFiles; using Microsoft.AspNetCore.StaticFiles;
using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.FileProviders;
using NotPogo.Core;
using NotPogo.Core.Rooms;
namespace NotPogo namespace NotPogo
{ {
@ -7,8 +9,11 @@ namespace NotPogo
{ {
public static void Main(string[] args) public static void Main(string[] args)
{ {
var builder = WebApplication.CreateBuilder(args); var builder = WebApplication.CreateBuilder(args);
Sessions.Init(builder.Configuration["SessionSecret"] ?? "change-me");
// Add services to the container. // Add services to the container.
builder.Services.AddControllersWithViews(); builder.Services.AddControllersWithViews();
@ -47,6 +52,11 @@ namespace NotPogo
app.MapControllers(); app.MapControllers();
app.MapFlashGateway(); 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(); app.Run();
} }
} }

View File

@ -1,6 +1,7 @@
using System.Net.Sockets; using System.Net.Sockets;
using System.Net; using System.Net;
using NotPogo.Core; using NotPogo.Core;
using NotPogo.Core.Rooms;
namespace NotPogo namespace NotPogo
{ {
@ -16,9 +17,12 @@ namespace NotPogo
public static List<TcpClient> Clients { get; } = []; public static List<TcpClient> Clients { get; } = [];
public static BowlingGame? SingleplayerBowlingGame; public static BowlingGame? SingleplayerBowlingGame;
static readonly HashSet<string> RoomOps = ["say", "rm.left", "disc", "gm.join"];
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
_tcpPort = _configuration.GetValue<int>("GameServerPort"); _tcpPort = _configuration.GetValue<int>("GameServerPort");
Listener = new(IPAddress.Any, _tcpPort); Listener = new(IPAddress.Any, _tcpPort);
Listener.Start(); Listener.Start();
_logger.LogInformation("Game Server Listening On Port {Port}", _tcpPort); _logger.LogInformation("Game Server Listening On Port {Port}", _tcpPort);
@ -33,9 +37,12 @@ namespace NotPogo
private async Task HandleClientAsync(TcpClient client) private async Task HandleClientAsync(TcpClient client)
{ {
Player? player = null;
using (client) using (client)
await using (var stream = client.GetStream()) await using (var stream = client.GetStream())
{ {
player = new(stream);
try try
{ {
while (true) while (true)
@ -47,64 +54,30 @@ namespace NotPogo
} }
catch (EndOfStreamException) { break; } 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; 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 if (op == "logn") // Login Request
{ {
// we know this is a compatible client now so let's add it to the client list // we know this is a compatible client now so let's add it to the client list
Clients.Add(client); Clients.Add(client);
// send logn response chain // handle logn
await SendAsync(stream, ("msg.op", "logn"), ("flags", "65535"), ("time", DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString())); await HandleLoginAsync(player, received);
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");
} }
else if (op == "gm.join") // Room Join Request else if (op == "ping")
{ {
// send gm.join response chain player.LastSeen = DateTime.UtcNow;
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)");
} }
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 // 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"); _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); Clients.Remove(client);
_logger.LogInformation("Client Disconnected"); _logger.LogInformation("Client Disconnected");
} }
private async Task HandleLoginAsync(Player player, Dictionary<string, string> 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) public static Task SendAsync(NetworkStream stream, params (string key, string value)[] fields)
{ {
var msg = new Dictionary<string, string>(); var msg = new Dictionary<string, string>();