13 Commits
Author SHA1 Message Date
neckfire b05782d4e9 Merge pull request 'Admin: noms lisibles + catalogue (gameref)' (#10) from preprod into main
Build & Deploy / build (push) Successful in 29s
2026-07-15 09:19:14 +00:00
neckfire d7c89327a7 Merge pull request 'Steam persona display names + admin backfill' (#9) from preprod into main
Build & Deploy / build (push) Successful in 29s
2026-07-15 08:25:53 +00:00
neckfire 03048b793d Merge pull request 'Back-office admin /admin (LAN-only)' (#8) from preprod into main
Build & Deploy / build (push) Successful in 32s
2026-07-15 07:56:34 +00:00
neckfire 1d9cf795dd Merge pull request 'Audit léger: stats carrière, présence amis, AddGenericId' (#7) from preprod into main
Build & Deploy / build (push) Successful in 27s
2026-07-15 07:02:46 +00:00
neckfire 7dbab1aab8 Merge pull request 'Audit fixes: Fortuna pass level, rename persist, loadout & craft-timer' (#6) from preprod into main
Build & Deploy / build (push) Successful in 26s
2026-07-15 00:57:11 +00:00
neckfire 078d2f80be Merge pull request 'Fix critique: achats bloqués (sentinelle ContractLockPurchase None)' (#5) from preprod into main
Build & Deploy / build (push) Successful in 24s
2026-07-15 00:15:14 +00:00
neckfire c986a98471 Merge pull request 'Promotion: fix sauvegarde des missions en zone (raid client-hosted)' (#4) from preprod into main
Build & Deploy / build (push) Successful in 23s
2026-07-14 23:58:55 +00:00
neckfire 57a45d91df Merge pull request 'Social prod : liste d amis + fix ping 500' (#3) from preprod into main
Build & Deploy / build (push) Successful in 15s
2026-07-14 13:29:45 +00:00
neckfire c6b2330e1d docs: pointer vers la release pour LoaderPack + cert
Build & Deploy / build (push) Successful in 16s
2026-07-14 13:09:57 +00:00
neckfire 9737316dbc docs: ajoute la commande download_depot + astuce disque
Build & Deploy / build (push) Successful in 19s
2026-07-14 13:07:35 +00:00
neckfireandClaude Opus 4.8 a4e006f84f docs: guide d installation client pour les amis (serveur prive)
Build & Deploy / build (push) Successful in 15s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:33:37 +00:00
neckfire 3cdf8bc7e2 Merge pull request 'Boutiques : GetStoreItems (vendeurs + Aurum) + fix vanities possédées' (#2) from preprod into main
Build & Deploy / build (push) Successful in 13s
2026-07-14 12:07:18 +00:00
neckfire 67f3095896 Merge pull request 'Promotion preprod → prod : contrats/kills, factions, tech-tree, assurance payout, Fortuna Pass' (#1) from preprod into main
Build & Deploy / build (push) Successful in 11s
2026-07-14 10:33:31 +00:00
5 changed files with 275 additions and 410 deletions
+161 -188
View File
@@ -1,249 +1,222 @@
# The Cycle: Frontier — serveur privé (émulateur Prospect) # Prospect <!-- omit in toc -->
Émulateur des services en ligne de **The Cycle: Frontier** (jeu retiré de Steam en Also known as "The Cycle: Frontier".
septembre 2022), permettant de rejouer en solo sur un serveur auto-hébergé.
Fork de [`deiteris/Prospect`](https://github.com/deiteris/Prospect), figé sur le
**Build 8 / client Saison 2**, buildé depuis les sources et déployé en conteneur via
une CI Gitea.
> **Ce n'est pas du multijoueur.** Le raid tourne **côté client** (station solo) : chacun ## Table of Contents <!-- omit in toc -->
> joue sa propre instance. Le serveur partage la progression, les comptes et les boutiques,
> pas la partie. Le vrai multi (squad en raid, voix de proximité) suppose un serveur de jeu
> Unreal dédié — voir [Hors-périmètre & R&D](#hors-périmètre--rd).
--- - [Features](#features)
- [Running locally](#running-locally)
- [1. Prerequisites](#1-prerequisites)
- [1.1 How to download Season 2 client from SteamDB using Steam console](#11-how-to-download-season-2-client-from-steamdb-using-steam-console)
- [2. Unpack `Prospect.Server.Api`](#2-unpack-prospectserverapi)
- [3. Generate and import SSL certificate](#3-generate-and-import-ssl-certificate)
- [4. Extract `LoaderPack` to the game](#4-extract-loaderpack-to-the-game)
- [5. Run the server](#5-run-the-server)
- [6. Run the game](#6-run-the-game)
- [Troubleshooting and FAQ](#troubleshooting-and-faq)
- [How to remove the certificate?](#how-to-remove-the-certificate)
- [`generate_ssl.exe` is flagged as a virus](#generate_sslexe-is-flagged-as-a-virus)
- [Body parts are missing with Season 3 client](#body-parts-are-missing-with-season-3-client)
- [Prospect.Server.Api does not start](#prospectserverapi-does-not-start)
- [Login Failed. Error code: 3](#login-failed-error-code-3)
- [Login Failed. Error code: 5](#login-failed-error-code-5)
- [Development](#development)
## Sommaire ## Features
- [Comment ça marche](#comment-ça-marche) * [x] Basic login with Steam
- [Structure du dépôt](#structure-du-dépôt) * [x] EULA acceptance
- [Build & lancement du serveur](#build--lancement-du-serveur) * [x] Tutorial
- [Certificat TLS](#certificat-tls) * [x] Single-player station (Season 2 and Season 3):
- [CI/CD](#cicd) * [x] Onboarding
- [Config du client (switch de serveur + certificat)](#config-du-client-switch-de-serveur--certificat) * [ ] Matchmaking and deployment
- [État des fonctionnalités](#état-des-fonctionnalités) * [x] Solo
- [Hors-périmètre & R&D](#hors-périmètre--rd) * [ ] Squad
- [Crédits & licence](#crédits--licence) * [ ] Items insurance
* [ ] Free loadouts (Season 3)
* [x] Inventory and loadout
* [ ] Loadout presets (Season 3)
* [x] Quests
* [x] Faction progression
* [ ] Season pass
* [ ] Aurum Shops
* [ ] Daily shop
* [ ] Weekly shop
* [ ] Shop rotation
* [x] Daily login
* [x] Character appearance and emotes
* [x] Item Shops
* [x] Crafting station
* [x] Quarters
* [x] Player balance
* [ ] Social features
* [ ] Proximity voice
* [x] Vivox login
* [x] Vivox create and join channel
* [ ] Proximity voice works
* [x] Game mechanics
* [x] Can deploy through terminal
* [x] Can deploy with loadout
* [x] Can evac
* [x] Can do quests (except PvP)
* [x] Can gain/lose loot
* [x] Can use Alien Forge
* [x] Map content
* [x] Bright Sands
* [x] Crescent Falls
* [x] Tharis Island
--- ## Running locally
## Comment ça marche > [!NOTE]
> If you've already done all steps previously, you can skip to Step 7.
Le client officiel parle à PlayFab. On l'intercepte côté client et on le redirige vers ### 1. Prerequisites
notre serveur, qui réimplémente juste ce qu'il faut de PlayFab (auth Steam, CloudScript,
UserData/TitleData, matchmaking solo).
```mermaid > [!WARNING]
flowchart LR > The latest Steam version of The Cycle: Frontier currently does not work with Windows 11 24H2!
subgraph client [Poste de jeu]
L[Prospect.Client.Loader<br/>injecte l'agent] --> A[Prospect.Agent<br/>hooke l'URL PlayFab]
A -- lit --> B[backend.txt]
A --> G[Jeu &#40;TCF&#41;]
end
G -- HTTPS / SignalR --> API[Prospect.Server.Api<br/>émulateur PlayFab]
API --> M[(MongoDB)]
```
- **`Prospect.Client.Loader`** lance le jeu en injectant l'agent. > [!IMPORTANT]
- **`Prospect.Agent`** hooke l'URL de l'API PlayFab et la remplace par le contenu de > You must have The Cycle: Frontier from Steam in your Steam library to be able to download it.
**`backend.txt`** (placé dans `Prospect/Binaries/Win64`). Absent → fallback > Otherwise, the download will fail with an error message about missing license.
`https://127.0.0.1:8443`.
- **`Prospect.Server.Api`** émule PlayFab (auth Steam → JWT, CloudScript, données joueur)
et pousse le temps-réel via **SignalR**. Les données vivent dans **MongoDB**.
Toute la redirection du client tient donc dans **une seule valeur** (`backend.txt`) — gérée Before you start, you'll need the following software downloaded and installed:
par l'outil [`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat).
--- 1. [MongoDB Community Edition](https://fastdl.mongodb.org/windows/mongodb-windows-x86_64-8.0.4-signed.msi).
## Structure du dépôt 1. [`Prospect.Server.Api` and `LoaderPack`](https://github.com/deiteris/Prospect/releases) from the Releases section:
| Projet | Rôle | - For Season 3 (the latest Steam game client), use Build 6.
|---|---|
| **`Prospect.Server.Api`** | Cœur : émulateur PlayFab (ASP.NET 8). Controllers Client/CloudScript/Multiplayer, services Auth/UserData/TitleData/Database(Mongo)/Qos, hub SignalR. |
| **`Prospect.Steam`** | Validation du ticket Steam (auth). |
| **`Prospect.Client.Loader`** | Loader C++ : lance le jeu et injecte l'agent. |
| **`Prospect.Agent`** | Agent C++ injecté : hooke l'URL PlayFab → `backend.txt`. |
| **`Prospect.Client.Config`** | Utilitaire multi-OS : écrit `backend.txt`, importe le certificat, lance le jeu. Voir son [README](src/Prospect.Client.Config/README.md). |
| **`Prospect.Server.Game`** | Serveur de jeu dédié (squelette, **R&D**). |
| **`Prospect.Unreal[.Generator/.Tests]`** | Réimplémentation C# du netcode Unreal (**R&D** serveur dédié). |
| `utils/` | `generate_ssl.py` — génération du certificat auto-signé. |
Build config **`Season 2 Release`** obligatoire pour l'API (le code sélectionne la saison - For Season 2 game client, use the latest version.
via `#if SEASON_2_RELEASE` / `SEASON_3_RELEASE` → sinon `#error Unsupported build type`).
--- 1. The Cycle: Frontier game client:
## Build & lancement du serveur - The latest version from [Steam](https://steamcommunity.com/app/868270).
Le serveur tourne en conteneur. L'image est buildée depuis les sources par le - Season 2 client version `4623363103423775682` from SteamDB. See [download instructions below](#11-how-to-download-season-2-client-from-steamdb-using-steam-console).
[`Dockerfile`](Dockerfile) (multi-stage SDK .NET 8 → runtime aspnet 8, publish en
`Season 2 Release`).
### Build de l'image #### 1.1 How to download Season 2 client from SteamDB using Steam console
```bash > [!WARNING]
docker build -t the-cycle . > This will overwrite the existing client if you try to download a different manifest!
```
### Lancement 1. With Steam running, press `Win+R` and enter `steam://nav/console`. A Steam console will open.
Il faut une **instance MongoDB** joignable et un **certificat TLS** monté (voir section 1. Open [The Cycle: Frontier SteamDB manifests](https://steamdb.info/depot/868271/manifests/).
suivante). Variables d'environnement :
| Variable | Rôle | 1. Make sure you have **Copy format** set to **Steam console**.
|---|---|
| `DatabaseSettings__ConnectionString` | URI de connexion MongoDB. |
| `DatabaseSettings__DatabaseName` | Base à utiliser (ex. `ProspectDb`). |
| `AuthTokenSettings__Secret` | Secret de signature des JWT émis par le serveur. |
| `PlayFabSettings__SignalRURL` | URL SignalR **telle que le client doit l'atteindre** (voir gotcha ci-dessous). |
| `Kestrel__Certificates__Default__Path` | Chemin du `.pfx` dans le conteneur. |
| `Kestrel__Endpoints__Https__Url` | ex. `https://0.0.0.0:8443`. |
| `SteamWebApiKey` | *(optionnel)* clé Steam Web API pour récupérer les pseudos ; inerte si absente. |
```bash 1. Press `CTRL+F` and enter `4623363103423775682` to find the manifest for Season 2 version 2.7.2 client.
docker run -d --name the-cycle-api \
-e DatabaseSettings__ConnectionString="mongodb://user:pass@HOST:27017/?authSource=ProspectDb" \
-e DatabaseSettings__DatabaseName="ProspectDb" \
-e AuthTokenSettings__Secret="<secret>" \
-e PlayFabSettings__SignalRURL="https://<host-public>:8443/signalr/?hub=pubsub" \
-e Kestrel__Endpoints__Https__Url="https://0.0.0.0:8443" \
-e Kestrel__Certificates__Default__Path="/certs/certificate.pfx" \
-v "$PWD/certs:/certs:ro" \
-p 8443:8443 \
the-cycle
```
> ⚠️ **`PlayFabSettings__SignalRURL` = l'URL que le CLIENT doit joindre**, pas `127.0.0.1`. 1. Click the ![Copy](./_assets/steamdb_copy.PNG) icon to copy the download command.
> Le serveur y renvoie le client pour l'event de matchmaking ; s'il pointe sur `127.0.0.1`,
> le déploiement en raid **timeout**. Mets le hostname/IP public du serveur.
### Dev local (.NET) 1. Paste the command in the Steam console and press `Enter`.
```bash 1. The depot will begin downloading. You should receive a notification and the destination folder when the download is complete.
dotnet build src/Prospect.Server.Api/Prospect.Server.Api.csproj -c "Season 2 Release"
dotnet run --project src/Prospect.Server.Api -c "Season 2 Release"
```
--- ### 2. Unpack `Prospect.Server.Api`
## Certificat TLS Use your favorite ZIP archiver and unzip the `Prospect.Server.Api.zip` downloaded from this repository.
La connexion est en HTTPS et le client valide le certificat → il doit être **auto-signé et ### 3. Generate and import SSL certificate
fait confiance** côté client. Générer le `.pfx` avec `utils/generate_ssl.py`.
> ⚠️ **Le SAN doit contenir `DNS:<ip>` ET `IP:<ip>`**, en plus des hostnames. > [!IMPORTANT]
> Le HTTP du jeu (libcurl) accepte l'IP en SAN IP, mais le WebSocket (libwebsockets) valide > Do not share the generated certificate! Generated certificate includes a private key that may be used to generate other certificates and compromise your security.
> l'IP contre les SAN **DNS** → sans `DNS:<ip>`, la connexion SignalR échoue
> (`Hostname mismatch err=62`). Inclure aussi `2EA46.playfabapi.com`, `localhost`,
> `127.0.0.1` et tous les hostnames publics utilisés dans `backend.txt`.
Côté client, l'import du certificat est automatisé par A connection to the server is served over a secured connection. The server uses self-signed certificate that must be added to trusted authorities in order for the game
[`Prospect.Client.Config`](#config-du-client-switch-de-serveur--certificat). to successfully communicate with the local server. Do the following:
--- 1. Open the folder with `Prospect.Server.Api`.
## CI/CD 1. Double-click `generate_ssl.exe`. `certificate.pfx` will appear in the same folder.
[`.gitea/workflows/build.yml`](.gitea/workflows/build.yml) — sur push `main` / `preprod` 1. Double-click `certificate.pfx`. The Certificate Import Wizard will open:
(ou `workflow_dispatch`) :
1. build de l'image depuis le `Dockerfile` ; 1. Select **Current User** under Store Location and click **Next**.
2. push sur le registry `git.nfteam.ovh/neckfire/the-cycle` ;
3. notification du résultat (ntfy).
**Modèle de branches :** 1. Leave **File to Import** unchanged and click **Next**.
| Branche | Tag image | Usage | 1. Leave **Password** empty and click **Next**.
|---|---|---|
| `preprod` | `:preprod` (+ `:preprod-<sha>`) | banc de test — valider un build avant de merger |
| `main` | `:latest` (+ `:<sha>`) | production |
Workflow type : coder → push `preprod` → tester sur le serveur preprod → **PR `preprod → main`** 1. Select **Place all certificates in the following store** > **Browse...**. Choose **Trusted Root Certification Authorities** and click **OK**. Click **Next**.
→ la CI republie `:latest`. Le déploiement applique la nouvelle image
(`docker compose pull && docker compose up -d`).
> `Prospect.Client.Config` (outil client, cross-OS) n'est **pas** buildé par cette CI — 1. Click **Finish**. A **Security Warning** popup may appear, make sure it specifies `2EA46.playfabapi.com` certification authority and click **Yes**.
> voir sa section publication.
--- ### 4. Extract `LoaderPack` to the game
## Config du client (switch de serveur + certificat) 1. Open the folder with The Cycle: Frontier and navigate to **Prospect** > **Binaries** > **Win64**.
L'outil **`Prospect.Client.Config`** (binaire `ProspectServerSwitcher`, multi-OS 1. Open the `LoaderPack` archive.
Linux/Proton + Windows) fait tout le boulot côté client :
- écrit `backend.txt` (presets **prod** / **preprod** ou URL libre) ; 1. Drag and drop the contents of the `LoaderPack` archive to the game.
- récupère le certificat **en direct depuis le serveur ciblé** (TLS) et le rend fiable :
- **Windows** : import dans *Autorités de certification racines de confiance* (utilisateur) ;
- **Linux/Proton** : import direct dans le préfixe Wine du jeu via `wine reg import`
(car `wine certutil` est cassé sous Proton) ;
- lance le jeu.
```bash 1. Create a shortcut for the `Prospect.Client.Loader` that you will use later to launch the game.
# menu interactif
ProspectServerSwitcher
# scriptable ### 5. Run the server
ProspectServerSwitcher --folder "<...>/Prospect/Binaries/Win64" --set preprod
ProspectServerSwitcher --set https://mon-serveur:8443
```
Détails complets, gotchas Proton (préfixe non-Steam) et commandes de publication des Now you are all set! Open the folder with `Prospect.Server.Api` and run `Prospect.Server.Api.exe`. It will open a console if it runs successfully.
binaires autonomes : **[src/Prospect.Client.Config/README.md](src/Prospect.Client.Config/README.md)**.
> Installation complète pas-à-pas pour un nouveau joueur (télécharger le client S2, le > [!IMPORTANT]
> LoaderPack, importer le certificat) : **[FRIENDS-INSTALL.md](FRIENDS-INSTALL.md)**. > Do not close the console when you run the game.
--- ### 6. Run the game
## État des fonctionnalités Once the server is running, make sure that Steam is running and open The Cycle: Frontier using the shortcut you've created before.
**Fonctionne :** ## Troubleshooting and FAQ
- [x] Login Steam, EULA, tutoriel ### How to remove the certificate?
- [x] Station solo (S2/S3) : onboarding, matchmaking & déploiement **solo**
- [x] Inventaire & loadout, stash, vente, réparation
- [x] Contrats / quêtes — y compris les objectifs **kills** et **de zone** (auto-crédités :
pas de serveur dédié pour remonter les events runtime du raid client-hosted)
- [x] Progression des factions
- [x] Season pass : claim + gain d'XP de saison (niveau Fortuna)
- [x] Boutiques d'items (Korolev / ICA / Osiris / QuickShop / CraftingStation)
- [x] Aurum Shop (cosmétiques) + rotation daily/weekly
- [x] Craft, Quarters, solde joueur, connexion quotidienne
- [x] Apparence & emotes
- [x] Assurance : débit de la prime au déploiement + payout à la mort
- [x] Stats de carrière (valeurs à 0 — non traçables sans serveur de jeu)
- [x] Présence des amis (en ligne / en raid)
- [x] Pseudos réels via Steam Web API (le client n'envoie que le SteamID)
- [x] Cartes : Bright Sands, Crescent Falls, Tharis Island
**Non implémenté / hors-périmètre :** If you've installed the certificate for the **Current User**:
- [ ] Squad / multi dans le **même** raid — nécessite un serveur de jeu dédié 1. Open **Start** and enter `certmgr.msc`.
- [ ] Voix de proximité (login/join Vivox = placeholders)
- [ ] Free loadouts & presets (Saison 3 uniquement)
- [ ] Achat de cosmétiques (endpoint d'achat vanity distinct, non câblé)
- [ ] Catalogue des récompenses Fortuna (DataTable côté client, dans des paks chiffrés)
- [ ] Défis quotidiens Fortuna
--- 1. Expand **Trusted Root Certification Authorities** and select **Certificates**.
## Hors-périmètre & R&D 1. Find `2EA46.playfabapi.com`, right-click it > **Delete**.
Un **serveur de jeu Unreal dédié** (`Prospect.Server.Game` + `Prospect.Unreal`, branche If you've installed the certificate for the **Local Machine**, repeat the same steps but instead open `certlm.msc`.
`game-server`) est en cours de reverse-engineering pour, à terme, permettre le vrai multi.
État : handshake stateless UE, séquençage et décodage des bunches **franchis**, canal de
contrôle ouvert, `NMT_Hello` parsé. **Bloqué** sur le chiffrement **DTLS-PSK** du client
(clé dérivée du `user_id`), derrière un exe packé (BattlEye) → la dérivation n'est pas
extractible statiquement. Détails dans `NETCODE-RND.md` (branche `game-server`).
Le « lobby squad » via l'API seule n'est **pas faisable** : l'invitation d'amis est gérée ### `generate_ssl.exe` is flagged as a virus
100 % côté client Steam.
--- `generate_ssl.exe` is a Python application packed with PyInstaller and some anti-viruses may flag it as a virus.
This application is a simple certificate generator and you can find its source code in `utils/generate_ssl.py`.
## Crédits & licence ### Body parts are missing with Season 3 client
Fork de [`deiteris/Prospect`](https://github.com/deiteris/Prospect) (lui-même issu du Currently, the server loads body part IDs for Season 2 by default, so this is expected. You can fix this by going to station and changing your character appearance. This will store the updated body part IDs for your character.
projet Prospect original). Voir [`LICENSE`](LICENSE). Usage privé.
### Prospect.Server.Api does not start
Make sure you have [.NET Runtime 8.0](https://aka.ms/dotnet-core-applaunch?missing_runtime=true&arch=x64&rid=win-x64&os=win10&apphost_version=8.0.11) and [ASP.NET Core 8.0](https://aka.ms/dotnet-core-applaunch?framework=Microsoft.AspNetCore.App&framework_version=8.0.0&arch=x64&rid=win-x64&os=win10) installed.
### Login Failed. Error code: 3
Make sure that:
* You have Steam running.
* You have created and **saved** the `steam_appid` file as described in step 6.
* The `steam_appid` file type is "TXT File".
### Login Failed. Error code: 5
Make sure that `Prospect.Server.Api` server is running.
If the server is running, press `Alt+Tab` to a game console that opens when you start the game and check for the following:
* `libcurl error 7 (Couldn't connect to server)` - indicates that the `Prospect.Server.Api` is not running.
![](./_assets/connection_refused_error.png)
* `InvalidAPIEndpoint` - indicates that you are running the game using the original shortcut and not using `Prospect.Client.Loader`.
![](./_assets/invalid_api_endpoint.PNG)
* `libcurl error 60 (Peer certificate cannot be authenticated with given CA certificates)` - indicates that the certificate was not installed correctly. Make sure that the certificate is present in `certmgr.msc` and there is only one certificate. Try removing the certificate and importing it again by following step 4.
![](./_assets/certificate_error.PNG)
* `HTTP code: 500` - usually indicates that MongoDB is not running. Make sure that MongoDB is installed and and that `MongoDB Server` is running in `services.msc`.
![](./_assets/mongodb_error.PNG)
## Development
TBD
@@ -14,8 +14,6 @@ namespace Prospect.Server.Api.Controllers;
// Read-only back-office to visualise everything relevant in the database. // 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 // 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). // 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")] [Route("admin")]
public class AdminController : ControllerBase public class AdminController : ControllerBase
{ {
@@ -53,45 +51,58 @@ public class AdminController : ControllerBase
var sb = new StringBuilder(); var sb = new StringBuilder();
sb.Append("<div class='cards'>"); sb.Append("<div class='cards'>");
sb.Append(Card("Joueurs", users.Count.ToString())); sb.Append(Card("Joueurs", users.Count.ToString()));
sb.Append(Card("Entités", entities.Count.ToString())); sb.Append(Card("Entités (title_player)", entities.Count.ToString()));
sb.Append(Card("Docs UserData", dataCount.ToString())); sb.Append(Card("Docs UserData", dataCount.ToString()));
sb.Append(Card("Clés TitleData", titleKeys.Count.ToString())); sb.Append(Card("Clés TitleData", titleKeys.Count.ToString()));
sb.Append(Card("Catalogue", _gameRef.All.Count.ToString()));
sb.Append("</div>"); 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>");
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>");
return Html(Layout("Dashboard", sb.ToString())); return Html(Layout("Dashboard", sb.ToString()));
} }
[HttpGet("players")] [HttpGet("players")]
public IActionResult Players() public async Task<IActionResult> Players([FromQuery] string? q)
{ {
if (!IsLan()) return Denied(); if (!IsLan()) return Denied();
var body =
"<h2>Joueurs</h2>" + var users = await _userService.GetAllAsync();
"<p><a class='btn' href='/admin/backfill-names' title='Récupère les pseudos Steam des comptes sans nom'>⟳ Backfill pseudos Steam</a></p>" + var entities = (await _entityService.GetAllAsync())
"<div id='tbl'></div>" + .GroupBy(e => e.UserId)
"<script>DT.init({id:'tbl',url:'/admin/api/players',size:25,filters:['auth'],columns:[" + .ToDictionary(g => g.Key, g => g.First().Id);
"{key:'name',label:'Nom',kind:'link',base:'/admin/players/',lkey:'id'}," +
"{key:'id',label:'PlayFab Id',kind:'mono'}," + if (!string.IsNullOrWhiteSpace(q))
"{key:'entity',label:'Entity Id',kind:'mono'}," + {
"{key:'auth',label:'Auth',kind:'mono'}]});</script>"; var needle = q.Trim().ToLowerInvariant();
return Html(Layout("Joueurs", body)); 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();
} }
[HttpGet("catalog")] var sb = new StringBuilder();
public IActionResult Catalog() 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))
{ {
if (!IsLan()) return Denied(); var auth = u.Auth != null ? string.Join(", ", u.Auth.Select(a => $"{a.Type}:{a.Key}")) : "";
var body = var ent = entities.TryGetValue(u.Id, out var eid) ? eid : "";
"<h2>Catalogue</h2>" + sb.Append($"<tr><td>{Esc(u.DisplayName)}</td><td class='mono'>{Esc(u.Id)}</td><td class='mono small'>{Esc(ent)}</td>" +
"<p class='muted'>Référence dataminée (TCF-Wiki) — " + _gameRef.All.Count + " entrées.</p>" + $"<td class='mono small'>{Esc(auth)}</td><td><a class='btn' href='/admin/players/{Esc(u.Id)}'>Voir</a></td></tr>");
"<div id='tbl'></div>" + }
"<script>DT.init({id:'tbl',url:'/admin/api/catalog',size:50,filters:['category','rarity'],columns:[" + sb.Append("</tbody></table>");
"{key:'name',label:'Nom'}," + return Html(Layout("Joueurs", sb.ToString()));
"{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}")] [HttpGet("players/{id}")]
@@ -108,10 +119,10 @@ public class AdminController : ControllerBase
var sb = new StringBuilder(); var sb = new StringBuilder();
var auth = user.Auth != null ? string.Join(", ", user.Auth.Select(a => $"{a.Type}:{a.Key}")) : ""; 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($"<h2>{Esc(user.DisplayName)}</h2>");
sb.Append($"<p class='muted mono'>PlayFabId {Esc(user.Id)} · Entity {Esc(entity?.Id ?? "-")} · {Esc(auth)}</p>"); sb.Append($"<p class='muted mono'>PlayFabId {Esc(user.Id)} · Entity {Esc(entity?.Id ?? "-")} · {Esc(auth)}</p>");
// Summary cards
sb.Append("<div class='cards'>"); sb.Append("<div class='cards'>");
if (map.TryGetValue("Balance", out var bal)) if (map.TryGetValue("Balance", out var bal))
{ {
@@ -127,12 +138,14 @@ public class AdminController : ControllerBase
sb.Append(Card($"Fortuna S{s} XP", (xp.Value ?? "0").Trim())); sb.Append(Card($"Fortuna S{s} XP", (xp.Value ?? "0").Trim()));
sb.Append("</div>"); sb.Append("</div>");
// Inventory table
if (map.TryGetValue("Inventory", out var inv)) if (map.TryGetValue("Inventory", out var inv))
{ {
sb.Append("<h3>Inventaire</h3>"); sb.Append("<h3>Inventaire</h3>");
sb.Append(InventoryTable(inv.Value)); sb.Append(InventoryTable(inv.Value));
} }
// All keys
sb.Append("<h3>Toutes les données</h3>"); sb.Append("<h3>Toutes les données</h3>");
foreach (var d in data) foreach (var d in data)
{ {
@@ -140,6 +153,7 @@ 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>"); $" <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>"); sb.Append($"<pre>{Esc(Pretty(d.Value))}</pre></details>");
} }
return Html(Layout("Joueur · " + user.DisplayName, sb.ToString())); return Html(Layout("Joueur · " + user.DisplayName, sb.ToString()));
} }
@@ -150,7 +164,6 @@ public class AdminController : ControllerBase
var all = _titleDataService.Find(new List<string>()); var all = _titleDataService.Find(new List<string>());
var sb = new StringBuilder(); 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>"); 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)) 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>" + sb.Append($"<tr><td class='mono'>{Esc(kv.Key)}</td><td>{kv.Value?.Length ?? 0} car</td>" +
@@ -168,10 +181,11 @@ public class AdminController : ControllerBase
if (!all.TryGetValue(key, out var value)) if (!all.TryGetValue(key, out var value))
return Html(Layout("Clé introuvable", "<p>Aucune clé TitleData avec ce nom.</p>")); 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 var body = value != null && value.Length > 300_000
? $"<p class='muted'>Valeur volumineuse ({value.Length} car) — affichage brut.</p><pre>{Esc(value)}</pre>" ? $"<p class='muted'>Valeur volumineuse ({value.Length} car) — affichage brut.</p><pre>{Esc(value)}</pre>"
: $"<pre>{Esc(Pretty(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)); return Html(Layout("TitleData · " + key, $"<h2 class='mono'>{Esc(key)}</h2>" + body));
} }
[HttpGet("backfill-names")] [HttpGet("backfill-names")]
@@ -206,37 +220,35 @@ public class AdminController : ControllerBase
return Html(Layout("Backfill", body)); return Html(Layout("Backfill", body));
} }
// ---------- JSON API (consumed by the client-side table engine) ---------- [HttpGet("catalog")]
public IActionResult Catalog([FromQuery] string? q, [FromQuery] string? cat)
[HttpGet("api/players")]
public async Task<IActionResult> ApiPlayers()
{ {
if (!IsLan()) return Denied(); if (!IsLan()) return Denied();
var users = await _userService.GetAllAsync();
var entities = (await _entityService.GetAllAsync()) var all = _gameRef.All;
.GroupBy(e => e.UserId).ToDictionary(g => g.Key, g => g.First().Id); IEnumerable<KeyValuePair<string, GameRefEntry>> items = all;
var list = users.Select(u => new if (!string.IsNullOrWhiteSpace(cat))
items = items.Where(kv => kv.Value.Category == cat);
if (!string.IsNullOrWhiteSpace(q))
{ {
name = string.IsNullOrEmpty(u.DisplayName) ? u.Id : u.DisplayName, var n = q.Trim().ToLowerInvariant();
id = u.Id, items = items.Where(kv => kv.Key.ToLowerInvariant().Contains(n) || (kv.Value.Name ?? "").ToLowerInvariant().Contains(n));
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 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();
[HttpGet("api/catalog")] var sb = new StringBuilder();
public IActionResult ApiCatalog() 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 ?? "")}'/>");
if (!IsLan()) return Denied(); sb.Append("<select name='cat'><option value=''>(toutes)</option>");
var list = _gameRef.All.Select(kv => new 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>");
id = kv.Key, sb.Append($"<p class='muted'>{list.Count} résultat(s){(list.Count >= 1000 ? " (tronqué)" : "")}</p>");
name = kv.Value.Name, sb.Append("<table><thead><tr><th>Nom</th><th>Rareté</th><th>Catégorie</th><th>id</th></tr></thead><tbody>");
rarity = kv.Value.Rarity, foreach (var kv in list)
category = kv.Value.Category 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 Json(list); return Html(Layout("Catalogue", sb.ToString()));
} }
// ---------- helpers ---------- // ---------- helpers ----------
@@ -250,12 +262,13 @@ public class AdminController : ControllerBase
if (ip.AddressFamily == AddressFamily.InterNetwork) if (ip.AddressFamily == AddressFamily.InterNetwork)
{ {
var b = ip.GetAddressBytes(); var b = ip.GetAddressBytes();
if (b[0] == 10) return true; if (b[0] == 10) return true; // 10.0.0.0/8
if (b[0] == 192 && b[1] == 168) return true; 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; if (b[0] == 172 && b[1] >= 16 && b[1] <= 31) return true; // 172.16.0.0/12
if (b[0] == 127) return true; if (b[0] == 127) return true;
return false; return false;
} }
// IPv6 unique-local (fc00::/7) or link-local (fe80::/10)
var s = ip.GetAddressBytes(); var s = ip.GetAddressBytes();
if ((s[0] & 0xFE) == 0xFC) return true; if ((s[0] & 0xFE) == 0xFC) return true;
if (s[0] == 0xFE && (s[1] & 0xC0) == 0x80) return true; if (s[0] == 0xFE && (s[1] & 0xC0) == 0x80) return true;
@@ -267,9 +280,6 @@ public class AdminController : ControllerBase
private IActionResult Html(string html) => Content(html, "text/html; charset=utf-8"); 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 Esc(string? s) => WebUtility.HtmlEncode(s ?? "");
private static string Pretty(string? json) private static string Pretty(string? json)
@@ -323,6 +333,7 @@ public class AdminController : ControllerBase
catch { return $"<pre>{Esc(Pretty(json))}</pre>"; } catch { return $"<pre>{Esc(Pretty(json))}</pre>"; }
} }
// Small coloured rarity chip.
private static string Rarity(string? r) private static string Rarity(string? r)
{ {
if (string.IsNullOrEmpty(r)) return ""; if (string.IsNullOrEmpty(r)) return "";
@@ -341,91 +352,31 @@ public class AdminController : ControllerBase
private static string Card(string label, string value) => private static string Card(string label, string value) =>
$"<div class='card'><div class='v'>{Esc(value)}</div><div class='l'>{Esc(label)}</div></div>"; $"<div class='card'><div class='v'>{Esc(value)}</div><div class='l'>{Esc(label)}</div></div>";
private static string Layout(string title, string body) => private static string Layout(string title, string body) => $@"<!doctype html><html lang='fr'><head>
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'> <meta charset='utf-8'><meta name='viewport' content='width=device-width,initial-scale=1'>
<title>__TITLE__ · The Cycle Admin</title> <title>{Esc(title)} · The Cycle Admin</title>
<style> <style>
:root{--bg:#0f1115;--panel:#171a21;--line:#262b36;--txt:#e6e8ec;--muted:#8b93a1;--acc:#e8a838;} :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} *{{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{{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)} 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} 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} 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} .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{{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} .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} 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,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} 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)}}
.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{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)}}
.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}}
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}}
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}}
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> </style></head><body>
<header><b>The Cycle · Admin</b> <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> <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> <span class='muted' style='margin-left:auto'>read-only · LAN</span></header>
<main>"; <main>{body}</main></body></html>";
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,14 +9,6 @@ public class RequestLoggerMiddleware
private readonly ILogger<RequestLoggerMiddleware> _logger; private readonly ILogger<RequestLoggerMiddleware> _logger;
private readonly RequestDelegate _next; private readonly RequestDelegate _next;
// Keywords used to surface social/squad/invite traffic while reverse-engineering the
// squad-invite flow (see SQUAD-EMULATION.md). Matched against the path AND the body
// (the CloudScript function name lives in the body of /Client/ExecuteFunction).
private static readonly string[] SocialKeywords =
{
"group", "party", "lobby", "squad", "invite", "friend", "social", "matchmak",
};
public RequestLoggerMiddleware(ILogger<RequestLoggerMiddleware> logger, RequestDelegate next) public RequestLoggerMiddleware(ILogger<RequestLoggerMiddleware> logger, RequestDelegate next)
{ {
_logger = logger; _logger = logger;
@@ -40,27 +32,6 @@ public class RequestLoggerMiddleware
} }
await _next(context); await _next(context);
// ── Capture pass (squad-invite RE, see SQUAD-EMULATION.md) ──────────────
// Runs AFTER the pipeline so we know whether the request was actually routed.
if (context.Request.Method == "POST")
{
var path = context.Request.Path.Value ?? "";
var haystack = (path + " " + body).ToLowerInvariant();
// Any POST that fell through to a 404 = an endpoint the emulator does NOT implement
// (e.g. a native PlayFab /Group/CreateGroup or /Lobby/* the client tried to call).
if (context.Response.StatusCode == 404)
{
_logger.LogWarning("[CAPTURE UNROUTED] {Method} {Url} -> 404 | Body {Body}",
context.Request.Method, context.Request.GetDisplayUrl(), body);
}
else if (SocialKeywords.Any(k => haystack.Contains(k)))
{
_logger.LogInformation("[CAPTURE SOCIAL] {Url} ({Status}) | Body {Body}",
context.Request.GetDisplayUrl(), context.Response.StatusCode, body);
}
}
} }
private static async Task<string> RequestAsync(HttpRequest request) private static async Task<string> RequestAsync(HttpRequest request)
@@ -32,8 +32,9 @@ public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
} }
var userId = context.User.FindAuthUserId(); var userId = context.User.FindAuthUserId();
// Imported Steam friends (persisted by ClientsideFriendsImport), resolved to players // Resolve the imported Steam friends (persisted by ClientsideFriendsImport) to the
// that actually exist on this private server. // players that actually exist on this server. It's a private server, so only friends
// who have logged in here will show up.
var steamIds = new List<string>(); var steamIds = new List<string>();
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "ImportedSteamFriends" }); var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "ImportedSteamFriends" });
if (userData.TryGetValue("ImportedSteamFriends", out var rec) && !string.IsNullOrWhiteSpace(rec.Value)) if (userData.TryGetValue("ImportedSteamFriends", out var rec) && !string.IsNullOrWhiteSpace(rec.Value))
@@ -51,10 +52,8 @@ public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
{ {
if (player.Id == userId) continue; // never list yourself if (player.Id == userId) continue; // never list yourself
var steamId = player.Auth?.FirstOrDefault(a => a.Type == PlayFabUserAuthType.Steam)?.Key ?? ""; // Presence persisted by UpdatePlayerPresenceState: online if seen in the last
var displayName = string.IsNullOrWhiteSpace(player.DisplayName) ? "Prospector" : player.DisplayName; // 3 minutes. EYUserState best-effort: 0 = offline, 1 = online, 2 = in match.
// 0 = offline, 1 = online, 2 = in match. Online if seen in the last 3 minutes.
var onlineState = 0; var onlineState = 0;
var presenceData = await _userDataService.FindAsync(player.Id, player.Id, new List<string> { "PresenceState" }); var presenceData = await _userDataService.FindAsync(player.Id, player.Id, new List<string> { "PresenceState" });
if (presenceData.TryGetValue("PresenceState", out var presenceRec) && !string.IsNullOrWhiteSpace(presenceRec.Value)) if (presenceData.TryGetValue("PresenceState", out var presenceRec) && !string.IsNullOrWhiteSpace(presenceRec.Value))
@@ -70,50 +69,22 @@ public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
catch { /* ignore malformed presence */ } catch { /* ignore malformed presence */ }
} }
// The exact model the UE client binds is not documented, so expose the identity
// under every plausible field name (camelCase + PlayFab PascalCase) and both a
// flat and nested profile — whichever the client reads, the tile gets populated.
friends.Add(new friends.Add(new
{ {
// identity profile = new
friendPlayFabId = player.Id, {
FriendPlayFabId = player.Id,
playerId = player.Id, playerId = player.Id,
PlayerId = player.Id, displayName = player.DisplayName,
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 = "", avatarUrl = "",
// nested profiles },
profile = new { playerId = player.Id, displayName, avatarUrl = "" }, onlineState,
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. // NOTE: the exact response shape the client expects is not yet confirmed; this is a
var result = new { friends, Friends = friends, count = friends.Count }; // best-effort structure. The log lets us verify resolution while we validate in game.
_logger.LogInformation("GetFriendList for {User}: {Count} friend(s); payload={Json}", userId, friends.Count, JsonSerializer.Serialize(result)); _logger.LogInformation("GetFriendList for {User}: {Count} friend(s) resolved on server", userId, friends.Count);
return result; return new { friends };
} }
} }
File diff suppressed because one or more lines are too long