1 Commits
44 changed files with 291 additions and 2324 deletions
-80
View File
@@ -1,80 +0,0 @@
# 🎮 The Cycle: Frontier — Rejoindre le serveur privé (installation client)
Guide pour **installer le client et jouer sur le serveur privé**. Tu n'installes **rien côté serveur** — juste le jeu + un loader + un certificat.
> ⚠️ **À savoir avant de commencer**
> - Le jeu a été retiré de Steam en septembre 2022 : il faut **l'avoir déjà eu dans ta bibliothèque Steam** pour le retélécharger.
> - **Ce n'est pas du co-op en raid.** Chacun joue sa propre instance (stations solo) ; l'émulateur ne supporte pas plusieurs joueurs dans le même raid. Vous partagez le même serveur (progression, boutiques, comptes), pas la même partie.
> - Le serveur doit être **allumé** au moment où tu joues (demande à l'hébergeur).
---
## Fichiers à télécharger (dans la release)
Les 2 fichiers sont dispo dans la **release Gitea** :
👉 **https://git.nfteam.ovh/neckfire/the-cycle/releases/tag/friends-client**
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)
### 1. Installer le client Saison 2 (via la console Steam)
1. Steam ouvert, `Win+R` → tape `steam://nav/console` → Entrée (la **console Steam** s'ouvre).
2. Colle **exactement** cette commande → Entrée :
```
download_depot 868270 868271 4623363103423775682
```
(`868270` = le jeu, `868271` = le dépôt client, `4623363103423775682` = manifest **Saison 2 v2.7.2**.)
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
1. Va dans le dossier du jeu → **`Prospect\Binaries\Win64`**.
2. Ouvre `LoaderPack.zip` et **glisse tout son contenu** dans ce dossier `Win64`.
3. Crée un **raccourci** vers `Prospect.Client.Loader.exe` (c'est avec ça que tu lanceras le jeu).
### 3. Faire confiance au certificat du serveur
1. Double-clique **`certificate.crt`** → **Installer le certificat**.
2. **Emplacement : Utilisateur actuel** → Suivant.
3. **Placer tous les certificats dans le magasin suivant****Parcourir****Autorités de certification racines de confiance** → OK → Suivant → Terminer.
4. Accepte l'avertissement de sécurité (il mentionne `2EA46.playfabapi.com`).
### 4. Pointer le client vers le serveur
Dans **`Prospect\Binaries\Win64`**, crée (ou édite) un fichier **`backend.txt`** contenant **exactement** cette ligne :
```
https://tc.nfteam.ovh:8443
```
### 5. Lancer le jeu
1. **Steam doit être ouvert** (connecté à ton compte).
2. Lance le jeu via le **raccourci `Prospect.Client.Loader`** (⚠️ pas via Steam directement).
3. Connexion automatique avec ton compte Steam. Bienvenue sur Fortuna III ! 🚀
---
## Dépannage
| Symptôme | Cause / solution |
|---|---|
| `libcurl error 7 (Couldn't connect to server)` | Le serveur est **éteint** → demande à l'hébergeur de l'allumer. |
| Erreur de certificat / `hostname mismatch` | `certificate.crt` **pas importé** (ou pas dans « Autorités racines de confiance »). Refais l'étape 3. |
| `missing license` au téléchargement | Tu n'as jamais eu le jeu sur ce compte Steam → impossible de le retélécharger. |
| Parties du corps manquantes (client S3) | Va en station → change ton apparence une fois (corrige les IDs). |
---
*Serveur privé The Cycle: Frontier — émulateur Prospect. Usage privé entre amis.*
+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
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.
Also known as "The Cycle: Frontier".
> **Ce n'est pas du multijoueur.** Le raid tourne **côté client** (station solo) : chacun
> 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).
## Table of Contents <!-- omit in toc -->
---
- [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)
- [Structure du dépôt](#structure-du-dépôt)
- [Build & lancement du serveur](#build--lancement-du-serveur)
- [Certificat TLS](#certificat-tls)
- [CI/CD](#cicd)
- [Config du client (switch de serveur + certificat)](#config-du-client-switch-de-serveur--certificat)
- [État des fonctionnalités](#état-des-fonctionnalités)
- [Hors-périmètre & R&D](#hors-périmètre--rd)
- [Crédits & licence](#crédits--licence)
* [x] Basic login with Steam
* [x] EULA acceptance
* [x] Tutorial
* [x] Single-player station (Season 2 and Season 3):
* [x] Onboarding
* [ ] Matchmaking and deployment
* [x] Solo
* [ ] Squad
* [ ] Items insurance
* [ ] Free loadouts (Season 3)
* [x] Inventory and loadout
* [ ] Loadout presets (Season 3)
* [x] Quests
* [x] Faction progression
* [ ] Season pass
* [ ] Aurum Shops
* [ ] Daily shop
* [ ] Weekly shop
* [ ] Shop rotation
* [x] Daily login
* [x] Character appearance and emotes
* [x] Item Shops
* [x] Crafting station
* [x] Quarters
* [x] Player balance
* [ ] Social features
* [ ] Proximity voice
* [x] Vivox login
* [x] Vivox create and join channel
* [ ] Proximity voice works
* [x] Game mechanics
* [x] Can deploy through terminal
* [x] Can deploy with loadout
* [x] Can evac
* [x] Can do quests (except PvP)
* [x] Can gain/lose loot
* [x] Can use Alien Forge
* [x] Map content
* [x] Bright Sands
* [x] Crescent Falls
* [x] Tharis Island
---
## Running locally
## 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
notre serveur, qui réimplémente juste ce qu'il faut de PlayFab (auth Steam, CloudScript,
UserData/TitleData, matchmaking solo).
### 1. Prerequisites
```mermaid
flowchart LR
subgraph client [Poste de jeu]
L[Prospect.Client.Loader<br/>injecte l'agent] --> A[Prospect.Agent<br/>hooke l'URL PlayFab]
A -- lit --> B[backend.txt]
A --> G[Jeu &#40;TCF&#41;]
end
G -- HTTPS / SignalR --> API[Prospect.Server.Api<br/>émulateur PlayFab]
API --> M[(MongoDB)]
```
> [!WARNING]
> The latest Steam version of The Cycle: Frontier currently does not work with Windows 11 24H2!
- **`Prospect.Client.Loader`** lance le jeu en injectant l'agent.
- **`Prospect.Agent`** hooke l'URL de l'API PlayFab et la remplace par le contenu de
**`backend.txt`** (placé dans `Prospect/Binaries/Win64`). Absent → fallback
`https://127.0.0.1:8443`.
- **`Prospect.Server.Api`** émule PlayFab (auth Steam → JWT, CloudScript, données joueur)
et pousse le temps-réel via **SignalR**. Les données vivent dans **MongoDB**.
> [!IMPORTANT]
> You must have The Cycle: Frontier from Steam in your Steam library to be able to download it.
> Otherwise, the download will fail with an error message about missing license.
Toute la redirection du client tient donc dans **une seule valeur** (`backend.txt`) — gérée
par l'outil [`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
Before you start, you'll need the following software downloaded and installed:
---
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 |
|---|---|
| **`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é. |
- For Season 3 (the latest Steam game client), use Build 6.
Build config **`Season 2 Release`** obligatoire pour l'API (le code sélectionne la saison
via `#if SEASON_2_RELEASE` / `SEASON_3_RELEASE` → sinon `#error Unsupported build type`).
- For Season 2 game client, use the latest version.
---
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
[`Dockerfile`](Dockerfile) (multi-stage SDK .NET 8 → runtime aspnet 8, publish en
`Season 2 Release`).
- Season 2 client version `4623363103423775682` from SteamDB. See [download instructions below](#11-how-to-download-season-2-client-from-steamdb-using-steam-console).
### Build de l'image
#### 1.1 How to download Season 2 client from SteamDB using Steam console
```bash
docker build -t the-cycle .
```
> [!WARNING]
> 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
suivante). Variables d'environnement :
1. Open [The Cycle: Frontier SteamDB manifests](https://steamdb.info/depot/868271/manifests/).
| Variable | Rôle |
|---|---|
| `DatabaseSettings__ConnectionString` | URI de connexion MongoDB. |
| `DatabaseSettings__DatabaseName` | Base à utiliser (ex. `ProspectDb`). |
| `AuthTokenSettings__Secret` | Secret de signature des JWT émis par le serveur. |
| `PlayFabSettings__SignalRURL` | URL SignalR **telle que le client doit l'atteindre** (voir gotcha ci-dessous). |
| `Kestrel__Certificates__Default__Path` | Chemin du `.pfx` dans le conteneur. |
| `Kestrel__Endpoints__Https__Url` | ex. `https://0.0.0.0:8443`. |
| `SteamWebApiKey` | *(optionnel)* clé Steam Web API pour récupérer les pseudos ; inerte si absente. |
1. Make sure you have **Copy format** set to **Steam console**.
```bash
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
```
1. Press `CTRL+F` and enter `4623363103423775682` to find the manifest for Season 2 version 2.7.2 client.
> ⚠️ **`PlayFabSettings__SignalRURL` = l'URL que le CLIENT doit joindre**, pas `127.0.0.1`.
> Le serveur y renvoie le client pour l'event de matchmaking ; s'il pointe sur `127.0.0.1`,
> le déploiement en raid **timeout**. Mets le hostname/IP public du serveur.
1. Click the ![Copy](./_assets/steamdb_copy.PNG) icon to copy the download command.
### Dev local (.NET)
1. Paste the command in the Steam console and press `Enter`.
```bash
dotnet build src/Prospect.Server.Api/Prospect.Server.Api.csproj -c "Season 2 Release"
dotnet run --project src/Prospect.Server.Api -c "Season 2 Release"
```
1. The depot will begin downloading. You should receive a notification and the destination folder when the download is complete.
---
### 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
fait confiance** côté client. Générer le `.pfx` avec `utils/generate_ssl.py`.
### 3. Generate and import SSL certificate
> ⚠️ **Le SAN doit contenir `DNS:<ip>` ET `IP:<ip>`**, en plus des hostnames.
> Le HTTP du jeu (libcurl) accepte l'IP en SAN IP, mais le WebSocket (libwebsockets) valide
> l'IP contre les SAN **DNS** → sans `DNS:<ip>`, la connexion SignalR échoue
> (`Hostname mismatch err=62`). Inclure aussi `2EA46.playfabapi.com`, `localhost`,
> `127.0.0.1` et tous les hostnames publics utilisés dans `backend.txt`.
> [!IMPORTANT]
> Do not share the generated certificate! Generated certificate includes a private key that may be used to generate other certificates and compromise your security.
Côté client, l'import du certificat est automatisé par
[`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
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
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`
(ou `workflow_dispatch`) :
1. Double-click `certificate.pfx`. The Certificate Import Wizard will open:
1. build de l'image depuis le `Dockerfile` ;
2. push sur le registry `git.nfteam.ovh/neckfire/the-cycle` ;
3. notification du résultat (ntfy).
1. Select **Current User** under Store Location and click **Next**.
**Modèle de branches :**
1. Leave **File to Import** unchanged and click **Next**.
| Branche | Tag image | Usage |
|---|---|---|
| `preprod` | `:preprod` (+ `:preprod-<sha>`) | banc de test — valider un build avant de merger |
| `main` | `:latest` (+ `:<sha>`) | production |
1. Leave **Password** empty and click **Next**.
Workflow type : coder → push `preprod` → tester sur le serveur preprod → **PR `preprod → main`**
→ la CI republie `:latest`. Le déploiement applique la nouvelle image
(`docker compose pull && docker compose up -d`).
1. Select **Place all certificates in the following store** > **Browse...**. Choose **Trusted Root Certification Authorities** and click **OK**. Click **Next**.
> `Prospect.Client.Config` (outil client, cross-OS) n'est **pas** buildé par cette CI —
> voir sa section publication.
1. Click **Finish**. A **Security Warning** popup may appear, make sure it specifies `2EA46.playfabapi.com` certification authority and click **Yes**.
---
### 4. Extract `LoaderPack` to the game
## 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
Linux/Proton + Windows) fait tout le boulot côté client :
1. Open the `LoaderPack` archive.
- écrit `backend.txt` (presets **prod** / **preprod** ou URL libre) ;
- récupère le certificat **en direct depuis le serveur ciblé** (TLS) et le rend fiable :
- **Windows** : import dans *Autorités de certification racines de confiance* (utilisateur) ;
- **Linux/Proton** : import direct dans le préfixe Wine du jeu via `wine reg import`
(car `wine certutil` est cassé sous Proton) ;
- lance le jeu.
1. Drag and drop the contents of the `LoaderPack` archive to the game.
```bash
# menu interactif
ProspectServerSwitcher
1. Create a shortcut for the `Prospect.Client.Loader` that you will use later to launch the game.
# scriptable
ProspectServerSwitcher --folder "<...>/Prospect/Binaries/Win64" --set preprod
ProspectServerSwitcher --set https://mon-serveur:8443
```
### 5. Run the server
Détails complets, gotchas Proton (préfixe non-Steam) et commandes de publication des
binaires autonomes : **[src/Prospect.Client.Config/README.md](src/Prospect.Client.Config/README.md)**.
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.
> Installation complète pas-à-pas pour un nouveau joueur (télécharger le client S2, le
> LoaderPack, importer le certificat) : **[FRIENDS-INSTALL.md](FRIENDS-INSTALL.md)**.
> [!IMPORTANT]
> 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
- [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
### How to remove the certificate?
**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é
- [ ] 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. Open **Start** and enter `certmgr.msc`.
---
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
`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`).
If you've installed the certificate for the **Local Machine**, repeat the same steps but instead open `certlm.msc`.
Le « lobby squad » via l'API seule n'est **pas faisable** : l'invitation d'amis est gérée
100 % côté client Steam.
### `generate_ssl.exe` is flagged as a virus
---
`generate_ssl.exe` is a Python application packed with PyInstaller and some anti-viruses may flag it as a virus.
This application is a simple certificate generator and you can find its source code in `utils/generate_ssl.py`.
## 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
projet Prospect original). Voir [`LICENSE`](LICENSE). Usage privé.
Currently, the server loads body part IDs for Season 2 by default, so this is expected. You can fix this by going to station and changing your character appearance. This will store the updated body part IDs for your character.
### Prospect.Server.Api does not start
Make sure you have [.NET Runtime 8.0](https://aka.ms/dotnet-core-applaunch?missing_runtime=true&arch=x64&rid=win-x64&os=win10&apphost_version=8.0.11) and [ASP.NET Core 8.0](https://aka.ms/dotnet-core-applaunch?framework=Microsoft.AspNetCore.App&framework_version=8.0.0&arch=x64&rid=win-x64&os=win10) installed.
### Login Failed. Error code: 3
Make sure that:
* You have Steam running.
* You have created and **saved** the `steam_appid` file as described in step 6.
* The `steam_appid` file type is "TXT File".
### Login Failed. Error code: 5
Make sure that `Prospect.Server.Api` server is running.
If the server is running, press `Alt+Tab` to a game console that opens when you start the game and check for the following:
* `libcurl error 7 (Couldn't connect to server)` - indicates that the `Prospect.Server.Api` is not running.
![](./_assets/connection_refused_error.png)
* `InvalidAPIEndpoint` - indicates that you are running the game using the original shortcut and not using `Prospect.Client.Loader`.
![](./_assets/invalid_api_endpoint.PNG)
* `libcurl error 60 (Peer certificate cannot be authenticated with given CA certificates)` - indicates that the certificate was not installed correctly. Make sure that the certificate is present in `certmgr.msc` and there is only one certificate. Try removing the certificate and importing it again by following step 4.
![](./_assets/certificate_error.PNG)
* `HTTP code: 500` - usually indicates that MongoDB is not running. Make sure that MongoDB is installed and and that `MongoDB Server` is running in `services.msc`.
![](./_assets/mongodb_error.PNG)
## Development
TBD
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
-404
View File
@@ -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 UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly Prospect.Server.Api.Services.Steam.SteamWebApiService _steamWebApi;
public ClientController(ILogger<ClientController> logger,
IOptions<PlayFabSettings> settings,
@@ -40,8 +39,7 @@ public class ClientController : Controller
DbUserService userService,
DbEntityService entityService,
UserDataService userDataService,
TitleDataService titleDataService,
Prospect.Server.Api.Services.Steam.SteamWebApiService steamWebApi)
TitleDataService titleDataService)
{
_settings = settings.Value;
_logger = logger;
@@ -50,7 +48,6 @@ public class ClientController : Controller
_entityService = entityService;
_userDataService = userDataService;
_titleDataService = titleDataService;
_steamWebApi = steamWebApi;
}
[AllowAnonymous]
@@ -66,19 +63,6 @@ public class ClientController : Controller
var userSteamId = ticket.SteamId.ToString();
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 userTicket = _authTokenService.GenerateUser(entity);
@@ -156,18 +140,8 @@ public class ClientController : Controller
[HttpPost("AddGenericID")]
[Produces(MediaTypeNames.Application.Json)]
[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>
{
Code = 200,
@@ -181,131 +155,19 @@ public class ClientController : Controller
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
public IActionResult GetStoreItems(FGetStoreItems request)
{
// The vendors (Korolev / ICA / Osiris / QuickShop) and other stores are defined
// inside the Blueprints title data: each item lists a per-store entry under
// ItemShopsCraftingData keyed by the store id, with the currency price in its
// recipe ingredients. This endpoint was previously a stub returning {}, which is
// why every shop showed up empty. We build the store on the fly from that data.
var storeId = request.StoreId ?? "";
var store = new List<FStoreItem>();
try
{
var titleData = _titleDataService.Find(new List<string> { "Blueprints" });
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
if (blueprints != null && !string.IsNullOrEmpty(storeId))
{
uint position = 0;
foreach (var (itemId, blueprint) in blueprints)
{
if (blueprint.ItemShopsCraftingData == null) continue;
if (!blueprint.ItemShopsCraftingData.TryGetValue(storeId, out var shopData)) continue;
if (shopData?.ItemRecipeIngredients == null) continue;
// Only currency ingredients form a purchasable price. Entries whose
// recipe is item-based (crafting) are handled by the crafting UI from
// the blueprint data the client already has, not by the buy store.
var prices = new Dictionary<string, uint>();
foreach (var ingredient in shopData.ItemRecipeIngredients)
{
var code = ingredient.Currency switch
{
"SoftCurrency" => "SC",
"Aurum" => "AU",
"InsuranceCurrency" => "IN",
_ => null,
};
if (code == null || ingredient.Amount < 0) continue;
prices[code] = prices.TryGetValue(code, out var current)
? current + (uint)ingredient.Amount
: (uint)ingredient.Amount;
}
if (prices.Count == 0) continue;
store.Add(new FStoreItem
{
ItemId = itemId,
VirtualCurrencyPrices = prices,
RealCurrencyPrices = new Dictionary<string, uint>(),
DisplayPosition = position++,
});
}
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to build store items for store {StoreId}", storeId);
}
// Cosmetic / premium stores are backed by the Vanities title data (not Blueprints):
// each vanity carries its price in StoreData.Amount, paid in Aurum. AurumShop lists
// the whole priced catalogue; the daily/weekly rotations (ids from RequestStore
// RotationData) show a deterministic slice so they change over time but stay stable
// within a period.
var isAurumShop = storeId == "AurumShop";
var isDailyShop = storeId.StartsWith("DailyShop_");
var isWeeklyShop = storeId.StartsWith("WeeklyShop_");
if (store.Count == 0 && (isAurumShop || isDailyShop || isWeeklyShop))
{
try
{
var vanityData = _titleDataService.Find(new List<string> { "Vanities" });
var vanities = JsonSerializer.Deserialize<List<TitleDataVanityInfo>>(vanityData["Vanities"]);
var priced = vanities?
.Where(v => v.StoreData != null && v.StoreData.Amount > 0)
.OrderBy(v => v.Name, StringComparer.Ordinal)
.ToList() ?? new List<TitleDataVanityInfo>();
IEnumerable<TitleDataVanityInfo> selection = priced;
if ((isDailyShop || isWeeklyShop) && priced.Count > 0)
{
var size = isWeeklyShop ? 12 : 6;
int.TryParse(storeId[(storeId.IndexOf('_') + 1)..], out var rotation);
var start = (int)(((long)rotation * size) % priced.Count);
selection = Enumerable.Range(0, Math.Min(size, priced.Count))
.Select(i => priced[(start + i) % priced.Count]);
}
uint position = 0;
foreach (var vanity in selection)
{
store.Add(new FStoreItem
{
ItemId = vanity.Name,
VirtualCurrencyPrices = new Dictionary<string, uint> { ["AU"] = (uint)vanity.StoreData.Amount },
RealCurrencyPrices = new Dictionary<string, uint>(),
DisplayPosition = position++,
});
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to build vanity store {StoreId}", storeId);
}
}
_logger.LogInformation("GetStoreItems '{StoreId}': {Count} item(s)", storeId, store.Count);
return Ok(new ClientResponse<FGetStoreItemsResult>
return Ok(new ClientResponse<object>
{
Code = 200,
Status = "OK",
Data = new FGetStoreItemsResult
{
StoreId = string.IsNullOrEmpty(storeId) ? null : storeId,
CatalogVersion = "StaticItems",
Store = store,
}
Data = new {}
});
}
[HttpPost("UpdateUserTitleDisplayName")]
[Produces(MediaTypeNames.Application.Json)]
[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>
{
Code = 200,
@@ -382,15 +244,13 @@ public class ClientController : Controller
public async Task<IActionResult> GetUserInventory(FGetUserInventoryRequest request)
{
var userId = User.FindAuthUserId();
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Balance", "Inventory", "VanityItems", "CharacterVanity", "GlobalVanity"});
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Balance", "Inventory", "VanityItems"});
var inventory = JsonSerializer.Deserialize<FYCustomItemInfo[]>(userData["Inventory"].Value);
// Per-user vanity ownership: the player owns the cosmetics they've bought (VanityItems)
// plus whatever is currently equipped (CharacterVanity / GlobalVanity), so the starter
// outfit stays owned. Previously the whole Vanities catalogue was returned as owned,
// which made every item in the Aurum shop show up as already "owned".
var ownedVanityIds = CollectOwnedVanityIds(userData);
var titleData = _titleDataService.Find(new List<string>{"Blueprints"});
// TODO: Per-user vanity
// var vanity = JsonSerializer.Deserialize<CustomVanityItem[]>(userData["VanityItems"].Value);
var titleData = _titleDataService.Find(new List<string>{"Blueprints", "Vanities"});
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
var vanity = JsonSerializer.Deserialize<TitleDataVanityInfo[]>(titleData["Vanities"]);
List<FItemInstance> items = new List<FItemInstance>();
foreach (var item in inventory) {
if (!blueprints.ContainsKey(item.BaseItemId)) {
@@ -416,9 +276,9 @@ public class ClientController : Controller
});
}
foreach (var vanityId in ownedVanityIds) {
foreach (var item in vanity) {
items.Add(new FItemInstance {
ItemId = vanityId,
ItemId = item.Name,
// ItemInstanceId is not required since it's not a unique item.
ItemClass = "Vanity",
// PurchaseDate = DateTime.Now, // TODO
@@ -443,60 +303,6 @@ public class ClientController : Controller
});
}
// Collects the vanity ids a player actually owns: everything they've purchased
// (VanityItems) plus everything currently equipped (CharacterVanity slots and the
// GlobalVanity sprays / banner / emotes / drop pod). Parsed defensively with
// JsonDocument so malformed / partial data never breaks the inventory response.
private static HashSet<string> CollectOwnedVanityIds(Dictionary<string, FUserDataRecord> userData)
{
var owned = new HashSet<string>(StringComparer.Ordinal);
void AddId(string? id) { if (!string.IsNullOrEmpty(id)) owned.Add(id); }
if (userData.TryGetValue("VanityItems", out var viRec) && !string.IsNullOrWhiteSpace(viRec.Value))
{
try
{
using var doc = JsonDocument.Parse(viRec.Value);
if (doc.RootElement.ValueKind == JsonValueKind.Array)
foreach (var el in doc.RootElement.EnumerateArray())
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("baseItemId", out var b))
AddId(b.GetString());
}
catch { /* ignore malformed VanityItems */ }
}
if (userData.TryGetValue("CharacterVanity", out var cvRec) && !string.IsNullOrWhiteSpace(cvRec.Value))
{
try
{
using var doc = JsonDocument.Parse(cvRec.Value);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
foreach (var prop in doc.RootElement.EnumerateObject())
if (prop.Value.ValueKind == JsonValueKind.Object && prop.Value.TryGetProperty("id", out var id))
AddId(id.GetString());
}
catch { /* ignore malformed CharacterVanity */ }
}
if (userData.TryGetValue("GlobalVanity", out var gvRec) && !string.IsNullOrWhiteSpace(gvRec.Value))
{
try
{
using var doc = JsonDocument.Parse(gvRec.Value);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
if (root.TryGetProperty("activeGlobalVanityIds", out var arr) && arr.ValueKind == JsonValueKind.Array)
foreach (var el in arr.EnumerateArray()) AddId(el.GetString());
if (root.TryGetProperty("droppodId", out var dp)) AddId(dp.GetString());
}
}
catch { /* ignore malformed GlobalVanity */ }
}
return owned;
}
[HttpPost("GetTitleData")]
[Produces(MediaTypeNames.Application.Json)]
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
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;
public class CycleHub : Hub
{
private readonly SignalRConnectionRegistry _registry;
public CycleHub(SignalRConnectionRegistry registry)
{
_registry = registry;
}
public override async Task OnConnectedAsync()
{
// The client connects with the SignalR URL returned by GetSignalRConnection, which
// carries a `uid` query param — use it to map this connection to the player so
// server-initiated messages can target a single player instead of everyone.
var uid = Context.GetHttpContext()?.Request.Query["uid"].ToString();
if (!string.IsNullOrEmpty(uid))
_registry.Add(uid, Context.ConnectionId);
Console.WriteLine("Connected {0} (uid={1})", Context.ConnectionId, uid);
Console.WriteLine("Connected {0}", Context.ConnectionId);
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 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)
{
_logger = logger;
@@ -40,27 +32,6 @@ public class RequestLoggerMiddleware
}
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)
@@ -1,39 +0,0 @@
using System.Text.Json.Serialization;
namespace Prospect.Server.Api.Models.Client.Data;
// PlayFab StoreItem: a single catalog item offered for sale in a store, with its prices.
public class FStoreItem
{
/// <summary>
/// [optional] Store specific custom data. The data only exists as part of this store; it is not transferred to item instances.
/// </summary>
[JsonPropertyName("CustomData")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public object? CustomData { get; set; }
/// <summary>
/// [optional] Intended display position for this item. Note that 0 is the first position and -1 is the last position.
/// </summary>
[JsonPropertyName("DisplayPosition")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public uint? DisplayPosition { get; set; }
/// <summary>
/// Unique identifier of the item as it exists in the catalog - note that this must exactly match the ItemId from the catalog.
/// </summary>
[JsonPropertyName("ItemId")]
public string ItemId { get; set; } = "";
/// <summary>
/// [optional] Override prices for this item for specific currencies.
/// </summary>
[JsonPropertyName("RealCurrencyPrices")]
public Dictionary<string, uint> RealCurrencyPrices { get; set; } = new();
/// <summary>
/// [optional] Override prices for this item in virtual currencies and "RM" (the base Real Money purchase price, in USD pennies).
/// </summary>
[JsonPropertyName("VirtualCurrencyPrices")]
public Dictionary<string, uint> VirtualCurrencyPrices { get; set; } = new();
}
@@ -1,19 +1,8 @@
using System.Text.Json.Serialization;
using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Client.Data;
namespace Prospect.Server.Api.Models.Client;
public class FGetStoreItems
{
/// <summary>
/// [optional] Catalog version to store items from. Use default catalog version if null.
/// </summary>
[JsonPropertyName("CatalogVersion")]
public string? CatalogVersion { get; set; }
/// <summary>
/// Unique identifier for the store which is being requested.
/// </summary>
[JsonPropertyName("StoreId")]
public string? StoreId { get; set; }
}
}
@@ -1,25 +0,0 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Client.Data;
namespace Prospect.Server.Api.Models.Client;
public class FGetStoreItemsResult
{
/// <summary>
/// [optional] The base catalog that this store is a part of.
/// </summary>
[JsonPropertyName("CatalogVersion")]
public string? CatalogVersion { get; set; }
/// <summary>
/// [optional] Array of items which can be purchased from this store.
/// </summary>
[JsonPropertyName("Store")]
public List<FStoreItem> Store { get; set; } = new();
/// <summary>
/// [optional] The ID of this store.
/// </summary>
[JsonPropertyName("StoreId")]
public string? StoreId { get; set; }
}
@@ -72,21 +72,6 @@ public enum EYContractObjectiveType {
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
{
Invalid = 0,
@@ -34,10 +34,4 @@
<ProjectReference Include="..\Prospect.Steam\Prospect.Steam.csproj" />
</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>
@@ -81,16 +81,9 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
}
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(
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);
@@ -138,17 +131,16 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
break;
}
}
} else if (objective.Type.IsRaidRuntime()) {
// Raid-runtime objectives (kills, dead-drops, visited areas, looted
// containers) can't be tracked in a client-hosted raid — no dedicated
// game server reports them, so their progress is never saved. They are
// auto-credited on deploy (EnterMatchmakingMatch); accept them here too
// (safety net for a contract activated after deploy) so these contracts
// remain completable instead of being stuck on "Objective not met".
} else if (objective.Type == EYContractObjectiveType.Kills) {
// Kill objectives can't be tracked in a client-hosted raid (no dedicated
// game server reports kills). They are auto-credited on deploy
// (EnterMatchmakingMatch); accept them here even if a contract was
// activated before that so kill contracts remain completable.
remaining = 0;
} else {
// Meta objectives (FactionLevel / CompletedMission): validated from the
// persisted progress. The objectives are mapped by array index.
// NOTE: A game server must write the correct progress
// for other types of objectives.
// The objectives are mapped by array index.
remaining -= targetContract.Progress[i];
}
if (remaining > 0) {
@@ -277,12 +269,6 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
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(
userId, userId,
new Dictionary<string, string>{
@@ -292,7 +278,6 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
["Inventory"] = JsonSerializer.Serialize(newInventory),
[factionKey] = JsonSerializer.Serialize(factionProgression),
["JobBoardsData"] = JsonSerializer.Serialize(jobBoardsData),
[seasonXpKey] = JsonSerializer.Serialize(seasonXp),
}
);
@@ -311,7 +296,7 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
CurrentProgression = factionProgression,
},
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 Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.UserData;
public class ClientsideFriendsImportRequest
{
@@ -21,33 +18,15 @@ public class ClientsideFriendsImportResponse
public class ClientsideFriendsImportFunction : ICloudScriptFunction<ClientsideFriendsImportRequest, ClientsideFriendsImportResponse>
{
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;
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
}
public async Task<ClientsideFriendsImportResponse> ExecuteAsync(ClientsideFriendsImportRequest request)
{
var context = _httpContextAccessor.HttpContext;
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);
_logger.LogInformation("Processing friends import");
return new ClientsideFriendsImportResponse{};
}
@@ -25,24 +25,6 @@ public class EnterMatchmakingFunction : ICloudScriptFunction<FYEnterMatchAzureFu
// 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
{
Success = true,
@@ -24,15 +24,13 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
private readonly IHubContext<CycleHub> _hubContext;
private readonly UserDataService _userDataService;
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;
_hubContext = hubContext;
_userDataService = userDataService;
_titleDataService = titleDataService;
_registry = registry;
}
public async Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction request)
@@ -62,17 +60,12 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
continue;
}
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];
// Raid-runtime objectives (kills, dead-drops, visited areas, looted
// containers) are simulated inside the client-hosted raid; with no
// dedicated game server their progress is never reported to the backend,
// so they would never be saved. Auto-credit them on deploy so the
// objective persists and the station shows the contract as completable.
if (objective.Type.IsRaidRuntime()) {
// Kill objectives: the client-hosted raid has no dedicated game server
// to report per-kill progress to the backend, so kills would never be
// saved. Auto-credit them on deploy so the objective can be completed
// (mirrors the player-kill auto-credit done in ActivateContract).
if (objective.Type == EYContractObjectiveType.Kills) {
contractActive.Progress[i] = objective.MaxProgress;
continue;
}
@@ -101,23 +94,11 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
}
);
// Send the "matchmaking succeeded / travel to match" signal only to the deploying
// player (via their mapped SignalR connection) — broadcasting to Clients.All made
// every connected player travel too. Fall back to broadcast only if the connection
// isn't mapped yet.
// Dedicated-server mode (2nd preprod): point the client at the real game server address
// to travel to; otherwise keep the map name (client-hosted). Same image — env flips it.
var gameServer = Environment.GetEnvironmentVariable("GAMESERVER_ADDRESS");
var deployMessage = new OnSquadMatchmakingSuccessMessage {
await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", new OnSquadMatchmakingSuccessMessage {
Success = true,
SessionID = string.IsNullOrEmpty(gameServer) ? request.MapName : gameServer,
SessionID = request.MapName, // TODO: Need to implement TryGetCompleteSquadInfo and pass squad info
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
{
@@ -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.Data;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -10,15 +8,13 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
public class GetCraftingInProgressData : ICloudScriptFunction<FYGetCraftingInProgressDataRequest, FYGetCraftingInProgressDataResult>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public GetCraftingInProgressData(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
public GetCraftingInProgressData(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
}
public async Task<FYGetCraftingInProgressDataResult> ExecuteAsync(FYGetCraftingInProgressDataRequest request)
public Task<FYGetCraftingInProgressDataResult> ExecuteAsync(FYGetCraftingInProgressDataRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
@@ -26,20 +22,11 @@ public class GetCraftingInProgressData : ICloudScriptFunction<FYGetCraftingInPro
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
// Return the persisted in-progress craft (written by StartItemCraftingClient)
// instead of an empty stub, so the crafting timer survives a menu reopen / relog.
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "CraftingTimer__2022_05_12" });
// Fully qualified: a different FYItemCurrentlyBeingCrafted also exists in this namespace.
var craft = JsonSerializer.Deserialize<Models.Data.FYItemCurrentlyBeingCrafted>(userData["CraftingTimer__2022_05_12"].Value)
?? new Models.Data.FYItemCurrentlyBeingCrafted();
return new FYGetCraftingInProgressDataResult
return Task.FromResult(new FYGetCraftingInProgressDataResult
{
UserId = userId,
UserId = context.User.FindAuthUserId(),
Error = string.Empty,
ItemCurrentlyBeingCrafted = craft,
};
ItemCurrentlyBeingCrafted = {},
});
}
}
}
@@ -1,119 +1,15 @@
using System.Text.Json;
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;
using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("GetFriendList")]
public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
{
private readonly ILogger<GetFriendList> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
private readonly DbUserService _userService;
public GetFriendList(ILogger<GetFriendList> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, DbUserService userService)
public Task<object?> ExecuteAsync(FYBaseSocialRequest request)
{
_logger = logger;
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_userService = userService;
return Task.FromResult<object?>(new
{
});
}
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.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -9,53 +7,43 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
public class GetPlayerSets : ICloudScriptFunction<FYGetPlayersSets, object?>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public GetPlayerSets(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
public GetPlayerSets(IHttpContextAccessor httpContextAccessor)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
}
public async Task<object?> ExecuteAsync(FYGetPlayersSets request)
public Task<object?> ExecuteAsync(FYGetPlayersSets request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
return null;
return Task.FromResult<object?>(null);
}
var userId = context.User.FindAuthUserId();
// Return the player's persisted loadout (written by CompleteInventoryUpdate /
// RequestUpdateStationInventory) instead of a blank set, so saved gear survives
// a menu reopen / relog. Same response shape as before, just populated.
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "LOADOUT" });
var loadout = JsonSerializer.Deserialize<LoadoutData>(userData["LOADOUT"].Value) ?? new LoadoutData();
return new
return Task.FromResult<object?>(new
{
success = true,
entries = new[]
entries = new []
{
new
{
setData = new
{
id = "",
userId,
userId = context.User.FindAuthUserId(),
kit = "",
shield = loadout.Shield ?? "",
helmet = loadout.Helmet ?? "",
weaponOne = loadout.WeaponOne ?? "",
weaponTwo = loadout.WeaponTwo ?? "",
bag = loadout.Bag ?? "",
bagItemsAsJsonStr = loadout.BagItemsAsJsonStr ?? "",
safeItemsAsJsonStr = loadout.SafeItemsAsJsonStr ?? ""
shield = "",
helmet = "",
weaponOne = "",
weaponTwo = "",
bag = "",
bagItemsAsJsonStr = "",
safeItemsAsJsonStr = ""
},
items = Array.Empty<object>()
}
}
};
});
}
}
}
@@ -8,8 +8,13 @@ public class GetPlayerStatisticsRequestsClientRequest
public string[] Statistics { get; set; }
}
public class GetPlayerStatisticsRequestsClientResponse
{
// TODO: Unknown structure
}
[CloudScriptFunction("GetPlayerStatisticsRequestsClient")]
public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<GetPlayerStatisticsRequestsClientRequest, object>
public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<GetPlayerStatisticsRequestsClientRequest, GetPlayerStatisticsRequestsClientResponse>
{
private readonly IHttpContextAccessor _httpContextAccessor;
@@ -18,7 +23,7 @@ public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<Ge
_httpContextAccessor = httpContextAccessor;
}
public Task<object> ExecuteAsync(GetPlayerStatisticsRequestsClientRequest request)
public async Task<GetPlayerStatisticsRequestsClientResponse> ExecuteAsync(GetPlayerStatisticsRequestsClientRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
@@ -26,15 +31,8 @@ public class GetPlayerStatisticsRequestsClientFunction : ICloudScriptFunction<Ge
throw new CloudScriptException("CloudScript was not called within a http request");
}
// Return each requested statistic with a value so the career screen renders instead
// of staying blank. Raid stats (kills/deaths/evacs/damage/…) come from the
// client-hosted raid and aren't tracked server-side, so they report 0 for now.
var stats = new List<object>();
foreach (var name in request.Statistics ?? Array.Empty<string>())
return new GetPlayerStatisticsRequestsClientResponse
{
stats.Add(new { statisticName = name, value = 0 });
}
return Task.FromResult<object>(new { statistics = stats });
};
}
}
@@ -1,8 +1,5 @@
using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Options;
using Microsoft.Extensions.Options;
using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -10,63 +7,20 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("GetSignalRConnection")]
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 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;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
}
public Task<FYGetSignalRConnectionResult> ExecuteAsync(FYGetSignalRConnection request)
{
// 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);
// The game client connects to SignalR only over HTTPS
return Task.FromResult(new FYGetSignalRConnectionResult
{
Url = url,
Url = _settings.SignalRURL,
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];
// 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
// items with no lock — treat that (and null/empty) as "no lock", otherwise EVERY
// item gets gated and nothing can be bought.
var contractLockPurchase = blueprintData.UnlockData?.ContractLockPurchase;
if (!string.IsNullOrEmpty(contractLockPurchase) && contractLockPurchase != "None")
// specific contract. Only gate when the item explicitly declares such a lock, so
// items with no lock keep working as before.
if (!string.IsNullOrEmpty(blueprintData.UnlockData?.ContractLockPurchase))
{
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
{
@@ -6,7 +6,7 @@ public class RegionObject
[JsonPropertyName("region")]
public string Region { get; set; }
[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")]
public int InstanceType { get; set; }
}
@@ -1,9 +1,6 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.CloudScript;
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 Prospect.Server.Api.Hubs;
@@ -31,16 +28,12 @@ public class SquadMemberReadyForMatchFunction : ICloudScriptFunction<SquadMember
private readonly ILogger<SquadMemberReadyForMatchFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
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;
_logger = logger;
_hubContext = hubContext;
_squadService = squadService;
_userService = userService;
}
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");
}
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
{
Result = 0, // EYSquadActionResult::OK
Squad = _squadService.ToClientSquad(squad),
IsSquadReadyForMatch = isSquadReady,
Squad = new FYPlayFabSquad {
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 Prospect.Server.Api.Services.Auth.Extensions;
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Squad;
public class SquadMemberSelectedMapRequest
{
[JsonPropertyName("squadId")]
@@ -20,26 +17,15 @@ public class SquadMemberSelectedMapResponse
public class SquadMemberSelectedMapFunction : ICloudScriptFunction<SquadMemberSelectedMapRequest, SquadMemberSelectedMapResponse>
{
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;
_httpContextAccessor = httpContextAccessor;
_squadService = squadService;
}
public Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request)
public async Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context != null && !string.IsNullOrEmpty(request.SquadID))
{
var userId = context.User.FindAuthUserId();
_squadService.JoinOrCreate(request.SquadID, userId, "");
_squadService.SetMap(request.SquadID, userId, request.SelectedMapName ?? "");
_logger.LogInformation("SquadMemberSelectedMap: {User} -> {Map} (squad {Squad})", userId, request.SelectedMapName, request.SquadID);
}
return Task.FromResult(new SquadMemberSelectedMapResponse { });
return new SquadMemberSelectedMapResponse
{};
}
}
@@ -1,8 +1,5 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Squad;
public class TryGetCompleteSquadInfoRequest
{
@@ -54,30 +51,26 @@ public class TryGetCompleteSquadInfoFunction : ICloudScriptFunction<TryGetComple
{
private readonly ILogger<TryGetCompleteSquadInfoFunction> _logger;
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;
_logger = logger;
_squadService = squadService;
}
public Task<FYPlayFabSquad> ExecuteAsync(TryGetCompleteSquadInfoRequest request)
public async Task<FYPlayFabSquad> ExecuteAsync(TryGetCompleteSquadInfoRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
var squad = _squadService.GetByUser(userId);
if (squad == null)
// var userId = context.User.FindAuthUserId();
return new FYPlayFabSquad
{
// Not in a squad yet — return an empty squad (same as the previous stub).
return Task.FromResult(new FYPlayFabSquad());
}
_logger.LogInformation("TryGetCompleteSquadInfo: user {User} in squad {Squad} ({Count} member(s))", userId, squad.SquadId, squad.Members.Count);
return Task.FromResult(_squadService.ToClientSquad(squad));
// SquadID = "100",
// Members = [],
};
}
}
@@ -1,51 +1,12 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.UserData;
using Prospect.Server.Api.Services.CloudScript.Models;
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")]
public class UpdatePlayerPresenceState : ICloudScriptFunction<FYUpdatePlayerPresenceStateRequest, object>
{
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public UpdatePlayerPresenceState(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
public Task<object> ExecuteAsync(FYUpdatePlayerPresenceStateRequest request)
{
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
return Task.FromResult<object>(new { });
}
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();
}
// 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)
{
var user = new PlayFabEntity
@@ -46,19 +46,6 @@ public class DbUserDataService : BaseDbService<PlayFabUserData>
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)
{
var update = Builders<PlayFabUserData>.Update
@@ -13,31 +13,11 @@ public class DbUserService : BaseDbService<PlayFabUser>
public async Task<PlayFabUser> FindAsync(PlayFabUserAuthType type, string key)
{
return await Collection.Find(user => user.Auth.Any(auth =>
auth.Type == type &&
return await Collection.Find(user => user.Auth.Any(auth =>
auth.Type == type &&
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)
{
var user = new PlayFabUser
@@ -60,21 +40,7 @@ public class DbUserService : BaseDbService<PlayFabUser>
public async Task<PlayFabUser> FindOrCreateAsync(PlayFabUserAuthType type, string key)
{
return await FindAsync(type, key) ??
return await FindAsync(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<TitleDataService>();
services.AddSingleton<Prospect.Server.Api.Services.Steam.SteamWebApiService>();
services.AddSingleton<Prospect.Server.Api.Services.GameRef.GameRefService>();
services.AddSingleton<DbUserService>();
services.AddSingleton<DbEntityService>();
services.AddSingleton<DbUserDataService>();
services.AddSingleton<Prospect.Server.Api.Services.Squad.SquadService>();
services.AddSingleton<Prospect.Server.Api.Hubs.SignalRConnectionRegistry>();
services.AddHostedService<QosService>();
services.AddSingleton<QosServer>();
-26
View File
@@ -25,8 +25,6 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Client.Loader", "P
EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Agent", "Prospect.Agent\Prospect.Agent.vcxproj", "{A9BA7D25-F239-4320-A0DC-85E8001FD669}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prospect.Client.Config", "Prospect.Client.Config\Prospect.Client.Config.csproj", "{6DECEB07-996B-4EBC-927C-13E170161107}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
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|x86.ActiveCfg = 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
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE