chore(game-server): merge main (emulator fixes: contracts, shop, fortuna, friends, admin, presence) #16

Merged
neckfire merged 35 commits from main into game-server 2026-07-16 08:25:21 +00:00
37 changed files with 1905 additions and 311 deletions
+215 -188
View File
@@ -1,222 +1,249 @@
# Prospect <!-- omit in toc --> # The Cycle: Frontier — serveur privé (émulateur Prospect)
Also known as "The Cycle: Frontier". Émulateur des services en ligne de **The Cycle: Frontier** (jeu retiré de Steam en
septembre 2022), permettant de rejouer en solo sur un serveur auto-hébergé.
## Table of Contents <!-- omit in toc --> Fork de [`deiteris/Prospect`](https://github.com/deiteris/Prospect), figé sur le
**Build 8 / client Saison 2**, buildé depuis les sources et déployé en conteneur via
- [Features](#features) une CI Gitea.
- [Running locally](#running-locally)
- [1. Prerequisites](#1-prerequisites) > **Ce n'est pas du multijoueur.** Le raid tourne **côté client** (station solo) : chacun
- [1.1 How to download Season 2 client from SteamDB using Steam console](#11-how-to-download-season-2-client-from-steamdb-using-steam-console) > joue sa propre instance. Le serveur partage la progression, les comptes et les boutiques,
- [2. Unpack `Prospect.Server.Api`](#2-unpack-prospectserverapi) > pas la partie. Le vrai multi (squad en raid, voix de proximité) suppose un serveur de jeu
- [3. Generate and import SSL certificate](#3-generate-and-import-ssl-certificate) > Unreal dédié — voir [Hors-périmètre & R&D](#hors-périmètre--rd).
- [4. Extract `LoaderPack` to the game](#4-extract-loaderpack-to-the-game)
- [5. Run the server](#5-run-the-server) ---
- [6. Run the game](#6-run-the-game)
- [Troubleshooting and FAQ](#troubleshooting-and-faq) ## Sommaire
- [How to remove the certificate?](#how-to-remove-the-certificate)
- [`generate_ssl.exe` is flagged as a virus](#generate_sslexe-is-flagged-as-a-virus) - [Comment ça marche](#comment-ça-marche)
- [Body parts are missing with Season 3 client](#body-parts-are-missing-with-season-3-client) - [Structure du dépôt](#structure-du-dépôt)
- [Prospect.Server.Api does not start](#prospectserverapi-does-not-start) - [Build & lancement du serveur](#build--lancement-du-serveur)
- [Login Failed. Error code: 3](#login-failed-error-code-3) - [Certificat TLS](#certificat-tls)
- [Login Failed. Error code: 5](#login-failed-error-code-5) - [CI/CD](#cicd)
- [Development](#development) - [Config du client (switch de serveur + certificat)](#config-du-client-switch-de-serveur--certificat)
- [État des fonctionnalités](#état-des-fonctionnalités)
- [Hors-périmètre & R&D](#hors-périmètre--rd)
- [Crédits & licence](#crédits--licence)
---
## Comment ça marche
Le client officiel parle à PlayFab. On l'intercepte côté client et on le redirige vers
notre serveur, qui réimplémente juste ce qu'il faut de PlayFab (auth Steam, CloudScript,
UserData/TitleData, matchmaking solo).
```mermaid
flowchart LR
subgraph client [Poste de jeu]
L[Prospect.Client.Loader<br/>injecte l'agent] --> A[Prospect.Agent<br/>hooke l'URL PlayFab]
A -- lit --> B[backend.txt]
A --> G[Jeu &#40;TCF&#41;]
end
G -- HTTPS / SignalR --> API[Prospect.Server.Api<br/>émulateur PlayFab]
API --> M[(MongoDB)]
```
- **`Prospect.Client.Loader`** lance le jeu en injectant l'agent.
- **`Prospect.Agent`** hooke l'URL de l'API PlayFab et la remplace par le contenu de
**`backend.txt`** (placé dans `Prospect/Binaries/Win64`). Absent → fallback
`https://127.0.0.1:8443`.
- **`Prospect.Server.Api`** émule PlayFab (auth Steam → JWT, CloudScript, données joueur)
et pousse le temps-réel via **SignalR**. Les données vivent dans **MongoDB**.
Toute la redirection du client tient donc dans **une seule valeur** (`backend.txt`) — gérée
par l'outil [`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
---
## Structure du dépôt
| Projet | Rôle |
|---|---|
| **`Prospect.Server.Api`** | Cœur : émulateur PlayFab (ASP.NET 8). Controllers Client/CloudScript/Multiplayer, services Auth/UserData/TitleData/Database(Mongo)/Qos, hub SignalR. |
| **`Prospect.Steam`** | Validation du ticket Steam (auth). |
| **`Prospect.Client.Loader`** | Loader C++ : lance le jeu et injecte l'agent. |
| **`Prospect.Agent`** | Agent C++ injecté : hooke l'URL PlayFab → `backend.txt`. |
| **`Prospect.Client.Config`** | Utilitaire multi-OS : écrit `backend.txt`, importe le certificat, lance le jeu. Voir son [README](src/Prospect.Client.Config/README.md). |
| **`Prospect.Server.Game`** | Serveur de jeu dédié (squelette, **R&D**). |
| **`Prospect.Unreal[.Generator/.Tests]`** | Réimplémentation C# du netcode Unreal (**R&D** serveur dédié). |
| `utils/` | `generate_ssl.py` — génération du certificat auto-signé. |
## Features Build config **`Season 2 Release`** obligatoire pour l'API (le code sélectionne la saison
via `#if SEASON_2_RELEASE` / `SEASON_3_RELEASE` → sinon `#error Unsupported build type`).
* [x] Basic login with Steam ---
* [x] EULA acceptance
* [x] Tutorial
* [x] Single-player station (Season 2 and Season 3):
* [x] Onboarding
* [ ] Matchmaking and deployment
* [x] Solo
* [ ] Squad
* [ ] Items insurance
* [ ] Free loadouts (Season 3)
* [x] Inventory and loadout
* [ ] Loadout presets (Season 3)
* [x] Quests
* [x] Faction progression
* [ ] Season pass
* [ ] Aurum Shops
* [ ] Daily shop
* [ ] Weekly shop
* [ ] Shop rotation
* [x] Daily login
* [x] Character appearance and emotes
* [x] Item Shops
* [x] Crafting station
* [x] Quarters
* [x] Player balance
* [ ] Social features
* [ ] Proximity voice
* [x] Vivox login
* [x] Vivox create and join channel
* [ ] Proximity voice works
* [x] Game mechanics
* [x] Can deploy through terminal
* [x] Can deploy with loadout
* [x] Can evac
* [x] Can do quests (except PvP)
* [x] Can gain/lose loot
* [x] Can use Alien Forge
* [x] Map content
* [x] Bright Sands
* [x] Crescent Falls
* [x] Tharis Island
## Running locally ## Build & lancement du serveur
> [!NOTE] Le serveur tourne en conteneur. L'image est buildée depuis les sources par le
> If you've already done all steps previously, you can skip to Step 7. [`Dockerfile`](Dockerfile) (multi-stage SDK .NET 8 → runtime aspnet 8, publish en
`Season 2 Release`).
### 1. Prerequisites ### Build de l'image
```bash
docker build -t the-cycle .
```
### Lancement
Il faut une **instance MongoDB** joignable et un **certificat TLS** monté (voir section
suivante). Variables d'environnement :
| Variable | Rôle |
|---|---|
| `DatabaseSettings__ConnectionString` | URI de connexion MongoDB. |
| `DatabaseSettings__DatabaseName` | Base à utiliser (ex. `ProspectDb`). |
| `AuthTokenSettings__Secret` | Secret de signature des JWT émis par le serveur. |
| `PlayFabSettings__SignalRURL` | URL SignalR **telle que le client doit l'atteindre** (voir gotcha ci-dessous). |
| `Kestrel__Certificates__Default__Path` | Chemin du `.pfx` dans le conteneur. |
| `Kestrel__Endpoints__Https__Url` | ex. `https://0.0.0.0:8443`. |
| `SteamWebApiKey` | *(optionnel)* clé Steam Web API pour récupérer les pseudos ; inerte si absente. |
> [!WARNING] ```bash
> The latest Steam version of The Cycle: Frontier currently does not work with Windows 11 24H2! docker run -d --name the-cycle-api \
-e DatabaseSettings__ConnectionString="mongodb://user:pass@HOST:27017/?authSource=ProspectDb" \
-e DatabaseSettings__DatabaseName="ProspectDb" \
-e AuthTokenSettings__Secret="<secret>" \
-e PlayFabSettings__SignalRURL="https://<host-public>:8443/signalr/?hub=pubsub" \
-e Kestrel__Endpoints__Https__Url="https://0.0.0.0:8443" \
-e Kestrel__Certificates__Default__Path="/certs/certificate.pfx" \
-v "$PWD/certs:/certs:ro" \
-p 8443:8443 \
the-cycle
```
> ⚠️ **`PlayFabSettings__SignalRURL` = l'URL que le CLIENT doit joindre**, pas `127.0.0.1`.
> Le serveur y renvoie le client pour l'event de matchmaking ; s'il pointe sur `127.0.0.1`,
> le déploiement en raid **timeout**. Mets le hostname/IP public du serveur.
### Dev local (.NET)
```bash
dotnet build src/Prospect.Server.Api/Prospect.Server.Api.csproj -c "Season 2 Release"
dotnet run --project src/Prospect.Server.Api -c "Season 2 Release"
```
---
## Certificat TLS
La connexion est en HTTPS et le client valide le certificat → il doit être **auto-signé et
fait confiance** côté client. Générer le `.pfx` avec `utils/generate_ssl.py`.
> ⚠️ **Le SAN doit contenir `DNS:<ip>` ET `IP:<ip>`**, en plus des hostnames.
> Le HTTP du jeu (libcurl) accepte l'IP en SAN IP, mais le WebSocket (libwebsockets) valide
> l'IP contre les SAN **DNS** → sans `DNS:<ip>`, la connexion SignalR échoue
> (`Hostname mismatch err=62`). Inclure aussi `2EA46.playfabapi.com`, `localhost`,
> `127.0.0.1` et tous les hostnames publics utilisés dans `backend.txt`.
Côté client, l'import du certificat est automatisé par
[`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
> [!IMPORTANT] ---
> You must have The Cycle: Frontier from Steam in your Steam library to be able to download it.
> Otherwise, the download will fail with an error message about missing license.
Before you start, you'll need the following software downloaded and installed: ## CI/CD
1. [MongoDB Community Edition](https://fastdl.mongodb.org/windows/mongodb-windows-x86_64-8.0.4-signed.msi). [`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) — sur push `main` / `preprod`
(ou `workflow_dispatch`) :
1. [`Prospect.Server.Api` and `LoaderPack`](https://github.com/deiteris/Prospect/releases) from the Releases section: 1. build de l'image depuis le `Dockerfile` ;
2. push sur le registry `git.nfteam.ovh/neckfire/the-cycle` ;
3. notification du résultat (ntfy).
- For Season 3 (the latest Steam game client), use Build 6. **Modèle de branches :**
- For Season 2 game client, use the latest version. | Branche | Tag image | Usage |
|---|---|---|
| `preprod` | `:preprod` (+ `:preprod-<sha>`) | banc de test — valider un build avant de merger |
| `main` | `:latest` (+ `:<sha>`) | production |
1. The Cycle: Frontier game client: Workflow type : coder → push `preprod` → tester sur le serveur preprod → **PR `preprod → main`**
→ la CI republie `:latest`. Le déploiement applique la nouvelle image
(`docker compose pull && docker compose up -d`).
- The latest version from [Steam](https://steamcommunity.com/app/868270). > `Prospect.Client.Config` (outil client, cross-OS) n'est **pas** buildé par cette CI —
> voir sa section publication.
- Season 2 client version `4623363103423775682` from SteamDB. See [download instructions below](#11-how-to-download-season-2-client-from-steamdb-using-steam-console). ---
#### 1.1 How to download Season 2 client from SteamDB using Steam console ## Config du client (switch de serveur + certificat)
> [!WARNING] L'outil **`Prospect.Client.Config`** (binaire `ProspectServerSwitcher`, multi-OS
> This will overwrite the existing client if you try to download a different manifest! Linux/Proton + Windows) fait tout le boulot côté client :
1. With Steam running, press `Win+R` and enter `steam://nav/console`. A Steam console will open. - écrit `backend.txt` (presets **prod** / **preprod** ou URL libre) ;
- récupère le certificat **en direct depuis le serveur ciblé** (TLS) et le rend fiable :
- **Windows** : import dans *Autorités de certification racines de confiance* (utilisateur) ;
- **Linux/Proton** : import direct dans le préfixe Wine du jeu via `wine reg import`
(car `wine certutil` est cassé sous Proton) ;
- lance le jeu.
1. Open [The Cycle: Frontier SteamDB manifests](https://steamdb.info/depot/868271/manifests/). ```bash
# menu interactif
ProspectServerSwitcher
1. Make sure you have **Copy format** set to **Steam console**. # scriptable
ProspectServerSwitcher --folder "<...>/Prospect/Binaries/Win64" --set preprod
ProspectServerSwitcher --set https://mon-serveur:8443
```
1. Press `CTRL+F` and enter `4623363103423775682` to find the manifest for Season 2 version 2.7.2 client. Détails complets, gotchas Proton (préfixe non-Steam) et commandes de publication des
binaires autonomes : **[src/Prospect.Client.Config/README.md](src/Prospect.Client.Config/README.md)**.
> Installation complète pas-à-pas pour un nouveau joueur (télécharger le client S2, le
> LoaderPack, importer le certificat) : **[FRIENDS-INSTALL.md](FRIENDS-INSTALL.md)**.
1. Click the ![Copy](./_assets/steamdb_copy.PNG) icon to copy the download command. ---
1. Paste the command in the Steam console and press `Enter`. ## État des fonctionnalités
1. The depot will begin downloading. You should receive a notification and the destination folder when the download is complete. **Fonctionne :**
### 2. Unpack `Prospect.Server.Api` - [x] Login Steam, EULA, tutoriel
- [x] Station solo (S2/S3) : onboarding, matchmaking & déploiement **solo**
- [x] Inventaire & loadout, stash, vente, réparation
- [x] Contrats / quêtes — y compris les objectifs **kills** et **de zone** (auto-crédités :
pas de serveur dédié pour remonter les events runtime du raid client-hosted)
- [x] Progression des factions
- [x] Season pass : claim + gain d'XP de saison (niveau Fortuna)
- [x] Boutiques d'items (Korolev / ICA / Osiris / QuickShop / CraftingStation)
- [x] Aurum Shop (cosmétiques) + rotation daily/weekly
- [x] Craft, Quarters, solde joueur, connexion quotidienne
- [x] Apparence & emotes
- [x] Assurance : débit de la prime au déploiement + payout à la mort
- [x] Stats de carrière (valeurs à 0 — non traçables sans serveur de jeu)
- [x] Présence des amis (en ligne / en raid)
- [x] Pseudos réels via Steam Web API (le client n'envoie que le SteamID)
- [x] Cartes : Bright Sands, Crescent Falls, Tharis Island
Use your favorite ZIP archiver and unzip the `Prospect.Server.Api.zip` downloaded from this repository. **Non implémenté / hors-périmètre :**
### 3. Generate and import SSL certificate - [ ] Squad / multi dans le **même** raid — nécessite un serveur de jeu dédié
- [ ] Voix de proximité (login/join Vivox = placeholders)
- [ ] Free loadouts & presets (Saison 3 uniquement)
- [ ] Achat de cosmétiques (endpoint d'achat vanity distinct, non câblé)
- [ ] Catalogue des récompenses Fortuna (DataTable côté client, dans des paks chiffrés)
- [ ] Défis quotidiens Fortuna
> [!IMPORTANT] ---
> Do not share the generated certificate! Generated certificate includes a private key that may be used to generate other certificates and compromise your security.
A connection to the server is served over a secured connection. The server uses self-signed certificate that must be added to trusted authorities in order for the game ## Hors-périmètre & R&D
to successfully communicate with the local server. Do the following:
1. Open the folder with `Prospect.Server.Api`. Un **serveur de jeu Unreal dédié** (`Prospect.Server.Game` + `Prospect.Unreal`, branche
`game-server`) est en cours de reverse-engineering pour, à terme, permettre le vrai multi.
État : handshake stateless UE, séquençage et décodage des bunches **franchis**, canal de
contrôle ouvert, `NMT_Hello` parsé. **Bloqué** sur le chiffrement **DTLS-PSK** du client
(clé dérivée du `user_id`), derrière un exe packé (BattlEye) → la dérivation n'est pas
extractible statiquement. Détails dans `NETCODE-RND.md` (branche `game-server`).
1. Double-click `generate_ssl.exe`. `certificate.pfx` will appear in the same folder. Le « lobby squad » via l'API seule n'est **pas faisable** : l'invitation d'amis est gérée
100 % côté client Steam.
1. Double-click `certificate.pfx`. The Certificate Import Wizard will open: ---
1. Select **Current User** under Store Location and click **Next**. ## Crédits & licence
1. Leave **File to Import** unchanged and click **Next**. Fork de [`deiteris/Prospect`](https://github.com/deiteris/Prospect) (lui-même issu du
projet Prospect original). Voir [`LICENSE`](LICENSE). Usage privé.
1. Leave **Password** empty and click **Next**.
1. Select **Place all certificates in the following store** > **Browse...**. Choose **Trusted Root Certification Authorities** and click **OK**. Click **Next**.
1. Click **Finish**. A **Security Warning** popup may appear, make sure it specifies `2EA46.playfabapi.com` certification authority and click **Yes**.
### 4. Extract `LoaderPack` to the game
1. Open the folder with The Cycle: Frontier and navigate to **Prospect** > **Binaries** > **Win64**.
1. Open the `LoaderPack` archive.
1. Drag and drop the contents of the `LoaderPack` archive to the game.
1. Create a shortcut for the `Prospect.Client.Loader` that you will use later to launch the game.
### 5. Run the server
Now you are all set! Open the folder with `Prospect.Server.Api` and run `Prospect.Server.Api.exe`. It will open a console if it runs successfully.
> [!IMPORTANT]
> Do not close the console when you run the game.
### 6. Run the game
Once the server is running, make sure that Steam is running and open The Cycle: Frontier using the shortcut you've created before.
## Troubleshooting and FAQ
### How to remove the certificate?
If you've installed the certificate for the **Current User**:
1. Open **Start** and enter `certmgr.msc`.
1. Expand **Trusted Root Certification Authorities** and select **Certificates**.
1. Find `2EA46.playfabapi.com`, right-click it > **Delete**.
If you've installed the certificate for the **Local Machine**, repeat the same steps but instead open `certlm.msc`.
### `generate_ssl.exe` is flagged as a virus
`generate_ssl.exe` is a Python application packed with PyInstaller and some anti-viruses may flag it as a virus.
This application is a simple certificate generator and you can find its source code in `utils/generate_ssl.py`.
### Body parts are missing with Season 3 client
Currently, the server loads body part IDs for Season 2 by default, so this is expected. You can fix this by going to station and changing your character appearance. This will store the updated body part IDs for your character.
### Prospect.Server.Api does not start
Make sure you have [.NET Runtime 8.0](https://aka.ms/dotnet-core-applaunch?missing_runtime=true&arch=x64&rid=win-x64&os=win10&apphost_version=8.0.11) and [ASP.NET Core 8.0](https://aka.ms/dotnet-core-applaunch?framework=Microsoft.AspNetCore.App&framework_version=8.0.0&arch=x64&rid=win-x64&os=win10) installed.
### Login Failed. Error code: 3
Make sure that:
* You have Steam running.
* You have created and **saved** the `steam_appid` file as described in step 6.
* The `steam_appid` file type is "TXT File".
### Login Failed. Error code: 5
Make sure that `Prospect.Server.Api` server is running.
If the server is running, press `Alt+Tab` to a game console that opens when you start the game and check for the following:
* `libcurl error 7 (Couldn't connect to server)` - indicates that the `Prospect.Server.Api` is not running.
![](./_assets/connection_refused_error.png)
* `InvalidAPIEndpoint` - indicates that you are running the game using the original shortcut and not using `Prospect.Client.Loader`.
![](./_assets/invalid_api_endpoint.PNG)
* `libcurl error 60 (Peer certificate cannot be authenticated with given CA certificates)` - indicates that the certificate was not installed correctly. Make sure that the certificate is present in `certmgr.msc` and there is only one certificate. Try removing the certificate and importing it again by following step 4.
![](./_assets/certificate_error.PNG)
* `HTTP code: 500` - usually indicates that MongoDB is not running. Make sure that MongoDB is installed and and that `MongoDB Server` is running in `services.msc`.
![](./_assets/mongodb_error.PNG)
## Development
TBD
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+404
View File
@@ -0,0 +1,404 @@
using System.Diagnostics;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
namespace Prospect.Client.Config;
// Cross-platform (Linux/Proton + Windows) helper to point a The Cycle: Frontier client at a
// server. The Prospect agent reads the target PlayFab URL from `backend.txt` next to the game
// executable, so switching servers = writing that one file. This tool also imports the server
// certificate and launches the game (natively on Windows, guided on Linux/Proton).
internal static class Program
{
private const string AppId = "868270";
// Presets use the public domains so the same target works for everyone (LAN or internet):
// DNS resolves to the public IP for friends, and the unified TLS cert covers both hostnames.
// On the same LAN, add a hosts entry (domain -> 192.168.1.136) to avoid hairpin NAT.
private static readonly (string Key, string Label, string Url)[] Presets =
{
("prod", "PROD", "https://tc.nfteam.ovh:8443"),
("preprod", "PREPROD", "https://rd-tc.nfteam.ovh:8444"),
};
private static string SettingsPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ProspectSwitcher", "settings.json");
private static Settings _settings = new();
private static int Main(string[] args)
{
try { Console.OutputEncoding = Encoding.UTF8; } catch { /* some terminals reject it */ }
LoadSettings();
return args.Length > 0 ? RunCli(args) : MenuLoop();
}
// ---- Scriptable CLI ----------------------------------------------------
private static int RunCli(string[] args)
{
string? folder = null, target = null;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--folder" when i + 1 < args.Length: folder = args[++i]; break;
case "--set" when i + 1 < args.Length: target = args[++i]; break;
case "-h" or "--help": PrintCliHelp(); return 0;
}
}
if (folder != null) { _settings.GameFolder = folder; SaveSettings(); }
if (target == null) { PrintCliHelp(); return folder != null ? 0 : 1; }
var url = ResolveTarget(target);
if (url == null) { Console.Error.WriteLine("Cible inconnue : " + target); return 1; }
return WriteBackend(url) ? 0 : 1;
}
private static void PrintCliHelp()
{
Console.WriteLine("ProspectServerSwitcher — sélecteur de serveur The Cycle (multi-OS)");
Console.WriteLine();
Console.WriteLine(" (sans argument) menu interactif");
Console.WriteLine(" --folder <chemin Win64> définit le dossier du jeu (mémorisé)");
Console.WriteLine(" --set <cible> écrit backend.txt");
Console.WriteLine();
Console.WriteLine("Cibles : " + string.Join(", ", Presets.Select(p => p.Key)) + ", ou une URL https://…");
Console.WriteLine("Ex : ProspectServerSwitcher --folder \"…/Prospect/Binaries/Win64\" --set preprod-lan");
}
private static string? ResolveTarget(string t)
{
foreach (var p in Presets)
if (string.Equals(p.Key, t, StringComparison.OrdinalIgnoreCase)) return p.Url;
if (t.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return t;
return null;
}
// ---- Interactive menu --------------------------------------------------
private static int MenuLoop()
{
while (true)
{
Console.WriteLine();
Console.WriteLine("=== The Cycle — Sélecteur de serveur ===");
Console.WriteLine("Dossier du jeu : " + (string.IsNullOrEmpty(_settings.GameFolder) ? "(non défini)" : _settings.GameFolder));
Console.WriteLine("backend.txt : " + ReadCurrentBackend());
Console.WriteLine();
Console.WriteLine(" 1) Définir le dossier du jeu (Win64)");
for (var i = 0; i < Presets.Length; i++)
Console.WriteLine($" {i + 2}) {Presets[i].Label,-24} {Presets[i].Url}");
var n = Presets.Length + 2;
Console.WriteLine($" {n}) Serveur personnalisé…");
Console.WriteLine($" {n + 1}) Importer un certificat…");
Console.WriteLine($" {n + 2}) Lancer le jeu");
Console.WriteLine(" 0) Quitter");
Console.Write("> ");
var raw = Console.ReadLine()?.Trim();
if (raw is null or "0") return 0;
if (!int.TryParse(raw, out var choice)) { Console.WriteLine("Choix invalide."); continue; }
if (choice == 1) SetFolder();
else if (choice >= 2 && choice < n) WriteBackend(Presets[choice - 2].Url);
else if (choice == n) SetCustom();
else if (choice == n + 1) ImportCert();
else if (choice == n + 2) LaunchGame();
else Console.WriteLine("Choix invalide.");
}
}
private static void SetFolder()
{
Console.Write("Chemin du dossier Win64 : ");
var p = Console.ReadLine()?.Trim().Trim('"');
if (string.IsNullOrEmpty(p)) return;
if (!Directory.Exists(p)) { Console.WriteLine("⚠ Dossier introuvable."); return; }
_settings.GameFolder = p;
SaveSettings();
Console.WriteLine("✔ Dossier mémorisé.");
}
private static void SetCustom()
{
Console.Write("URL du serveur (ex : https://mon-serveur:8443) : ");
var u = Console.ReadLine()?.Trim();
if (!string.IsNullOrWhiteSpace(u)) WriteBackend(u);
}
// ---- backend.txt -------------------------------------------------------
private static bool WriteBackend(string url)
{
if (!EnsureFolder(out var dir)) return false;
try
{
// No trailing newline: the agent reads the raw file and requires it to start with https://.
File.WriteAllText(Path.Combine(dir, "backend.txt"), url, new UTF8Encoding(false));
Console.WriteLine($"✔ backend.txt → {url}");
Console.WriteLine(" (relance le jeu : backend.txt n'est lu qu'au démarrage du loader.)");
return true;
}
catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); return false; }
}
private static string ReadCurrentBackend()
{
if (string.IsNullOrEmpty(_settings.GameFolder)) return "(dossier non défini)";
var f = Path.Combine(_settings.GameFolder, "backend.txt");
try
{
if (!File.Exists(f)) return "(absent)";
var v = File.ReadAllText(f).Trim();
var match = Presets.FirstOrDefault(p => p.Url == v);
return match.Key != null ? $"{v} [{match.Label.Trim()}]" : v;
}
catch { return "(illisible)"; }
}
// ---- Certificate -------------------------------------------------------
private static void ImportCert()
{
// Prefer fetching the cert live from the server the client points at (backend.txt), so
// it's always the current one; fall back to a .crt file.
X509Certificate2? cert = null;
var target = ReadBackendUrl();
if (target != null && TryFetchServerCert(target, out cert))
Console.WriteLine($"Certificat récupéré depuis {target}");
else
{
Console.Write("Chemin d'un certificat (.crt) : ");
var p = Console.ReadLine()?.Trim().Trim('"');
if (string.IsNullOrEmpty(p) || !File.Exists(p)) { Console.WriteLine("⚠ Annulé."); return; }
try { cert = new X509Certificate2(p); } catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); return; }
}
if (cert == null) { Console.WriteLine("⚠ Aucun certificat."); return; }
Console.WriteLine($" empreinte : {cert.Thumbprint}");
if (OperatingSystem.IsWindows()) ImportCertWindows(cert);
else ImportCertProton(cert);
}
private static string? ReadBackendUrl()
{
if (string.IsNullOrEmpty(_settings.GameFolder)) return null;
var f = Path.Combine(_settings.GameFolder, "backend.txt");
try { return File.Exists(f) ? File.ReadAllText(f).Trim() : null; } catch { return null; }
}
private static bool TryFetchServerCert(string url, out X509Certificate2? cert)
{
cert = null;
try
{
var uri = new Uri(url);
using var tcp = new System.Net.Sockets.TcpClient();
tcp.Connect(uri.Host, uri.Port <= 0 ? 443 : uri.Port);
X509Certificate2? captured = null;
using var ssl = new System.Net.Security.SslStream(tcp.GetStream(), false,
(_, c, _, _) => { if (c != null) captured = new X509Certificate2(c.Export(X509ContentType.Cert)); return true; });
ssl.AuthenticateAsClient(uri.Host);
cert = captured;
return cert != null;
}
catch { return false; }
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static void ImportCertWindows(X509Certificate2 cert)
{
try
{
using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadWrite);
store.Add(cert);
Console.WriteLine("✔ Certificat importé (Autorités racines de confiance, utilisateur).");
}
catch (Exception ex) { Console.WriteLine("✖ Import : " + ex.Message); }
}
// Trust the cert inside the game's Proton/Wine prefix by writing the serialized cert blob
// into the prefix Root store via `wine reg import` (the wine `certutil` tool is unreliable
// under Proton). The prefix is chosen explicitly, so it works even though the client runs
// as a non-Steam shortcut (whose appid isn't the game's 868270).
private static void ImportCertProton(X509Certificate2 cert)
{
var wine = FindProtonWine();
if (wine == null) { Console.WriteLine("⚠ Aucun wine Proton trouvé (Steam/Proton installé ?)."); return; }
var prefix = SelectPrefix();
if (prefix == null) return;
// Wine serialized cert blob: SHA1-hash property (id 3) + encoded cert property (id 0x20).
var der = cert.RawData;
var sha1 = cert.GetCertHash();
byte[] blob;
using (var ms = new MemoryStream())
{
void Prop(uint id, byte[] data)
{
ms.Write(BitConverter.GetBytes(id));
ms.Write(BitConverter.GetBytes(1u));
ms.Write(BitConverter.GetBytes((uint)data.Length));
ms.Write(data);
}
Prop(3, sha1);
Prop(0x20, der);
blob = ms.ToArray();
}
var thumb = Convert.ToHexString(sha1);
var hex = string.Join(",", blob.Select(b => b.ToString("x2")));
var regFile = Path.Combine(Path.GetTempPath(), "prospect-cert.reg");
File.WriteAllText(regFile,
"REGEDIT4\r\n\r\n" +
$"[HKEY_LOCAL_MACHINE\\Software\\Microsoft\\SystemCertificates\\Root\\Certificates\\{thumb}]\r\n" +
$"\"Blob\"=hex:{hex}\r\n");
Console.WriteLine("⚠ Ferme complètement le jeu avant de continuer (sinon le registre ne sera pas pris en compte).");
Console.Write("Appuie sur Entrée pour importer… ");
Console.ReadLine();
var wineserver = Path.Combine(Path.GetDirectoryName(wine)!, "wineserver");
var regWinePath = "Z:" + regFile.Replace('/', '\\'); // Z: maps to / inside the prefix
RunWine(wineserver, "-k", prefix); // stop any running wineserver (flush)
RunWine(wine, $"reg import \"{regWinePath}\"", prefix);
RunWine(wineserver, "-k", prefix); // flush the change to disk
var sysreg = Path.Combine(prefix, "system.reg");
var ok = File.Exists(sysreg) && File.ReadAllText(sysreg).Contains(thumb, StringComparison.OrdinalIgnoreCase);
Console.WriteLine(ok
? $"✔ Certificat importé dans le préfixe : {prefix}\n Relance le jeu."
: "✖ Import non confirmé — assure-toi que le jeu est fermé et réessaie.");
}
private static void RunWine(string exe, string args, string prefix)
{
try
{
var psi = new ProcessStartInfo(exe, args)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.Environment["WINEPREFIX"] = prefix;
psi.Environment["WINEDEBUG"] = "-all";
using var p = Process.Start(psi);
p?.WaitForExit(20000);
}
catch (Exception ex) { Console.WriteLine(" (wine) " + ex.Message); }
}
private static string? FindProtonWine()
{
foreach (var root in SteamRoots())
{
var common = Path.Combine(root, "steamapps", "common");
if (!Directory.Exists(common)) continue;
var candidates = Directory.GetDirectories(common, "Proton*")
.Select(d => Path.Combine(d, "files", "bin", "wine"))
.Where(File.Exists)
.OrderByDescending(w => w.Contains("Experimental"))
.ThenByDescending(File.GetLastWriteTimeUtc)
.ToList();
if (candidates.Count > 0) return candidates[0];
}
return null;
}
private static string? SelectPrefix()
{
if (!string.IsNullOrEmpty(_settings.WinePrefix) && Directory.Exists(_settings.WinePrefix))
return _settings.WinePrefix;
var prefixes = new List<string>();
foreach (var root in SteamRoots())
{
var cd = Path.Combine(root, "steamapps", "compatdata");
if (!Directory.Exists(cd)) continue;
foreach (var d in Directory.GetDirectories(cd))
{
var pfx = Path.Combine(d, "pfx");
if (File.Exists(Path.Combine(pfx, "system.reg"))) prefixes.Add(Path.GetFullPath(pfx));
}
}
prefixes = prefixes.Distinct()
.OrderByDescending(p => File.GetLastWriteTimeUtc(Path.Combine(p, "system.reg")))
.ToList();
if (prefixes.Count == 0) { Console.WriteLine("⚠ Aucun préfixe Proton trouvé."); return null; }
Console.WriteLine("Préfixes Proton détectés (le plus récemment utilisé en premier = sûrement ton jeu) :");
for (var i = 0; i < prefixes.Count; i++)
Console.WriteLine($" {i + 1}) {prefixes[i]}");
Console.Write("Numéro du préfixe (Entrée = 1) : ");
var raw = Console.ReadLine()?.Trim();
var idx = string.IsNullOrEmpty(raw) ? 0 : (int.TryParse(raw, out var n) ? n - 1 : -1);
if (idx < 0 || idx >= prefixes.Count) { Console.WriteLine("⚠ Choix invalide."); return null; }
_settings.WinePrefix = prefixes[idx];
SaveSettings();
return _settings.WinePrefix;
}
private static IEnumerable<string> SteamRoots()
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
yield return Path.Combine(home, ".steam", "steam");
yield return Path.Combine(home, ".local", "share", "Steam");
}
// ---- Launch ------------------------------------------------------------
private static void LaunchGame()
{
if (OperatingSystem.IsWindows())
{
if (!EnsureFolder(out var dir)) return;
var loader = Path.Combine(dir, "Prospect.Client.Loader.exe");
if (!File.Exists(loader)) { Console.WriteLine("⚠ Prospect.Client.Loader.exe introuvable dans le dossier."); return; }
try
{
Process.Start(new ProcessStartInfo(loader) { WorkingDirectory = dir, UseShellExecute = true });
Console.WriteLine("▶ Jeu lancé.");
}
catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); }
return;
}
Console.WriteLine("Sous Linux/Proton, lance via Steam (le loader doit tourner dans le préfixe Proton) :");
Console.WriteLine(" steam steam://rungameid/" + AppId);
}
// ---- Helpers -----------------------------------------------------------
private static bool EnsureFolder(out string dir)
{
dir = _settings.GameFolder;
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
{
Console.WriteLine("⚠ Définis d'abord le dossier du jeu (option 1).");
return false;
}
return true;
}
private static void LoadSettings()
{
try
{
if (File.Exists(SettingsPath))
_settings = JsonSerializer.Deserialize<Settings>(File.ReadAllText(SettingsPath)) ?? new Settings();
}
catch { _settings = new Settings(); }
}
private static void SaveSettings()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
File.WriteAllText(SettingsPath, JsonSerializer.Serialize(_settings));
}
catch { /* best-effort persistence */ }
}
private sealed class Settings
{
public string GameFolder { get; set; } = "";
public string WinePrefix { get; set; } = "";
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ProspectServerSwitcher</AssemblyName>
<RootNamespace>Prospect.Client.Config</RootNamespace>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Cross-platform client tool (Linux/Proton + Windows). Never built by the Linux server CI. -->
<Configurations>Debug;Release;Season 3 Release;Season 2 Release;Season 2 Debug;Season 3 Debug</Configurations>
</PropertyGroup>
</Project>
+102
View File
@@ -0,0 +1,102 @@
# ProspectServerSwitcher
Petit utilitaire **multi-OS** (Linux/Proton + Windows) pour pointer un client
*The Cycle: Frontier* vers un serveur, importer le certificat, et lancer le jeu.
## Pourquoi
L'agent Prospect (`Prospect.Agent.dll`, injecté par `Prospect.Client.Loader.exe`)
hooke l'URL de l'API PlayFab et la remplace par le contenu de **`backend.txt`**,
placé à côté de l'exécutable du jeu (`Prospect/Binaries/Win64`). S'il est absent,
l'agent retombe sur `https://127.0.0.1:8443`.
**Tout l'aiguillage du client tient donc dans cette seule valeur.** Cet outil ne fait
essentiellement qu'écrire le bon `backend.txt` — plus quelques commodités (cert, launch).
## Serveurs (presets)
Les presets utilisent les **domaines publics** pour que la même cible marche pour
tout le monde (le DNS résout vers l'IP publique pour les amis, et le certificat TLS
unifié couvre les deux hostnames) :
| Preset | URL |
|-----------|----------------------------------|
| `prod` | `https://tc.nfteam.ovh:8443` |
| `preprod` | `https://rd-tc.nfteam.ovh:8444` |
> **Sur le même LAN que le serveur**, ajoute une entrée hosts pour éviter le hairpin NAT :
> ```
> 192.168.1.136 tc.nfteam.ovh
> 192.168.1.136 rd-tc.nfteam.ovh
> ```
## Utilisation
### Menu interactif
Lance le binaire sans argument :
- **1** : définir le dossier du jeu (`…/Prospect/Binaries/Win64`) — mémorisé
- **2/3** : basculer sur PROD / PREPROD
- **4** : URL personnalisée
- **5** : importer un certificat (`.crt`)
- **6** : lancer le jeu
- **0** : quitter
### En ligne de commande (scriptable)
```
ProspectServerSwitcher --folder "<...>/Prospect/Binaries/Win64" --set preprod
ProspectServerSwitcher --set prod
ProspectServerSwitcher --set https://mon-serveur:8443 # URL libre
```
> ⚠️ `backend.txt` n'est lu qu'**au démarrage** du loader → relance le jeu après un switch.
> Le serveur ciblé doit **tourner** (conteneurs on-demand `the-cycle-api` / `the-cycle-api-rd`).
## Certificat
L'outil récupère le certificat **en direct depuis le serveur** ciblé (il lit `backend.txt`
et se connecte en TLS), puis le rend fiable :
- **Windows** : import automatique dans *Autorités de certification racines de confiance*
(utilisateur courant), via le magasin `X509Store`.
- **Linux/Proton** : import **automatique dans le préfixe Wine** du jeu. Comme `wine certutil`
est cassé sous Proton, l'outil écrit directement le *blob* sérialisé du certificat
(propriété SHA1 `id=3` + certificat encodé `id=0x20`) dans le magasin `Root` du préfixe via
`wine reg import`. Il :
1. détecte le binaire `wine` de Proton (`…/common/Proton*/files/bin/wine`),
2. liste les préfixes `compatdata/*/pfx` (le plus récemment utilisé en premier),
3. importe dans le préfixe choisi (⚠️ **le jeu doit être fermé**), mémorisé ensuite.
> ⚠️ Le client tourne souvent comme **raccourci non-Steam** → son préfixe n'est **pas**
> `compatdata/868270` mais un appid généré (ex. `3883998305`). D'où la sélection explicite
> du préfixe plutôt qu'une déduction sur l'appid du jeu.
## Lancement
- **Windows** : démarre `Prospect.Client.Loader.exe` depuis le dossier du jeu.
- **Linux/Proton** : passe par Steam (`steam steam://rungameid/868270`) — le loader doit
tourner dans le préfixe Proton.
## Réglages
Le dossier du jeu est mémorisé dans
`…/ApplicationData/ProspectSwitcher/settings.json`
(`%AppData%` sous Windows, `~/.config` sous Linux).
## Build / publication
Projet console **net8.0** (cross-platform, `InvariantGlobalization`), jamais buildé par
la CI serveur (Linux).
```bash
# build simple
dotnet build src/Prospect.Client.Config -c Release
# binaires autonomes single-file (aucun .NET requis chez l'utilisateur)
dotnet publish src/Prospect.Client.Config -c Release -r win-x64 --self-contained \
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true
dotnet publish src/Prospect.Client.Config -c Release -r linux-x64 --self-contained \
-p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true
```
Les binaires publiés sont distribués via la release Gitea **`friends-client`**
(`ProspectServerSwitcher.exe` pour Windows, `ProspectServerSwitcher-linux-x64` pour Linux).
@@ -0,0 +1,431 @@
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using Microsoft.AspNetCore.Mvc;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Database.Models;
using Prospect.Server.Api.Services.GameRef;
using Prospect.Server.Api.Services.Steam;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Controllers;
// Read-only back-office to visualise everything relevant in the database.
// Served at /admin, restricted to LAN / loopback callers (an admin panel must not be
// reachable from the public internet even though :8443 is port-forwarded).
// List pages (players, catalog) are driven by a small self-contained client-side table
// engine (sort / filter / search / paging) fed by the /admin/api/* JSON endpoints.
[Route("admin")]
public class AdminController : ControllerBase
{
private readonly DbUserService _userService;
private readonly DbEntityService _entityService;
private readonly DbUserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly SteamWebApiService _steamWebApi;
private readonly GameRefService _gameRef;
public AdminController(DbUserService userService, DbEntityService entityService,
DbUserDataService userDataService, TitleDataService titleDataService, SteamWebApiService steamWebApi,
GameRefService gameRef)
{
_userService = userService;
_entityService = entityService;
_userDataService = userDataService;
_titleDataService = titleDataService;
_steamWebApi = steamWebApi;
_gameRef = gameRef;
}
// ---------- pages ----------
[HttpGet("")]
public async Task<IActionResult> Index()
{
if (!IsLan()) return Denied();
var users = await _userService.GetAllAsync();
var entities = await _entityService.GetAllAsync();
var dataCount = await _userDataService.CountAsync();
var titleKeys = _titleDataService.Find(new List<string>());
var sb = new StringBuilder();
sb.Append("<div class='cards'>");
sb.Append(Card("Joueurs", users.Count.ToString()));
sb.Append(Card("Entités", entities.Count.ToString()));
sb.Append(Card("Docs UserData", dataCount.ToString()));
sb.Append(Card("Clés TitleData", titleKeys.Count.ToString()));
sb.Append(Card("Catalogue", _gameRef.All.Count.ToString()));
sb.Append("</div>");
sb.Append("<p><a class='btn' href='/admin/players'>Joueurs →</a> &nbsp; <a class='btn' href='/admin/catalog'>Catalogue →</a> &nbsp; <a class='btn' href='/admin/titledata'>TitleData →</a></p>");
return Html(Layout("Dashboard", sb.ToString()));
}
[HttpGet("players")]
public IActionResult Players()
{
if (!IsLan()) return Denied();
var body =
"<h2>Joueurs</h2>" +
"<p><a class='btn' href='/admin/backfill-names' title='Récupère les pseudos Steam des comptes sans nom'>⟳ Backfill pseudos Steam</a></p>" +
"<div id='tbl'></div>" +
"<script>DT.init({id:'tbl',url:'/admin/api/players',size:25,filters:['auth'],columns:[" +
"{key:'name',label:'Nom',kind:'link',base:'/admin/players/',lkey:'id'}," +
"{key:'id',label:'PlayFab Id',kind:'mono'}," +
"{key:'entity',label:'Entity Id',kind:'mono'}," +
"{key:'auth',label:'Auth',kind:'mono'}]});</script>";
return Html(Layout("Joueurs", body));
}
[HttpGet("catalog")]
public IActionResult Catalog()
{
if (!IsLan()) return Denied();
var body =
"<h2>Catalogue</h2>" +
"<p class='muted'>Référence dataminée (TCF-Wiki) — " + _gameRef.All.Count + " entrées.</p>" +
"<div id='tbl'></div>" +
"<script>DT.init({id:'tbl',url:'/admin/api/catalog',size:50,filters:['category','rarity'],columns:[" +
"{key:'name',label:'Nom'}," +
"{key:'rarity',label:'Rareté',kind:'rarity'}," +
"{key:'category',label:'Catégorie'}," +
"{key:'id',label:'id',kind:'mono'}]});</script>";
return Html(Layout("Catalogue", body));
}
[HttpGet("players/{id}")]
public async Task<IActionResult> Player(string id)
{
if (!IsLan()) return Denied();
var user = await _userService.FindByIdAsync(id);
if (user == null) return Html(Layout("Joueur introuvable", "<p>Aucun joueur avec cet id.</p>"));
var entity = await _entityService.FindAsync(id);
var data = await _userDataService.GetAllForUserAsync(id);
var map = data.ToDictionary(d => d.Key, d => d);
var sb = new StringBuilder();
var auth = user.Auth != null ? string.Join(", ", user.Auth.Select(a => $"{a.Type}:{a.Key}")) : "";
sb.Append($"<p><a class='lnk' href='/admin/players'>← Joueurs</a></p>");
sb.Append($"<h2>{Esc(user.DisplayName)}</h2>");
sb.Append($"<p class='muted mono'>PlayFabId {Esc(user.Id)} · Entity {Esc(entity?.Id ?? "-")} · {Esc(auth)}</p>");
sb.Append("<div class='cards'>");
if (map.TryGetValue("Balance", out var bal))
{
sb.Append(Card("K-Marks", GetNum(bal.Value, "SoftCurrency").ToString()));
sb.Append(Card("Aurum", GetNum(bal.Value, "HardCurrency").ToString()));
sb.Append(Card("Insurance", GetNum(bal.Value, "InsuranceCurrency").ToString()));
}
foreach (var f in new[] { "Korolev", "Osiris", "ICA" })
if (map.TryGetValue("FactionProgression" + f, out var fp))
sb.Append(Card("Rép. " + f, (fp.Value ?? "0").Trim()));
foreach (var s in new[] { "2", "3" })
if (map.TryGetValue($"FortunaPass{s}_SeasonXp", out var xp) && (xp.Value ?? "0").Trim() != "0")
sb.Append(Card($"Fortuna S{s} XP", (xp.Value ?? "0").Trim()));
sb.Append("</div>");
if (map.TryGetValue("Inventory", out var inv))
{
sb.Append("<h3>Inventaire</h3>");
sb.Append(InventoryTable(inv.Value));
}
sb.Append("<h3>Toutes les données</h3>");
foreach (var d in data)
{
sb.Append("<details><summary>" + Esc(d.Key) +
$" <span class='muted small'>· {d.Value?.Length ?? 0} car · maj {d.LastUpdated:yyyy-MM-dd HH:mm}{(d.Public ? " · public" : "")}</span></summary>");
sb.Append($"<pre>{Esc(Pretty(d.Value))}</pre></details>");
}
return Html(Layout("Joueur · " + user.DisplayName, sb.ToString()));
}
[HttpGet("titledata")]
public IActionResult TitleData()
{
if (!IsLan()) return Denied();
var all = _titleDataService.Find(new List<string>());
var sb = new StringBuilder();
sb.Append("<h2>TitleData</h2>");
sb.Append("<table><thead><tr><th>Clé</th><th>Taille</th><th></th></tr></thead><tbody>");
foreach (var kv in all.OrderBy(k => k.Key))
sb.Append($"<tr><td class='mono'>{Esc(kv.Key)}</td><td>{kv.Value?.Length ?? 0} car</td>" +
$"<td><a class='btn' href='/admin/titledata/{Esc(kv.Key)}'>Voir</a></td></tr>");
sb.Append("</tbody></table>");
return Html(Layout("TitleData", sb.ToString()));
}
[HttpGet("titledata/{key}")]
public IActionResult TitleDataKey(string key)
{
if (!IsLan()) return Denied();
var all = _titleDataService.Find(new List<string> { key });
if (!all.TryGetValue(key, out var value))
return Html(Layout("Clé introuvable", "<p>Aucune clé TitleData avec ce nom.</p>"));
var body = value != null && value.Length > 300_000
? $"<p class='muted'>Valeur volumineuse ({value.Length} car) — affichage brut.</p><pre>{Esc(value)}</pre>"
: $"<pre>{Esc(Pretty(value))}</pre>";
return Html(Layout("TitleData · " + key, $"<p><a class='lnk' href='/admin/titledata'>← TitleData</a></p><h2 class='mono'>{Esc(key)}</h2>" + body));
}
[HttpGet("backfill-names")]
public async Task<IActionResult> BackfillNames()
{
if (!IsLan()) return Denied();
if (!_steamWebApi.Enabled)
return Html(Layout("Backfill", "<p class='muted'>Steam Web API non configurée (clé absente).</p><p><a class='btn' href='/admin/players'>← Joueurs</a></p>"));
var users = await _userService.GetAllAsync();
int updated = 0, skipped = 0, failed = 0;
var rows = new StringBuilder();
foreach (var u in users)
{
if (!string.IsNullOrEmpty(u.DisplayName) && u.DisplayName != "Unknown") { skipped++; continue; }
var steam = u.Auth?.FirstOrDefault(a => a.Type == PlayFabUserAuthType.Steam)?.Key;
if (string.IsNullOrEmpty(steam)) { skipped++; continue; }
var persona = await _steamWebApi.GetPersonaNameAsync(steam);
if (!string.IsNullOrWhiteSpace(persona))
{
await _userService.UpdateDisplayNameAsync(u.Id, persona);
updated++;
rows.Append($"<tr><td class='mono small'>{Esc(u.Id)}</td><td>{Esc(persona)}</td></tr>");
}
else { failed++; }
}
var body = $"<h2>Backfill des pseudos Steam</h2><p class='muted'>{updated} mis à jour · {skipped} ignorés · {failed} échec(s)</p>" +
(updated > 0 ? $"<table><thead><tr><th>PlayFabId</th><th>Nouveau nom</th></tr></thead><tbody>{rows}</tbody></table>" : "") +
"<p><a class='btn' href='/admin/players'>← Joueurs</a></p>";
return Html(Layout("Backfill", body));
}
// ---------- JSON API (consumed by the client-side table engine) ----------
[HttpGet("api/players")]
public async Task<IActionResult> ApiPlayers()
{
if (!IsLan()) return Denied();
var users = await _userService.GetAllAsync();
var entities = (await _entityService.GetAllAsync())
.GroupBy(e => e.UserId).ToDictionary(g => g.Key, g => g.First().Id);
var list = users.Select(u => new
{
name = string.IsNullOrEmpty(u.DisplayName) ? u.Id : u.DisplayName,
id = u.Id,
entity = entities.TryGetValue(u.Id, out var e) ? e : "",
auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : ""
});
return Json(list);
}
[HttpGet("api/catalog")]
public IActionResult ApiCatalog()
{
if (!IsLan()) return Denied();
var list = _gameRef.All.Select(kv => new
{
id = kv.Key,
name = kv.Value.Name,
rarity = kv.Value.Rarity,
category = kv.Value.Category
});
return Json(list);
}
// ---------- helpers ----------
private bool IsLan()
{
var ip = HttpContext.Connection.RemoteIpAddress;
if (ip == null) return false;
if (IPAddress.IsLoopback(ip)) return true;
if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
if (ip.AddressFamily == AddressFamily.InterNetwork)
{
var b = ip.GetAddressBytes();
if (b[0] == 10) return true;
if (b[0] == 192 && b[1] == 168) return true;
if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true;
if (b[0] == 127) return true;
return false;
}
var s = ip.GetAddressBytes();
if ((s[0] & 0xFE) == 0xFC) return true;
if (s[0] == 0xFE && (s[1] & 0xC0) == 0x80) return true;
return false;
}
private IActionResult Denied() =>
StatusCode(403, "Admin back-office is LAN-only. Access it from the local network (e.g. https://192.168.1.136:8443/admin).");
private IActionResult Html(string html) => Content(html, "text/html; charset=utf-8");
private IActionResult Json(object data) =>
Content(JsonSerializer.Serialize(data), "application/json; charset=utf-8");
private static string Esc(string? s) => WebUtility.HtmlEncode(s ?? "");
private static string Pretty(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return json ?? "";
try
{
using var doc = JsonDocument.Parse(json);
return JsonSerializer.Serialize(doc.RootElement, new JsonSerializerOptions { WriteIndented = true });
}
catch { return json; }
}
private static long GetNum(string? json, string prop)
{
if (string.IsNullOrWhiteSpace(json)) return 0;
try
{
using var doc = JsonDocument.Parse(json);
foreach (var p in doc.RootElement.EnumerateObject())
if (string.Equals(p.Name, prop, StringComparison.OrdinalIgnoreCase) && p.Value.ValueKind == JsonValueKind.Number)
return p.Value.GetInt64();
}
catch { }
return 0;
}
private string InventoryTable(string? json)
{
if (string.IsNullOrWhiteSpace(json)) return "<p class='muted'>vide</p>";
try
{
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.ValueKind != JsonValueKind.Array) return $"<pre>{Esc(Pretty(json))}</pre>";
var sb = new StringBuilder();
var count = doc.RootElement.GetArrayLength();
sb.Append($"<p class='muted'>{count} item(s)</p>");
sb.Append("<table><thead><tr><th>Nom</th><th>Rareté</th><th>baseItemId</th><th>qté</th><th>durab.</th><th>assurance</th></tr></thead><tbody>");
foreach (var it in doc.RootElement.EnumerateArray())
{
string S(string k) => it.TryGetProperty(k, out var v) ? (v.ValueKind == JsonValueKind.Number ? v.GetRawText() : v.ToString()) : "";
var baseId = S("baseItemId");
var entry = _gameRef.Get(baseId);
var name = entry?.Name ?? baseId;
sb.Append($"<tr><td>{Esc(name)}</td><td>{Rarity(entry?.Rarity)}</td><td class='mono small'>{Esc(baseId)}</td>" +
$"<td>{Esc(S("amount"))}</td><td>{Esc(S("durability"))}</td><td>{Esc(S("insurance"))}</td></tr>");
}
sb.Append("</tbody></table>");
return sb.ToString();
}
catch { return $"<pre>{Esc(Pretty(json))}</pre>"; }
}
private static string Rarity(string? r)
{
if (string.IsNullOrEmpty(r)) return "";
var color = r.ToLowerInvariant() switch
{
"common" => "#9aa4b2",
"uncommon" => "#4caf50",
"rare" => "#2f81f7",
"epic" => "#a371f7",
"legendary" or "exotic" => "#e8a838",
_ => "#9aa4b2",
};
return $"<span style='color:{color};font-weight:600'>{Esc(r)}</span>";
}
private static string Card(string label, string value) =>
$"<div class='card'><div class='v'>{Esc(value)}</div><div class='l'>{Esc(label)}</div></div>";
private static string Layout(string title, string body) =>
Head.Replace("__TITLE__", Esc(title)) + DtScript + body + Foot;
// Static shell (no C# interpolation → JS braces/quotes stay literal). Single quotes only.
private const string Head = @"<!doctype html><html lang='fr'><head>
<meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>
<title>__TITLE__ · The Cycle Admin</title>
<style>
:root{--bg:#0f1115;--panel:#171a21;--line:#262b36;--txt:#e6e8ec;--muted:#8b93a1;--acc:#e8a838;}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--txt);font:14px/1.5 system-ui,Segoe UI,Roboto,sans-serif}
header{background:var(--panel);border-bottom:1px solid var(--line);padding:12px 20px;display:flex;gap:18px;align-items:center;position:sticky;top:0;z-index:10}
header b{color:var(--acc)}header a{color:var(--muted);text-decoration:none}header a:hover{color:var(--txt)}
main{padding:20px;max-width:1200px;margin:0 auto}
h2{margin:18px 0 10px}h3{margin:22px 0 8px;border-bottom:1px solid var(--line);padding-bottom:4px}
.cards{display:flex;flex-wrap:wrap;gap:12px;margin:12px 0}
.card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:14px 18px;min-width:130px}
.card .v{font-size:22px;font-weight:700;color:var(--acc)}.card .l{color:var(--muted);font-size:12px}
table{width:100%;border-collapse:collapse;background:var(--panel);border:1px solid var(--line);border-radius:10px;overflow:hidden}
th,td{text-align:left;padding:8px 12px;border-bottom:1px solid var(--line);vertical-align:top}
th{background:#1c2029;color:var(--muted);font-weight:600}tr:last-child td{border-bottom:none}
tbody tr:hover{background:#1b2028}
.mono{font-family:ui-monospace,Consolas,monospace}.small{font-size:12px}.muted{color:var(--muted)}
.btn{display:inline-block;background:#222834;color:var(--txt);border:1px solid var(--line);border-radius:8px;padding:5px 12px;text-decoration:none;font-size:13px;cursor:pointer}
.btn:hover{border-color:var(--acc);color:var(--acc)}
a.lnk{color:var(--acc);text-decoration:none}a.lnk:hover{text-decoration:underline}
details{background:var(--panel);border:1px solid var(--line);border-radius:8px;margin:6px 0}summary{cursor:pointer;padding:8px 12px}
pre{margin:0;padding:12px;background:#0b0d11;border-top:1px solid var(--line);overflow:auto;max-height:480px;white-space:pre-wrap;word-break:break-word}
.dtbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:12px 0}
.dtbar input,.dtbar select{background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:7px 10px}
.dtsearch{flex:1;min-width:220px}
.dtcount{margin-left:auto}
th.dth{cursor:pointer;user-select:none;white-space:nowrap}th.dth:hover{color:var(--txt)}
.dtfoot{display:flex;gap:12px;align-items:center;margin:10px 0}
</style></head><body>
<header><b>The Cycle · Admin</b>
<a href='/admin'>Dashboard</a><a href='/admin/players'>Joueurs</a><a href='/admin/catalog'>Catalogue</a><a href='/admin/titledata'>TitleData</a>
<span class='muted' style='margin-left:auto'>read-only · LAN</span></header>
<main>";
private const string Foot = @"</main></body></html>";
// Client-side table engine, injected right after <main> so DT is defined before any
// page-level DT.init() runs. Single quotes only (verbatim string → JS braces stay literal).
private const string DtScript = @"<script>
const DT={
init(cfg){const el=document.getElementById(cfg.id);el.innerHTML='<p class=muted>Chargement…</p>';
fetch(cfg.url).then(r=>r.json()).then(rows=>DT.build(el,cfg,rows)).catch(e=>{el.innerHTML='<p class=muted>Erreur: '+e+'</p>';});},
build(el,cfg,rows){el.innerHTML='';el._rows=rows;el._cfg=cfg;el._sort={k:null,d:1};el._page=1;el._size=cfg.size||25;el._filters={};el._q='';
const bar=document.createElement('div');bar.className='dtbar';
const s=document.createElement('input');s.className='dtsearch';s.placeholder='Rechercher…';s.oninput=()=>{el._q=s.value.toLowerCase();el._page=1;DT.draw(el);};bar.appendChild(s);
(cfg.filters||[]).forEach(fk=>{const vals=[...new Set(rows.map(r=>r[fk]).filter(v=>v!=null&&v!==''))].sort();
const sel=document.createElement('select');const o0=document.createElement('option');o0.value='';o0.textContent=fk;sel.appendChild(o0);
vals.forEach(v=>{const o=document.createElement('option');o.value=v;o.textContent=v;sel.appendChild(o);});
sel.onchange=()=>{el._filters[fk]=sel.value;el._page=1;DT.draw(el);};bar.appendChild(sel);});
const sz=document.createElement('select');[25,50,100,500].forEach(n=>{const o=document.createElement('option');o.value=n;o.textContent=n+'/page';sz.appendChild(o);});
const oa=document.createElement('option');oa.value='0';oa.textContent='Tout';sz.appendChild(oa);sz.value=el._size;
sz.onchange=()=>{el._size=+sz.value;el._page=1;DT.draw(el);};bar.appendChild(sz);
const cnt=document.createElement('span');cnt.className='dtcount muted';bar.appendChild(cnt);el._cnt=cnt;el.appendChild(bar);
const tbl=document.createElement('table');const th=document.createElement('thead');const tr=document.createElement('tr');
cfg.columns.forEach(c=>{const h=document.createElement('th');h.className='dth';h.dataset.k=c.key;h.textContent=c.label;h.onclick=()=>DT.sort(el,c.key);tr.appendChild(h);});
th.appendChild(tr);tbl.appendChild(th);el._head=tr;const tb=document.createElement('tbody');tbl.appendChild(tb);el._tb=tb;el.appendChild(tbl);
const ft=document.createElement('div');ft.className='dtfoot';
const pv=document.createElement('button');pv.className='btn';pv.textContent=' Préc.';pv.onclick=()=>{if(el._page>1){el._page--;DT.draw(el);}};
const nf=document.createElement('span');nf.className='muted';el._nfo=nf;
const nx=document.createElement('button');nx.className='btn';nx.textContent='Suiv. ';nx.onclick=()=>{el._page++;DT.draw(el);};
ft.appendChild(pv);ft.appendChild(nf);ft.appendChild(nx);el.appendChild(ft);DT.draw(el);},
rows(el){const cfg=el._cfg;let rows=el._rows;
Object.keys(el._filters).forEach(k=>{const fv=el._filters[k];if(fv)rows=rows.filter(r=>(''+(r[k]??''))===fv);});
if(el._q)rows=rows.filter(r=>cfg.columns.some(c=>(''+(r[c.key]??'')).toLowerCase().includes(el._q)));
if(el._sort.k){const k=el._sort.k,d=el._sort.d;rows=[...rows].sort((a,b)=>{let x=a[k],y=b[k];const nx=parseFloat(x),ny=parseFloat(y);
if(!isNaN(nx)&&!isNaN(ny)&&(''+x).trim()!==''&&(''+y).trim()!==''){x=nx;y=ny;}else{x=(''+(x??'')).toLowerCase();y=(''+(y??'')).toLowerCase();}return x<y?-d:x>y?d:0;});}
return rows;},
sort(el,k){if(el._sort.k===k)el._sort.d*=-1;else{el._sort.k=k;el._sort.d=1;}el._page=1;DT.draw(el);},
draw(el){const cfg=el._cfg,rows=DT.rows(el),total=rows.length,size=el._size||total||1,pages=Math.max(1,Math.ceil(total/size));
if(el._page>pages)el._page=pages;const start=el._size?(el._page-1)*size:0;const slice=el._size?rows.slice(start,start+size):rows;
el._tb.innerHTML=slice.map(r=>'<tr>'+cfg.columns.map(c=>DT.cell(c,r)).join('')+'</tr>').join('')||'<tr><td colspan='+cfg.columns.length+' class=muted>Aucun résultat</td></tr>';
el._cnt.textContent=total+' résultat(s)';el._nfo.textContent='page '+el._page+'/'+pages;
[...el._head.children].forEach(h=>{const c=cfg.columns.find(x=>x.key===h.dataset.k);h.textContent=c.label+(el._sort.k===h.dataset.k?(el._sort.d>0?' ▲':' ▼'):'');});},
cell(c,r){let v=r[c.key];v=v==null?'':(''+v);
if(c.kind==='rarity')return '<td>'+DT.rarity(v)+'</td>';
if(c.kind==='link')return '<td><a class=lnk href='+c.base+encodeURIComponent(r[c.lkey||'id'])+'>'+DT.esc(v)+'</a></td>';
const cls=c.kind==='mono'?'mono small':'';return '<td class='+cls+'>'+DT.esc(v)+'</td>';},
rarity(r){if(!r)return '';const c={common:'#9aa4b2',uncommon:'#4caf50',rare:'#2f81f7',epic:'#a371f7',legendary:'#e8a838',exotic:'#e8a838'}[r.toLowerCase()]||'#9aa4b2';
return '<span style=color:'+c+';font-weight:600>'+DT.esc(r)+'</span>';},
esc(s){const d=document.createElement('div');d.textContent=(s==null?'':''+s);return d.innerHTML;}
};
</script>";
}
@@ -32,6 +32,7 @@ public class ClientController : Controller
private readonly DbEntityService _entityService; private readonly DbEntityService _entityService;
private readonly UserDataService _userDataService; private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService; private readonly TitleDataService _titleDataService;
private readonly Prospect.Server.Api.Services.Steam.SteamWebApiService _steamWebApi;
public ClientController(ILogger<ClientController> logger, public ClientController(ILogger<ClientController> logger,
IOptions<PlayFabSettings> settings, IOptions<PlayFabSettings> settings,
@@ -39,7 +40,8 @@ public class ClientController : Controller
DbUserService userService, DbUserService userService,
DbEntityService entityService, DbEntityService entityService,
UserDataService userDataService, UserDataService userDataService,
TitleDataService titleDataService) TitleDataService titleDataService,
Prospect.Server.Api.Services.Steam.SteamWebApiService steamWebApi)
{ {
_settings = settings.Value; _settings = settings.Value;
_logger = logger; _logger = logger;
@@ -48,6 +50,7 @@ public class ClientController : Controller
_entityService = entityService; _entityService = entityService;
_userDataService = userDataService; _userDataService = userDataService;
_titleDataService = titleDataService; _titleDataService = titleDataService;
_steamWebApi = steamWebApi;
} }
[AllowAnonymous] [AllowAnonymous]
@@ -63,6 +66,19 @@ public class ClientController : Controller
var userSteamId = ticket.SteamId.ToString(); var userSteamId = ticket.SteamId.ToString();
var user = await _userService.FindOrCreateAsync(PlayFabUserAuthType.Steam, userSteamId); var user = await _userService.FindOrCreateAsync(PlayFabUserAuthType.Steam, userSteamId);
// Backfill the display name from the Steam persona when we don't have a real
// one yet (accounts are created as "Unknown"; the client never sends a name).
if (string.IsNullOrEmpty(user.DisplayName) || user.DisplayName == "Unknown")
{
var persona = await _steamWebApi.GetPersonaNameAsync(userSteamId);
if (!string.IsNullOrWhiteSpace(persona))
{
await _userService.UpdateDisplayNameAsync(user.Id, persona);
user.DisplayName = persona;
}
}
var entity = await _entityService.FindOrCreateAsync(user.Id); var entity = await _entityService.FindOrCreateAsync(user.Id);
var userTicket = _authTokenService.GenerateUser(entity); var userTicket = _authTokenService.GenerateUser(entity);
@@ -140,8 +156,18 @@ public class ClientController : Controller
[HttpPost("AddGenericID")] [HttpPost("AddGenericID")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
public IActionResult AddGenericId(FAddGenericIDRequest request) public async Task<IActionResult> AddGenericId(FAddGenericIDRequest request)
{ {
// Persist the linked generic service id instead of dropping it (was a no-op stub).
var userId = User.FindAuthUserId();
if (request.GenericId != null && !string.IsNullOrEmpty(request.GenericId.ServiceName))
{
await _userDataService.UpdateAsync(userId, userId, new Dictionary<string, string>
{
["GenericId_" + request.GenericId.ServiceName] = request.GenericId.UserId ?? ""
});
}
return Ok(new ClientResponse<object> return Ok(new ClientResponse<object>
{ {
Code = 200, Code = 200,
@@ -274,8 +300,12 @@ public class ClientController : Controller
[HttpPost("UpdateUserTitleDisplayName")] [HttpPost("UpdateUserTitleDisplayName")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
public IActionResult UpdateUserTitleDisplayName(FUpdateUserTitleDisplayNameRequest request) public async Task<IActionResult> UpdateUserTitleDisplayName(FUpdateUserTitleDisplayNameRequest request)
{ {
// Persist the rename, otherwise it is echoed back but lost on next login.
var userId = User.FindAuthUserId();
await _userService.UpdateDisplayNameAsync(userId, request.DisplayName);
return Ok(new ClientResponse<FUpdateUserTitleDisplayNameResult> return Ok(new ClientResponse<FUpdateUserTitleDisplayNameResult>
{ {
Code = 200, Code = 200,
File diff suppressed because one or more lines are too long
+22 -3
View File
@@ -1,12 +1,31 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
namespace Prospect.Server.Api.Hubs; namespace Prospect.Server.Api.Hubs;
public class CycleHub : Hub public class CycleHub : Hub
{ {
private readonly SignalRConnectionRegistry _registry;
public CycleHub(SignalRConnectionRegistry registry)
{
_registry = registry;
}
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
Console.WriteLine("Connected {0}", Context.ConnectionId); // The client connects with the SignalR URL returned by GetSignalRConnection, which
// carries a `uid` query param — use it to map this connection to the player so
// server-initiated messages can target a single player instead of everyone.
var uid = Context.GetHttpContext()?.Request.Query["uid"].ToString();
if (!string.IsNullOrEmpty(uid))
_registry.Add(uid, Context.ConnectionId);
Console.WriteLine("Connected {0} (uid={1})", Context.ConnectionId, uid);
await base.OnConnectedAsync(); await base.OnConnectedAsync();
} }
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_registry.Remove(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
@@ -0,0 +1,29 @@
using System.Collections.Concurrent;
namespace Prospect.Server.Api.Hubs;
// Maps players to their live SignalR connection so server-initiated messages (e.g. the
// matchmaking-success "travel to match" signal) can target a single player instead of being
// broadcast to everyone. The client's SignalR URL carries a `uid` query param (added by
// GetSignalRConnection); CycleHub records it on connect.
public class SignalRConnectionRegistry
{
private readonly ConcurrentDictionary<string, string> _userToConn = new();
private readonly ConcurrentDictionary<string, string> _connToUser = new();
public void Add(string userId, string connectionId)
{
if (string.IsNullOrEmpty(userId)) return;
_userToConn[userId] = connectionId;
_connToUser[connectionId] = userId;
}
public void Remove(string connectionId)
{
if (_connToUser.TryRemove(connectionId, out var userId))
_userToConn.TryRemove(userId, out _);
}
public string? GetConnection(string userId)
=> _userToConn.TryGetValue(userId, out var c) ? c : null;
}
@@ -72,6 +72,21 @@ public enum EYContractObjectiveType {
MAX = 8 MAX = 8
} }
public static class ContractObjectiveTypeExtensions {
// Objectives that can only be observed while a raid is running (kills, dead-drops,
// visiting an area, looting a container). The raid is hosted by the player's own
// client and there is no dedicated game server, so their progress is never reported
// to the backend (UpdatePlayerActiveContracts is never called). They are therefore
// auto-credited server-side, otherwise these contracts can never be completed.
// OwnNumOfItem is excluded (validated against the real stash) and so are the meta
// objectives FactionLevel / CompletedMission (validated from persisted player data).
public static bool IsRaidRuntime(this EYContractObjectiveType type) =>
type is EYContractObjectiveType.Kills
or EYContractObjectiveType.DeadDrop
or EYContractObjectiveType.VisitArea
or EYContractObjectiveType.LootContainer;
}
public enum EYContractDifficulty public enum EYContractDifficulty
{ {
Invalid = 0, Invalid = 0,
@@ -34,4 +34,10 @@
<ProjectReference Include="..\Prospect.Steam\Prospect.Steam.csproj" /> <ProjectReference Include="..\Prospect.Steam\Prospect.Steam.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup>
<!-- Community-datamined id -> name/rarity/category lookup (TCF-Wiki), used by the
admin back-office to show readable item/vanity names instead of raw ids. -->
<Content Update="Data\gameref.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
</Project> </Project>
@@ -81,9 +81,16 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
} }
var factionKey = "FactionProgression" + contract.Faction; var factionKey = "FactionProgression" + contract.Faction;
// Fortuna pass season XP is stored per season; the client derives the pass level
// from this key. No challenge system is emulated, so contracts are the XP source.
#if SEASON_3_RELEASE || SEASON_3_DEBUG
const string seasonXpKey = "FortunaPass3_SeasonXp";
#else
const string seasonXpKey = "FortunaPass2_SeasonXp";
#endif
var userData = await _userDataService.FindAsync( var userData = await _userDataService.FindAsync(
userId, userId, userId, userId,
new List<string>{"ContractsActive", "ContractsOneTimeCompleted", "Balance", "Inventory", factionKey, "JobBoardsData" } new List<string>{"ContractsActive", "ContractsOneTimeCompleted", "Balance", "Inventory", factionKey, "JobBoardsData", seasonXpKey }
); );
var factionProgression = JsonSerializer.Deserialize<int>(userData[factionKey].Value); var factionProgression = JsonSerializer.Deserialize<int>(userData[factionKey].Value);
@@ -131,16 +138,17 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
break; break;
} }
} }
} else if (objective.Type == EYContractObjectiveType.Kills) { } else if (objective.Type.IsRaidRuntime()) {
// Kill objectives can't be tracked in a client-hosted raid (no dedicated // Raid-runtime objectives (kills, dead-drops, visited areas, looted
// game server reports kills). They are auto-credited on deploy // containers) can't be tracked in a client-hosted raid — no dedicated
// (EnterMatchmakingMatch); accept them here even if a contract was // game server reports them, so their progress is never saved. They are
// activated before that so kill contracts remain completable. // auto-credited on deploy (EnterMatchmakingMatch); accept them here too
// (safety net for a contract activated after deploy) so these contracts
// remain completable instead of being stuck on "Objective not met".
remaining = 0; remaining = 0;
} else { } else {
// NOTE: A game server must write the correct progress // Meta objectives (FactionLevel / CompletedMission): validated from the
// for other types of objectives. // persisted progress. The objectives are mapped by array index.
// The objectives are mapped by array index.
remaining -= targetContract.Progress[i]; remaining -= targetContract.Progress[i];
} }
if (remaining > 0) { if (remaining > 0) {
@@ -269,6 +277,12 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
newContracts.Add(newContract); newContracts.Add(newContract);
} }
// Grant Fortuna pass season XP for completing this contract and persist it, so the
// pass level (derived client-side from FortunaPass{season}_SeasonXp) actually moves.
// No exact rate ships in the data, so scale it to the contract's reputation value.
var seasonXp = int.TryParse(userData[seasonXpKey].Value, out var currentSeasonXp) ? currentSeasonXp : 0;
seasonXp += contract.ReputationIncrease * 10;
await _userDataService.UpdateAsync( await _userDataService.UpdateAsync(
userId, userId, userId, userId,
new Dictionary<string, string>{ new Dictionary<string, string>{
@@ -278,6 +292,7 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
["Inventory"] = JsonSerializer.Serialize(newInventory), ["Inventory"] = JsonSerializer.Serialize(newInventory),
[factionKey] = JsonSerializer.Serialize(factionProgression), [factionKey] = JsonSerializer.Serialize(factionProgression),
["JobBoardsData"] = JsonSerializer.Serialize(jobBoardsData), ["JobBoardsData"] = JsonSerializer.Serialize(jobBoardsData),
[seasonXpKey] = JsonSerializer.Serialize(seasonXp),
} }
); );
@@ -296,7 +311,7 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
CurrentProgression = factionProgression, CurrentProgression = factionProgression,
}, },
Status = 18, // EYClaimContractRewardsStatus::OK Status = 18, // EYClaimContractRewardsStatus::OK
UpdatedSeasonXp = 0 // TODO: Probably a separate season XP rate configured by server? UpdatedSeasonXp = seasonXp // new season XP total (client derives the pass level from it)
}; };
} }
} }
@@ -25,6 +25,24 @@ public class EnterMatchmakingFunction : ICloudScriptFunction<FYEnterMatchAzureFu
// SquadID = request.SquadId, // SquadID = request.SquadId,
//}); //});
// Dedicated-server mode (2nd preprod): if GAMESERVER_ADDRESS ("host:port") is set, hand
// the client the real game server to travel to instead of the client-hosted station.
// Same image as preprod — only this env var flips the deployment type.
var gameServer = Environment.GetEnvironmentVariable("GAMESERVER_ADDRESS");
if (!string.IsNullOrEmpty(gameServer))
{
var parts = gameServer.Split(':');
return new FYEnterMatchAzureFunctionResult
{
Success = true,
ErrorMessage = "",
SingleplayerStation = false,
Address = parts[0],
Port = parts.Length > 1 && int.TryParse(parts[1], out var gsPort) ? gsPort : 7777,
MaintenanceMode = false,
};
}
return new FYEnterMatchAzureFunctionResult return new FYEnterMatchAzureFunctionResult
{ {
Success = true, Success = true,
@@ -24,13 +24,15 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
private readonly IHubContext<CycleHub> _hubContext; private readonly IHubContext<CycleHub> _hubContext;
private readonly UserDataService _userDataService; private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService; private readonly TitleDataService _titleDataService;
private readonly SignalRConnectionRegistry _registry;
public EnterMatchmakingMatchFunction(IHubContext<CycleHub> hubContext, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService) public EnterMatchmakingMatchFunction(IHubContext<CycleHub> hubContext, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService, SignalRConnectionRegistry registry)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_hubContext = hubContext; _hubContext = hubContext;
_userDataService = userDataService; _userDataService = userDataService;
_titleDataService = titleDataService; _titleDataService = titleDataService;
_registry = registry;
} }
public async Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction request) public async Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction request)
@@ -60,12 +62,17 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
continue; continue;
} }
for (var i = 0; i < contract.Objectives.Length; i++) { for (var i = 0; i < contract.Objectives.Length; i++) {
if (i >= contractActive.Progress.Length) {
// Defensive: player progress array shorter than the objective list.
break;
}
var objective = contract.Objectives[i]; var objective = contract.Objectives[i];
// Kill objectives: the client-hosted raid has no dedicated game server // Raid-runtime objectives (kills, dead-drops, visited areas, looted
// to report per-kill progress to the backend, so kills would never be // containers) are simulated inside the client-hosted raid; with no
// saved. Auto-credit them on deploy so the objective can be completed // dedicated game server their progress is never reported to the backend,
// (mirrors the player-kill auto-credit done in ActivateContract). // so they would never be saved. Auto-credit them on deploy so the
if (objective.Type == EYContractObjectiveType.Kills) { // objective persists and the station shows the contract as completable.
if (objective.Type.IsRaidRuntime()) {
contractActive.Progress[i] = objective.MaxProgress; contractActive.Progress[i] = objective.MaxProgress;
continue; continue;
} }
@@ -94,11 +101,23 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
} }
); );
await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", new OnSquadMatchmakingSuccessMessage { // Send the "matchmaking succeeded / travel to match" signal only to the deploying
// player (via their mapped SignalR connection) — broadcasting to Clients.All made
// every connected player travel too. Fall back to broadcast only if the connection
// isn't mapped yet.
// Dedicated-server mode (2nd preprod): point the client at the real game server address
// to travel to; otherwise keep the map name (client-hosted). Same image — env flips it.
var gameServer = Environment.GetEnvironmentVariable("GAMESERVER_ADDRESS");
var deployMessage = new OnSquadMatchmakingSuccessMessage {
Success = true, Success = true,
SessionID = request.MapName, // TODO: Need to implement TryGetCompleteSquadInfo and pass squad info SessionID = string.IsNullOrEmpty(gameServer) ? request.MapName : gameServer,
SquadID = request.SquadId, SquadID = request.SquadId,
}); };
var connectionId = _registry.GetConnection(userId);
if (connectionId != null)
await _hubContext.Clients.Client(connectionId).SendAsync("OnSquadMatchmakingSuccess", deployMessage);
else
await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", deployMessage);
return new FYEnterMatchmakingResult return new FYEnterMatchmakingResult
{ {
@@ -1,6 +1,8 @@
using Prospect.Server.Api.Services.Auth.Extensions; using System.Text.Json;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models; using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.CloudScript.Models.Data; using Prospect.Server.Api.Services.CloudScript.Models.Data;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -8,13 +10,15 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
public class GetCraftingInProgressData : ICloudScriptFunction<FYGetCraftingInProgressDataRequest, FYGetCraftingInProgressDataResult> public class GetCraftingInProgressData : ICloudScriptFunction<FYGetCraftingInProgressDataRequest, FYGetCraftingInProgressDataResult>
{ {
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public GetCraftingInProgressData(IHttpContextAccessor httpContextAccessor) public GetCraftingInProgressData(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
} }
public Task<FYGetCraftingInProgressDataResult> ExecuteAsync(FYGetCraftingInProgressDataRequest request) public async Task<FYGetCraftingInProgressDataResult> ExecuteAsync(FYGetCraftingInProgressDataRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
@@ -22,11 +26,20 @@ public class GetCraftingInProgressData : ICloudScriptFunction<FYGetCraftingInPro
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
return Task.FromResult(new FYGetCraftingInProgressDataResult var userId = context.User.FindAuthUserId();
// Return the persisted in-progress craft (written by StartItemCraftingClient)
// instead of an empty stub, so the crafting timer survives a menu reopen / relog.
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "CraftingTimer__2022_05_12" });
// Fully qualified: a different FYItemCurrentlyBeingCrafted also exists in this namespace.
var craft = JsonSerializer.Deserialize<Models.Data.FYItemCurrentlyBeingCrafted>(userData["CraftingTimer__2022_05_12"].Value)
?? new Models.Data.FYItemCurrentlyBeingCrafted();
return new FYGetCraftingInProgressDataResult
{ {
UserId = context.User.FindAuthUserId(), UserId = userId,
Error = string.Empty, Error = string.Empty,
ItemCurrentlyBeingCrafted = {}, ItemCurrentlyBeingCrafted = craft,
}); };
} }
} }
@@ -32,9 +32,8 @@ public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
} }
var userId = context.User.FindAuthUserId(); var userId = context.User.FindAuthUserId();
// Resolve the imported Steam friends (persisted by ClientsideFriendsImport) to the // Imported Steam friends (persisted by ClientsideFriendsImport), resolved to players
// players that actually exist on this server. It's a private server, so only friends // that actually exist on this private server.
// who have logged in here will show up.
var steamIds = new List<string>(); var steamIds = new List<string>();
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "ImportedSteamFriends" }); var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "ImportedSteamFriends" });
if (userData.TryGetValue("ImportedSteamFriends", out var rec) && !string.IsNullOrWhiteSpace(rec.Value)) if (userData.TryGetValue("ImportedSteamFriends", out var rec) && !string.IsNullOrWhiteSpace(rec.Value))
@@ -47,25 +46,74 @@ public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
if (steamIds.Count > 0) if (steamIds.Count > 0)
{ {
var players = await _userService.FindManyAsync(PlayFabUserAuthType.Steam, steamIds); var players = await _userService.FindManyAsync(PlayFabUserAuthType.Steam, steamIds);
var nowUtc = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
foreach (var player in players) foreach (var player in players)
{ {
if (player.Id == userId) continue; // never list yourself if (player.Id == userId) continue; // never list yourself
var steamId = player.Auth?.FirstOrDefault(a => a.Type == PlayFabUserAuthType.Steam)?.Key ?? "";
var displayName = string.IsNullOrWhiteSpace(player.DisplayName) ? "Prospector" : player.DisplayName;
// 0 = offline, 1 = online, 2 = in match. Online if seen in the last 3 minutes.
var onlineState = 0;
var presenceData = await _userDataService.FindAsync(player.Id, player.Id, new List<string> { "PresenceState" });
if (presenceData.TryGetValue("PresenceState", out var presenceRec) && !string.IsNullOrWhiteSpace(presenceRec.Value))
{
try
{
var presence = JsonSerializer.Deserialize<PlayerPresenceState>(presenceRec.Value);
if (presence != null && nowUtc - presence.LastSeenUtc <= 180)
{
onlineState = presence.InMatch ? 2 : 1;
}
}
catch { /* ignore malformed presence */ }
}
// The exact model the UE client binds is not documented, so expose the identity
// under every plausible field name (camelCase + PlayFab PascalCase) and both a
// flat and nested profile — whichever the client reads, the tile gets populated.
friends.Add(new friends.Add(new
{ {
profile = new // identity
{ friendPlayFabId = player.Id,
playerId = player.Id, FriendPlayFabId = player.Id,
displayName = player.DisplayName, playerId = player.Id,
avatarUrl = "", PlayerId = player.Id,
}, playFabId = player.Id,
onlineState = 0, // EYUserState — real presence tracking is a later phase PlayFabId = player.Id,
// name
displayName,
DisplayName = displayName,
titleDisplayName = displayName,
TitleDisplayName = displayName,
name = displayName,
username = displayName,
Username = displayName,
// steam
steamId,
SteamId = steamId,
steamInfo = new { steamId, SteamId = steamId },
SteamInfo = new { SteamId = steamId, steamId },
// presence
onlineState,
OnlineState = onlineState,
isOnline = onlineState > 0,
inMatch = onlineState == 2,
presence = new { onlineState, state = onlineState },
avatarUrl = "",
// nested profiles
profile = new { playerId = player.Id, displayName, avatarUrl = "" },
Profile = new { PlayerId = player.Id, DisplayName = displayName, PlayerProfileModel = new { DisplayName = displayName } },
tags = Array.Empty<string>(),
Tags = Array.Empty<string>(),
}); });
} }
} }
// NOTE: the exact response shape the client expects is not yet confirmed; this is a // Return the list under both the camelCase and PlayFab-cased container keys.
// best-effort structure. The log lets us verify resolution while we validate in game. var result = new { friends, Friends = friends, count = friends.Count };
_logger.LogInformation("GetFriendList for {User}: {Count} friend(s) resolved on server", userId, friends.Count); _logger.LogInformation("GetFriendList for {User}: {Count} friend(s); payload={Json}", userId, friends.Count, JsonSerializer.Serialize(result));
return new { friends }; return result;
} }
} }
@@ -1,5 +1,7 @@
using Prospect.Server.Api.Services.Auth.Extensions; using System.Text.Json;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models; using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -7,43 +9,53 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
public class GetPlayerSets : ICloudScriptFunction<FYGetPlayersSets, object?> public class GetPlayerSets : ICloudScriptFunction<FYGetPlayersSets, object?>
{ {
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public GetPlayerSets(IHttpContextAccessor httpContextAccessor) public GetPlayerSets(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
} }
public Task<object?> ExecuteAsync(FYGetPlayersSets request) public async Task<object?> ExecuteAsync(FYGetPlayersSets request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
{ {
return Task.FromResult<object?>(null); return null;
} }
return Task.FromResult<object?>(new var userId = context.User.FindAuthUserId();
// Return the player's persisted loadout (written by CompleteInventoryUpdate /
// RequestUpdateStationInventory) instead of a blank set, so saved gear survives
// a menu reopen / relog. Same response shape as before, just populated.
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "LOADOUT" });
var loadout = JsonSerializer.Deserialize<LoadoutData>(userData["LOADOUT"].Value) ?? new LoadoutData();
return new
{ {
success = true, success = true,
entries = new [] entries = new[]
{ {
new new
{ {
setData = new setData = new
{ {
id = "", id = "",
userId = context.User.FindAuthUserId(), userId,
kit = "", kit = "",
shield = "", shield = loadout.Shield ?? "",
helmet = "", helmet = loadout.Helmet ?? "",
weaponOne = "", weaponOne = loadout.WeaponOne ?? "",
weaponTwo = "", weaponTwo = loadout.WeaponTwo ?? "",
bag = "", bag = loadout.Bag ?? "",
bagItemsAsJsonStr = "", bagItemsAsJsonStr = loadout.BagItemsAsJsonStr ?? "",
safeItemsAsJsonStr = "" safeItemsAsJsonStr = loadout.SafeItemsAsJsonStr ?? ""
}, },
items = Array.Empty<object>() items = Array.Empty<object>()
} }
} }
}); };
} }
} }
@@ -8,13 +8,8 @@ public class GetPlayerStatisticsRequestsClientRequest
public string[] Statistics { get; set; } public string[] Statistics { get; set; }
} }
public class GetPlayerStatisticsRequestsClientResponse
{
// TODO: Unknown structure
}
[CloudScriptFunction("GetPlayerStatisticsRequestsClient")] [CloudScriptFunction("GetPlayerStatisticsRequestsClient")]
public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<GetPlayerStatisticsRequestsClientRequest, GetPlayerStatisticsRequestsClientResponse> public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<GetPlayerStatisticsRequestsClientRequest, object>
{ {
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
@@ -23,7 +18,7 @@ public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<Ge
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
} }
public async Task<GetPlayerStatisticsRequestsClientResponse> ExecuteAsync(GetPlayerStatisticsRequestsClientRequest request) public Task<object> ExecuteAsync(GetPlayerStatisticsRequestsClientRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
@@ -31,8 +26,15 @@ public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<Ge
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
return new GetPlayerStatisticsRequestsClientResponse // Return each requested statistic with a value so the career screen renders instead
// of staying blank. Raid stats (kills/deaths/evacs/damage/…) come from the
// client-hosted raid and aren't tracked server-side, so they report 0 for now.
var stats = new List<object>();
foreach (var name in request.Statistics ?? Array.Empty<string>())
{ {
}; stats.Add(new { statisticName = name, value = 0 });
}
return Task.FromResult<object>(new { statistics = stats });
} }
} }
@@ -1,5 +1,8 @@
using Microsoft.Extensions.Options; using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Options;
using Prospect.Server.Api.Config; using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models; using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -7,20 +10,63 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("GetSignalRConnection")] [CloudScriptFunction("GetSignalRConnection")]
public class GetSignalRConnection : ICloudScriptFunction<FYGetSignalRConnection, FYGetSignalRConnectionResult> public class GetSignalRConnection : ICloudScriptFunction<FYGetSignalRConnection, FYGetSignalRConnectionResult>
{ {
private readonly PlayFabSettings _settings; // LAN address of the homelab host. Clients on the local network get the SignalR URL on
// this IP (which their cert already validates via the IP/DNS SAN), while external clients
// keep the public domain from configuration. This matters because the real-time WebSocket
// (libwebsockets, incl. under Proton/Wine) can't reach the public domain from inside the
// LAN (hairpin NAT), which surfaced as "error code 5" right after the intro.
private const string LanHost = "192.168.1.136";
public GetSignalRConnection(IOptions<PlayFabSettings> settings) private readonly PlayFabSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<GetSignalRConnection> _logger;
public GetSignalRConnection(IOptions<PlayFabSettings> settings, IHttpContextAccessor httpContextAccessor, ILogger<GetSignalRConnection> logger)
{ {
_settings = settings.Value; _settings = settings.Value;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
} }
public Task<FYGetSignalRConnectionResult> ExecuteAsync(FYGetSignalRConnection request) public Task<FYGetSignalRConnectionResult> ExecuteAsync(FYGetSignalRConnection request)
{ {
// The game client connects to SignalR only over HTTPS // The game client connects to SignalR only over HTTPS.
var url = _settings.SignalRURL;
var remote = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
var lan = remote != null && IsLan(remote);
if (lan)
{
try
{
url = new UriBuilder(url) { Host = LanHost }.Uri.AbsoluteUri;
}
catch { /* keep the configured URL on any parsing issue */ }
}
// Tag the connection with the player id so the hub can map connection -> user and
// target server messages (deploy signal) instead of broadcasting to everyone.
var userId = _httpContextAccessor.HttpContext?.User.FindAuthUserId();
if (!string.IsNullOrEmpty(userId))
url += (url.Contains('?') ? "&" : "?") + "uid=" + Uri.EscapeDataString(userId);
_logger.LogInformation("GetSignalRConnection: remoteIp={Remote} lan={Lan} uid={Uid} -> {Url}", remote, lan, userId, url);
return Task.FromResult(new FYGetSignalRConnectionResult return Task.FromResult(new FYGetSignalRConnectionResult
{ {
Url = _settings.SignalRURL, Url = url,
AccessToken = _settings.SignalRAccessToken AccessToken = _settings.SignalRAccessToken
}); });
} }
}
private static bool IsLan(IPAddress ip)
{
if (IPAddress.IsLoopback(ip)) return true;
if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
if (ip.AddressFamily != AddressFamily.InterNetwork) return false;
var b = ip.GetAddressBytes();
return b[0] == 10
|| (b[0] == 172 && b[1] >= 16 && b[1] <= 31)
|| (b[0] == 192 && b[1] == 168);
}
}
@@ -155,12 +155,14 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction<PurchaseWeapo
var blueprintData = blueprints[request.BaseItemID]; var blueprintData = blueprints[request.BaseItemID];
// Contract unlock criteria: some items can only be purchased after completing a // Contract unlock criteria: some items can only be purchased after completing a
// specific contract. Only gate when the item explicitly declares such a lock, so // specific contract. The data uses the sentinel "None" (not an empty string) for
// items with no lock keep working as before. // items with no lock — treat that (and null/empty) as "no lock", otherwise EVERY
if (!string.IsNullOrEmpty(blueprintData.UnlockData?.ContractLockPurchase)) // item gets gated and nothing can be bought.
var contractLockPurchase = blueprintData.UnlockData?.ContractLockPurchase;
if (!string.IsNullOrEmpty(contractLockPurchase) && contractLockPurchase != "None")
{ {
var completed = JsonSerializer.Deserialize<FYGetCompletedContractsResult>(userData["ContractsOneTimeCompleted"].Value); var completed = JsonSerializer.Deserialize<FYGetCompletedContractsResult>(userData["ContractsOneTimeCompleted"].Value);
if (completed?.ContractsIDs == null || !completed.ContractsIDs.Contains(blueprintData.UnlockData.ContractLockPurchase)) if (completed?.ContractsIDs == null || !completed.ContractsIDs.Contains(contractLockPurchase))
{ {
return new PurchaseWeaponShopItemResponse return new PurchaseWeaponShopItemResponse
{ {
@@ -1,6 +1,9 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Database.Models;
using Prospect.Server.Api.Services.Squad;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Prospect.Server.Api.Hubs; using Prospect.Server.Api.Hubs;
@@ -28,12 +31,16 @@ public class SquadMemberReadyForMatchFunction : ICloudScriptFunction<SquadMember
private readonly ILogger<SquadMemberReadyForMatchFunction> _logger; private readonly ILogger<SquadMemberReadyForMatchFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHubContext<CycleHub> _hubContext; private readonly IHubContext<CycleHub> _hubContext;
private readonly SquadService _squadService;
private readonly DbUserService _userService;
public SquadMemberReadyForMatchFunction(ILogger<SquadMemberReadyForMatchFunction> logger, IHttpContextAccessor httpContextAccessor, IHubContext<CycleHub> hubContext) public SquadMemberReadyForMatchFunction(ILogger<SquadMemberReadyForMatchFunction> logger, IHttpContextAccessor httpContextAccessor, IHubContext<CycleHub> hubContext, SquadService squadService, DbUserService userService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_logger = logger; _logger = logger;
_hubContext = hubContext; _hubContext = hubContext;
_squadService = squadService;
_userService = userService;
} }
public async Task<SquadMemberReadyForMatchResponse> ExecuteAsync(SquadMemberReadyForMatchRequest request) public async Task<SquadMemberReadyForMatchResponse> ExecuteAsync(SquadMemberReadyForMatchRequest request)
@@ -44,27 +51,25 @@ public class SquadMemberReadyForMatchFunction : ICloudScriptFunction<SquadMember
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
var userId = context.User.FindAuthUserId(); var userId = context.User.FindAuthUserId();
var squadId = string.IsNullOrEmpty(request.SquadID) ? userId : request.SquadID;
// Register the caller into the squad (party is formed client-side; each member's client
// calls this with the shared squadId) and record their readiness / selected map.
var displayName = (await _userService.FindByIdAsync(userId))?.DisplayName ?? "";
_squadService.JoinOrCreate(squadId, userId, displayName);
var ready = request.matchmakingSettings?.isReadyForMatch ?? false;
_squadService.SetReady(squadId, userId, ready, request.matchmakingSettings?.selectedMapName);
var squad = _squadService.Get(squadId)!;
var isSquadReady = _squadService.IsSquadReady(squadId);
_logger.LogInformation("SquadMemberReadyForMatch: {User} ready={Ready} in squad {Squad} ({Count} member(s), squadReady={SquadReady})",
userId, ready, squadId, squad.Members.Count, isSquadReady);
return new SquadMemberReadyForMatchResponse return new SquadMemberReadyForMatchResponse
{ {
Result = 0, // EYSquadActionResult::OK Result = 0, // EYSquadActionResult::OK
Squad = new FYPlayFabSquad { Squad = _squadService.ToClientSquad(squad),
SquadID = "100", // TODO IsSquadReadyForMatch = isSquadReady,
// Members = [
// new FYPlayFabSquadMember {
// Profile = new FYPlayFabPlayerProfile {
// PlayerId = userId,
// },
// onlineState = 0, // EYUserState::IN_STATION
// matchmakingSettings = new FYUserMatchmakingSettings {
// isReadyForMatch = true,
// isSecretLeader = true,
// selectedMapName = "Map01",
// }
// }
// ],
},
IsSquadReadyForMatch = true,
}; };
} }
} }
@@ -1,5 +1,8 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Squad;
public class SquadMemberSelectedMapRequest public class SquadMemberSelectedMapRequest
{ {
[JsonPropertyName("squadId")] [JsonPropertyName("squadId")]
@@ -17,15 +20,26 @@ public class SquadMemberSelectedMapResponse
public class SquadMemberSelectedMapFunction : ICloudScriptFunction<SquadMemberSelectedMapRequest, SquadMemberSelectedMapResponse> public class SquadMemberSelectedMapFunction : ICloudScriptFunction<SquadMemberSelectedMapRequest, SquadMemberSelectedMapResponse>
{ {
private readonly ILogger<SquadMemberSelectedMapFunction> _logger; private readonly ILogger<SquadMemberSelectedMapFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly SquadService _squadService;
public SquadMemberSelectedMapFunction(ILogger<SquadMemberSelectedMapFunction> logger) public SquadMemberSelectedMapFunction(ILogger<SquadMemberSelectedMapFunction> logger, IHttpContextAccessor httpContextAccessor, SquadService squadService)
{ {
_logger = logger; _logger = logger;
_httpContextAccessor = httpContextAccessor;
_squadService = squadService;
} }
public async Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request) public Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request)
{ {
return new SquadMemberSelectedMapResponse var context = _httpContextAccessor.HttpContext;
{}; if (context != null && !string.IsNullOrEmpty(request.SquadID))
{
var userId = context.User.FindAuthUserId();
_squadService.JoinOrCreate(request.SquadID, userId, "");
_squadService.SetMap(request.SquadID, userId, request.SelectedMapName ?? "");
_logger.LogInformation("SquadMemberSelectedMap: {User} -> {Map} (squad {Squad})", userId, request.SelectedMapName, request.SquadID);
}
return Task.FromResult(new SquadMemberSelectedMapResponse { });
} }
} }
@@ -1,5 +1,8 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Squad;
public class TryGetCompleteSquadInfoRequest public class TryGetCompleteSquadInfoRequest
{ {
@@ -51,26 +54,30 @@ public class TryGetCompleteSquadInfoFunction : ICloudScriptFunction<TryGetComple
{ {
private readonly ILogger<TryGetCompleteSquadInfoFunction> _logger; private readonly ILogger<TryGetCompleteSquadInfoFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly SquadService _squadService;
public TryGetCompleteSquadInfoFunction(ILogger<TryGetCompleteSquadInfoFunction> logger, IHttpContextAccessor httpContextAccessor) public TryGetCompleteSquadInfoFunction(ILogger<TryGetCompleteSquadInfoFunction> logger, IHttpContextAccessor httpContextAccessor, SquadService squadService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_logger = logger; _logger = logger;
_squadService = squadService;
} }
public async Task<FYPlayFabSquad> ExecuteAsync(TryGetCompleteSquadInfoRequest request) public Task<FYPlayFabSquad> ExecuteAsync(TryGetCompleteSquadInfoRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
{ {
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
// var userId = context.User.FindAuthUserId(); var userId = context.User.FindAuthUserId();
var squad = _squadService.GetByUser(userId);
return new FYPlayFabSquad if (squad == null)
{ {
// SquadID = "100", // Not in a squad yet — return an empty squad (same as the previous stub).
// Members = [], return Task.FromResult(new FYPlayFabSquad());
}; }
_logger.LogInformation("TryGetCompleteSquadInfo: user {User} in squad {Squad} ({Count} member(s))", userId, squad.SquadId, squad.Members.Count);
return Task.FromResult(_squadService.ToClientSquad(squad));
} }
} }
@@ -1,12 +1,51 @@
using Prospect.Server.Api.Services.CloudScript.Models; using System.Text.Json;
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
// Persisted presence, read back by GetFriendList to show friends' online / in-match state.
public class PlayerPresenceState
{
[JsonPropertyName("inMatch")]
public bool InMatch { get; set; }
[JsonPropertyName("lastSeenUtc")]
public long LastSeenUtc { get; set; }
}
[CloudScriptFunction("UpdatePlayerPresenceState")] [CloudScriptFunction("UpdatePlayerPresenceState")]
public class UpdatePlayerPresenceState : ICloudScriptFunction<FYUpdatePlayerPresenceStateRequest, object> public class UpdatePlayerPresenceState : ICloudScriptFunction<FYUpdatePlayerPresenceStateRequest, object>
{ {
public Task<object> ExecuteAsync(FYUpdatePlayerPresenceStateRequest request) private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public UpdatePlayerPresenceState(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{ {
return Task.FromResult<object>(new { }); _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
} }
}
public async Task<object> ExecuteAsync(FYUpdatePlayerPresenceStateRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
return new { };
}
var userId = context.User.FindAuthUserId();
var state = new PlayerPresenceState
{
InMatch = request.InMatch,
LastSeenUtc = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
};
await _userDataService.UpdateAsync(
userId, userId,
new Dictionary<string, string> { ["PresenceState"] = JsonSerializer.Serialize(state) }
);
return new { };
}
}
@@ -16,6 +16,12 @@ public class DbEntityService : BaseDbService<PlayFabEntity>
return await Collection.Find(user => user.UserId == userId).SingleOrDefaultAsync(); return await Collection.Find(user => user.UserId == userId).SingleOrDefaultAsync();
} }
// Admin back-office: list every entity (title_player_account).
public async Task<List<PlayFabEntity>> GetAllAsync()
{
return await Collection.Find(_ => true).ToListAsync();
}
private async Task<PlayFabEntity> CreateAsync(string userId) private async Task<PlayFabEntity> CreateAsync(string userId)
{ {
var user = new PlayFabEntity var user = new PlayFabEntity
@@ -46,6 +46,19 @@ public class DbUserDataService : BaseDbService<PlayFabUserData>
return await query.ToCursorAsync(); return await query.ToCursorAsync();
} }
// Admin back-office: every key/value stored for a player.
public async Task<List<PlayFabUserData>> GetAllForUserAsync(string playFabId)
{
return await Collection.Find(data => data.PlayFabId == playFabId)
.SortBy(data => data.Key).ToListAsync();
}
// Admin back-office: total stored key/value documents.
public async Task<long> CountAsync()
{
return await Collection.CountDocumentsAsync(_ => true);
}
public async Task UpdateValueAsync(string dataId, string value) public async Task UpdateValueAsync(string dataId, string value)
{ {
var update = Builders<PlayFabUserData>.Update var update = Builders<PlayFabUserData>.Update
@@ -13,11 +13,31 @@ public class DbUserService : BaseDbService<PlayFabUser>
public async Task<PlayFabUser> FindAsync(PlayFabUserAuthType type, string key) public async Task<PlayFabUser> FindAsync(PlayFabUserAuthType type, string key)
{ {
return await Collection.Find(user => user.Auth.Any(auth => return await Collection.Find(user => user.Auth.Any(auth =>
auth.Type == type && auth.Type == type &&
auth.Key == key)).FirstOrDefaultAsync(); auth.Key == key)).FirstOrDefaultAsync();
} }
// Resolve a player by its PlayFab id (used to display squad member names).
public async Task<PlayFabUser> FindByIdAsync(string id)
{
return await Collection.Find(user => user.Id == id).FirstOrDefaultAsync();
}
// Admin back-office: list every player.
public async Task<List<PlayFabUser>> GetAllAsync()
{
return await Collection.Find(_ => true).ToListAsync();
}
// Persist a display-name change (UpdateUserTitleDisplayName). Without this the rename
// is echoed back to the client but lost on next login.
public async Task UpdateDisplayNameAsync(string id, string displayName)
{
var update = Builders<PlayFabUser>.Update.Set(user => user.DisplayName, displayName);
await Collection.UpdateOneAsync(user => user.Id == id, update);
}
private async Task<PlayFabUser> CreateAsync(PlayFabUserAuthType type, string key) private async Task<PlayFabUser> CreateAsync(PlayFabUserAuthType type, string key)
{ {
var user = new PlayFabUser var user = new PlayFabUser
@@ -0,0 +1,49 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Prospect.Server.Api.Services.GameRef;
// One reference entry: readable name, rarity, category. Sourced from community-datamined
// data (TCF-Wiki), bundled as Data/gameref.json. Compact keys n/r/c to keep the file small.
public class GameRefEntry
{
[JsonPropertyName("n")] public string Name { get; set; } = "";
[JsonPropertyName("r")] public string Rarity { get; set; } = "";
[JsonPropertyName("c")] public string Category { get; set; } = "";
}
// Resolves internal item / vanity ids to readable names for the admin back-office.
public class GameRefService
{
private readonly Dictionary<string, GameRefEntry> _ref = new(StringComparer.OrdinalIgnoreCase);
public GameRefService(ILogger<GameRefService> logger)
{
try
{
var path = Path.Combine(AppContext.BaseDirectory, "Data", "gameref.json");
if (File.Exists(path))
{
var data = JsonSerializer.Deserialize<Dictionary<string, GameRefEntry>>(File.ReadAllText(path));
if (data != null)
{
foreach (var kv in data)
{
_ref[kv.Key] = kv.Value;
}
}
}
logger.LogInformation("GameRef loaded {Count} entries", _ref.Count);
}
catch (Exception e)
{
logger.LogWarning(e, "GameRef load failed");
}
}
public GameRefEntry? Get(string? id) => id != null && _ref.TryGetValue(id, out var e) ? e : null;
public string Name(string? id) => Get(id)?.Name ?? id ?? "";
public IReadOnlyDictionary<string, GameRefEntry> All => _ref;
}
@@ -0,0 +1,99 @@
using System.Collections.Concurrent;
namespace Prospect.Server.Api.Services.Squad;
// In-memory squad/lobby state. Squads are keyed by the squadId the client provides on the
// SquadMember* calls (the client forms the party and shares a squadId; each member's client
// then calls the backend with it). A member is (lazily) registered into that squad on any
// squad call, and the current squad for a user is tracked so TryGetCompleteSquadInfo — which
// carries no squadId — can still return it. State is process-local (fine for a small server).
public class SquadMemberInfo
{
public string UserId { get; set; } = "";
public string DisplayName { get; set; } = "";
public int OnlineState { get; set; }
public bool IsReady { get; set; }
public string SelectedMap { get; set; } = "";
public bool IsLeader { get; set; }
}
public class SquadInfo
{
public string SquadId { get; set; } = "";
public ConcurrentDictionary<string, SquadMemberInfo> Members { get; } = new();
}
public class SquadService
{
private readonly ConcurrentDictionary<string, SquadInfo> _squads = new();
private readonly ConcurrentDictionary<string, string> _userToSquad = new();
public SquadInfo JoinOrCreate(string squadId, string userId, string displayName)
{
var squad = _squads.GetOrAdd(squadId, id => new SquadInfo { SquadId = id });
var isFirst = squad.Members.IsEmpty;
var member = squad.Members.GetOrAdd(userId, uid => new SquadMemberInfo { UserId = uid, IsLeader = isFirst });
if (!string.IsNullOrEmpty(displayName)) member.DisplayName = displayName;
_userToSquad[userId] = squadId;
return squad;
}
public SquadInfo? GetByUser(string userId)
=> _userToSquad.TryGetValue(userId, out var sid) && _squads.TryGetValue(sid, out var sq) ? sq : null;
public SquadInfo? Get(string squadId)
=> _squads.TryGetValue(squadId, out var sq) ? sq : null;
public void SetReady(string squadId, string userId, bool ready, string? selectedMap)
{
if (_squads.TryGetValue(squadId, out var sq) && sq.Members.TryGetValue(userId, out var m))
{
m.IsReady = ready;
if (!string.IsNullOrEmpty(selectedMap)) m.SelectedMap = selectedMap;
}
}
public void SetMap(string squadId, string userId, string map)
{
if (_squads.TryGetValue(squadId, out var sq) && sq.Members.TryGetValue(userId, out var m))
m.SelectedMap = map;
}
public bool IsSquadReady(string squadId)
=> _squads.TryGetValue(squadId, out var sq) && !sq.Members.IsEmpty && sq.Members.Values.All(m => m.IsReady);
public void Leave(string userId)
{
if (_userToSquad.TryRemove(userId, out var sid) && _squads.TryGetValue(sid, out var sq))
{
sq.Members.TryRemove(userId, out _);
if (sq.Members.IsEmpty) _squads.TryRemove(sid, out _);
}
}
// Build the client-facing squad object (models live in the global namespace).
public FYPlayFabSquad ToClientSquad(SquadInfo squad)
{
return new FYPlayFabSquad
{
SquadID = squad.SquadId,
Members = squad.Members.Values.Select(m => new FYPlayFabSquadMember
{
Profile = new FYPlayFabPlayerProfile
{
PlayerId = m.UserId,
DisplayName = m.DisplayName,
AvatarUrl = "",
},
onlineState = m.OnlineState,
matchmakingSettings = new FYUserMatchmakingSettings
{
isReadyForMatch = m.IsReady,
selectedMapName = m.SelectedMap,
isSecretLeader = m.IsLeader,
},
mapRowNamesUnlocked = Array.Empty<string>(),
}).ToArray(),
};
}
}
@@ -0,0 +1,55 @@
using System.Text.Json;
namespace Prospect.Server.Api.Services.Steam;
// Resolves a Steam persona (display) name from a SteamID64 via the Steam Web API.
// PlayFab does this server-side too; our emulator only gets the SteamID from the auth
// ticket, so the real name needs an API key. Inert (no-op) when no key is configured.
public class SteamWebApiService
{
private static readonly HttpClient Http = new() { Timeout = TimeSpan.FromSeconds(5) };
private readonly string? _apiKey;
private readonly ILogger<SteamWebApiService> _logger;
public SteamWebApiService(IConfiguration configuration, ILogger<SteamWebApiService> logger)
{
_apiKey = configuration["SteamWebApiKey"];
_logger = logger;
}
public bool Enabled => !string.IsNullOrWhiteSpace(_apiKey);
public async Task<string?> GetPersonaNameAsync(string steamId64)
{
if (!Enabled)
{
return null;
}
try
{
var url = $"https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v2/?key={_apiKey}&steamids={steamId64}";
var json = await Http.GetStringAsync(url);
using var doc = JsonDocument.Parse(json);
if (doc.RootElement.TryGetProperty("response", out var response) &&
response.TryGetProperty("players", out var players) &&
players.ValueKind == JsonValueKind.Array)
{
foreach (var player in players.EnumerateArray())
{
if (player.TryGetProperty("personaname", out var name))
{
return name.GetString();
}
}
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Steam GetPlayerSummaries failed for {SteamId}", steamId64);
}
return null;
}
}
File diff suppressed because one or more lines are too long
+6
View File
@@ -30,11 +30,17 @@ public class Startup
services.AddSingleton<UserDataService>(); services.AddSingleton<UserDataService>();
services.AddSingleton<TitleDataService>(); services.AddSingleton<TitleDataService>();
services.AddSingleton<Prospect.Server.Api.Services.Steam.SteamWebApiService>();
services.AddSingleton<Prospect.Server.Api.Services.GameRef.GameRefService>();
services.AddSingleton<DbUserService>(); services.AddSingleton<DbUserService>();
services.AddSingleton<DbEntityService>(); services.AddSingleton<DbEntityService>();
services.AddSingleton<DbUserDataService>(); services.AddSingleton<DbUserDataService>();
services.AddSingleton<Prospect.Server.Api.Services.Squad.SquadService>();
services.AddSingleton<Prospect.Server.Api.Hubs.SignalRConnectionRegistry>();
services.AddHostedService<QosService>(); services.AddHostedService<QosService>();
services.AddSingleton<QosServer>(); services.AddSingleton<QosServer>();
+26
View File
@@ -25,6 +25,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Client.Loader", "P
EndProject EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Agent", "Prospect.Agent\Prospect.Agent.vcxproj", "{A9BA7D25-F239-4320-A0DC-85E8001FD669}" Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Agent", "Prospect.Agent\Prospect.Agent.vcxproj", "{A9BA7D25-F239-4320-A0DC-85E8001FD669}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prospect.Client.Config", "Prospect.Client.Config\Prospect.Client.Config.csproj", "{6DECEB07-996B-4EBC-927C-13E170161107}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64 Debug|x64 = Debug|x64
@@ -233,6 +235,30 @@ Global
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x64.Build.0 = Release|x64 {A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x64.Build.0 = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.ActiveCfg = Release|x64 {A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.ActiveCfg = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.Build.0 = Release|x64 {A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.Build.0 = Release|x64
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x64.ActiveCfg = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x64.Build.0 = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x86.ActiveCfg = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x86.Build.0 = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x64.ActiveCfg = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x64.Build.0 = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x86.ActiveCfg = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x86.Build.0 = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x64.ActiveCfg = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x64.Build.0 = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x86.ActiveCfg = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x86.Build.0 = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x64.ActiveCfg = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x64.Build.0 = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x86.ActiveCfg = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x86.Build.0 = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x64.ActiveCfg = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x64.Build.0 = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x86.ActiveCfg = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x86.Build.0 = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x64.ActiveCfg = Season 3 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x64.Build.0 = Season 3 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x86.ActiveCfg = Season 3 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x86.Build.0 = Season 3 Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE