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 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($"<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")]

View File

@ -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 <applet> 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", "" },

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;
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<string, Func<List<object?>, 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<object>(), // stored, never read by the SWF
ruleTabs = new[]
{
new { id = "classic", index = 0 },
new { id = "rally", index = 1 },
new { id = "advanced", index = 2 },
}, // [{ id = <ruleId>, 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");

View File

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

View File

@ -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<TcpClient> Clients { get; } = [];
public static BowlingGame? SingleplayerBowlingGame;
static readonly HashSet<string> RoomOps = ["say", "rm.left", "disc", "gm.join"];
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_tcpPort = _configuration.GetValue<int>("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<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)
{
var msg = new Dictionary<string, string>();