12 Commits
Author SHA1 Message Date
neckfire 73178ed8c3 Merge pull request 'capture: catch-all logging for squad-invite RE (unrouted + social)' (#17) from preprod into main
Build & Deploy / build (push) Successful in 38s
2026-07-16 09:45:44 +00:00
neckfireandClaude Opus 4.8 3243b70555 game-server: catch-all request capture for squad-invite RE
Build & Deploy / build (push) Successful in 50s
Log every unrouted POST at Warning [CAPTURE UNROUTED] (reveals native PlayFab
endpoints the emulator doesn't implement, e.g. /Group/CreateGroup, /Lobby/*) and
every social/squad/invite/friend/matchmaking call (path or body) at Information
[CAPTURE SOCIAL]. Lets us pin the exact invite mechanism when reproduced in game.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 11:45:24 +02:00
neckfire 91bdde3797 Merge pull request 'friends: richer GetFriendList payload + logging' (#15) from preprod into main
Build & Deploy / build (push) Successful in 38s
2026-07-16 07:06:20 +00:00
neckfireandClaude Opus 4.8 d7821ebf1f friends: richer GetFriendList payload (field aliases) + log returned JSON
Build & Deploy / build (push) Successful in 53s
The friend tile rendered empty/offline because the UE client couldn't read our
best-guess response shape. Expose each friend's identity/name/steam/presence under
every plausible field name (camelCase + PlayFab PascalCase, flat + nested profile)
under both friends/Friends keys, and log the JSON so we can pin the exact shape.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 09:06:17 +02:00
neckfire e5ab911dfd Merge pull request 'fortuna: FortunaPass2_Rewards catalog (claimable tiers)' (#14) from preprod into main
Build & Deploy / build (push) Successful in 34s
2026-07-15 20:17:37 +00:00
neckfireandClaude Opus 4.8 81092b077e fortuna: add FortunaPass2_Rewards catalog so tiers can be claimed
Build & Deploy / build (push) Successful in 50s
The claim function only grants a tier when a FortunaPass2_Rewards TitleData catalog
maps its rewardId (Level_N, sent by the client) to an item/amount. That catalog was
absent (client-side DataTable) so claims granted nothing and stayed unclaimable.
Add a Level_1..Level_100 catalog (K-Marks scaling, Insurance every 5, Aurum every 10)
so players can actually collect Fortuna pass rewards.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 22:17:34 +02:00
neckfire 042f806697 Merge pull request 'docs: rewrite main README (dev + CI + client switch tool)' (#13) from preprod into main
Build & Deploy / build (push) Successful in 32s
2026-07-15 11:54:39 +00:00
neckfireandClaude Opus 4.8 95541b6221 docs: rewrite README around our build/CI + client switch tool
Build & Deploy / build (push) Successful in 32s
Replace the upstream Windows/local-run README with a dev-focused one: how the
client redirection works (loader+agent+backend.txt), repo structure, building
and running the API in a container, the TLS cert SAN gotcha, the Gitea CI/CD
branch model (preprod:preprod / main:latest), the Prospect.Client.Config
switch+cert tool, and an up-to-date feature status.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 13:54:36 +02:00
neckfire a4c8cf5955 Merge pull request 'admin: fix empty tables (DT engine ordering)' (#12) from preprod into main
Build & Deploy / build (push) Successful in 33s
2026-07-15 09:52:00 +00:00
neckfireandClaude Opus 4.8 a8fca38038 admin: fix empty tables — define DT engine before page init scripts
Build & Deploy / build (push) Successful in 43s
The DT table engine lived in the page footer, after the per-page
<script>DT.init(...)</script> in the body, so DT was undefined when init ran
(ReferenceError, nothing rendered). Move the engine into a DtScript const
injected right after <main>, before any body content, so it is always defined
first.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:51:58 +02:00
neckfire d1f3fd1125 Merge pull request 'admin: interactive tables (sort/filter/search/paging)' (#11) from preprod into main
Build & Deploy / build (push) Successful in 31s
2026-07-15 09:31:21 +00:00
neckfireandClaude Opus 4.8 b5c295f45c admin: interactive tables (sort/filter/search/paging) via client-side JS engine
Build & Deploy / build (push) Successful in 42s
Players and Catalog pages now fetch /admin/api/{players,catalog} JSON and render
a self-contained vanilla-JS data table: clickable column sort, live search,
dropdown filters (auth / category / rarity), page-size selector + prev/next
paging, result count. No CDN, works offline. Server-rendered dashboard,
player detail and titledata pages unchanged.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 11:30:22 +02:00
5 changed files with 437 additions and 302 deletions
+215 -188
View File
@@ -1,222 +1,249 @@
# Prospect <!-- omit in toc -->
Also known as "The Cycle: Frontier".
## 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)
# The Cycle: Frontier — serveur privé (émulateur Prospect)
É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.
> **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).
---
## Sommaire
- [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)
---
## Comment ça marche
Le client officiel parle à PlayFab. On l'intercepte côté client et on le redirige vers
notre serveur, qui réimplémente juste ce qu'il faut de PlayFab (auth Steam, CloudScript,
UserData/TitleData, matchmaking solo).
```mermaid
flowchart LR
subgraph client [Poste de jeu]
L[Prospect.Client.Loader<br/>injecte l'agent] --> A[Prospect.Agent<br/>hooke l'URL PlayFab]
A -- lit --> B[backend.txt]
A --> G[Jeu &#40;TCF&#41;]
end
G -- HTTPS / SignalR --> API[Prospect.Server.Api<br/>émulateur PlayFab]
API --> M[(MongoDB)]
```
- **`Prospect.Client.Loader`** lance le jeu en injectant l'agent.
- **`Prospect.Agent`** hooke l'URL de l'API PlayFab et la remplace par le contenu de
**`backend.txt`** (placé dans `Prospect/Binaries/Win64`). Absent → fallback
`https://127.0.0.1:8443`.
- **`Prospect.Server.Api`** émule PlayFab (auth Steam → JWT, CloudScript, données joueur)
et pousse le temps-réel via **SignalR**. Les données vivent dans **MongoDB**.
Toute la redirection du client tient donc dans **une seule valeur** (`backend.txt`) — gérée
par l'outil [`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
---
## Structure du dépôt
| Projet | Rôle |
|---|---|
| **`Prospect.Server.Api`** | Cœur : émulateur PlayFab (ASP.NET 8). Controllers Client/CloudScript/Multiplayer, services Auth/UserData/TitleData/Database(Mongo)/Qos, hub SignalR. |
| **`Prospect.Steam`** | Validation du ticket Steam (auth). |
| **`Prospect.Client.Loader`** | Loader C++ : lance le jeu et injecte l'agent. |
| **`Prospect.Agent`** | Agent C++ injecté : hooke l'URL PlayFab → `backend.txt`. |
| **`Prospect.Client.Config`** | Utilitaire multi-OS : écrit `backend.txt`, importe le certificat, lance le jeu. Voir son [README](src/Prospect.Client.Config/README.md). |
| **`Prospect.Server.Game`** | Serveur de jeu dédié (squelette, **R&D**). |
| **`Prospect.Unreal[.Generator/.Tests]`** | Réimplémentation C# du netcode Unreal (**R&D** serveur dédié). |
| `utils/` | `generate_ssl.py` — génération du certificat auto-signé. |
## Features
Build config **`Season 2 Release`** obligatoire pour l'API (le code sélectionne la saison
via `#if SEASON_2_RELEASE` / `SEASON_3_RELEASE` → sinon `#error Unsupported build type`).
* [x] Basic login with Steam
* [x] EULA acceptance
* [x] Tutorial
* [x] Single-player station (Season 2 and Season 3):
* [x] Onboarding
* [ ] Matchmaking and deployment
* [x] Solo
* [ ] Squad
* [ ] Items insurance
* [ ] Free loadouts (Season 3)
* [x] Inventory and loadout
* [ ] Loadout presets (Season 3)
* [x] Quests
* [x] Faction progression
* [ ] Season pass
* [ ] Aurum Shops
* [ ] Daily shop
* [ ] Weekly shop
* [ ] Shop rotation
* [x] Daily login
* [x] Character appearance and emotes
* [x] Item Shops
* [x] Crafting station
* [x] Quarters
* [x] Player balance
* [ ] Social features
* [ ] Proximity voice
* [x] Vivox login
* [x] Vivox create and join channel
* [ ] Proximity voice works
* [x] Game mechanics
* [x] Can deploy through terminal
* [x] Can deploy with loadout
* [x] Can evac
* [x] Can do quests (except PvP)
* [x] Can gain/lose loot
* [x] Can use Alien Forge
* [x] Map content
* [x] Bright Sands
* [x] Crescent Falls
* [x] Tharis Island
---
## Running locally
## Build & lancement du serveur
> [!NOTE]
> If you've already done all steps previously, you can skip to Step 7.
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`).
### 1. Prerequisites
### Build de l'image
```bash
docker build -t the-cycle .
```
### Lancement
Il faut une **instance MongoDB** joignable et un **certificat TLS** monté (voir section
suivante). Variables d'environnement :
| Variable | Rôle |
|---|---|
| `DatabaseSettings__ConnectionString` | URI de connexion MongoDB. |
| `DatabaseSettings__DatabaseName` | Base à utiliser (ex. `ProspectDb`). |
| `AuthTokenSettings__Secret` | Secret de signature des JWT émis par le serveur. |
| `PlayFabSettings__SignalRURL` | URL SignalR **telle que le client doit l'atteindre** (voir gotcha ci-dessous). |
| `Kestrel__Certificates__Default__Path` | Chemin du `.pfx` dans le conteneur. |
| `Kestrel__Endpoints__Https__Url` | ex. `https://0.0.0.0:8443`. |
| `SteamWebApiKey` | *(optionnel)* clé Steam Web API pour récupérer les pseudos ; inerte si absente. |
> [!WARNING]
> The latest Steam version of The Cycle: Frontier currently does not work with Windows 11 24H2!
```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
```
> ⚠️ **`PlayFabSettings__SignalRURL` = l'URL que le CLIENT doit joindre**, pas `127.0.0.1`.
> Le serveur y renvoie le client pour l'event de matchmaking ; s'il pointe sur `127.0.0.1`,
> le déploiement en raid **timeout**. Mets le hostname/IP public du serveur.
### Dev local (.NET)
```bash
dotnet build src/Prospect.Server.Api/Prospect.Server.Api.csproj -c "Season 2 Release"
dotnet run --project src/Prospect.Server.Api -c "Season 2 Release"
```
---
## Certificat TLS
La connexion est en HTTPS et le client valide le certificat → il doit être **auto-signé et
fait confiance** côté client. Générer le `.pfx` avec `utils/generate_ssl.py`.
> ⚠️ **Le SAN doit contenir `DNS:<ip>` ET `IP:<ip>`**, en plus des hostnames.
> Le HTTP du jeu (libcurl) accepte l'IP en SAN IP, mais le WebSocket (libwebsockets) valide
> l'IP contre les SAN **DNS** → sans `DNS:<ip>`, la connexion SignalR échoue
> (`Hostname mismatch err=62`). Inclure aussi `2EA46.playfabapi.com`, `localhost`,
> `127.0.0.1` et tous les hostnames publics utilisés dans `backend.txt`.
Côté client, l'import du certificat est automatisé par
[`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
> [!IMPORTANT]
> You must have The Cycle: Frontier from Steam in your Steam library to be able to download it.
> Otherwise, the download will fail with an error message about missing license.
---
Before you start, you'll need the following software downloaded and installed:
## CI/CD
1. [MongoDB Community Edition](https://fastdl.mongodb.org/windows/mongodb-windows-x86_64-8.0.4-signed.msi).
[`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) — sur push `main` / `preprod`
(ou `workflow_dispatch`) :
1. [`Prospect.Server.Api` and `LoaderPack`](https://github.com/deiteris/Prospect/releases) from the Releases section:
1. build de l'image depuis le `Dockerfile` ;
2. push sur le registry `git.nfteam.ovh/neckfire/the-cycle` ;
3. notification du résultat (ntfy).
- For Season 3 (the latest Steam game client), use Build 6.
**Modèle de branches :**
- For Season 2 game client, use the latest version.
| Branche | Tag image | Usage |
|---|---|---|
| `preprod` | `:preprod` (+ `:preprod-<sha>`) | banc de test — valider un build avant de merger |
| `main` | `:latest` (+ `:<sha>`) | production |
1. The Cycle: Frontier game client:
Workflow type : coder → push `preprod` → tester sur le serveur preprod → **PR `preprod → main`**
→ la CI republie `:latest`. Le déploiement applique la nouvelle image
(`docker compose pull && docker compose up -d`).
- The latest version from [Steam](https://steamcommunity.com/app/868270).
> `Prospect.Client.Config` (outil client, cross-OS) n'est **pas** buildé par cette CI —
> voir sa section publication.
- Season 2 client version `4623363103423775682` from SteamDB. See [download instructions below](#11-how-to-download-season-2-client-from-steamdb-using-steam-console).
---
#### 1.1 How to download Season 2 client from SteamDB using Steam console
## Config du client (switch de serveur + certificat)
> [!WARNING]
> This will overwrite the existing client if you try to download a different manifest!
L'outil **`Prospect.Client.Config`** (binaire `ProspectServerSwitcher`, multi-OS
Linux/Proton + Windows) fait tout le boulot côté client :
1. With Steam running, press `Win+R` and enter `steam://nav/console`. A Steam console will open.
- écrit `backend.txt` (presets **prod** / **preprod** ou URL libre) ;
- récupère le certificat **en direct depuis le serveur ciblé** (TLS) et le rend fiable :
- **Windows** : import dans *Autorités de certification racines de confiance* (utilisateur) ;
- **Linux/Proton** : import direct dans le préfixe Wine du jeu via `wine reg import`
(car `wine certutil` est cassé sous Proton) ;
- lance le jeu.
1. Open [The Cycle: Frontier SteamDB manifests](https://steamdb.info/depot/868271/manifests/).
```bash
# menu interactif
ProspectServerSwitcher
1. Make sure you have **Copy format** set to **Steam console**.
# scriptable
ProspectServerSwitcher --folder "<...>/Prospect/Binaries/Win64" --set preprod
ProspectServerSwitcher --set https://mon-serveur:8443
```
1. Press `CTRL+F` and enter `4623363103423775682` to find the manifest for Season 2 version 2.7.2 client.
Détails complets, gotchas Proton (préfixe non-Steam) et commandes de publication des
binaires autonomes : **[src/Prospect.Client.Config/README.md](src/Prospect.Client.Config/README.md)**.
> Installation complète pas-à-pas pour un nouveau joueur (télécharger le client S2, le
> LoaderPack, importer le certificat) : **[FRIENDS-INSTALL.md](FRIENDS-INSTALL.md)**.
1. Click the ![Copy](./_assets/steamdb_copy.PNG) icon to copy the download command.
---
1. Paste the command in the Steam console and press `Enter`.
## État des fonctionnalités
1. The depot will begin downloading. You should receive a notification and the destination folder when the download is complete.
**Fonctionne :**
### 2. Unpack `Prospect.Server.Api`
- [x] Login Steam, EULA, tutoriel
- [x] Station solo (S2/S3) : onboarding, matchmaking & déploiement **solo**
- [x] Inventaire & loadout, stash, vente, réparation
- [x] Contrats / quêtes — y compris les objectifs **kills** et **de zone** (auto-crédités :
pas de serveur dédié pour remonter les events runtime du raid client-hosted)
- [x] Progression des factions
- [x] Season pass : claim + gain d'XP de saison (niveau Fortuna)
- [x] Boutiques d'items (Korolev / ICA / Osiris / QuickShop / CraftingStation)
- [x] Aurum Shop (cosmétiques) + rotation daily/weekly
- [x] Craft, Quarters, solde joueur, connexion quotidienne
- [x] Apparence & emotes
- [x] Assurance : débit de la prime au déploiement + payout à la mort
- [x] Stats de carrière (valeurs à 0 — non traçables sans serveur de jeu)
- [x] Présence des amis (en ligne / en raid)
- [x] Pseudos réels via Steam Web API (le client n'envoie que le SteamID)
- [x] Cartes : Bright Sands, Crescent Falls, Tharis Island
Use your favorite ZIP archiver and unzip the `Prospect.Server.Api.zip` downloaded from this repository.
**Non implémenté / hors-périmètre :**
### 3. Generate and import SSL certificate
- [ ] Squad / multi dans le **même** raid — nécessite un serveur de jeu dédié
- [ ] Voix de proximité (login/join Vivox = placeholders)
- [ ] Free loadouts & presets (Saison 3 uniquement)
- [ ] Achat de cosmétiques (endpoint d'achat vanity distinct, non câblé)
- [ ] Catalogue des récompenses Fortuna (DataTable côté client, dans des paks chiffrés)
- [ ] Défis quotidiens Fortuna
> [!IMPORTANT]
> Do not share the generated certificate! Generated certificate includes a private key that may be used to generate other certificates and compromise your security.
---
A connection to the server is served over a secured connection. The server uses self-signed certificate that must be added to trusted authorities in order for the game
to successfully communicate with the local server. Do the following:
## Hors-périmètre & R&D
1. Open the folder with `Prospect.Server.Api`.
Un **serveur de jeu Unreal dédié** (`Prospect.Server.Game` + `Prospect.Unreal`, branche
`game-server`) est en cours de reverse-engineering pour, à terme, permettre le vrai multi.
État : handshake stateless UE, séquençage et décodage des bunches **franchis**, canal de
contrôle ouvert, `NMT_Hello` parsé. **Bloqué** sur le chiffrement **DTLS-PSK** du client
(clé dérivée du `user_id`), derrière un exe packé (BattlEye) → la dérivation n'est pas
extractible statiquement. Détails dans `NETCODE-RND.md` (branche `game-server`).
1. Double-click `generate_ssl.exe`. `certificate.pfx` will appear in the same folder.
Le « lobby squad » via l'API seule n'est **pas faisable** : l'invitation d'amis est gérée
100 % côté client Steam.
1. Double-click `certificate.pfx`. The Certificate Import Wizard will open:
---
1. Select **Current User** under Store Location and click **Next**.
## Crédits & licence
1. Leave **File to Import** unchanged and click **Next**.
1. Leave **Password** empty and click **Next**.
1. Select **Place all certificates in the following store** > **Browse...**. Choose **Trusted Root Certification Authorities** and click **OK**. Click **Next**.
1. Click **Finish**. A **Security Warning** popup may appear, make sure it specifies `2EA46.playfabapi.com` certification authority and click **Yes**.
### 4. Extract `LoaderPack` to the game
1. Open the folder with The Cycle: Frontier and navigate to **Prospect** > **Binaries** > **Win64**.
1. Open the `LoaderPack` archive.
1. Drag and drop the contents of the `LoaderPack` archive to the game.
1. Create a shortcut for the `Prospect.Client.Loader` that you will use later to launch the game.
### 5. Run the server
Now you are all set! Open the folder with `Prospect.Server.Api` and run `Prospect.Server.Api.exe`. It will open a console if it runs successfully.
> [!IMPORTANT]
> Do not close the console when you run the game.
### 6. Run the game
Once the server is running, make sure that Steam is running and open The Cycle: Frontier using the shortcut you've created before.
## Troubleshooting and FAQ
### How to remove the certificate?
If you've installed the certificate for the **Current User**:
1. Open **Start** and enter `certmgr.msc`.
1. Expand **Trusted Root Certification Authorities** and select **Certificates**.
1. Find `2EA46.playfabapi.com`, right-click it > **Delete**.
If you've installed the certificate for the **Local Machine**, repeat the same steps but instead open `certlm.msc`.
### `generate_ssl.exe` is flagged as a virus
`generate_ssl.exe` is a Python application packed with PyInstaller and some anti-viruses may flag it as a virus.
This application is a simple certificate generator and you can find its source code in `utils/generate_ssl.py`.
### Body parts are missing with Season 3 client
Currently, the server loads body part IDs for Season 2 by default, so this is expected. You can fix this by going to station and changing your character appearance. This will store the updated body part IDs for your character.
### Prospect.Server.Api does not start
Make sure you have [.NET Runtime 8.0](https://aka.ms/dotnet-core-applaunch?missing_runtime=true&arch=x64&rid=win-x64&os=win10&apphost_version=8.0.11) and [ASP.NET Core 8.0](https://aka.ms/dotnet-core-applaunch?framework=Microsoft.AspNetCore.App&framework_version=8.0.0&arch=x64&rid=win-x64&os=win10) installed.
### Login Failed. Error code: 3
Make sure that:
* You have Steam running.
* You have created and **saved** the `steam_appid` file as described in step 6.
* The `steam_appid` file type is "TXT File".
### Login Failed. Error code: 5
Make sure that `Prospect.Server.Api` server is running.
If the server is running, press `Alt+Tab` to a game console that opens when you start the game and check for the following:
* `libcurl error 7 (Couldn't connect to server)` - indicates that the `Prospect.Server.Api` is not running.
![](./_assets/connection_refused_error.png)
* `InvalidAPIEndpoint` - indicates that you are running the game using the original shortcut and not using `Prospect.Client.Loader`.
![](./_assets/invalid_api_endpoint.PNG)
* `libcurl error 60 (Peer certificate cannot be authenticated with given CA certificates)` - indicates that the certificate was not installed correctly. Make sure that the certificate is present in `certmgr.msc` and there is only one certificate. Try removing the certificate and importing it again by following step 4.
![](./_assets/certificate_error.PNG)
* `HTTP code: 500` - usually indicates that MongoDB is not running. Make sure that MongoDB is installed and and that `MongoDB Server` is running in `services.msc`.
![](./_assets/mongodb_error.PNG)
## Development
TBD
Fork de [`deiteris/Prospect`](https://github.com/deiteris/Prospect) (lui-même issu du
projet Prospect original). Voir [`LICENSE`](LICENSE). Usage privé.
@@ -14,6 +14,8 @@ 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
{
@@ -51,58 +53,45 @@ public class AdminController : ControllerBase
var sb = new StringBuilder();
sb.Append("<div class='cards'>");
sb.Append(Card("Joueurs", users.Count.ToString()));
sb.Append(Card("Entités (title_player)", entities.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("<h2>Joueurs récents</h2>");
sb.Append("<table><thead><tr><th>DisplayName</th><th>PlayFab Id</th><th>Auth</th><th></th></tr></thead><tbody>");
foreach (var u in users.Take(15))
{
var auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : "";
sb.Append($"<tr><td>{Esc(u.DisplayName)}</td><td class='mono'>{Esc(u.Id)}</td><td class='mono small'>{Esc(auth)}</td>" +
$"<td><a class='btn' href='/admin/players/{Esc(u.Id)}'>Voir</a></td></tr>");
}
sb.Append("</tbody></table>");
sb.Append("<p><a class='btn' href='/admin/players'>Tous les joueurs →</a> &nbsp; <a class='btn' href='/admin/titledata'>TitleData →</a></p>");
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 async Task<IActionResult> Players([FromQuery] string? q)
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));
}
var users = await _userService.GetAllAsync();
var entities = (await _entityService.GetAllAsync())
.GroupBy(e => e.UserId)
.ToDictionary(g => g.Key, g => g.First().Id);
if (!string.IsNullOrWhiteSpace(q))
{
var needle = q.Trim().ToLowerInvariant();
users = users.Where(u =>
(u.DisplayName ?? "").ToLowerInvariant().Contains(needle) ||
(u.Id ?? "").ToLowerInvariant().Contains(needle) ||
(u.Auth != null && u.Auth.Any(a => (a.Key ?? "").ToLowerInvariant().Contains(needle)))).ToList();
}
var sb = new StringBuilder();
sb.Append($"<form method='get' class='search'><input name='q' placeholder='Rechercher nom / id / steam…' value='{Esc(q ?? "")}'/><button class='btn'>Rechercher</button>" +
"<a class='btn' href='/admin/backfill-names' title='Récupère les pseudos Steam des comptes sans nom'>Backfill pseudos Steam</a></form>");
sb.Append($"<p class='muted'>{users.Count} joueur(s)</p>");
sb.Append("<table><thead><tr><th>DisplayName</th><th>PlayFab Id</th><th>Entity Id</th><th>Auth</th><th></th></tr></thead><tbody>");
foreach (var u in users.OrderBy(u => u.DisplayName))
{
var auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : "";
var ent = entities.TryGetValue(u.Id, out var eid) ? eid : "";
sb.Append($"<tr><td>{Esc(u.DisplayName)}</td><td class='mono'>{Esc(u.Id)}</td><td class='mono small'>{Esc(ent)}</td>" +
$"<td class='mono small'>{Esc(auth)}</td><td><a class='btn' href='/admin/players/{Esc(u.Id)}'>Voir</a></td></tr>");
}
sb.Append("</tbody></table>");
return Html(Layout("Joueurs", sb.ToString()));
[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}")]
@@ -119,10 +108,10 @@ public class AdminController : ControllerBase
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>");
// Summary cards
sb.Append("<div class='cards'>");
if (map.TryGetValue("Balance", out var bal))
{
@@ -138,14 +127,12 @@ public class AdminController : ControllerBase
sb.Append(Card($"Fortuna S{s} XP", (xp.Value ?? "0").Trim()));
sb.Append("</div>");
// Inventory table
if (map.TryGetValue("Inventory", out var inv))
{
sb.Append("<h3>Inventaire</h3>");
sb.Append(InventoryTable(inv.Value));
}
// All keys
sb.Append("<h3>Toutes les données</h3>");
foreach (var d in data)
{
@@ -153,7 +140,6 @@ public class AdminController : ControllerBase
$" <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()));
}
@@ -164,6 +150,7 @@ public class AdminController : ControllerBase
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>" +
@@ -181,11 +168,10 @@ public class AdminController : ControllerBase
if (!all.TryGetValue(key, out var value))
return Html(Layout("Clé introuvable", "<p>Aucune clé TitleData avec ce nom.</p>"));
// Large values (Blueprints, Contracts…) can be huge — pretty-print only if reasonable.
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, $"<h2 class='mono'>{Esc(key)}</h2>" + body));
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")]
@@ -220,35 +206,37 @@ public class AdminController : ControllerBase
return Html(Layout("Backfill", body));
}
[HttpGet("catalog")]
public IActionResult Catalog([FromQuery] string? q, [FromQuery] string? cat)
// ---------- JSON API (consumed by the client-side table engine) ----------
[HttpGet("api/players")]
public async Task<IActionResult> ApiPlayers()
{
if (!IsLan()) return Denied();
var all = _gameRef.All;
IEnumerable<KeyValuePair<string, GameRefEntry>> items = all;
if (!string.IsNullOrWhiteSpace(cat))
items = items.Where(kv => kv.Value.Category == cat);
if (!string.IsNullOrWhiteSpace(q))
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
{
var n = q.Trim().ToLowerInvariant();
items = items.Where(kv => kv.Key.ToLowerInvariant().Contains(n) || (kv.Value.Name ?? "").ToLowerInvariant().Contains(n));
}
var cats = all.Values.Select(v => v.Category).Distinct().OrderBy(x => x).ToList();
var list = items.OrderBy(kv => kv.Value.Category).ThenBy(kv => kv.Value.Name).Take(1000).ToList();
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);
}
var sb = new StringBuilder();
sb.Append($"<p class='muted'>Référence dataminée (TCF-Wiki) — {all.Count} entrées connues.</p>");
sb.Append($"<form method='get' class='search'><input name='q' placeholder='nom / id…' value='{Esc(q ?? "")}'/>");
sb.Append("<select name='cat'><option value=''>(toutes)</option>");
foreach (var c in cats) sb.Append($"<option value='{Esc(c)}'{(c == cat ? " selected" : "")}>{Esc(c)}</option>");
sb.Append("</select><button class='btn'>Filtrer</button></form>");
sb.Append($"<p class='muted'>{list.Count} résultat(s){(list.Count >= 1000 ? " (tronqué)" : "")}</p>");
sb.Append("<table><thead><tr><th>Nom</th><th>Rareté</th><th>Catégorie</th><th>id</th></tr></thead><tbody>");
foreach (var kv in list)
sb.Append($"<tr><td>{Esc(kv.Value.Name)}</td><td>{Rarity(kv.Value.Rarity)}</td><td>{Esc(kv.Value.Category)}</td><td class='mono small'>{Esc(kv.Key)}</td></tr>");
sb.Append("</tbody></table>");
return Html(Layout("Catalogue", sb.ToString()));
[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 ----------
@@ -262,13 +250,12 @@ public class AdminController : ControllerBase
if (ip.AddressFamily == AddressFamily.InterNetwork)
{
var b = ip.GetAddressBytes();
if (b[0] == 10) return true; // 10.0.0.0/8
if (b[0] == 192 && b[1] == 168) return true; // 192.168.0.0/16
if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true; // 172.16.0.0/12
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;
}
// IPv6 unique-local (fc00::/7) or link-local (fe80::/10)
var s = ip.GetAddressBytes();
if ((s[0] & 0xFE) == 0xFC) return true;
if (s[0] == 0xFE && (s[1] & 0xC0) == 0x80) return true;
@@ -280,6 +267,9 @@ public class AdminController : ControllerBase
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)
@@ -333,7 +323,6 @@ public class AdminController : ControllerBase
catch { return $"<pre>{Esc(Pretty(json))}</pre>"; }
}
// Small coloured rarity chip.
private static string Rarity(string? r)
{
if (string.IsNullOrEmpty(r)) return "";
@@ -352,31 +341,91 @@ public class AdminController : ControllerBase
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) => $@"<!doctype html><html lang='fr'><head>
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>{Esc(title)} · The Cycle Admin</title>
<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}}
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}}
.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}}
.btn:hover{{border-color:var(--acc);color:var(--acc)}}
.search{{display:flex;gap:8px;margin:12px 0}}.search input{{flex:1;background:var(--panel);border:1px solid var(--line);border-radius:8px;color:var(--txt);padding:8px 12px}}
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}}
: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>{body}</main></body></html>";
<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>";
}
@@ -9,6 +9,14 @@ 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;
@@ -32,6 +40,27 @@ 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)
@@ -32,9 +32,8 @@ public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
}
var userId = context.User.FindAuthUserId();
// Resolve the imported Steam friends (persisted by ClientsideFriendsImport) to the
// players that actually exist on this server. It's a private server, so only friends
// who have logged in here will show up.
// 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))
@@ -52,8 +51,10 @@ public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
{
if (player.Id == userId) continue; // never list yourself
// Presence persisted by UpdatePlayerPresenceState: online if seen in the last
// 3 minutes. EYUserState best-effort: 0 = offline, 1 = online, 2 = in match.
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))
@@ -69,22 +70,50 @@ public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
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
{
profile = new
{
playerId = player.Id,
displayName = player.DisplayName,
avatarUrl = "",
},
// 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>(),
});
}
}
// NOTE: the exact response shape the client expects is not yet confirmed; this is a
// best-effort structure. The log lets us verify resolution while we validate in game.
_logger.LogInformation("GetFriendList for {User}: {Count} friend(s) resolved on server", userId, friends.Count);
return new { friends };
// 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;
}
}
File diff suppressed because one or more lines are too long