From 9749828af81cb7804825f98834777e58200e55d6 Mon Sep 17 00:00:00 2001 From: neckfire Date: Tue, 14 Jul 2026 18:30:09 +0200 Subject: [PATCH] feat(game-server): scaffold dedicated server toward player join 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 --- .gitea/workflows/game-server.yml | 28 ++++++++++++ Dockerfile.gameserver | 14 ++++++ GAMESERVER.md | 62 +++++++++++++++++++++++++++ src/Prospect.Server.Game/Program.cs | 57 ++++++++++++++++-------- src/Prospect.Unreal/Runtime/UWorld.cs | 13 +++--- 5 files changed, 149 insertions(+), 25 deletions(-) create mode 100644 .gitea/workflows/game-server.yml create mode 100644 Dockerfile.gameserver create mode 100644 GAMESERVER.md diff --git a/.gitea/workflows/game-server.yml b/.gitea/workflows/game-server.yml new file mode 100644 index 0000000..cfba6ca --- /dev/null +++ b/.gitea/workflows/game-server.yml @@ -0,0 +1,28 @@ +name: Build Game Server +on: + push: + branches: [game-server] + workflow_dispatch: {} +env: + IMAGE: git.nfteam.ovh/neckfire/the-cycle-game +jobs: + build: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Build & push image + run: | + set -e + echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin + SHA="${GITHUB_SHA::12}" + docker build -t "${IMAGE}:game-server" -t "${IMAGE}:${SHA}" -f Dockerfile.gameserver . + docker push --all-tags "${IMAGE}" + - name: Notify ntfy + if: always() + run: | + if [ "${{ job.status }}" = "success" ]; then EMOJI="white_check_mark"; PRIO="default"; else EMOJI="rotating_light"; PRIO="high"; fi + curl -s -H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" -H "Title: ${GITHUB_REPOSITORY} game-server — ${{ job.status }}" \ + -H "Priority: ${PRIO}" -H "Tags: ${EMOJI}" -H "Click: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions" \ + -d "${GITHUB_WORKFLOW} (${GITHUB_REF_NAME} #${GITHUB_RUN_NUMBER}) : ${{ job.status }}" \ + "${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true diff --git a/Dockerfile.gameserver b/Dockerfile.gameserver new file mode 100644 index 0000000..3cc6d4c --- /dev/null +++ b/Dockerfile.gameserver @@ -0,0 +1,14 @@ +# The Cycle: Frontier — serveur de jeu dédié (EXPÉRIMENTAL / R&D). +# Réimplémentation du serveur autoritaire Unreal (Prospect.Unreal). Build depuis les sources. +FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build +WORKDIR /src +COPY src/ ./src/ +RUN dotnet publish src/Prospect.Server.Game/Prospect.Server.Game.csproj -c Release -o /app + +FROM mcr.microsoft.com/dotnet/runtime:8.0 +WORKDIR /app +COPY --from=build /app ./ +# Le réseau Unreal est en UDP. +EXPOSE 7777/udp +# Configurable : PROSPECT_MAP / PROSPECT_GAMEMODE / PROSPECT_PORT +ENTRYPOINT ["dotnet", "Prospect.Server.Game.dll"] diff --git a/GAMESERVER.md b/GAMESERVER.md new file mode 100644 index 0000000..a4bb35b --- /dev/null +++ b/GAMESERVER.md @@ -0,0 +1,62 @@ +# Serveur de jeu dédié — R&D (branche `game-server`) + +> ⚠️ **Expérimental.** Objectif : un serveur de jeu **autoritaire** pour que plusieurs +> joueurs soient dans la **même instance** (se voir, bouger). C'est un chantier de +> reverse-engineering du serveur Unreal du Cycle. **Un raid co-op complet reste hors de portée +> réaliste** ; on avance par jalons. **IA et loot volontairement hors périmètre pour l'instant.** + +## Pièces en jeu +- **`Prospect.Unreal`** — réimplémentation en C# de la couche réseau d'Unreal Engine + (NetDriver UDP, channels control/actor, bunches, packet handler, handshake, `UWorld`, + `AGameModeBase`/`APlayerController`/`APawn`). +- **`Prospect.Server.Game`** — l'exécutable serveur (host loop, monde, game mode). + +## État actuel (ce qui marche côté serveur) +Le **handshake de connexion Unreal est implémenté** et va jusqu'au spawn du PlayerController : + +``` +NMT_Hello → SendChallenge +NMT_Login → PreLogin → WelcomePlayer (envoie map + game mode) +NMT_Join → SpawnPlayActor → GameMode.Login → APlayerController +``` + +Corrections/avancées de cette branche : +- **Cible la map/gamemode du Cycle** (`/Game/Maps/MP/Station/Station_P` + `YGameMode_Station`) + au lieu de la map template d'UE. Configurable via `PROSPECT_MAP` / `PROSPECT_GAMEMODE` / `PROSPECT_PORT`. +- **`WelcomePlayer`** envoie désormais la **vraie** map/gamemode du monde (plus le template). +- **`GameSession`** est initialisée → le login ne plante plus sur `"GameSession is null"` + (c'était le point de blocage juste avant le spawn). + +## Ce qui manque (roadmap, du plus atteignable au plus dur) +1. **Connexion client réelle** : valider le handshake complet avec le **vrai client** (pas le + harnais `Client.cs`). Nécessite des **tests en live** (impossible à valider hors client). +2. **Spawn du Pawn du Cycle** : `GameMode.Login` spawn un `APlayerController` mais **pas** le + personnage. Il faut spawner la **classe de Pawn spécifique du Cycle** (`YCharacter…`) avec le + bon **NetGUID / class path** pour que le client l'instancie. +3. **Réplication du mouvement** : répliquer les propriétés du `CharacterMovementComponent` + (position/rotation/état) chaque tick → **le premier vrai « se voir bouger »**. +4. *(plus tard)* IA, loot, dégâts, tempête, évac… — **hors périmètre pour l'instant**. + +Les jalons 2–3 demandent de connaître les **classes répliquées du jeu** (côté client, non +présentes dans le code serveur) et **itèrent en live** avec le client. C'est le vrai mur. + +## Build / run +```bash +# build +dotnet build src/Prospect.Server.Game/Prospect.Server.Game.csproj -c Release +# run (défauts : station, port 7777 UDP) +dotnet run --project src/Prospect.Server.Game +# ou conteneur +docker build -t the-cycle-game -f Dockerfile.gameserver . +docker run --rm -p 7777:7777/udp the-cycle-game +``` + +## CI/CD +`.gitea/workflows/game-server.yml` : à chaque push sur `game-server`, build de `Dockerfile.gameserver` +→ image **`git.nfteam.ovh/neckfire/the-cycle-game`** (tags `game-server` + sha) + notif ntfy. +Image **séparée** de l'API (`the-cycle`) — les deux ne se marchent pas dessus. + +## Honnêteté +Ceci est une **base d'exploration**. Le handshake + le spawn du controller avancent ; le +« 2 joueurs se voient bouger » dépend du spawn du pawn du Cycle + réplication, qui exige du RE +spécifique au jeu **et** des tests dans le client réel. Aucune garantie d'aboutir. diff --git a/src/Prospect.Server.Game/Program.cs b/src/Prospect.Server.Game/Program.cs index 811d2b6..f69e584 100644 --- a/src/Prospect.Server.Game/Program.cs +++ b/src/Prospect.Server.Game/Program.cs @@ -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"); } -} \ No newline at end of file +} diff --git a/src/Prospect.Unreal/Runtime/UWorld.cs b/src/Prospect.Unreal/Runtime/UWorld.cs index 0dc631c..34db3f9 100644 --- a/src/Prospect.Unreal/Runtime/UWorld.cs +++ b/src/Prospect.Unreal/Runtime/UWorld.cs @@ -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();