Author SHA1 Message Date
neckfireandClaude Opus 4.8 a4e006f84f docs: guide d installation client pour les amis (serveur prive)
Build & Deploy / build (push) Successful in 15s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:33:37 +00:00
neckfire 3cdf8bc7e2 Merge pull request 'Boutiques : GetStoreItems (vendeurs + Aurum) + fix vanities possédées' (#2) from preprod into main
Build & Deploy / build (push) Successful in 13s
2026-07-14 12:07:18 +00:00
neckfire 67f3095896 Merge pull request 'Promotion preprod → prod : contrats/kills, factions, tech-tree, assurance payout, Fortuna Pass' (#1) from preprod into main
Build & Deploy / build (push) Successful in 11s
2026-07-14 10:33:31 +00:00
41 changed files with 290 additions and 2021 deletions
+10 -27
View File
@@ -9,38 +9,21 @@ Guide pour **installer le client et jouer sur le serveur privé**. Tu n'installe
--- ---
## Fichiers à télécharger (dans la release) ## Ce que l'hébergeur doit te fournir
Les 2 fichiers sont dispo dans la **release Gitea** : 1. **`LoaderPack.zip`** (le loader qui redirige le jeu vers le serveur privé).
👉 **https://git.nfteam.ovh/neckfire/the-cycle/releases/tag/friends-client** 2. **`certificate.crt`** (le certificat **public** du serveur — sans clé privée, safe à partager).
1. **`LoaderPack.zip`** — le loader qui redirige le jeu vers le serveur privé (build b8 / Saison 2).
2. **`certificate.crt`** — le certificat **public** du serveur (sans clé privée, safe).
Télécharge les deux depuis la page de release avant de commencer.
--- ---
## Étapes (Windows) ## Étapes (Windows)
### 1. Installer le client Saison 2 (via la console Steam) ### 1. Installer le client Saison 2
1. Steam ouvert, `Win+R` → tape `steam://nav/console` → Entrée (la **console Steam** s'ouvre). Deux options :
2. Colle **exactement** cette commande → Entrée : - **Simple** : installe The Cycle: Frontier depuis ta bibliothèque Steam (`steam://install/868270`).
``` - **Recommandé (client Saison 2 exact)** via la console Steam :
download_depot 868270 868271 4623363103423775682 1. Steam ouvert, `Win+R``steam://nav/console`.
``` 2. Va sur les manifests SteamDB du dépôt 868271, cherche `4623363103423775682` (client S2 v2.7.2).
(`868270` = le jeu, `868271` = le dépôt client, `4623363103423775682` = manifest **Saison 2 v2.7.2**.) 3. Copie la commande **Steam console** et colle-la dans la console Steam → le téléchargement démarre.
3. Le téléchargement démarre (⚠️ **pas de barre de progression**, sois patient — plusieurs Go).
4. À la fin, Steam affiche **« Depot download complete »** + le **chemin du dossier**
(ex. `...\Steam\steamapps\content\app_868270\depot_868271`). **Ce dossier = ton install du jeu.**
> Il faut **avoir eu The Cycle: Frontier dans ta bibliothèque Steam** avant, sinon → erreur `missing license`.
> **Pas assez d'espace sur le disque principal ?** `download_depot` écrit toujours dans `...\Steam\steamapps\content`. Pour le mettre sur un autre disque : ferme Steam, supprime `...\Steam\steamapps\content`, puis en **cmd admin** :
> ```
> mkdir "D:\SteamContent"
> mklink /J "C:\Program Files (x86)\Steam\steamapps\content" "D:\SteamContent"
> ```
> Rouvre Steam et relance la commande → le jeu ira sur `D:\`.
### 2. Installer le LoaderPack ### 2. Installer le LoaderPack
1. Va dans le dossier du jeu → **`Prospect\Binaries\Win64`**. 1. Va dans le dossier du jeu → **`Prospect\Binaries\Win64`**.
+161 -188
View File
@@ -1,249 +1,222 @@
# The Cycle: Frontier — serveur privé (émulateur Prospect) # Prospect <!-- omit in toc -->
Émulateur des services en ligne de **The Cycle: Frontier** (jeu retiré de Steam en Also known as "The Cycle: Frontier".
septembre 2022), permettant de rejouer en solo sur un serveur auto-hébergé.
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
une CI Gitea.
> **Ce n'est pas du multijoueur.** Le raid tourne **côté client** (station solo) : chacun ## Table of Contents <!-- omit in toc -->
> joue sa propre instance. Le serveur partage la progression, les comptes et les boutiques,
> pas la partie. Le vrai multi (squad en raid, voix de proximité) suppose un serveur de jeu
> Unreal dédié — voir [Hors-périmètre & R&D](#hors-périmètre--rd).
--- - [Features](#features)
- [Running locally](#running-locally)
- [1. Prerequisites](#1-prerequisites)
- [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)
- [2. Unpack `Prospect.Server.Api`](#2-unpack-prospectserverapi)
- [3. Generate and import SSL certificate](#3-generate-and-import-ssl-certificate)
- [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)
- [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)
- [Body parts are missing with Season 3 client](#body-parts-are-missing-with-season-3-client)
- [Prospect.Server.Api does not start](#prospectserverapi-does-not-start)
- [Login Failed. Error code: 3](#login-failed-error-code-3)
- [Login Failed. Error code: 5](#login-failed-error-code-5)
- [Development](#development)
## Sommaire ## Features
- [Comment ça marche](#comment-ça-marche) * [x] Basic login with Steam
- [Structure du dépôt](#structure-du-dépôt) * [x] EULA acceptance
- [Build & lancement du serveur](#build--lancement-du-serveur) * [x] Tutorial
- [Certificat TLS](#certificat-tls) * [x] Single-player station (Season 2 and Season 3):
- [CI/CD](#cicd) * [x] Onboarding
- [Config du client (switch de serveur + certificat)](#config-du-client-switch-de-serveur--certificat) * [ ] Matchmaking and deployment
- [État des fonctionnalités](#état-des-fonctionnalités) * [x] Solo
- [Hors-périmètre & R&D](#hors-périmètre--rd) * [ ] Squad
- [Crédits & licence](#crédits--licence) * [ ] 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
## Comment ça marche > [!NOTE]
> If you've already done all steps previously, you can skip to Step 7.
Le client officiel parle à PlayFab. On l'intercepte côté client et on le redirige vers ### 1. Prerequisites
notre serveur, qui réimplémente juste ce qu'il faut de PlayFab (auth Steam, CloudScript,
UserData/TitleData, matchmaking solo).
```mermaid > [!WARNING]
flowchart LR > The latest Steam version of The Cycle: Frontier currently does not work with Windows 11 24H2!
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. > [!IMPORTANT]
- **`Prospect.Agent`** hooke l'URL de l'API PlayFab et la remplace par le contenu de > You must have The Cycle: Frontier from Steam in your Steam library to be able to download it.
**`backend.txt`** (placé dans `Prospect/Binaries/Win64`). Absent → fallback > Otherwise, the download will fail with an error message about missing license.
`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 Before you start, you'll need the following software downloaded and installed:
par l'outil [`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
--- 1. [MongoDB Community Edition](https://fastdl.mongodb.org/windows/mongodb-windows-x86_64-8.0.4-signed.msi).
## Structure du dépôt 1. [`Prospect.Server.Api` and `LoaderPack`](https://github.com/deiteris/Prospect/releases) from the Releases section:
| Projet | Rôle | - For Season 3 (the latest Steam game client), use Build 6.
|---|---|
| **`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é. |
Build config **`Season 2 Release`** obligatoire pour l'API (le code sélectionne la saison - For Season 2 game client, use the latest version.
via `#if SEASON_2_RELEASE` / `SEASON_3_RELEASE` → sinon `#error Unsupported build type`).
--- 1. The Cycle: Frontier game client:
## Build & lancement du serveur - The latest version from [Steam](https://steamcommunity.com/app/868270).
Le serveur tourne en conteneur. L'image est buildée depuis les sources par le - Season 2 client version `4623363103423775682` from SteamDB. See [download instructions below](#11-how-to-download-season-2-client-from-steamdb-using-steam-console).
[`Dockerfile`](Dockerfile) (multi-stage SDK .NET 8 → runtime aspnet 8, publish en
`Season 2 Release`).
### Build de l'image #### 1.1 How to download Season 2 client from SteamDB using Steam console
```bash > [!WARNING]
docker build -t the-cycle . > This will overwrite the existing client if you try to download a different manifest!
```
### Lancement 1. With Steam running, press `Win+R` and enter `steam://nav/console`. A Steam console will open.
Il faut une **instance MongoDB** joignable et un **certificat TLS** monté (voir section 1. Open [The Cycle: Frontier SteamDB manifests](https://steamdb.info/depot/868271/manifests/).
suivante). Variables d'environnement :
| Variable | Rôle | 1. Make sure you have **Copy format** set to **Steam console**.
|---|---|
| `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. |
```bash 1. Press `CTRL+F` and enter `4623363103423775682` to find the manifest for Season 2 version 2.7.2 client.
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`. 1. Click the ![Copy](./_assets/steamdb_copy.PNG) icon to copy the download command.
> 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) 1. Paste the command in the Steam console and press `Enter`.
```bash 1. The depot will begin downloading. You should receive a notification and the destination folder when the download is complete.
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"
```
--- ### 2. Unpack `Prospect.Server.Api`
## Certificat TLS Use your favorite ZIP archiver and unzip the `Prospect.Server.Api.zip` downloaded from this repository.
La connexion est en HTTPS et le client valide le certificat → il doit être **auto-signé et ### 3. Generate and import SSL certificate
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. > [!IMPORTANT]
> Le HTTP du jeu (libcurl) accepte l'IP en SAN IP, mais le WebSocket (libwebsockets) valide > Do not share the generated certificate! Generated certificate includes a private key that may be used to generate other certificates and compromise your security.
> 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 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
[`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat). to successfully communicate with the local server. Do the following:
--- 1. Open the folder with `Prospect.Server.Api`.
## CI/CD 1. Double-click `generate_ssl.exe`. `certificate.pfx` will appear in the same folder.
[`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) — sur push `main` / `preprod` 1. Double-click `certificate.pfx`. The Certificate Import Wizard will open:
(ou `workflow_dispatch`) :
1. build de l'image depuis le `Dockerfile` ; 1. Select **Current User** under Store Location and click **Next**.
2. push sur le registry `git.nfteam.ovh/neckfire/the-cycle` ;
3. notification du résultat (ntfy).
**Modèle de branches :** 1. Leave **File to Import** unchanged and click **Next**.
| Branche | Tag image | Usage | 1. Leave **Password** empty and click **Next**.
|---|---|---|
| `preprod` | `:preprod` (+ `:preprod-<sha>`) | banc de test — valider un build avant de merger |
| `main` | `:latest` (+ `:<sha>`) | production |
Workflow type : coder → push `preprod` → tester sur le serveur preprod → **PR `preprod → main`** 1. Select **Place all certificates in the following store** > **Browse...**. Choose **Trusted Root Certification Authorities** and click **OK**. Click **Next**.
→ la CI republie `:latest`. Le déploiement applique la nouvelle image
(`docker compose pull && docker compose up -d`).
> `Prospect.Client.Config` (outil client, cross-OS) n'est **pas** buildé par cette CI — 1. Click **Finish**. A **Security Warning** popup may appear, make sure it specifies `2EA46.playfabapi.com` certification authority and click **Yes**.
> voir sa section publication.
--- ### 4. Extract `LoaderPack` to the game
## Config du client (switch de serveur + certificat) 1. Open the folder with The Cycle: Frontier and navigate to **Prospect** > **Binaries** > **Win64**.
L'outil **`Prospect.Client.Config`** (binaire `ProspectServerSwitcher`, multi-OS 1. Open the `LoaderPack` archive.
Linux/Proton + Windows) fait tout le boulot côté client :
- écrit `backend.txt` (presets **prod** / **preprod** ou URL libre) ; 1. Drag and drop the contents of the `LoaderPack` archive to the game.
- 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.
```bash 1. Create a shortcut for the `Prospect.Client.Loader` that you will use later to launch the game.
# menu interactif
ProspectServerSwitcher
# scriptable ### 5. Run the server
ProspectServerSwitcher --folder "<...>/Prospect/Binaries/Win64" --set preprod
ProspectServerSwitcher --set https://mon-serveur:8443
```
Détails complets, gotchas Proton (préfixe non-Steam) et commandes de publication des 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.
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 > [!IMPORTANT]
> LoaderPack, importer le certificat) : **[FRIENDS-INSTALL.md](FRIENDS-INSTALL.md)**. > Do not close the console when you run the game.
--- ### 6. Run the game
## État des fonctionnalités Once the server is running, make sure that Steam is running and open The Cycle: Frontier using the shortcut you've created before.
**Fonctionne :** ## Troubleshooting and FAQ
- [x] Login Steam, EULA, tutoriel ### How to remove the certificate?
- [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
**Non implémenté / hors-périmètre :** If you've installed the certificate for the **Current User**:
- [ ] Squad / multi dans le **même** raid — nécessite un serveur de jeu dédié 1. Open **Start** and enter `certmgr.msc`.
- [ ] 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
--- 1. Expand **Trusted Root Certification Authorities** and select **Certificates**.
## Hors-périmètre & R&D 1. Find `2EA46.playfabapi.com`, right-click it > **Delete**.
Un **serveur de jeu Unreal dédié** (`Prospect.Server.Game` + `Prospect.Unreal`, branche If you've installed the certificate for the **Local Machine**, repeat the same steps but instead open `certlm.msc`.
`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`).
Le « lobby squad » via l'API seule n'est **pas faisable** : l'invitation d'amis est gérée ### `generate_ssl.exe` is flagged as a virus
100 % côté client Steam.
--- `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`.
## Crédits & licence ### Body parts are missing with Season 3 client
Fork de [`deiteris/Prospect`](https://github.com/deiteris/Prospect) (lui-même issu du 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.
projet Prospect original). Voir [`LICENSE`](LICENSE). Usage privé.
### 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
@@ -1,404 +0,0 @@
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; } = "";
}
}
@@ -1,15 +0,0 @@
<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
@@ -1,102 +0,0 @@
# 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).
@@ -1,431 +0,0 @@
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,7 +32,6 @@ 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,
@@ -40,8 +39,7 @@ 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;
@@ -50,7 +48,6 @@ public class ClientController : Controller
_entityService = entityService; _entityService = entityService;
_userDataService = userDataService; _userDataService = userDataService;
_titleDataService = titleDataService; _titleDataService = titleDataService;
_steamWebApi = steamWebApi;
} }
[AllowAnonymous] [AllowAnonymous]
@@ -66,19 +63,6 @@ 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);
@@ -156,18 +140,8 @@ 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 async Task<IActionResult> AddGenericId(FAddGenericIDRequest request) public 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,
@@ -300,12 +274,8 @@ 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 async Task<IActionResult> UpdateUserTitleDisplayName(FUpdateUserTitleDisplayNameRequest request) public 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
+3 -22
View File
@@ -1,31 +1,12 @@
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()
{ {
// The client connects with the SignalR URL returned by GetSignalRConnection, which Console.WriteLine("Connected {0}", Context.ConnectionId);
// 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);
}
}
@@ -1,29 +0,0 @@
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;
}
@@ -9,14 +9,6 @@ public class RequestLoggerMiddleware
private readonly ILogger<RequestLoggerMiddleware> _logger; private readonly ILogger<RequestLoggerMiddleware> _logger;
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
// Keywords used to surface social/squad/invite traffic while reverse-engineering the
// squad-invite flow (see SQUAD-EMULATION.md). Matched against the path AND the body
// (the CloudScript function name lives in the body of /Client/ExecuteFunction).
private static readonly string[] SocialKeywords =
{
"group", "party", "lobby", "squad", "invite", "friend", "social", "matchmak",
};
public RequestLoggerMiddleware(ILogger<RequestLoggerMiddleware> logger, RequestDelegate next) public RequestLoggerMiddleware(ILogger<RequestLoggerMiddleware> logger, RequestDelegate next)
{ {
_logger = logger; _logger = logger;
@@ -40,27 +32,6 @@ public class RequestLoggerMiddleware
} }
await _next(context); await _next(context);
// ── Capture pass (squad-invite RE, see SQUAD-EMULATION.md) ──────────────
// Runs AFTER the pipeline so we know whether the request was actually routed.
if (context.Request.Method == "POST")
{
var path = context.Request.Path.Value ?? "";
var haystack = (path + " " + body).ToLowerInvariant();
// Any POST that fell through to a 404 = an endpoint the emulator does NOT implement
// (e.g. a native PlayFab /Group/CreateGroup or /Lobby/* the client tried to call).
if (context.Response.StatusCode == 404)
{
_logger.LogWarning("[CAPTURE UNROUTED] {Method} {Url} -> 404 | Body {Body}",
context.Request.Method, context.Request.GetDisplayUrl(), body);
}
else if (SocialKeywords.Any(k => haystack.Contains(k)))
{
_logger.LogInformation("[CAPTURE SOCIAL] {Url} ({Status}) | Body {Body}",
context.Request.GetDisplayUrl(), context.Response.StatusCode, body);
}
}
} }
private static async Task<string> RequestAsync(HttpRequest request) private static async Task<string> RequestAsync(HttpRequest request)
@@ -72,21 +72,6 @@ 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,10 +34,4 @@
<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,16 +81,9 @@ 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", seasonXpKey } new List<string>{"ContractsActive", "ContractsOneTimeCompleted", "Balance", "Inventory", factionKey, "JobBoardsData" }
); );
var factionProgression = JsonSerializer.Deserialize<int>(userData[factionKey].Value); var factionProgression = JsonSerializer.Deserialize<int>(userData[factionKey].Value);
@@ -138,17 +131,16 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
break; break;
} }
} }
} else if (objective.Type.IsRaidRuntime()) { } else if (objective.Type == EYContractObjectiveType.Kills) {
// Raid-runtime objectives (kills, dead-drops, visited areas, looted // Kill objectives can't be tracked in a client-hosted raid (no dedicated
// containers) can't be tracked in a client-hosted raid — no dedicated // game server reports kills). They are auto-credited on deploy
// game server reports them, so their progress is never saved. They are // (EnterMatchmakingMatch); accept them here even if a contract was
// auto-credited on deploy (EnterMatchmakingMatch); accept them here too // activated before that so kill contracts remain completable.
// (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 {
// Meta objectives (FactionLevel / CompletedMission): validated from the // NOTE: A game server must write the correct progress
// persisted progress. The objectives are mapped by array index. // for other types of objectives.
// The objectives are mapped by array index.
remaining -= targetContract.Progress[i]; remaining -= targetContract.Progress[i];
} }
if (remaining > 0) { if (remaining > 0) {
@@ -277,12 +269,6 @@ 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>{
@@ -292,7 +278,6 @@ 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),
} }
); );
@@ -311,7 +296,7 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
CurrentProgression = factionProgression, CurrentProgression = factionProgression,
}, },
Status = 18, // EYClaimContractRewardsStatus::OK Status = 18, // EYClaimContractRewardsStatus::OK
UpdatedSeasonXp = seasonXp // new season XP total (client derives the pass level from it) UpdatedSeasonXp = 0 // TODO: Probably a separate season XP rate configured by server?
}; };
} }
} }
@@ -1,8 +1,5 @@
using System.Text.Json;
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.UserData;
public class ClientsideFriendsImportRequest public class ClientsideFriendsImportRequest
{ {
@@ -21,33 +18,15 @@ public class ClientsideFriendsImportResponse
public class ClientsideFriendsImportFunction : ICloudScriptFunction<ClientsideFriendsImportRequest, ClientsideFriendsImportResponse> public class ClientsideFriendsImportFunction : ICloudScriptFunction<ClientsideFriendsImportRequest, ClientsideFriendsImportResponse>
{ {
private readonly ILogger<ClientsideFriendsImportFunction> _logger; private readonly ILogger<ClientsideFriendsImportFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public ClientsideFriendsImportFunction(ILogger<ClientsideFriendsImportFunction> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService) public ClientsideFriendsImportFunction(ILogger<ClientsideFriendsImportFunction> logger)
{ {
_logger = logger; _logger = logger;
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
} }
public async Task<ClientsideFriendsImportResponse> ExecuteAsync(ClientsideFriendsImportRequest request) public async Task<ClientsideFriendsImportResponse> ExecuteAsync(ClientsideFriendsImportRequest request)
{ {
var context = _httpContextAccessor.HttpContext; _logger.LogInformation("Processing friends import");
if (context == null)
{
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
// Persist the platform friend ids the client imports (Steam ids) so GetFriendList
// can later resolve which of them actually play on this server.
var ids = request.UserIDs ?? Array.Empty<string>();
await _userDataService.UpdateAsync(userId, userId, new Dictionary<string, string>
{
["ImportedSteamFriends"] = JsonSerializer.Serialize(ids),
});
_logger.LogInformation("Imported {Count} {Platform} friend id(s) for user {User}", ids.Length, request.Platform, userId);
return new ClientsideFriendsImportResponse{}; return new ClientsideFriendsImportResponse{};
} }
@@ -25,24 +25,6 @@ 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,15 +24,13 @@ 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, SignalRConnectionRegistry registry) public EnterMatchmakingMatchFunction(IHubContext<CycleHub> hubContext, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService)
{ {
_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)
@@ -62,17 +60,12 @@ 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];
// Raid-runtime objectives (kills, dead-drops, visited areas, looted // Kill objectives: the client-hosted raid has no dedicated game server
// containers) are simulated inside the client-hosted raid; with no // to report per-kill progress to the backend, so kills would never be
// dedicated game server their progress is never reported to the backend, // saved. Auto-credit them on deploy so the objective can be completed
// so they would never be saved. Auto-credit them on deploy so the // (mirrors the player-kill auto-credit done in ActivateContract).
// objective persists and the station shows the contract as completable. if (objective.Type == EYContractObjectiveType.Kills) {
if (objective.Type.IsRaidRuntime()) {
contractActive.Progress[i] = objective.MaxProgress; contractActive.Progress[i] = objective.MaxProgress;
continue; continue;
} }
@@ -101,23 +94,11 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
} }
); );
// Send the "matchmaking succeeded / travel to match" signal only to the deploying await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", new OnSquadMatchmakingSuccessMessage {
// 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 = string.IsNullOrEmpty(gameServer) ? request.MapName : gameServer, SessionID = request.MapName, // TODO: Need to implement TryGetCompleteSquadInfo and pass squad info
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,8 +1,6 @@
using System.Text.Json; using Prospect.Server.Api.Services.Auth.Extensions;
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;
@@ -10,15 +8,13 @@ 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, UserDataService userDataService) public GetCraftingInProgressData(IHttpContextAccessor httpContextAccessor)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
} }
public async Task<FYGetCraftingInProgressDataResult> ExecuteAsync(FYGetCraftingInProgressDataRequest request) public Task<FYGetCraftingInProgressDataResult> ExecuteAsync(FYGetCraftingInProgressDataRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
@@ -26,20 +22,11 @@ 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");
} }
var userId = context.User.FindAuthUserId(); return Task.FromResult(new FYGetCraftingInProgressDataResult
// 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 = userId, UserId = context.User.FindAuthUserId(),
Error = string.Empty, Error = string.Empty,
ItemCurrentlyBeingCrafted = craft, ItemCurrentlyBeingCrafted = {},
}; });
} }
} }
@@ -1,119 +1,15 @@
using System.Text.Json; using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Database.Models;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("GetFriendList")] [CloudScriptFunction("GetFriendList")]
public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?> public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
{ {
private readonly ILogger<GetFriendList> _logger; public Task<object?> ExecuteAsync(FYBaseSocialRequest request)
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
private readonly DbUserService _userService;
public GetFriendList(ILogger<GetFriendList> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, DbUserService userService)
{ {
_logger = logger; return Task.FromResult<object?>(new
_httpContextAccessor = httpContextAccessor; {
_userDataService = userDataService;
_userService = userService; });
} }
}
public async Task<object?> ExecuteAsync(FYBaseSocialRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
// Imported Steam friends (persisted by ClientsideFriendsImport), resolved to players
// that actually exist on this private server.
var steamIds = new List<string>();
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "ImportedSteamFriends" });
if (userData.TryGetValue("ImportedSteamFriends", out var rec) && !string.IsNullOrWhiteSpace(rec.Value))
{
try { steamIds = JsonSerializer.Deserialize<List<string>>(rec.Value) ?? new List<string>(); }
catch { /* ignore malformed */ }
}
var friends = new List<object>();
if (steamIds.Count > 0)
{
var players = await _userService.FindManyAsync(PlayFabUserAuthType.Steam, steamIds);
var nowUtc = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
foreach (var player in players)
{
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
{
// identity
friendPlayFabId = player.Id,
FriendPlayFabId = player.Id,
playerId = player.Id,
PlayerId = player.Id,
playFabId = player.Id,
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>(),
});
}
}
// Return the list under both the camelCase and PlayFab-cased container keys.
var result = new { friends, Friends = friends, count = friends.Count };
_logger.LogInformation("GetFriendList for {User}: {Count} friend(s); payload={Json}", userId, friends.Count, JsonSerializer.Serialize(result));
return result;
}
}
@@ -1,7 +1,5 @@
using System.Text.Json; using Prospect.Server.Api.Services.Auth.Extensions;
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;
@@ -9,53 +7,43 @@ 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, UserDataService userDataService) public GetPlayerSets(IHttpContextAccessor httpContextAccessor)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
} }
public async Task<object?> ExecuteAsync(FYGetPlayersSets request) public Task<object?> ExecuteAsync(FYGetPlayersSets request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
{ {
return null; return Task.FromResult<object?>(null);
} }
var userId = context.User.FindAuthUserId(); return Task.FromResult<object?>(new
// 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, userId = context.User.FindAuthUserId(),
kit = "", kit = "",
shield = loadout.Shield ?? "", shield = "",
helmet = loadout.Helmet ?? "", helmet = "",
weaponOne = loadout.WeaponOne ?? "", weaponOne = "",
weaponTwo = loadout.WeaponTwo ?? "", weaponTwo = "",
bag = loadout.Bag ?? "", bag = "",
bagItemsAsJsonStr = loadout.BagItemsAsJsonStr ?? "", bagItemsAsJsonStr = "",
safeItemsAsJsonStr = loadout.SafeItemsAsJsonStr ?? "" safeItemsAsJsonStr = ""
}, },
items = Array.Empty<object>() items = Array.Empty<object>()
} }
} }
}; });
} }
} }
@@ -8,8 +8,13 @@ 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, object> public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<GetPlayerStatisticsRequestsClientRequest, GetPlayerStatisticsRequestsClientResponse>
{ {
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
@@ -18,7 +23,7 @@ public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<Ge
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
} }
public Task<object> ExecuteAsync(GetPlayerStatisticsRequestsClientRequest request) public async Task<GetPlayerStatisticsRequestsClientResponse> ExecuteAsync(GetPlayerStatisticsRequestsClientRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
@@ -26,15 +31,8 @@ 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 each requested statistic with a value so the career screen renders instead return new GetPlayerStatisticsRequestsClientResponse
// 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,8 +1,5 @@
using System.Net; using Microsoft.Extensions.Options;
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;
@@ -10,63 +7,20 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("GetSignalRConnection")] [CloudScriptFunction("GetSignalRConnection")]
public class GetSignalRConnection : ICloudScriptFunction<FYGetSignalRConnection, FYGetSignalRConnectionResult> public class GetSignalRConnection : ICloudScriptFunction<FYGetSignalRConnection, FYGetSignalRConnectionResult>
{ {
// 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";
private readonly 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) public GetSignalRConnection(IOptions<PlayFabSettings> settings)
{ {
_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 = url, Url = _settings.SignalRURL,
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,14 +155,12 @@ 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. The data uses the sentinel "None" (not an empty string) for // specific contract. Only gate when the item explicitly declares such a lock, so
// items with no lock — treat that (and null/empty) as "no lock", otherwise EVERY // items with no lock keep working as before.
// item gets gated and nothing can be bought. if (!string.IsNullOrEmpty(blueprintData.UnlockData?.ContractLockPurchase))
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(contractLockPurchase)) if (completed?.ContractsIDs == null || !completed.ContractsIDs.Contains(blueprintData.UnlockData.ContractLockPurchase))
{ {
return new PurchaseWeaponShopItemResponse return new PurchaseWeaponShopItemResponse
{ {
@@ -6,7 +6,7 @@ public class RegionObject
[JsonPropertyName("region")] [JsonPropertyName("region")]
public string Region { get; set; } public string Region { get; set; }
[JsonPropertyName("ping")] [JsonPropertyName("ping")]
public long Ping { get; set; } // client sends 2147483648 (int.MaxValue+1) for "no ping" -> must be long or deserialization 500s public int Ping { get; set; }
[JsonPropertyName("instanceType")] [JsonPropertyName("instanceType")]
public int InstanceType { get; set; } public int InstanceType { get; set; }
} }
@@ -1,9 +1,6 @@
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;
@@ -31,16 +28,12 @@ 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, SquadService squadService, DbUserService userService) public SquadMemberReadyForMatchFunction(ILogger<SquadMemberReadyForMatchFunction> logger, IHttpContextAccessor httpContextAccessor, IHubContext<CycleHub> hubContext)
{ {
_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)
@@ -51,25 +44,27 @@ 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 = _squadService.ToClientSquad(squad), Squad = new FYPlayFabSquad {
IsSquadReadyForMatch = isSquadReady, SquadID = "100", // TODO
// 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,8 +1,5 @@
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")]
@@ -20,26 +17,15 @@ 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, IHttpContextAccessor httpContextAccessor, SquadService squadService) public SquadMemberSelectedMapFunction(ILogger<SquadMemberSelectedMapFunction> logger)
{ {
_logger = logger; _logger = logger;
_httpContextAccessor = httpContextAccessor;
_squadService = squadService;
} }
public Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request) public async Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request)
{ {
var context = _httpContextAccessor.HttpContext; return new SquadMemberSelectedMapResponse
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,8 +1,5 @@
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
{ {
@@ -54,30 +51,26 @@ 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, SquadService squadService) public TryGetCompleteSquadInfoFunction(ILogger<TryGetCompleteSquadInfoFunction> logger, IHttpContextAccessor httpContextAccessor)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_logger = logger; _logger = logger;
_squadService = squadService;
} }
public Task<FYPlayFabSquad> ExecuteAsync(TryGetCompleteSquadInfoRequest request) public async 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);
if (squad == null) return new FYPlayFabSquad
{ {
// Not in a squad yet — return an empty squad (same as the previous stub). // SquadID = "100",
return Task.FromResult(new FYPlayFabSquad()); // Members = [],
} };
_logger.LogInformation("TryGetCompleteSquadInfo: user {User} in squad {Squad} ({Count} member(s))", userId, squad.SquadId, squad.Members.Count);
return Task.FromResult(_squadService.ToClientSquad(squad));
} }
} }
@@ -1,51 +1,12 @@
using System.Text.Json; using Prospect.Server.Api.Services.CloudScript.Models;
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>
{ {
private readonly IHttpContextAccessor _httpContextAccessor; public Task<object> ExecuteAsync(FYUpdatePlayerPresenceStateRequest request)
private readonly UserDataService _userDataService;
public UpdatePlayerPresenceState(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{ {
_httpContextAccessor = httpContextAccessor; return Task.FromResult<object>(new { });
_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,12 +16,6 @@ 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,19 +46,6 @@ 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,31 +13,11 @@ 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
@@ -60,21 +40,7 @@ public class DbUserService : BaseDbService<PlayFabUser>
public async Task<PlayFabUser> FindOrCreateAsync(PlayFabUserAuthType type, string key) public async Task<PlayFabUser> FindOrCreateAsync(PlayFabUserAuthType type, string key)
{ {
return await FindAsync(type, key) ?? return await FindAsync(type, key) ??
await CreateAsync(type, key); await CreateAsync(type, key);
} }
// Resolve several auth keys (e.g. a batch of Steam IDs) to the players that exist on
// this server in a single query. Used by the friends list to keep only the imported
// Steam friends who have actually played here.
public async Task<List<PlayFabUser>> FindManyAsync(PlayFabUserAuthType type, IReadOnlyCollection<string> keys)
{
if (keys == null || keys.Count == 0)
{
return new List<PlayFabUser>();
}
var wanted = new HashSet<string>(keys);
return await Collection.Find(user => user.Auth.Any(auth =>
auth.Type == type && wanted.Contains(auth.Key))).ToListAsync();
}
} }
@@ -1,49 +0,0 @@
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;
}
@@ -1,99 +0,0 @@
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(),
};
}
}
@@ -1,55 +0,0 @@
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,17 +30,11 @@ 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,8 +25,6 @@ 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
@@ -235,30 +233,6 @@ 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