feat(game-server): scaffold dedicated server toward player join
Build Game Server / build (push) Successful in 17s

R&D branch for an authoritative Unreal game server (Prospect.Unreal).
The connection handshake already reaches PlayerController spawn; this
commit pushes it toward real players joining:

- Program.cs: target the Cycle station map + game mode (configurable via
  PROSPECT_MAP/GAMEMODE/PORT), proper host loop, and initialise a
  GameSession so NMT_Join -> Login no longer fails ("GameSession is null").
- UWorld.WelcomePlayer: send the world's real map/game mode to the client
  instead of the UE ThirdPerson template.
- Dockerfile.gameserver + .gitea/workflows/game-server.yml: build/push a
  separate image (the-cycle-game) on the game-server branch, ntfy notify.
- GAMESERVER.md: honest state + roadmap (player spawn + movement
  replication need live client testing; AI/loot intentionally deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-14 18:30:09 +02:00
co-authored by Claude Opus 4.8
parent 57a45d91df
commit 9749828af8
5 changed files with 149 additions and 25 deletions
+38 -19
View File
@@ -1,4 +1,5 @@
using Prospect.Unreal.Core;
using Prospect.Unreal.Core;
using Prospect.Unreal.Net.Actors;
using Prospect.Unreal.Runtime;
using Serilog;
@@ -7,10 +8,10 @@ namespace Prospect.Server.Game;
internal static class Program
{
private const float TickRate = (1000.0f / 60.0f) / 1000.0f;
private static readonly ILogger Logger = Log.ForContext(typeof(Program));
private static readonly PeriodicTimer Tick = new PeriodicTimer(TimeSpan.FromSeconds(TickRate));
public static async Task Main()
{
Console.CancelKeyPress += (_, e) =>
@@ -18,37 +19,55 @@ internal static class Program
Tick.Dispose();
e.Cancel = true;
};
Log.Logger = new LoggerConfiguration()
.MinimumLevel.Verbose()
.Enrich.FromLogContext()
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] ({SourceContext,-52}) {Message:lj}{NewLine}{Exception}")
.CreateLogger();
Logger.Information("Starting Prospect.Server.Game");
// Prospect:
// Map: /Game/Maps/MP/Station/Station_P
// GameMode: /Script/Prospect/YGameMode_Station
var worldUrl = new FUrl
{
Map = "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap"
};
// The Cycle: Frontier authoritative game server.
// We start on the station map (the simplest shared space to get two players spawned
// and moving); the raid maps (Bright Sands, …) come once spawn + movement replication
// work end to end. AI and loot are intentionally out of scope for now.
var map = Environment.GetEnvironmentVariable("PROSPECT_MAP") ?? "/Game/Maps/MP/Station/Station_P";
var gameMode = Environment.GetEnvironmentVariable("PROSPECT_GAMEMODE") ?? "/Script/Prospect/YGameMode_Station";
var port = int.TryParse(Environment.GetEnvironmentVariable("PROSPECT_PORT"), out var p) ? p : 7777;
Logger.Information("Starting Prospect.Server.Game — map={Map} gameMode={GameMode} port={Port}", map, gameMode, port);
var worldUrl = new FUrl { Map = map, Port = port };
worldUrl.Options.Add($"game={gameMode}");
await using (var world = new ProspectWorld())
{
world.SetGameInstance(new UGameInstance());
world.SetGameMode(worldUrl);
// The game mode needs a GameSession, otherwise NMT_Join -> SpawnPlayActor -> Login
// fails with "GameSession is null" and the player is never spawned.
var authGameMode = world.GetAuthGameMode();
if (authGameMode != null && authGameMode.GameSession == null)
{
authGameMode.GameSession = new AGameSession();
}
world.InitializeActorsForPlay(worldUrl, true);
world.Listen();
if (!world.Listen())
{
Logger.Fatal("Failed to start listening (port {Port} already in use?)", port);
return;
}
Logger.Information("Server listening on :{Port}. Waiting for players…", port);
while (await Tick.WaitForNextTickAsync())
{
world.Tick(TickRate);
}
}
Logger.Information("Shutting down");
}
}
}
+7 -6
View File
@@ -434,13 +434,14 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
private void WelcomePlayer(UNetConnection connection)
{
// TODO: Properly fetch level name from CurrentLevel
var levelName = "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap";
// TODO: Properly fetch from AuthorityGameMode
var gameName = "/Script/ThirdPersonMP.ThirdPersonMPGameMode";
// Tell the client which level + game mode to travel to. Previously hardcoded to the
// UE ThirdPerson template; now use the world's configured map and the "game=" option
// (set by the host), falling back to the template if unset.
var levelName = string.IsNullOrEmpty(Url.Map) ? "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap" : Url.Map;
var gameName = Url.GetOption("game=", "/Script/ThirdPersonMP.ThirdPersonMPGameMode") ?? string.Empty;
var redirectUrl = string.Empty;
Logger.Information("Welcoming player -> level={Level} game={Game}", levelName, gameName);
NMT_Welcome.Send(connection, levelName, gameName, redirectUrl);
connection.FlushNet();