32 Commits
Author SHA1 Message Date
neckfireandClaude Opus 4.8 f0343bc1ea gameserver: consolidate netcode R&D — remove diag spam, document DTLS-PSK wall
Build Game Server / build (push) Successful in 21s
- Keep the R3.5.0 header-bit fix (bunch alignment breakthrough)
- Remove per-packet [HS-RAW]/[HS-HDR]/[HS-BUNCH] diagnostics
- Document the DTLS-PSK encryption wall + packed-binary finding in NETCODE-RND.md
- UWorld NMT_Hello: honest comment on why the connection stops here

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:13:03 +02:00
neckfireandClaude Opus 4.8 ba9976c5b6 gameserver: skip encryption on Hello (try plaintext challenge)
Build Game Server / build (push) Successful in 19s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:54:01 +02:00
neckfireandClaude Opus 4.8 fb73038901 gameserver: consume Cycle's extra header bit -> bunches align (NMT_Hello parses)
Build Game Server / build (push) Successful in 22s
Bit-decoded real client packets: the header is 65 bits (not stock UE 4.27's 64).
One extra bit sits between the ack history and the packet-info payload; consuming
it realigns bHasPacketInfoPayload + jitter + bHasServerFrameTime, and the first
control-channel bunch parses (ChIndex 0, open, reliable = NMT_Hello).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:50:19 +02:00
neckfireandClaude Opus 4.8 629e8a4c31 gameserver: dump full packet bits (512) to decode NMT_Hello netver
Build Game Server / build (push) Successful in 18s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 20:29:11 +02:00
neckfireandClaude Opus 4.8 1691af4be6 gameserver: dump raw packet bit-stream for hand-decode
Build Game Server / build (push) Successful in 18s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:53:53 +02:00
neckfireandClaude Opus 4.8 b1386bef56 gameserver: bit-position diagnostics in ReceivedPacket (header + bunch flags)
Build Game Server / build (push) Successful in 17s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:41:39 +02:00
neckfireandClaude Opus 4.8 71aca5e3d0 fix(game-server): survive bad-packet exceptions in tick loop
Build Game Server / build (push) Successful in 17s
Wrap world.Tick in try/catch so an incompatible/misaligned client packet
logs an error and the server keeps running (stable R&D bench).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:30:12 +02:00
neckfireandClaude Opus 4.8 4d2280f8ab fix(game-server): don't crash on malformed bunch channel index
Build Game Server / build (push) Successful in 16s
Log + drop the packet (with net version) instead of throwing, so a
bit-alignment/version mismatch with the client doesn't kill the server.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:26:53 +02:00
neckfireandClaude Opus 4.8 1caa46f846 fix(game-server): adopt client packet sequences on first packet
Build Game Server / build (push) Successful in 16s
The Cycle client does not derive its initial packet sequences from the
handshake cookie the way stock UE does (its acked server-seq isn't present
anywhere in the cookie), so the cookie-based InitSequence disagreed and
every post-handshake packet was rejected as out-of-order. On the first
packet, adopt the client's announced Seq/AckedSeq instead.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:21:49 +02:00
neckfireandClaude Opus 4.8 9092dda950 diag(game-server): log packet sequence mismatch (post-handshake)
Build Game Server / build (push) Successful in 21s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:08:52 +02:00
neckfireandClaude Opus 4.8 6ccad507a8 diag(game-server): log connectionless handshake (challenge/response)
Build Game Server / build (push) Successful in 16s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 19:00:19 +02:00
neckfireandClaude Opus 4.8 f4d1757840 diag(game-server): log stateless handshake packet size vs expected
Build Game Server / build (push) Successful in 15s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:52:52 +02:00
neckfireandClaude Opus 4.8 9749828af8 feat(game-server): scaffold dedicated server toward player join
Build Game Server / build (push) Successful in 17s
R&D branch for an authoritative Unreal game server (Prospect.Unreal).
The connection handshake already reaches PlayerController spawn; this
commit pushes it toward real players joining:

- Program.cs: target the Cycle station map + game mode (configurable via
  PROSPECT_MAP/GAMEMODE/PORT), proper host loop, and initialise a
  GameSession so NMT_Join -> Login no longer fails ("GameSession is null").
- UWorld.WelcomePlayer: send the world's real map/game mode to the client
  instead of the UE ThirdPerson template.
- Dockerfile.gameserver + .gitea/workflows/game-server.yml: build/push a
  separate image (the-cycle-game) on the game-server branch, ntfy notify.
- GAMESERVER.md: honest state + roadmap (player spawn + movement
  replication need live client testing; AI/loot intentionally deferred).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:30:09 +02: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 922deb8397 docs: pointer vers la release pour LoaderPack + cert
Build & Deploy / build (push) Successful in 16s
2026-07-14 13:09:57 +00:00
neckfire f4d93c9e71 docs: ajoute la commande download_depot + astuce disque
Build & Deploy / build (push) Successful in 14s
2026-07-14 13:07:36 +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
neckfire b0822c34cd docs: guide install client amis
Build & Deploy / build (push) Successful in 14s
2026-07-14 12:33:52 +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
neckfireandClaude Opus 4.8 310e2bf7e9 feat(social): friends list resolution + fix ping request 500
Build & Deploy / build (push) Successful in 24s
- RequestUsersPingRequest: the client sends ping=2147483648 (int.MaxValue+1)
  for "no ping", which overflowed the int field and made every ping request
  return 500. Widened to long.
- ClientsideFriendsImport: persist the imported Steam friend ids per user.
- GetFriendList: resolve those ids to the players that exist on this server
  (batch lookup by Steam auth key) and return them as friends. Response
  shape is best-effort pending in-game confirmation.
- DbUserService.FindManyAsync: batch-resolve auth keys to players.

First step of the station lobby (see friends on the server). Squad
formation / invite flow to follow once friends are visible.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 14:18:44 +02: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
neckfireandClaude Opus 4.8 5d93b3541d fix(store): return per-user owned vanities, not the whole catalogue
Build & Deploy / build (push) Successful in 23s
GetUserInventory returned every vanity in the Vanities title data as an
owned item, so the client showed all Aurum-shop cosmetics as already
owned. Return only the cosmetics the player actually owns: purchased
(VanityItems) plus currently equipped (CharacterVanity slots and the
GlobalVanity sprays / banner / emotes / drop pod), so the starter outfit
stays owned while the rest of the Aurum shop becomes purchasable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:55:50 +02:00
neckfireandClaude Opus 4.8 40dad82559 feat(store): populate Aurum/cosmetic shops from Vanities
Build & Deploy / build (push) Successful in 23s
GetStoreItems now also serves the cosmetic stores the client queries
(AurumShop, and the DailyShop_/WeeklyShop_ rotations) from the Vanities
title data: every vanity with StoreData.Amount > 0 is offered for that
price in Aurum (AU). AurumShop lists the full priced catalogue (632
items); the daily/weekly ids show a deterministic rotating slice so they
stay stable within a period but change across periods.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 13:46:17 +02:00
neckfireandClaude Opus 4.8 3b108d99ca feat(store): populate shops via GetStoreItems
Build & Deploy / build (push) Successful in 23s
GetStoreItems was a stub returning an empty object, so every shop
(Korolev / ICA / Osiris / QuickShop vendors) showed up empty. Build the
store on the fly from the Blueprints title data: for the requested
StoreId, list every item that declares an ItemShopsCraftingData entry
for that store, converting its currency recipe ingredients into
VirtualCurrencyPrices (SoftCurrency->SC, Aurum->AU, InsuranceCurrency->IN).
Item-ingredient (crafting) entries are left to the crafting UI.

Adds the StoreId/CatalogVersion fields to the request and the
FGetStoreItemsResult / FStoreItem PlayFab response models.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:53:37 +02: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
neckfireandClaude Opus 4.8 89e7a69049 feat(insurance,fortuna): payout on death + Fortuna Pass claim safety
Build & Deploy / build (push) Successful in 21s
- CompleteInventoryUpdate: insured items lost in a raid (present in
  ItemsToRemove) are recovered back to the stash and the insurance is
  consumed. This is the payout counterpart to the premium already
  debited on deploy (EnterMatchmakingStation). Gated on the policy
  declaring a positive Payout; never fails the inventory commit.
- RequestClaimFortunaPassRewards: log the client-sent reward IDs so a
  real FortunaPass{2,3}_Rewards catalog can be built to match, and stop
  recording a reward as claimed when there is no catalog entry to grant
  (previously it was burned permanently and became unclaimable).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 12:30:11 +02:00
neckfireandClaude Opus 4.8 6f3410b37d fix(contracts): auto-credit kill objectives in client-hosted raids
Build & Deploy / build (push) Successful in 21s
Kill-type contract objectives were never persisted: the client-hosted
raid has no dedicated game server to report per-kill progress, so
creature kills stayed at 0 and contracts reported "no kills" at claim.

- EnterMatchmakingMatch: auto-credit Kills objectives to MaxProgress on
  deploy (mirrors the item-ownership pre-compute already done there and
  the player-kill auto-credit in ActivateContract).
- ClaimActiveContract: treat Kills objectives as met as a safety net for
  contracts activated before deploy.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 11:56:14 +02:00
neckfireandClaude Opus 4.8 345a822a2e feat(preprod): contrats fonctionnels, réputation factions, gating tech-tree & shop
Build & Deploy / build (push) Successful in 21s
- RequestPlayerContracts: renvoie les vrais contrats actifs + job boards du joueur
  (peuplement initial des boards vides) au lieu d'une liste hardcodée.
- RequestFactionProgression: renvoie la réputation réelle des 3 factions (au lieu de 0).
- StartTechTreeNodeUpgradeClient: dependency check PlayerQuarterLevelRequired.
- PurchaseWeaponShopItem: critère de déblocage par contrat (UnlockData.ContractLockPurchase).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 02:20:27 +02:00
neckfireandClaude Opus 4.8 1e79360b6e feat: rotation shops (daily/weekly) + items insurance + season pass
Build & Deploy / build (push) Successful in 10s
- RequestStoreRotationData: rotation daily (reset 00:00 UTC) + weekly (lundi) avec
  storeIDs rotatifs déterministes et timers d'expiration corrects.
- EnterMatchmakingStation: traite purchaseInsuranceRequest au déploiement — marque
  les items assurés (Insurance + owner) et débite la prime (TitleData InventoryInsurance).
- FYPurchaseInventoryInsuranceRequest: modèle complété (insuranceId + itemIds).
- RequestClaimFortunaPassRewards: claim saison-correct (S2/S3 séparés, plus d'écrasement)
  + octroi des récompenses depuis catalogue TitleData optionnel (repli sûr sinon).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:55:54 +02:00
neckfireandClaude Opus 4.8 31aa20abd8 fix(ci): publier en configuration 'Season 2 Release' (client S2 déployé)
Build & Deploy / build (push) Successful in 8s
Le code sélectionne la saison via #if SEASON_2_RELEASE ; sans cette config le
build échoue (#error Unsupported build type). Build validé en local.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:37:11 +02:00
neckfireandClaude Opus 4.8 2630a9f2af ci: build serveur API depuis les sources + déploiement registry (prod/preprod)
Build & Deploy / build (push) Failing after 12s
Dockerfile multi-stage (SDK 8 publish → aspnet 8) et workflow Gitea Actions :
main → :latest (prod), preprod → :preprod. Base = tag b8 (état déployé).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 02:33:04 +02:00
50 changed files with 1327 additions and 395 deletions
+33
View File
@@ -0,0 +1,33 @@
name: Build & Deploy
on:
push:
branches: [main, preprod]
workflow_dispatch: {}
env:
IMAGE: git.nfteam.ovh/neckfire/the-cycle
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build & push image
run: |
set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
SHA="${GITHUB_SHA::12}"
BRANCH="${GITHUB_REF_NAME}"
if [ "$BRANCH" = "main" ]; then TAGS="-t ${IMAGE}:latest -t ${IMAGE}:${SHA}"; else TAGS="-t ${IMAGE}:${BRANCH} -t ${IMAGE}:${BRANCH}-${SHA}"; fi
docker build $TAGS -f Dockerfile .
docker push --all-tags "${IMAGE}"
- name: Notify ntfy
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then EMOJI="white_check_mark"; PRIO="default"; else EMOJI="rotating_light"; PRIO="high"; fi
curl -s -H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" -H "Title: ${GITHUB_REPOSITORY} — ${{ job.status }}" \
-H "Priority: ${PRIO}" -H "Tags: ${EMOJI}" -H "Click: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions" \
-d "${GITHUB_WORKFLOW} (${GITHUB_REF_NAME} #${GITHUB_RUN_NUMBER}) : ${{ job.status }}" \
"${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true
+28
View File
@@ -0,0 +1,28 @@
name: Build Game Server
on:
push:
branches: [game-server]
workflow_dispatch: {}
env:
IMAGE: git.nfteam.ovh/neckfire/the-cycle-game
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build & push image
run: |
set -e
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login git.nfteam.ovh -u "${{ secrets.REGISTRY_USER }}" --password-stdin
SHA="${GITHUB_SHA::12}"
docker build -t "${IMAGE}:game-server" -t "${IMAGE}:${SHA}" -f Dockerfile.gameserver .
docker push --all-tags "${IMAGE}"
- name: Notify ntfy
if: always()
run: |
if [ "${{ job.status }}" = "success" ]; then EMOJI="white_check_mark"; PRIO="default"; else EMOJI="rotating_light"; PRIO="high"; fi
curl -s -H "Authorization: Bearer ${{ secrets.NTFY_TOKEN }}" -H "Title: ${GITHUB_REPOSITORY} game-server — ${{ job.status }}" \
-H "Priority: ${PRIO}" -H "Tags: ${EMOJI}" -H "Click: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions" \
-d "${GITHUB_WORKFLOW} (${GITHUB_REF_NAME} #${GITHUB_RUN_NUMBER}) : ${{ job.status }}" \
"${{ secrets.NTFY_URL }}/${{ secrets.NTFY_TOPIC }}" || true
+1
View File
@@ -0,0 +1 @@
_out/
+15
View File
@@ -0,0 +1,15 @@
# The Cycle: Frontier — serveur API (émulateur Prospect), build depuis les sources.
# Multi-stage : SDK .NET 8 pour publier le projet API → runtime aspnet 8.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY src/ ./src/
# Configuration "Season 2 Release" = build qui correspond au client S2 déployé
# (le code sélectionne la saison via des #if SEASON_2_RELEASE / SEASON_3_RELEASE).
RUN dotnet publish src/Prospect.Server.Api/Prospect.Server.Api.csproj \
-c "Season 2 Release" -o /app
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app ./
EXPOSE 8443 8000
ENTRYPOINT ["dotnet", "Prospect.Server.Api.dll"]
+14
View File
@@ -0,0 +1,14 @@
# The Cycle: Frontier — serveur de jeu dédié (EXPÉRIMENTAL / R&D).
# Réimplémentation du serveur autoritaire Unreal (Prospect.Unreal). Build depuis les sources.
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /src
COPY src/ ./src/
RUN dotnet publish src/Prospect.Server.Game/Prospect.Server.Game.csproj -c Release -o /app
FROM mcr.microsoft.com/dotnet/runtime:8.0
WORKDIR /app
COPY --from=build /app ./
# Le réseau Unreal est en UDP.
EXPOSE 7777/udp
# Configurable : PROSPECT_MAP / PROSPECT_GAMEMODE / PROSPECT_PORT
ENTRYPOINT ["dotnet", "Prospect.Server.Game.dll"]
+80
View File
@@ -0,0 +1,80 @@
# 🎮 The Cycle: Frontier — Rejoindre le serveur privé (installation client)
Guide pour **installer le client et jouer sur le serveur privé**. Tu n'installes **rien côté serveur** — juste le jeu + un loader + un certificat.
> ⚠️ **À savoir avant de commencer**
> - Le jeu a été retiré de Steam en septembre 2022 : il faut **l'avoir déjà eu dans ta bibliothèque Steam** pour le retélécharger.
> - **Ce n'est pas du co-op en raid.** Chacun joue sa propre instance (stations solo) ; l'émulateur ne supporte pas plusieurs joueurs dans le même raid. Vous partagez le même serveur (progression, boutiques, comptes), pas la même partie.
> - Le serveur doit être **allumé** au moment où tu joues (demande à l'hébergeur).
---
## Fichiers à télécharger (dans la release)
Les 2 fichiers sont dispo dans la **release Gitea** :
👉 **https://git.nfteam.ovh/neckfire/the-cycle/releases/tag/friends-client**
1. **`LoaderPack.zip`** — le loader qui redirige le jeu vers le serveur privé (build b8 / Saison 2).
2. **`certificate.crt`** — le certificat **public** du serveur (sans clé privée, safe).
Télécharge les deux depuis la page de release avant de commencer.
---
## Étapes (Windows)
### 1. Installer le client Saison 2 (via la console Steam)
1. Steam ouvert, `Win+R` → tape `steam://nav/console` → Entrée (la **console Steam** s'ouvre).
2. Colle **exactement** cette commande → Entrée :
```
download_depot 868270 868271 4623363103423775682
```
(`868270` = le jeu, `868271` = le dépôt client, `4623363103423775682` = manifest **Saison 2 v2.7.2**.)
3. Le téléchargement démarre (⚠️ **pas de barre de progression**, sois patient — plusieurs Go).
4. À la fin, Steam affiche **« Depot download complete »** + le **chemin du dossier**
(ex. `...\Steam\steamapps\content\app_868270\depot_868271`). **Ce dossier = ton install du jeu.**
> Il faut **avoir eu The Cycle: Frontier dans ta bibliothèque Steam** avant, sinon → erreur `missing license`.
> **Pas assez d'espace sur le disque principal ?** `download_depot` écrit toujours dans `...\Steam\steamapps\content`. Pour le mettre sur un autre disque : ferme Steam, supprime `...\Steam\steamapps\content`, puis en **cmd admin** :
> ```
> mkdir "D:\SteamContent"
> mklink /J "C:\Program Files (x86)\Steam\steamapps\content" "D:\SteamContent"
> ```
> Rouvre Steam et relance la commande → le jeu ira sur `D:\`.
### 2. Installer le LoaderPack
1. Va dans le dossier du jeu → **`Prospect\Binaries\Win64`**.
2. Ouvre `LoaderPack.zip` et **glisse tout son contenu** dans ce dossier `Win64`.
3. Crée un **raccourci** vers `Prospect.Client.Loader.exe` (c'est avec ça que tu lanceras le jeu).
### 3. Faire confiance au certificat du serveur
1. Double-clique **`certificate.crt`** → **Installer le certificat**.
2. **Emplacement : Utilisateur actuel** → Suivant.
3. **Placer tous les certificats dans le magasin suivant****Parcourir****Autorités de certification racines de confiance** → OK → Suivant → Terminer.
4. Accepte l'avertissement de sécurité (il mentionne `2EA46.playfabapi.com`).
### 4. Pointer le client vers le serveur
Dans **`Prospect\Binaries\Win64`**, crée (ou édite) un fichier **`backend.txt`** contenant **exactement** cette ligne :
```
https://tc.nfteam.ovh:8443
```
### 5. Lancer le jeu
1. **Steam doit être ouvert** (connecté à ton compte).
2. Lance le jeu via le **raccourci `Prospect.Client.Loader`** (⚠️ pas via Steam directement).
3. Connexion automatique avec ton compte Steam. Bienvenue sur Fortuna III ! 🚀
---
## Dépannage
| Symptôme | Cause / solution |
|---|---|
| `libcurl error 7 (Couldn't connect to server)` | Le serveur est **éteint** → demande à l'hébergeur de l'allumer. |
| Erreur de certificat / `hostname mismatch` | `certificate.crt` **pas importé** (ou pas dans « Autorités racines de confiance »). Refais l'étape 3. |
| `missing license` au téléchargement | Tu n'as jamais eu le jeu sur ce compte Steam → impossible de le retélécharger. |
| Parties du corps manquantes (client S3) | Va en station → change ton apparence une fois (corrige les IDs). |
---
*Serveur privé The Cycle: Frontier — émulateur Prospect. Usage privé entre amis.*
+62
View File
@@ -0,0 +1,62 @@
# Serveur de jeu dédié — R&D (branche `game-server`)
> ⚠️ **Expérimental.** Objectif : un serveur de jeu **autoritaire** pour que plusieurs
> joueurs soient dans la **même instance** (se voir, bouger). C'est un chantier de
> reverse-engineering du serveur Unreal du Cycle. **Un raid co-op complet reste hors de portée
> réaliste** ; on avance par jalons. **IA et loot volontairement hors périmètre pour l'instant.**
## Pièces en jeu
- **`Prospect.Unreal`** — réimplémentation en C# de la couche réseau d'Unreal Engine
(NetDriver UDP, channels control/actor, bunches, packet handler, handshake, `UWorld`,
`AGameModeBase`/`APlayerController`/`APawn`).
- **`Prospect.Server.Game`** — l'exécutable serveur (host loop, monde, game mode).
## État actuel (ce qui marche côté serveur)
Le **handshake de connexion Unreal est implémenté** et va jusqu'au spawn du PlayerController :
```
NMT_Hello → SendChallenge
NMT_Login → PreLogin → WelcomePlayer (envoie map + game mode)
NMT_Join → SpawnPlayActor → GameMode.Login → APlayerController
```
Corrections/avancées de cette branche :
- **Cible la map/gamemode du Cycle** (`/Game/Maps/MP/Station/Station_P` + `YGameMode_Station`)
au lieu de la map template d'UE. Configurable via `PROSPECT_MAP` / `PROSPECT_GAMEMODE` / `PROSPECT_PORT`.
- **`WelcomePlayer`** envoie désormais la **vraie** map/gamemode du monde (plus le template).
- **`GameSession`** est initialisée → le login ne plante plus sur `"GameSession is null"`
(c'était le point de blocage juste avant le spawn).
## Ce qui manque (roadmap, du plus atteignable au plus dur)
1. **Connexion client réelle** : valider le handshake complet avec le **vrai client** (pas le
harnais `Client.cs`). Nécessite des **tests en live** (impossible à valider hors client).
2. **Spawn du Pawn du Cycle** : `GameMode.Login` spawn un `APlayerController` mais **pas** le
personnage. Il faut spawner la **classe de Pawn spécifique du Cycle** (`YCharacter…`) avec le
bon **NetGUID / class path** pour que le client l'instancie.
3. **Réplication du mouvement** : répliquer les propriétés du `CharacterMovementComponent`
(position/rotation/état) chaque tick → **le premier vrai « se voir bouger »**.
4. *(plus tard)* IA, loot, dégâts, tempête, évac… — **hors périmètre pour l'instant**.
Les jalons 23 demandent de connaître les **classes répliquées du jeu** (côté client, non
présentes dans le code serveur) et **itèrent en live** avec le client. C'est le vrai mur.
## Build / run
```bash
# build
dotnet build src/Prospect.Server.Game/Prospect.Server.Game.csproj -c Release
# run (défauts : station, port 7777 UDP)
dotnet run --project src/Prospect.Server.Game
# ou conteneur
docker build -t the-cycle-game -f Dockerfile.gameserver .
docker run --rm -p 7777:7777/udp the-cycle-game
```
## CI/CD
`.gitea/workflows/game-server.yml` : à chaque push sur `game-server`, build de `Dockerfile.gameserver`
→ image **`git.nfteam.ovh/neckfire/the-cycle-game`** (tags `game-server` + sha) + notif ntfy.
Image **séparée** de l'API (`the-cycle`) — les deux ne se marchent pas dessus.
## Honnêteté
Ceci est une **base d'exploration**. Le handshake + le spawn du controller avancent ; le
« 2 joueurs se voient bouger » dépend du spawn du pawn du Cycle + réplication, qui exige du RE
spécifique au jeu **et** des tests dans le client réel. Aucune garantie d'aboutir.
+90
View File
@@ -0,0 +1,90 @@
# The Cycle: Frontier — serveur dédié gameplay (R&D netcode)
Branche `game-server`. Objectif : un serveur de jeu autoritatif écrit from scratch
(Prospect.Unreal, réimplémentation C# du netcode Unreal) pour du vrai co-op, sans
passer par un client-hôte P2P. **Statut : bloqué au chiffrement (voir plus bas).**
Client cible : **UE4 build `R3.5.0`** (`4.27.2` netcode), Steam depot 868271.
## Ce qui fonctionne (murs franchis)
La connexion d'un vrai client va jusqu'à l'entrée du login :
```
Handshake stateless UDP (cookie/challenge) ✅
Reconstruction du paquet (PacketHandler) ✅
Séquençage des paquets (adopt des seq client) ✅
Alignement des bunches ✅ ← percée
Canal de contrôle ouvert ✅
NMT_Hello reçu et parsé ✅
Transition login Hello → Login ✅
Chiffrement DTLS-PSK ❌ ← mur final
```
### Percée : le bit de header spécifique R3.5.0
Le client écrit **un bit de plus** entre l'historique d'ack du `FNetPacketNotify` et
le payload packet-info, que l'UE 4.27 stock (EngineNetVer 16) n'a pas. Décodage
bit-à-bit d'un vrai paquet : l'en-tête fait **65 bits, pas 64**. En consommant ce bit
(`bCycleExtraHeaderBit` dans `UNetConnection.ReceivedPacket`), tout se réaligne :
`bHasPacketInfoPayload`, l'horloge jitter (10 bits) et `bHasServerFrameTime` tombent
juste, et le premier bunch du canal de contrôle parse proprement (ChIndex 0, bOpen,
bReliable = NMT_Hello). Sans ce fix, le `ChIndex` sortait en vrac (~1 049 000) et le
serveur droppait/plantait.
## Le mur final : chiffrement DTLS-PSK
Le client **exige** le chiffrement. Établi par reverse-engineering :
- `NMT_Hello` porte `EncryptionToken` = le **PlayFab user_id** du joueur
(ex. `92EBCFE8C3EAF3AC`). Vu dans l'URL de connexion du client :
`...?EntityToken=<JWT>?EncryptionToken=92EBCFE8C3EAF3AC`.
- Pile PacketHandler du client (ses propres logs) :
`[DTLSHandlerComponent, StatelessConnectHandlerComponent]`.
- Le exe embarque les suites **`ECDHE-PSK-AES256-*`, `DHE-PSK-AES256-GCM-SHA384`**,
la cvar **`DTLS.PreSharedKeys`**, et `DTLSPSKClientCallback` / `DTLSPSKServerCallback`
**DTLS en mode PSK** (clé pré-partagée 32 octets, identité = user_id).
- Compression : **OodleNetwork** compilé, mais **aucun dictionnaire `.udic`**
pass-through (les paquets ne sont ni compressés ni chiffrés au niveau paquet ;
entropie faible + longues suites de zéros le confirment).
Proposer un challenge en clair (sans `NMT_EncryptionAck`) ne marche pas : le client
ferme le canal de contrôle juste après.
Pour finir il faudrait : (1) un **serveur DTLS-PSK** collé au framing du
`DTLSHandlerComponent` d'UE, et (2) la **PSK de 32 octets** dérivée par le client à
partir du user_id/EntityToken.
## Pourquoi la clé est inaccessible (statique)
L'exe `Prospect-Win64-Shipping.exe` est **packé/chiffré** (protection anti-triche,
BattlEye) :
- **Entropie de `.text` = 8.000** (maximum = aléatoire/chiffré ; du code normal ≈ 6.3).
- `.rdata` = 4.96 (normal → les strings restent lisibles, d'où les découvertes ci-dessus).
- Un scan brut du `.text` (76 Mo) ne trouve que **~76 instructions** → niveau du bruit :
ce n'est pas du code sur disque, c'est du chiffré déchiffré au runtime.
Conséquence : Ghidra / radare2 n'analysent que du ciphertext ; **aucune référence** aux
fonctions de chiffrement n'est trouvable statiquement. La dérivation de la PSK vit dans
ce code chiffré.
**Seule voie restante (non tentée)** : dump mémoire au runtime. Le jeu tourne sous
Proton/Linux (BattlEye n'a pas de driver kernel sous Linux) → un autre process Linux
peut lire `/proc/<pid>/mem` et récupérer le `.text` **déchiffré**, puis l'analyser dans
Ghidra pour retrouver la dérivation. Zone grise ToS, plusieurs étapes.
## Fichiers clés
- `src/Prospect.Server.Game/Program.cs` — hôte du serveur de jeu (map Station, GameSession).
- `src/Prospect.Unreal/Net/UNetConnection.cs``ReceivedPacket` : fix du bit de header
(`bCycleExtraHeaderBit`), adopt des séquences client, drop gracieux des bunches.
- `src/Prospect.Unreal/Runtime/UWorld.cs``NotifyControlMessage` : Hello/Login ;
le `else` du bloc `NMT.Hello` documente le mur DTLS-PSK.
## Verdict
On a amené un serveur dédié gameplay The Cycle plus loin qu'aucun projet public connu
(le projet communautaire deiteris/Prospect n'émule que les services en ligne, pas le
netcode de jeu). Le mur restant — DTLS-PSK dont la clé est derrière un packer
anti-triche — est un chantier crypto + RE dynamique d'un autre ordre de grandeur.
+2 -2
View File
@@ -141,7 +141,7 @@ to successfully communicate with the local server. Do the following:
1. Select **Place all certificates in the following store** > **Browse...**. Choose **Trusted Root Certification Authorities** and click **OK**. 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 `127.0.0.1` certification authority and click **Yes**. 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 ### 4. Extract `LoaderPack` to the game
@@ -174,7 +174,7 @@ If you've installed the certificate for the **Current User**:
1. Expand **Trusted Root Certification Authorities** and select **Certificates**. 1. Expand **Trusted Root Certification Authorities** and select **Certificates**.
1. Find `127.0.0.1`, right-click it > **Delete**. 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`. If you've installed the certificate for the **Local Machine**, repeat the same steps but instead open `certlm.msc`.
@@ -4,4 +4,6 @@ public class PlayFabSettings
{ {
public string PublisherId { get; set; } public string PublisherId { get; set; }
public string TitleId { get; set; } public string TitleId { get; set; }
public string SignalRURL { get; set; }
public string SignalRAccessToken { get; set; }
} }
@@ -1,7 +0,0 @@
namespace Prospect.Server.Api.Config;
public class SignalRSettings
{
public string SignalRURL { get; set; }
public string SignalRAccessToken { get; set; }
}
@@ -1,9 +0,0 @@
namespace Prospect.Server.Api.Config;
public class VivoxConfig
{
public string Key { get; set; }
public string Issuer { get; set; }
public string Domain { get; set; }
public string Server { get; set; }
}
@@ -155,11 +155,119 @@ public class ClientController : Controller
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
public IActionResult GetStoreItems(FGetStoreItems request) public IActionResult GetStoreItems(FGetStoreItems request)
{ {
return Ok(new ClientResponse<object> // The vendors (Korolev / ICA / Osiris / QuickShop) and other stores are defined
// inside the Blueprints title data: each item lists a per-store entry under
// ItemShopsCraftingData keyed by the store id, with the currency price in its
// recipe ingredients. This endpoint was previously a stub returning {}, which is
// why every shop showed up empty. We build the store on the fly from that data.
var storeId = request.StoreId ?? "";
var store = new List<FStoreItem>();
try
{
var titleData = _titleDataService.Find(new List<string> { "Blueprints" });
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
if (blueprints != null && !string.IsNullOrEmpty(storeId))
{
uint position = 0;
foreach (var (itemId, blueprint) in blueprints)
{
if (blueprint.ItemShopsCraftingData == null) continue;
if (!blueprint.ItemShopsCraftingData.TryGetValue(storeId, out var shopData)) continue;
if (shopData?.ItemRecipeIngredients == null) continue;
// Only currency ingredients form a purchasable price. Entries whose
// recipe is item-based (crafting) are handled by the crafting UI from
// the blueprint data the client already has, not by the buy store.
var prices = new Dictionary<string, uint>();
foreach (var ingredient in shopData.ItemRecipeIngredients)
{
var code = ingredient.Currency switch
{
"SoftCurrency" => "SC",
"Aurum" => "AU",
"InsuranceCurrency" => "IN",
_ => null,
};
if (code == null || ingredient.Amount < 0) continue;
prices[code] = prices.TryGetValue(code, out var current)
? current + (uint)ingredient.Amount
: (uint)ingredient.Amount;
}
if (prices.Count == 0) continue;
store.Add(new FStoreItem
{
ItemId = itemId,
VirtualCurrencyPrices = prices,
RealCurrencyPrices = new Dictionary<string, uint>(),
DisplayPosition = position++,
});
}
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to build store items for store {StoreId}", storeId);
}
// Cosmetic / premium stores are backed by the Vanities title data (not Blueprints):
// each vanity carries its price in StoreData.Amount, paid in Aurum. AurumShop lists
// the whole priced catalogue; the daily/weekly rotations (ids from RequestStore
// RotationData) show a deterministic slice so they change over time but stay stable
// within a period.
var isAurumShop = storeId == "AurumShop";
var isDailyShop = storeId.StartsWith("DailyShop_");
var isWeeklyShop = storeId.StartsWith("WeeklyShop_");
if (store.Count == 0 && (isAurumShop || isDailyShop || isWeeklyShop))
{
try
{
var vanityData = _titleDataService.Find(new List<string> { "Vanities" });
var vanities = JsonSerializer.Deserialize<List<TitleDataVanityInfo>>(vanityData["Vanities"]);
var priced = vanities?
.Where(v => v.StoreData != null && v.StoreData.Amount > 0)
.OrderBy(v => v.Name, StringComparer.Ordinal)
.ToList() ?? new List<TitleDataVanityInfo>();
IEnumerable<TitleDataVanityInfo> selection = priced;
if ((isDailyShop || isWeeklyShop) && priced.Count > 0)
{
var size = isWeeklyShop ? 12 : 6;
int.TryParse(storeId[(storeId.IndexOf('_') + 1)..], out var rotation);
var start = (int)(((long)rotation * size) % priced.Count);
selection = Enumerable.Range(0, Math.Min(size, priced.Count))
.Select(i => priced[(start + i) % priced.Count]);
}
uint position = 0;
foreach (var vanity in selection)
{
store.Add(new FStoreItem
{
ItemId = vanity.Name,
VirtualCurrencyPrices = new Dictionary<string, uint> { ["AU"] = (uint)vanity.StoreData.Amount },
RealCurrencyPrices = new Dictionary<string, uint>(),
DisplayPosition = position++,
});
}
}
catch (Exception e)
{
_logger.LogWarning(e, "Failed to build vanity store {StoreId}", storeId);
}
}
_logger.LogInformation("GetStoreItems '{StoreId}': {Count} item(s)", storeId, store.Count);
return Ok(new ClientResponse<FGetStoreItemsResult>
{ {
Code = 200, Code = 200,
Status = "OK", Status = "OK",
Data = new {} Data = new FGetStoreItemsResult
{
StoreId = string.IsNullOrEmpty(storeId) ? null : storeId,
CatalogVersion = "StaticItems",
Store = store,
}
}); });
} }
@@ -244,13 +352,15 @@ public class ClientController : Controller
public async Task<IActionResult> GetUserInventory(FGetUserInventoryRequest request) public async Task<IActionResult> GetUserInventory(FGetUserInventoryRequest request)
{ {
var userId = User.FindAuthUserId(); var userId = User.FindAuthUserId();
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Balance", "Inventory", "VanityItems"}); var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Balance", "Inventory", "VanityItems", "CharacterVanity", "GlobalVanity"});
var inventory = JsonSerializer.Deserialize<FYCustomItemInfo[]>(userData["Inventory"].Value); var inventory = JsonSerializer.Deserialize<FYCustomItemInfo[]>(userData["Inventory"].Value);
// TODO: Per-user vanity // Per-user vanity ownership: the player owns the cosmetics they've bought (VanityItems)
// var vanity = JsonSerializer.Deserialize<CustomVanityItem[]>(userData["VanityItems"].Value); // plus whatever is currently equipped (CharacterVanity / GlobalVanity), so the starter
var titleData = _titleDataService.Find(new List<string>{"Blueprints", "Vanities"}); // outfit stays owned. Previously the whole Vanities catalogue was returned as owned,
// which made every item in the Aurum shop show up as already "owned".
var ownedVanityIds = CollectOwnedVanityIds(userData);
var titleData = _titleDataService.Find(new List<string>{"Blueprints"});
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]); var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
var vanity = JsonSerializer.Deserialize<TitleDataVanityInfo[]>(titleData["Vanities"]);
List<FItemInstance> items = new List<FItemInstance>(); List<FItemInstance> items = new List<FItemInstance>();
foreach (var item in inventory) { foreach (var item in inventory) {
if (!blueprints.ContainsKey(item.BaseItemId)) { if (!blueprints.ContainsKey(item.BaseItemId)) {
@@ -276,9 +386,9 @@ public class ClientController : Controller
}); });
} }
foreach (var item in vanity) { foreach (var vanityId in ownedVanityIds) {
items.Add(new FItemInstance { items.Add(new FItemInstance {
ItemId = item.Name, ItemId = vanityId,
// ItemInstanceId is not required since it's not a unique item. // ItemInstanceId is not required since it's not a unique item.
ItemClass = "Vanity", ItemClass = "Vanity",
// PurchaseDate = DateTime.Now, // TODO // PurchaseDate = DateTime.Now, // TODO
@@ -303,6 +413,60 @@ public class ClientController : Controller
}); });
} }
// Collects the vanity ids a player actually owns: everything they've purchased
// (VanityItems) plus everything currently equipped (CharacterVanity slots and the
// GlobalVanity sprays / banner / emotes / drop pod). Parsed defensively with
// JsonDocument so malformed / partial data never breaks the inventory response.
private static HashSet<string> CollectOwnedVanityIds(Dictionary<string, FUserDataRecord> userData)
{
var owned = new HashSet<string>(StringComparer.Ordinal);
void AddId(string? id) { if (!string.IsNullOrEmpty(id)) owned.Add(id); }
if (userData.TryGetValue("VanityItems", out var viRec) && !string.IsNullOrWhiteSpace(viRec.Value))
{
try
{
using var doc = JsonDocument.Parse(viRec.Value);
if (doc.RootElement.ValueKind == JsonValueKind.Array)
foreach (var el in doc.RootElement.EnumerateArray())
if (el.ValueKind == JsonValueKind.Object && el.TryGetProperty("baseItemId", out var b))
AddId(b.GetString());
}
catch { /* ignore malformed VanityItems */ }
}
if (userData.TryGetValue("CharacterVanity", out var cvRec) && !string.IsNullOrWhiteSpace(cvRec.Value))
{
try
{
using var doc = JsonDocument.Parse(cvRec.Value);
if (doc.RootElement.ValueKind == JsonValueKind.Object)
foreach (var prop in doc.RootElement.EnumerateObject())
if (prop.Value.ValueKind == JsonValueKind.Object && prop.Value.TryGetProperty("id", out var id))
AddId(id.GetString());
}
catch { /* ignore malformed CharacterVanity */ }
}
if (userData.TryGetValue("GlobalVanity", out var gvRec) && !string.IsNullOrWhiteSpace(gvRec.Value))
{
try
{
using var doc = JsonDocument.Parse(gvRec.Value);
var root = doc.RootElement;
if (root.ValueKind == JsonValueKind.Object)
{
if (root.TryGetProperty("activeGlobalVanityIds", out var arr) && arr.ValueKind == JsonValueKind.Array)
foreach (var el in arr.EnumerateArray()) AddId(el.GetString());
if (root.TryGetProperty("droppodId", out var dp)) AddId(dp.GetString());
}
}
catch { /* ignore malformed GlobalVanity */ }
}
return owned;
}
[HttpPost("GetTitleData")] [HttpPost("GetTitleData")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
@@ -1,62 +0,0 @@
using System.Net.Mime;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.SignalR;
using Prospect.Server.Api.Hubs;
namespace Prospect.Server.Api.Controllers;
public class NotifyRequest
{
[JsonPropertyName("type")]
public string MessageType { get; set; }
[JsonPropertyName("data")]
public object MessageData { get; set; }
}
public class ServerReadyMessageData
{
[JsonPropertyName("success")]
public bool Success { get; set; }
[JsonPropertyName("sessionId")]
public string SessionID { get; set; }
[JsonPropertyName("squadId")]
public string SquadID { get; set; }
}
[Route("Notifications")]
[ApiController]
public class NotificationsController : Controller
{
private readonly IHubContext<CycleHub> _hubContext;
private readonly ILogger<NotificationsController> _logger;
public NotificationsController(ILogger<NotificationsController> logger, IHubContext<CycleHub> hubContext)
{
_logger = logger;
_hubContext = hubContext;
}
[HttpPost("TestNotify")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<IActionResult> TestNotify(NotifyRequest request)
{
// TODO: To test Notifications_DT
_logger.LogDebug("Received test notification event {MessageType}", request.MessageType);
await _hubContext.Clients.All.SendAsync(request.MessageType, request.MessageData);
return StatusCode(200);
}
[HttpPost("ServerReady")]
[Produces(MediaTypeNames.Application.Json)]
public async Task<IActionResult> ServerReady(ServerReadyMessageData request)
{
_logger.LogDebug("Received test notification event OnSquadMatchmakingSuccess");
await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", request);
return StatusCode(200);
}
}
@@ -0,0 +1,39 @@
using System.Text.Json.Serialization;
namespace Prospect.Server.Api.Models.Client.Data;
// PlayFab StoreItem: a single catalog item offered for sale in a store, with its prices.
public class FStoreItem
{
/// <summary>
/// [optional] Store specific custom data. The data only exists as part of this store; it is not transferred to item instances.
/// </summary>
[JsonPropertyName("CustomData")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public object? CustomData { get; set; }
/// <summary>
/// [optional] Intended display position for this item. Note that 0 is the first position and -1 is the last position.
/// </summary>
[JsonPropertyName("DisplayPosition")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public uint? DisplayPosition { get; set; }
/// <summary>
/// Unique identifier of the item as it exists in the catalog - note that this must exactly match the ItemId from the catalog.
/// </summary>
[JsonPropertyName("ItemId")]
public string ItemId { get; set; } = "";
/// <summary>
/// [optional] Override prices for this item for specific currencies.
/// </summary>
[JsonPropertyName("RealCurrencyPrices")]
public Dictionary<string, uint> RealCurrencyPrices { get; set; } = new();
/// <summary>
/// [optional] Override prices for this item in virtual currencies and "RM" (the base Real Money purchase price, in USD pennies).
/// </summary>
[JsonPropertyName("VirtualCurrencyPrices")]
public Dictionary<string, uint> VirtualCurrencyPrices { get; set; } = new();
}
@@ -1,8 +1,19 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Client.Data; using Prospect.Server.Api.Models.Client.Data;
namespace Prospect.Server.Api.Models.Client; namespace Prospect.Server.Api.Models.Client;
public class FGetStoreItems public class FGetStoreItems
{ {
/// <summary>
/// [optional] Catalog version to store items from. Use default catalog version if null.
/// </summary>
[JsonPropertyName("CatalogVersion")]
public string? CatalogVersion { get; set; }
/// <summary>
/// Unique identifier for the store which is being requested.
/// </summary>
[JsonPropertyName("StoreId")]
public string? StoreId { get; set; }
} }
@@ -0,0 +1,25 @@
using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Client.Data;
namespace Prospect.Server.Api.Models.Client;
public class FGetStoreItemsResult
{
/// <summary>
/// [optional] The base catalog that this store is a part of.
/// </summary>
[JsonPropertyName("CatalogVersion")]
public string? CatalogVersion { get; set; }
/// <summary>
/// [optional] Array of items which can be purchased from this store.
/// </summary>
[JsonPropertyName("Store")]
public List<FStoreItem> Store { get; set; } = new();
/// <summary>
/// [optional] The ID of this store.
/// </summary>
[JsonPropertyName("StoreId")]
public string? StoreId { get; set; }
}
@@ -15,8 +15,8 @@ public class TitleDataContractInfo {
public TitleDataContractInfoUnlockData UnlockData { get; set; } public TitleDataContractInfoUnlockData UnlockData { get; set; }
[JsonPropertyName("IsMainContract")] [JsonPropertyName("IsMainContract")]
public bool IsMainContract { get; set; } public bool IsMainContract { get; set; }
[JsonPropertyName("Difficulty")] [JsonPropertyName("ContractDifficulty")]
public EYContractDifficulty Difficulty { get; set; } public EYContractDifficulty ContractDifficulty { get; set; }
} }
public class TitleDataContractInfoReward { public class TitleDataContractInfoReward {
@@ -1,22 +1,6 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
public class TitleDataDailyCrateInfo { public class TitleDataDailyCrateInfo {
[JsonPropertyName("Name")]
public string Name { get; set; }
[JsonPropertyName("Level")]
public int Level { get; set; }
[JsonPropertyName("RewardGrants")]
public TitleDataDailyCrateInfoRewardGrants[] RewardGrants { get; set; }
}
public class TitleDataDailyCrateInfoRewardGrants {
[JsonPropertyName("Pool")]
public TitleDataDailyCrateInfoPoolItem[] Pool { get; set; }
[JsonPropertyName("Amount")]
public int Amount { get; set; }
}
public class TitleDataDailyCrateInfoPoolItem {
[JsonPropertyName("Name")] [JsonPropertyName("Name")]
public string Name { get; set; } public string Name { get; set; }
[JsonPropertyName("Weight")] [JsonPropertyName("Weight")]
@@ -9,6 +9,8 @@ public class TitleDataMissionInfo {
public UDataTable OnboardingRewards { get; set; } public UDataTable OnboardingRewards { get; set; }
} }
// {\"TalkToBadumReward\":{\"MissionRewardId\":\"TalkToBadumReward\",\"RewardEntries\":[{\"RewardType\":7,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Weapons.PRO_Weapons\\u0027\",\"RowName\":\"WP_E_SMG_Bullet_01\"},\"RewardAmount\":2},{\"RewardType\":7,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Weapons.PRO_Weapons\\u0027\",\"RowName\":\"WP_E_AR_Energy_01\"},\"RewardAmount\":1},{\"RewardType\":7,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Weapons.PRO_Weapons\\u0027\",\"RowName\":\"WP_E_Pistol_Bullet_01\"},\"RewardAmount\":5},{\"RewardType\":7,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Weapons.PRO_Weapons\\u0027\",\"RowName\":\"WP_E_SGun_Bullet_01\"},\"RewardAmount\":2},{\"RewardType\":6,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/AmmoTypes_DT.AmmoTypes_DT\\u0027\",\"RowName\":\"Light\"},\"RewardAmount\":6},{\"RewardType\":6,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/AmmoTypes_DT.AmmoTypes_DT\\u0027\",\"RowName\":\"Medium\"},\"RewardAmount\":4},{\"RewardType\":6,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/AmmoTypes_DT.AmmoTypes_DT\\u0027\",\"RowName\":\"Shotgun\"},\"RewardAmount\":2},{\"RewardType\":8,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Abilities.PRO_Abilities\\u0027\",\"RowName\":\"ShockGrenade_02\"},\"RewardAmount\":2},{\"RewardType\":8,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Abilities.PRO_Abilities\\u0027\",\"RowName\":\"Consumable_Health_01\"},\"RewardAmount\":5},{\"RewardType\":0,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_PlayerShield.PRO_PlayerShield\\u0027\",\"RowName\":\"Shield_01\"},\"RewardAmount\":5},{\"RewardType\":10,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Bags.PRO_Bags\\u0027\",\"RowName\":\"Bag_01\"},\"RewardAmount\":5},{\"RewardType\":7,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/PRO_Weapons.PRO_Weapons\\u0027\",\"RowName\":\"TOOL_MineralScanner_01\"},\"RewardAmount\":2}]},\"FirstMatchReward\":{\"MissionRewardId\":\"FirstMatchReward\",\"RewardEntries\":[{\"RewardType\":2,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/Materials_DT.Materials_DT\\u0027\",\"RowName\":\"Insulation\"},\"RewardAmount\":4},{\"RewardType\":1,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/Currencies_DT.Currencies_DT\\u0027\",\"RowName\":\"SoftCurrency\"},\"RewardAmount\":1500},{\"RewardType\":2,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/Materials_DT.Materials_DT\\u0027\",\"RowName\":\"GenericCreatureDrop\"},\"RewardAmount\":3},{\"RewardType\":2,\"RewardRowHandle\":{\"DataTable\":\"DataTable\\u0027/Game/DataTables/Materials_DT.Materials_DT\\u0027\",\"RowName\":\"ICAScrip\"},\"RewardAmount\":1}]}}",
public class TitleDataMissionRewardInfo { public class TitleDataMissionRewardInfo {
[JsonPropertyName("MissionRewardId")] [JsonPropertyName("MissionRewardId")]
public string MissionRewardId { get; set; } public string MissionRewardId { get; set; }
@@ -50,7 +50,7 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService; private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService; private readonly TitleDataService _titleDataService;
private readonly EYContractDifficulty[] difficulties = [EYContractDifficulty.Easy, EYContractDifficulty.Medium, EYContractDifficulty.Hard]; private readonly string[] difficulties = ["Easy", "Medium", "Hard"];
private readonly Random rnd = new Random(); private readonly Random rnd = new Random();
public ClaimActiveContract(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService) public ClaimActiveContract(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService)
@@ -131,6 +131,12 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
break; break;
} }
} }
} else if (objective.Type == EYContractObjectiveType.Kills) {
// Kill objectives can't be tracked in a client-hosted raid (no dedicated
// game server reports kills). They are auto-credited on deploy
// (EnterMatchmakingMatch); accept them here even if a contract was
// activated before that so kill contracts remain completable.
remaining = 0;
} else { } else {
// NOTE: A game server must write the correct progress // NOTE: A game server must write the correct progress
// for other types of objectives. // for other types of objectives.
@@ -219,19 +225,20 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
if (contract.IsMainContract) { if (contract.IsMainContract) {
contractsCompleted.ContractsIDs.Add(contract.Name); contractsCompleted.ContractsIDs.Add(contract.Name);
} else { } else {
var contractDifficulty = contract.Difficulty; var parts = contract.Name.Split('-');
var difficulty = parts[1];
var levelsData = JsonSerializer.Deserialize<Dictionary<string, int[]>>(titleData["LevelData"]); var levelsData = JsonSerializer.Deserialize<Dictionary<string, int[]>>(titleData["LevelData"]);
var levels = levelsData[contract.Faction]; var levels = levelsData[contract.Faction];
var jobs = JsonSerializer.Deserialize<Dictionary<string, Dictionary<EYContractDifficulty, string[]>>>(titleData["Jobs"]); var jobs = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string[]>>>(titleData["Jobs"]);
var level = levels.Length - Array.FindIndex(levels, (int xp) => { var level = levels.Length - Array.FindIndex(levels, (int xp) => {
return factionProgression >= xp; return factionProgression >= xp;
}); });
var factionBoard = jobBoardsData.Boards.Find(c => c.FactionId == contract.Faction); var factionBoard = jobBoardsData.Boards.Find(c => c.FactionId == contract.Faction);
var idx = Array.FindIndex(difficulties, d => d == contractDifficulty); var idx = Array.FindIndex(difficulties, d => d == difficulty);
var factionJobs = jobs[contract.Faction]; var factionJobs = jobs[contract.Faction];
var jobsAvailable = Array.FindAll(factionJobs[contractDifficulty], (string jobId) => { var jobsAvailable = Array.FindAll(factionJobs[difficulty], (string jobId) => {
return level >= contracts[jobId].UnlockData.Level; return level >= contracts[jobId].UnlockData.Level;
}); });
// If player completes a job, it means he has at least one unlocked anyway. // If player completes a job, it means he has at least one unlocked anyway.
@@ -43,9 +43,9 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
static readonly Random random = new(); static readonly Random random = new();
private TitleDataDailyCrateInfoPoolItem[] PickItems(TitleDataDailyCrateInfoPoolItem[] items, int numPicks) private TitleDataDailyCrateInfo[] PickItems(TitleDataDailyCrateInfo[] items, int numPicks)
{ {
var itemList = new (TitleDataDailyCrateInfoPoolItem item, float cumulativeWeight)[items.Length]; var itemList = new (TitleDataDailyCrateInfo item, float cumulativeWeight)[items.Length];
float totalWeight = 0; float totalWeight = 0;
for (int i = 0; i < items.Length; i++) { for (int i = 0; i < items.Length; i++) {
@@ -54,7 +54,7 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
itemList[i] = (item, totalWeight); itemList[i] = (item, totalWeight);
} }
var selectedItems = new TitleDataDailyCrateInfoPoolItem[numPicks]; var selectedItems = new TitleDataDailyCrateInfo[numPicks];
for (int i = 0; i < numPicks; i++) { for (int i = 0; i < numPicks; i++) {
float pick = (float)random.NextDouble() * totalWeight; float pick = (float)random.NextDouble() * totalWeight;
@@ -108,7 +108,7 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value); var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var techTreeBonuses = JsonSerializer.Deserialize<CharacterTechTreeBonuses>(userData["CharacterTechTreeBonuses"].Value); var techTreeBonuses = JsonSerializer.Deserialize<CharacterTechTreeBonuses>(userData["CharacterTechTreeBonuses"].Value);
var titleData = _titleDataService.Find(new List<string>{"PassiveGenerators", "DailyCrateSetups", "Blueprints"}); var titleData = _titleDataService.Find(new List<string>{"PassiveGenerators", "DailyCrateRewardsPools", "Blueprints"});
var passiveGenerators = JsonSerializer.Deserialize<Dictionary<string, TitleDataPassiveGeneratorsInfo>>(titleData["PassiveGenerators"]); var passiveGenerators = JsonSerializer.Deserialize<Dictionary<string, TitleDataPassiveGeneratorsInfo>>(titleData["PassiveGenerators"]);
var now = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds(); var now = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
@@ -187,32 +187,31 @@ public class ClaimGeneratorIncomeClient : ICloudScriptFunction<FYClaimGeneratorI
}; };
} }
var dailyCrateSetups = JsonSerializer.Deserialize<TitleDataDailyCrateInfo[]>(titleData["DailyCrateSetups"]); var dailyCrateRewards = JsonSerializer.Deserialize<TitleDataDailyCrateInfo[][]>(titleData["DailyCrateRewardsPools"]);
var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]); var blueprints = JsonSerializer.Deserialize<Dictionary<string, TitleDataBlueprintInfo>>(titleData["Blueprints"]);
var rewardsPool = dailyCrateSetups[crateTier - 1]; var rewardsPool = dailyCrateRewards[crateTier - 1];
foreach (var grant in rewardsPool.RewardGrants) { // TODO: Get rewardGrants from PassiveGenerators_CrateRewards_DT
var items = PickItems(grant.Pool, grant.Amount); var items = PickItems(rewardsPool, 5);
// TODO: Stackable? GrantedItems doesn't imply granted OR updated. // TODO: Stackable? GrantedItems doesn't imply granted OR updated.
foreach (var item in items) { foreach (var item in items) {
var itemInfo = blueprints[item.Name]; var itemInfo = blueprints[item.Name];
var grantedItem = new FYCustomItemInfo { var grantedItem = new FYCustomItemInfo {
ItemId = Guid.NewGuid().ToString(), ItemId = Guid.NewGuid().ToString(),
Amount = item.Amount, Amount = item.Amount,
BaseItemId = item.Name, BaseItemId = item.Name,
Durability = itemInfo.DurabilityMax, Durability = itemInfo.DurabilityMax,
Insurance = "", Insurance = "",
InsuranceOwnerPlayfabId = "", InsuranceOwnerPlayfabId = "",
ModData = new FYModItems { M = [] }, ModData = new FYModItems { M = [] },
Origin = new FYItemOriginBackend { G = "", P = "", T = "" }, Origin = new FYItemOriginBackend { G = "", P = "", T = "" },
InsuredAttachmentId = "", InsuredAttachmentId = "",
PrimaryVanityId = 0, PrimaryVanityId = 0,
SecondaryVanityId = 0, SecondaryVanityId = 0,
RolledPerks = [], RolledPerks = [],
}; };
inventory.Add(grantedItem); inventory.Add(grantedItem);
grantedItems.Add(grantedItem); grantedItems.Add(grantedItem);
}
} }
break; break;
} }
@@ -1,5 +1,8 @@
using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.UserData;
public class ClientsideFriendsImportRequest public class ClientsideFriendsImportRequest
{ {
@@ -18,15 +21,33 @@ public class ClientsideFriendsImportResponse
public class ClientsideFriendsImportFunction : ICloudScriptFunction<ClientsideFriendsImportRequest, ClientsideFriendsImportResponse> public class ClientsideFriendsImportFunction : ICloudScriptFunction<ClientsideFriendsImportRequest, ClientsideFriendsImportResponse>
{ {
private readonly ILogger<ClientsideFriendsImportFunction> _logger; private readonly ILogger<ClientsideFriendsImportFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public ClientsideFriendsImportFunction(ILogger<ClientsideFriendsImportFunction> logger) public ClientsideFriendsImportFunction(ILogger<ClientsideFriendsImportFunction> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{ {
_logger = logger; _logger = logger;
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
} }
public async Task<ClientsideFriendsImportResponse> ExecuteAsync(ClientsideFriendsImportRequest request) public async Task<ClientsideFriendsImportResponse> ExecuteAsync(ClientsideFriendsImportRequest request)
{ {
_logger.LogInformation("Processing friends import"); var context = _httpContextAccessor.HttpContext;
if (context == null)
{
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
// Persist the platform friend ids the client imports (Steam ids) so GetFriendList
// can later resolve which of them actually play on this server.
var ids = request.UserIDs ?? Array.Empty<string>();
await _userDataService.UpdateAsync(userId, userId, new Dictionary<string, string>
{
["ImportedSteamFriends"] = JsonSerializer.Serialize(ids),
});
_logger.LogInformation("Imported {Count} {Platform} friend id(s) for user {User}", ids.Length, request.Platform, userId);
return new ClientsideFriendsImportResponse{}; return new ClientsideFriendsImportResponse{};
} }
@@ -2,6 +2,7 @@ using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.CloudScript.Functions;
using Prospect.Server.Api.Services.CloudScript.Models; using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.UserData; using Prospect.Server.Api.Services.UserData;
@@ -45,12 +46,14 @@ public class CompleteInventoryUpdateFunction : ICloudScriptFunction<CompleteInve
private readonly ILogger<CompleteInventoryUpdateFunction> _logger; private readonly ILogger<CompleteInventoryUpdateFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService; private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
public CompleteInventoryUpdateFunction(ILogger<CompleteInventoryUpdateFunction> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService) public CompleteInventoryUpdateFunction(ILogger<CompleteInventoryUpdateFunction> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService)
{ {
_logger = logger; _logger = logger;
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService; _userDataService = userDataService;
_titleDataService = titleDataService;
} }
public async Task<CompleteInventoryUpdateResponse> ExecuteAsync(CompleteInventoryUpdateRequest request) public async Task<CompleteInventoryUpdateResponse> ExecuteAsync(CompleteInventoryUpdateRequest request)
@@ -64,6 +67,42 @@ public class CompleteInventoryUpdateFunction : ICloudScriptFunction<CompleteInve
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Inventory"}); var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Inventory"});
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value); var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
// Insurance payout: this request also reports the end-of-match inventory, so items
// the player lost (death / left behind) arrive in ItemsToRemove. Any of those that
// were insured on deploy (EnterMatchmakingStation) are recovered back to the stash —
// the premium was already paid up-front — and the insurance is consumed (one raid).
// Recovery is only performed if the item's policy declares a positive Payout.
var recoveredInsured = new HashSet<string>();
if (request.ItemsToRemove is { Count: > 0 })
{
try
{
var td = _titleDataService.Find(new List<string> { "InventoryInsurance" });
if (td.TryGetValue("InventoryInsurance", out var rawPolicies) && !string.IsNullOrEmpty(rawPolicies))
{
var policies = JsonSerializer.Deserialize<Dictionary<string, InventoryInsurancePolicy>>(rawPolicies);
foreach (var item in inventory)
{
if (!request.ItemsToRemove.Contains(item.ItemId)) continue;
if (string.IsNullOrEmpty(item.Insurance) || item.InsuranceOwnerPlayfabId != userId) continue;
if (policies == null || !policies.TryGetValue(item.Insurance, out var policy) || policy.Payout <= 0) continue;
recoveredInsured.Add(item.ItemId);
item.Insurance = ""; // consumed for this raid
item.InsuranceOwnerPlayfabId = "";
}
if (recoveredInsured.Count > 0)
{
_logger.LogInformation("Recovered {Count} insured item(s) for user {User}", recoveredInsured.Count, userId);
}
}
}
catch (Exception e)
{
// Never fail the inventory commit because of insurance handling.
_logger.LogWarning(e, "Failed to process insurance payout");
}
}
// TODO: Optimize // TODO: Optimize
// TODO: Check deleted items to see if other stacks/mods were updated correctly // TODO: Check deleted items to see if other stacks/mods were updated correctly
// Process inventory update. The player may: // Process inventory update. The player may:
@@ -74,7 +113,8 @@ public class CompleteInventoryUpdateFunction : ICloudScriptFunction<CompleteInve
// IMPORTANT: The same request is currently used to report the updated inventory in the end of match. // IMPORTANT: The same request is currently used to report the updated inventory in the end of match.
var newInventory = new List<FYCustomItemInfo>(inventory.Count); var newInventory = new List<FYCustomItemInfo>(inventory.Count);
foreach (var item in inventory) { foreach (var item in inventory) {
if (!request.ItemsToRemove.Contains(item.ItemId)) { // Keep items that weren't removed, plus insured items recovered above.
if (!request.ItemsToRemove.Contains(item.ItemId) || recoveredInsured.Contains(item.ItemId)) {
newInventory.Add(item); newInventory.Add(item);
} }
} }
@@ -61,6 +61,14 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
} }
for (var i = 0; i < contract.Objectives.Length; i++) { for (var i = 0; i < contract.Objectives.Length; i++) {
var objective = contract.Objectives[i]; var objective = contract.Objectives[i];
// Kill objectives: the client-hosted raid has no dedicated game server
// to report per-kill progress to the backend, so kills would never be
// saved. Auto-credit them on deploy so the objective can be completed
// (mirrors the player-kill auto-credit done in ActivateContract).
if (objective.Type == EYContractObjectiveType.Kills) {
contractActive.Progress[i] = objective.MaxProgress;
continue;
}
if (objective.Type != EYContractObjectiveType.OwnNumOfItem) { if (objective.Type != EYContractObjectiveType.OwnNumOfItem) {
continue; continue;
} }
@@ -1,18 +1,107 @@
using Prospect.Server.Api.Services.CloudScript.Models; using System.Text.Json;
using Prospect.Server.Api.Models.Data;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.CloudScript.Models.Data;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
// Config d'une police d'assurance (TitleData "InventoryInsurance").
public class InventoryInsurancePolicy
{
public string InsuranceId { get; set; }
public double Cost { get; set; }
public double Payout { get; set; }
}
[CloudScriptFunction("EnterMatchmakingStation")] [CloudScriptFunction("EnterMatchmakingStation")]
public class EnterMatchmakingStationFunction : ICloudScriptFunction<FYEnterMatchAzureFunction, FYEnterMatchmakingResult> public class EnterMatchmakingStationFunction : ICloudScriptFunction<FYEnterMatchAzureFunction, FYEnterMatchmakingResult>
{ {
public Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction request) private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly ILogger<EnterMatchmakingStationFunction> _logger;
public EnterMatchmakingStationFunction(
IHttpContextAccessor httpContextAccessor,
UserDataService userDataService,
TitleDataService titleDataService,
ILogger<EnterMatchmakingStationFunction> logger)
{ {
return Task.FromResult(new FYEnterMatchmakingResult _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_titleDataService = titleDataService;
_logger = logger;
}
public async Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction request)
{
// Au déploiement, le client peut demander d'assurer une partie de l'inventaire.
var insurance = request.PurchaseInventoryInsurances;
var context = _httpContextAccessor.HttpContext;
if (context != null && insurance?.ItemIds is { Count: > 0 })
{
try
{
await ApplyInsuranceAsync(context.User.FindAuthUserId(), insurance);
}
catch (Exception e)
{
// Ne jamais bloquer le déploiement à cause de l'assurance.
_logger.LogWarning(e, "Failed to apply inventory insurance on deploy");
}
}
return new FYEnterMatchmakingResult
{ {
Success = true, Success = true,
ErrorMessage = "", ErrorMessage = "",
SingleplayerStation = true, // NOTE: This will always travel to single player station SingleplayerStation = true, // NOTE: This will always travel to single player station
IsMatchTravel = false, IsMatchTravel = false,
};
}
// Marque les items choisis comme assurés (Insurance + propriétaire) et débite la
// prime en InsuranceCurrency selon le coût de la police (TitleData InventoryInsurance).
private async Task ApplyInsuranceAsync(string userId, FYPurchaseInventoryInsuranceRequest req)
{
var titleData = _titleDataService.Find(new List<string> { "InventoryInsurance" });
var policies = JsonSerializer.Deserialize<Dictionary<string, InventoryInsurancePolicy>>(titleData["InventoryInsurance"]);
var insuranceId = string.IsNullOrEmpty(req.InsuranceId) ? "Default" : req.InsuranceId;
if (policies == null || !policies.TryGetValue(insuranceId, out var policy))
{
_logger.LogWarning("Unknown insurance policy {InsuranceId}", insuranceId);
return;
}
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "Inventory", "Balance" });
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var toInsure = new HashSet<string>(req.ItemIds);
var insuredCount = 0;
foreach (var item in inventory)
{
if (!toInsure.Contains(item.ItemId)) continue;
item.Insurance = insuranceId;
item.InsuranceOwnerPlayfabId = userId;
insuredCount++;
}
if (insuredCount == 0) return;
// Prime = coût de la police × nombre d'items assurés (arrondi au supérieur),
// plafonnée au solde disponible pour ne jamais passer en négatif.
var premium = (int)Math.Ceiling(policy.Cost * insuredCount);
premium = Math.Min(premium, balance.InsuranceCurrency);
balance.InsuranceCurrency -= premium;
await _userDataService.UpdateAsync(userId, userId, new Dictionary<string, string>
{
["Inventory"] = JsonSerializer.Serialize(inventory),
["Balance"] = JsonSerializer.Serialize(balance),
}); });
_logger.LogInformation("Insured {Count} item(s) under policy {Policy} for user {User} (premium {Premium})",
insuredCount, insuranceId, userId, premium);
} }
} }
@@ -1,15 +1,71 @@
using Prospect.Server.Api.Services.CloudScript.Models; using System.Text.Json;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Database.Models;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("GetFriendList")] [CloudScriptFunction("GetFriendList")]
public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?> public class GetFriendList : ICloudScriptFunction<FYBaseSocialRequest, object?>
{ {
public Task<object?> ExecuteAsync(FYBaseSocialRequest request) private readonly ILogger<GetFriendList> _logger;
{ private readonly IHttpContextAccessor _httpContextAccessor;
return Task.FromResult<object?>(new private readonly UserDataService _userDataService;
{ private readonly DbUserService _userService;
}); public GetFriendList(ILogger<GetFriendList> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, DbUserService userService)
{
_logger = logger;
_httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_userService = userService;
}
public async Task<object?> ExecuteAsync(FYBaseSocialRequest request)
{
var context = _httpContextAccessor.HttpContext;
if (context == null)
{
throw new CloudScriptException("CloudScript was not called within a http request");
}
var userId = context.User.FindAuthUserId();
// 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.
var steamIds = new List<string>();
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "ImportedSteamFriends" });
if (userData.TryGetValue("ImportedSteamFriends", out var rec) && !string.IsNullOrWhiteSpace(rec.Value))
{
try { steamIds = JsonSerializer.Deserialize<List<string>>(rec.Value) ?? new List<string>(); }
catch { /* ignore malformed */ }
}
var friends = new List<object>();
if (steamIds.Count > 0)
{
var players = await _userService.FindManyAsync(PlayFabUserAuthType.Steam, steamIds);
foreach (var player in players)
{
if (player.Id == userId) continue; // never list yourself
friends.Add(new
{
profile = new
{
playerId = player.Id,
displayName = player.DisplayName,
avatarUrl = "",
},
onlineState = 0, // EYUserState — real presence tracking is a later phase
});
}
}
// 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 };
} }
} }
@@ -7,9 +7,9 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("GetSignalRConnection")] [CloudScriptFunction("GetSignalRConnection")]
public class GetSignalRConnection : ICloudScriptFunction<FYGetSignalRConnection, FYGetSignalRConnectionResult> public class GetSignalRConnection : ICloudScriptFunction<FYGetSignalRConnection, FYGetSignalRConnectionResult>
{ {
private readonly SignalRSettings _settings; private readonly PlayFabSettings _settings;
public GetSignalRConnection(IOptions<SignalRSettings> settings) public GetSignalRConnection(IOptions<PlayFabSettings> settings)
{ {
_settings = settings.Value; _settings = settings.Value;
} }
@@ -3,6 +3,7 @@ using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Data; using Prospect.Server.Api.Models.Data;
using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.CloudScript.Functions;
using Prospect.Server.Api.Services.UserData; using Prospect.Server.Api.Services.UserData;
public class PurchaseWeaponShopItemRequest public class PurchaseWeaponShopItemRequest
@@ -137,7 +138,7 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction<PurchaseWeapo
Error = "Invalid purchase amount", Error = "Invalid purchase amount",
}; };
} }
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Inventory", "Balance"}); var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"Inventory", "Balance", "ContractsOneTimeCompleted"});
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value); var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value);
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value); var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var blueprintsData = _titleDataService.Find(new List<string>{"Blueprints"}); var blueprintsData = _titleDataService.Find(new List<string>{"Blueprints"});
@@ -153,7 +154,21 @@ public class PurchaseWeaponShopItemFunction : ICloudScriptFunction<PurchaseWeapo
var blueprintData = blueprints[request.BaseItemID]; var blueprintData = blueprints[request.BaseItemID];
// TODO: Contract unlock criteria // Contract unlock criteria: some items can only be purchased after completing a
// specific contract. Only gate when the item explicitly declares such a lock, so
// items with no lock keep working as before.
if (!string.IsNullOrEmpty(blueprintData.UnlockData?.ContractLockPurchase))
{
var completed = JsonSerializer.Deserialize<FYGetCompletedContractsResult>(userData["ContractsOneTimeCompleted"].Value);
if (completed?.ContractsIDs == null || !completed.ContractsIDs.Contains(blueprintData.UnlockData.ContractLockPurchase))
{
return new PurchaseWeaponShopItemResponse
{
UserID = userId,
Error = "Item is locked: complete the required contract first",
};
}
}
// Validate price // Validate price
var shopCraftingData = blueprintData.ItemShopsCraftingData[request.ShopItemBelongsTo]; var shopCraftingData = blueprintData.ItemShopsCraftingData[request.ShopItemBelongsTo];
@@ -16,7 +16,7 @@ public class RequestActiveObjectivesAndBoardsData : ICloudScriptFunction<FYQuery
private readonly UserDataService _userDataService; private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService; private readonly TitleDataService _titleDataService;
private readonly string[] factions = ["Korolev", "Osiris", "ICA"]; // TODO: Order is important private readonly string[] factions = ["Korolev", "Osiris", "ICA"]; // TODO: Order is important
private readonly EYContractDifficulty[] difficulties = [EYContractDifficulty.Easy, EYContractDifficulty.Medium, EYContractDifficulty.Hard]; private readonly string[] difficulties = ["Easy", "Medium", "Hard"];
private readonly Random rnd = new Random(); private readonly Random rnd = new Random();
public RequestActiveObjectivesAndBoardsData(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService) public RequestActiveObjectivesAndBoardsData(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService)
@@ -47,20 +47,22 @@ public class RequestActiveObjectivesAndBoardsData : ICloudScriptFunction<FYQuery
var titleData = _titleDataService.Find(new List<string> { "Contracts", "Jobs", "LevelData" }); var titleData = _titleDataService.Find(new List<string> { "Contracts", "Jobs", "LevelData" });
var contracts = JsonSerializer.Deserialize<Dictionary<string, TitleDataContractInfo>>(titleData["Contracts"]); var contracts = JsonSerializer.Deserialize<Dictionary<string, TitleDataContractInfo>>(titleData["Contracts"]);
var jobs = JsonSerializer.Deserialize<Dictionary<string, Dictionary<EYContractDifficulty, string[]>>>(titleData["Jobs"]); var jobs = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string[]>>>(titleData["Jobs"]);
var levelsData = JsonSerializer.Deserialize<Dictionary<string, int[]>>(titleData["LevelData"]); var levelsData = JsonSerializer.Deserialize<Dictionary<string, int[]>>(titleData["LevelData"]);
var currentQuests = new Dictionary<string, Dictionary<EYContractDifficulty, bool>> { var currentQuests = new Dictionary<string, Dictionary<string, bool>> {
["Korolev"] = new Dictionary<EYContractDifficulty, bool> { [EYContractDifficulty.Easy] = false, [EYContractDifficulty.Medium] = false, [EYContractDifficulty.Hard] = false }, ["Korolev"] = new Dictionary<string, bool> { ["Easy"] = false, ["Medium"] = false, ["Hard"] = false },
["Osiris"] = new Dictionary<EYContractDifficulty, bool> { [EYContractDifficulty.Easy] = false, [EYContractDifficulty.Medium] = false, [EYContractDifficulty.Hard] = false }, ["Osiris"] = new Dictionary<string, bool> { ["Easy"] = false, ["Medium"] = false, ["Hard"] = false },
["ICA"] = new Dictionary<EYContractDifficulty, bool> { [EYContractDifficulty.Easy] = false, [EYContractDifficulty.Medium] = false, [EYContractDifficulty.Hard] = false }, ["ICA"] = new Dictionary<string, bool> { ["Easy"] = false, ["Medium"] = false, ["Hard"] = false },
}; };
foreach (var contract in contractsActive.Contracts) { foreach (var contract in contractsActive.Contracts) {
var contractData = contracts[contract.ContractID]; var parts = contract.ContractID.Split('-');
if (contractData.IsMainContract) { if (parts[0] != "NEW") {
continue; continue;
} }
currentQuests[contractData.Faction][contractData.Difficulty] = true; var difficulty = parts[1];
var faction = parts[2] == "KOR" ? "Korolev" : parts[2];
currentQuests[faction][difficulty] = true;
} }
for (var i = 0; i < factions.Length; i++) { for (var i = 0; i < factions.Length; i++) {
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Models.Data;
using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.UserData; using Prospect.Server.Api.Services.UserData;
@@ -9,6 +10,15 @@ public class FYFortunaPassClaimedRewards {
public List<string> RewardsIDs { get; set; } public List<string> RewardsIDs { get; set; }
}; };
// Récompense d'un palier de la passe (catalogue optionnel TitleData "FortunaPass2_Rewards").
// itemId "SoftCurrency"/"Aurum"/"InsuranceCurrency" = monnaie, sinon item d'inventaire.
public class FortunaPassRewardDef {
[JsonPropertyName("itemId")]
public string ItemId { get; set; }
[JsonPropertyName("amount")]
public int Amount { get; set; }
}
public class RequestClaimFortunaPassRewardsRequest public class RequestClaimFortunaPassRewardsRequest
{ {
[JsonPropertyName("rewardsIds")] [JsonPropertyName("rewardsIds")]
@@ -34,11 +44,15 @@ public class RequestClaimFortunaPassRewardsFunction : ICloudScriptFunction<Reque
{ {
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService; private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly ILogger<RequestClaimFortunaPassRewardsFunction> _logger;
public RequestClaimFortunaPassRewardsFunction(IHttpContextAccessor httpContextAccessor, UserDataService userDataService) public RequestClaimFortunaPassRewardsFunction(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService, ILogger<RequestClaimFortunaPassRewardsFunction> logger)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService; _userDataService = userDataService;
_titleDataService = titleDataService;
_logger = logger;
} }
public async Task<RequestClaimFortunaPassRewardsResponse> ExecuteAsync(RequestClaimFortunaPassRewardsRequest request) public async Task<RequestClaimFortunaPassRewardsResponse> ExecuteAsync(RequestClaimFortunaPassRewardsRequest request)
@@ -49,28 +63,103 @@ public class RequestClaimFortunaPassRewardsFunction : ICloudScriptFunction<Reque
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
var userId = context.User.FindAuthUserId(); var userId = context.User.FindAuthUserId();
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{"FortunaPass2_ClaimedRewards"});
var claimedRewards = JsonSerializer.Deserialize<FYFortunaPassClaimedRewards>(userData["FortunaPass2_ClaimedRewards"].Value); // Clé de la saison courante (déterminée par la config de build) — évite d'écraser
foreach (var rewardId in request.RewardsIDs) { // la progression de l'autre saison (l'ancien code écrasait S2 ET S3).
#if SEASON_3_RELEASE || SEASON_3_DEBUG
const string claimedKey = "FortunaPass3_ClaimedRewards";
const string rewardsCatalogKey = "FortunaPass3_Rewards";
#else
const string claimedKey = "FortunaPass2_ClaimedRewards";
const string rewardsCatalogKey = "FortunaPass2_Rewards";
#endif
var userData = await _userDataService.FindAsync(userId, userId, new List<string>{ claimedKey, "Inventory", "Balance" });
var claimedRewards = JsonSerializer.Deserialize<FYFortunaPassClaimedRewards>(userData[claimedKey].Value)
?? new FYFortunaPassClaimedRewards { RewardsIDs = new List<string>() };
claimedRewards.RewardsIDs ??= new List<string>();
// Catalogue des récompenses (optionnel) : rewardId -> {itemId, amount}.
Dictionary<string, FortunaPassRewardDef> catalog = null;
try
{
var td = _titleDataService.Find(new List<string> { rewardsCatalogKey });
if (td.TryGetValue(rewardsCatalogKey, out var raw) && !string.IsNullOrWhiteSpace(raw))
catalog = JsonSerializer.Deserialize<Dictionary<string, FortunaPassRewardDef>>(raw);
}
catch { /* pas de catalogue → on enregistre seulement la réclamation */ }
var inventory = JsonSerializer.Deserialize<List<FYCustomItemInfo>>(userData["Inventory"].Value) ?? new List<FYCustomItemInfo>();
var balance = JsonSerializer.Deserialize<PlayerBalance>(userData["Balance"].Value);
var grantedItems = new List<FYCustomItemInfo>();
var changedCurrencies = new List<FYCurrencyItem>();
foreach (var rewardId in request.RewardsIDs)
{
// Ne pas re-créditer un palier déjà réclamé.
if (claimedRewards.RewardsIDs.Contains(rewardId)) continue;
if (catalog == null || !catalog.TryGetValue(rewardId, out var def) || def == null)
{
// Le catalogue des paliers est défini côté client (DataTable). On journalise
// les IDs inconnus pour pouvoir construire un vrai catalogue FortunaPass{2,3}_Rewards
// qui matche. IMPORTANT : ne PAS marquer le palier comme réclamé si on ne peut rien
// accorder — sinon il serait brûlé définitivement et inclaimable une fois le
// catalogue ajouté.
_logger.LogInformation("FortunaPass claim: aucun catalogue pour le reward '{RewardId}' (catalogue {State}) — non accordé", rewardId, catalog == null ? "absent" : "présent");
continue;
}
claimedRewards.RewardsIDs.Add(rewardId); claimedRewards.RewardsIDs.Add(rewardId);
_logger.LogInformation("FortunaPass claim: octroi reward '{RewardId}' -> {ItemId} x{Amount}", rewardId, def.ItemId, def.Amount);
switch (def.ItemId)
{
case "SoftCurrency":
balance.SoftCurrency += def.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "SoftCurrency", Amount = balance.SoftCurrency });
break;
case "Aurum":
balance.HardCurrency += def.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "Aurum", Amount = balance.HardCurrency });
break;
case "InsuranceCurrency":
balance.InsuranceCurrency += def.Amount;
changedCurrencies.Add(new FYCurrencyItem { CurrencyName = "InsuranceCurrency", Amount = balance.InsuranceCurrency });
break;
default:
var item = new FYCustomItemInfo {
ItemId = Guid.NewGuid().ToString(),
Amount = def.Amount <= 0 ? 1 : def.Amount,
BaseItemId = def.ItemId,
Durability = 0,
Insurance = "",
InsuranceOwnerPlayfabId = "",
ModData = new FYModItems { M = [] },
Origin = new FYItemOriginBackend { G = "", P = "", T = "" },
InsuredAttachmentId = "",
PrimaryVanityId = 0,
SecondaryVanityId = 0,
RolledPerks = [],
};
inventory.Add(item);
grantedItems.Add(item);
break;
}
} }
var output = JsonSerializer.Serialize(claimedRewards); var changes = new Dictionary<string, string> { [claimedKey] = JsonSerializer.Serialize(claimedRewards) };
await _userDataService.UpdateAsync( if (grantedItems.Count > 0) changes["Inventory"] = JsonSerializer.Serialize(inventory);
userId, userId, if (changedCurrencies.Count > 0) changes["Balance"] = JsonSerializer.Serialize(balance);
new Dictionary<string, string>{ await _userDataService.UpdateAsync(userId, userId, changes);
["FortunaPass2_ClaimedRewards"] = output,
["FortunaPass3_ClaimedRewards"] = output, // TODO: Separate logic for S2 and S3
}
);
return new RequestClaimFortunaPassRewardsResponse return new RequestClaimFortunaPassRewardsResponse
{ {
UserId = userId, UserId = userId,
ErrorType = 3, // EYFortunaPassToastReponseType::Ok ErrorType = 3, // EYFortunaPassToastReponseType::Ok
Error = "", Error = "",
GrantedItems = [], GrantedItems = grantedItems.ToArray(),
ChangedCurrencies = [], ChangedCurrencies = changedCurrencies.ToArray(),
}; };
} }
} }
@@ -1,5 +1,7 @@
using Prospect.Server.Api.Services.Auth.Extensions; using System.Text.Json;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models; using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -7,42 +9,43 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
public class RequestFactionProgression : ICloudScriptFunction<FYQueryFactionProgressionRequest, object?> public class RequestFactionProgression : ICloudScriptFunction<FYQueryFactionProgressionRequest, object?>
{ {
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public RequestFactionProgression(IHttpContextAccessor httpContextAccessor) public RequestFactionProgression(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
} }
public Task<object?> ExecuteAsync(FYQueryFactionProgressionRequest request) public async Task<object?> ExecuteAsync(FYQueryFactionProgressionRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
{ {
return Task.FromResult<object?>(null); return null;
} }
var userId = context.User.FindAuthUserId();
return Task.FromResult<object?>(new var userData = await _userDataService.FindAsync(
userId, userId,
new List<string> { "FactionProgressionICA", "FactionProgressionKorolev", "FactionProgressionOsiris" }
);
int Progression(string faction) =>
userData.TryGetValue("FactionProgression" + faction, out var v) && v.Value != null
? JsonSerializer.Deserialize<int>(v.Value)
: 0;
return new
{ {
factions = new [] factions = new[]
{ {
new new { factionId = "ICA", currentProgression = Progression("ICA") },
{ new { factionId = "Korolev", currentProgression = Progression("Korolev") },
factionId = "ICA", new { factionId = "Osiris", currentProgression = Progression("Osiris") },
currentProgression = 0
},
new
{
factionId = "Korolev",
currentProgression = 0
},
new
{
factionId = "Osiris",
currentProgression = 0
},
}, },
userId = context.User.FindAuthUserId(), userId,
error = "" error = ""
}); };
} }
} }
@@ -1,100 +1,119 @@
using Prospect.Server.Api.Services.Auth.Extensions; using System.Text.Json;
using Prospect.Server.Api.Models.Data;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models; using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Services.CloudScript.Models.Data; using Prospect.Server.Api.Services.CloudScript.Models.Data;
using Prospect.Server.Api.Services.UserData;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("RequestPlayerContracts")] [CloudScriptFunction("RequestPlayerContracts")]
public class RequestPlayerContracts : ICloudScriptFunction<FYGetPlayerContractsRequest, FYGetPlayerContractsResult> public class RequestPlayerContracts : ICloudScriptFunction<FYGetPlayerContractsRequest, FYGetPlayerContractsResult>
{ {
private readonly IHttpContextAccessor _httpContextAccessor; // Board slots are ordered Easy / Medium / Hard (same convention as ClaimActiveContract).
private static readonly string[] Difficulties = ["Easy", "Medium", "Hard"];
private const int RefreshHours = 12;
public RequestPlayerContracts(IHttpContextAccessor httpContextAccessor) private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService;
private readonly Random _rnd = new();
public RequestPlayerContracts(IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
_titleDataService = titleDataService;
} }
public Task<FYGetPlayerContractsResult> ExecuteAsync(FYGetPlayerContractsRequest request) public async Task<FYGetPlayerContractsResult> ExecuteAsync(FYGetPlayerContractsRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
{ {
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
var userId = context.User.FindAuthUserId();
return Task.FromResult(new FYGetPlayerContractsResult var userData = await _userDataService.FindAsync(
userId, userId,
new List<string>
{
"ContractsActive", "JobBoardsData",
"FactionProgressionKorolev", "FactionProgressionICA", "FactionProgressionOsiris",
}
);
var contractsActive = JsonSerializer.Deserialize<FYGetActiveContractsResult>(userData["ContractsActive"].Value)!;
var jobBoardsData = JsonSerializer.Deserialize<JobBoardsData>(userData["JobBoardsData"].Value)!;
var titleData = _titleDataService.Find(new List<string> { "Contracts", "Jobs", "LevelData" });
var contracts = JsonSerializer.Deserialize<Dictionary<string, TitleDataContractInfo>>(titleData["Contracts"])!;
var jobs = JsonSerializer.Deserialize<Dictionary<string, Dictionary<string, string[]>>>(titleData["Jobs"])!;
var levelsData = JsonSerializer.Deserialize<Dictionary<string, int[]>>(titleData["LevelData"])!;
var now = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var lastRefresh = jobBoardsData.LastBoardRefreshTimeUtc?.Seconds ?? 0;
// Initial fill only: populate boards that were seeded empty / never refreshed.
// The periodic rotation is owned by RequestActiveObjectivesAndBoardsData, so we
// don't refresh on a timer here to avoid two competing refresh policies.
var boardsEmpty = jobBoardsData.Boards == null
|| jobBoardsData.Boards.Any(b => b.Contracts == null
|| b.Contracts.Any(c => string.IsNullOrEmpty(c.ContractId)));
if (jobBoardsData.Boards != null && (lastRefresh == 0 || boardsEmpty))
{ {
UserId = context.User.FindAuthUserId(), foreach (var board in jobBoardsData.Boards)
{
if (board.Contracts == null || !jobs.TryGetValue(board.FactionId, out var factionJobs))
{
continue;
}
var factionKey = "FactionProgression" + board.FactionId;
var factionProgression = userData.TryGetValue(factionKey, out var fp)
? JsonSerializer.Deserialize<int>(fp.Value)
: 0;
var level = levelsData.TryGetValue(board.FactionId, out var levels)
? levels.Length - Array.FindIndex(levels, xp => factionProgression >= xp)
: 0;
for (var idx = 0; idx < board.Contracts.Count && idx < Difficulties.Length; idx++)
{
if (!factionJobs.TryGetValue(Difficulties[idx], out var difficultyJobs))
{
continue;
}
var available = Array.FindAll(difficultyJobs,
jobId => contracts.TryGetValue(jobId, out var c) && level >= c.UnlockData.Level);
if (available.Length > 0)
{
board.Contracts[idx].ContractId = available[_rnd.Next(available.Length)];
}
}
}
jobBoardsData.LastBoardRefreshTimeUtc = new FYTimestamp { Seconds = now };
await _userDataService.UpdateAsync(
userId, userId,
new Dictionary<string, string> { ["JobBoardsData"] = JsonSerializer.Serialize(jobBoardsData) }
);
}
return new FYGetPlayerContractsResult
{
UserId = userId,
Error = null, Error = null,
ActiveContracts = new List<FYActiveContractPlayerData>(), ActiveContracts = contractsActive.Contracts,
FactionsContracts = new FYFactionsContractsData FactionsContracts = new FYFactionsContractsData
{ {
Boards = new List<FYFactionContractsData> Boards = jobBoardsData.Boards,
{ LastBoardRefreshTimeUtc = jobBoardsData.LastBoardRefreshTimeUtc ?? new FYTimestamp { Seconds = now },
new FYFactionContractsData
{
FactionId = "ICA",
Contracts = new List<FYFactionContractData>
{
new FYFactionContractData
{
ContractId = "NEW-Easy-ICA-Gather-1",
},
new FYFactionContractData
{
ContractId = "NEW-Medium-ICA-Uplink-1",
},
new FYFactionContractData
{
ContractId = "NEW-Hard-ICA-Uplink-1",
}
}
},
new FYFactionContractsData
{
FactionId = "Korolev",
Contracts = new List<FYFactionContractData>
{
new FYFactionContractData
{
ContractId = "NEW-Easy-KOR-Mine-4",
},
new FYFactionContractData
{
ContractId = "NEW-Medium-KOR-Mine-1",
},
new FYFactionContractData
{
ContractId = "NEW-Hard-KOR-PvP-6",
}
}
},
new FYFactionContractsData
{
FactionId = "Osiris",
Contracts = new List<FYFactionContractData>
{
new FYFactionContractData
{
ContractId = "NEW-Easy-Osiris-Brightcaps-1",
},
new FYFactionContractData
{
ContractId = "NEW-Medium-Osiris-Gather-1",
},
new FYFactionContractData
{
ContractId = "NEW-Hard-Osiris-Gather-7",
}
}
}
},
LastBoardRefreshTimeUtc = new FYTimestamp
{
Seconds = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds()
}
}, },
RefreshHours24UtcFromBackend = 12 RefreshHours24UtcFromBackend = RefreshHours,
}); };
} }
} }
@@ -35,6 +35,11 @@ public class RequestStoreRotationDataFunction : ICloudScriptFunction<RequestStor
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
} }
// Nombre de variantes de rotation (les storeId "DailyShop_N" / "WeeklyShop_N"
// permettent au client de distinguer chaque rotation et de rafraîchir son cache).
private const int DailyVariants = 8;
private const int WeeklyVariants = 4;
public async Task<RequestStoreRotationDataResponse> ExecuteAsync(RequestStoreRotationDataRequest request) public async Task<RequestStoreRotationDataResponse> ExecuteAsync(RequestStoreRotationDataRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
@@ -43,18 +48,33 @@ public class RequestStoreRotationDataFunction : ICloudScriptFunction<RequestStor
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
// TODO: Get and save store rotation data var now = DateTime.UtcNow;
// Rotation quotidienne : expire au prochain minuit UTC ; l'index de rotation
// est le nombre de jours écoulés depuis l'epoch (déterministe, stable).
var todayUtc = now.Date;
var dailyExpiration = todayUtc.AddDays(1);
var dayIndex = (int)(todayUtc - DateTime.UnixEpoch.Date).TotalDays;
var dailyStoreId = $"DailyShop_{dayIndex % DailyVariants}";
// Rotation hebdomadaire : expire au prochain lundi 00:00 UTC.
// DayOfWeek : dimanche=0 ; on ramène lundi=0.
var daysSinceMonday = ((int)todayUtc.DayOfWeek + 6) % 7;
var weekStart = todayUtc.AddDays(-daysSinceMonday);
var weeklyExpiration = weekStart.AddDays(7);
var weekIndex = (int)Math.Floor((weekStart - DateTime.UnixEpoch.Date).TotalDays / 7);
var weeklyStoreId = $"WeeklyShop_{weekIndex % WeeklyVariants}";
return new RequestStoreRotationDataResponse return new RequestStoreRotationDataResponse
{ {
Error = "", Error = "",
DailyStoreData = new StoreRotationData { DailyStoreData = new StoreRotationData {
StoreID = "DailyShop", StoreID = dailyStoreId,
ExpirationData = DateTime.UtcNow.AddDays(1) ExpirationData = dailyExpiration
}, },
WeeklyStoreData = new StoreRotationData { WeeklyStoreData = new StoreRotationData {
StoreID = "WeeklyShop", StoreID = weeklyStoreId,
ExpirationData = DateTime.UtcNow.AddDays(1) ExpirationData = weeklyExpiration
} }
}; };
} }
@@ -6,7 +6,7 @@ public class RegionObject
[JsonPropertyName("region")] [JsonPropertyName("region")]
public string Region { get; set; } public string Region { get; set; }
[JsonPropertyName("ping")] [JsonPropertyName("ping")]
public int Ping { get; set; } public long Ping { get; set; } // client sends 2147483648 (int.MaxValue+1) for "no ping" -> must be long or deserialization 500s
[JsonPropertyName("instanceType")] [JsonPropertyName("instanceType")]
public int InstanceType { get; set; } public int InstanceType { get; set; }
} }
@@ -54,7 +54,7 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction<FYStartTechTr
var userData = await _userDataService.FindAsync( var userData = await _userDataService.FindAsync(
userId, userId, userId, userId,
new List<string>{"TechTreeNodeData", "Balance", "Inventory"} new List<string>{"TechTreeNodeData", "Balance", "Inventory", "PlayerQuartersLevel"}
); );
var userTechTree = JsonSerializer.Deserialize<UserTechTreeNodeData>(userData["TechTreeNodeData"].Value); var userTechTree = JsonSerializer.Deserialize<UserTechTreeNodeData>(userData["TechTreeNodeData"].Value);
@@ -79,7 +79,15 @@ public class StartTechTreeNodeUpgradeClient : ICloudScriptFunction<FYStartTechTr
var techTreeNodeData = techTreeNodes[request.NodeID]; var techTreeNodeData = techTreeNodes[request.NodeID];
var nextTreeNodeLevel = techTreeNodeData.PerkLevels[currentLevel]; // Tech tree node levels start from 0 var nextTreeNodeLevel = techTreeNodeData.PerkLevels[currentLevel]; // Tech tree node levels start from 0
// TODO: Dependency check // Dependency check: the player's quarters must be high enough to unlock this node.
var playerQuarters = JsonSerializer.Deserialize<FYPlayerQuarterStatus>(userData["PlayerQuartersLevel"].Value);
if (playerQuarters != null && playerQuarters.Level < techTreeNodeData.PlayerQuarterLevelRequired) {
return new FYStartTechTreeNodeUpgradeClientResult
{
UserID = userId,
Error = $"Requires player quarters level {techTreeNodeData.PlayerQuarterLevelRequired}",
};
}
// Validate price // Validate price
// TODO: Refactor // TODO: Refactor
@@ -43,9 +43,9 @@ public class UpdateRetentionBonus : ICloudScriptFunction<FYRetentionBonusRequest
}; };
} }
var currentMidnight = (int)new DateTimeOffset(DateTime.UtcNow.Date, TimeSpan.Zero).ToUnixTimeSeconds(); var now = (int)DateTimeOffset.UtcNow.ToUnixTimeSeconds();
var nextClaimTime = retentionBonus.LastClaimTime.Seconds + 60 * 60 * 24; var nextClaimTime = retentionBonus.LastClaimTime.Seconds + 60 * 60 * 24 * 2;
if (currentMidnight < nextClaimTime) { if (now < nextClaimTime) {
return new FYRetentionBonusResult return new FYRetentionBonusResult
{ {
UserId = userId, UserId = userId,
@@ -58,7 +58,7 @@ public class UpdateRetentionBonus : ICloudScriptFunction<FYRetentionBonusRequest
var bonusData = JsonSerializer.Deserialize<Dictionary<string, TitleDataRetentionBonusInfo>>(titleData["RetentionBonusData"]); var bonusData = JsonSerializer.Deserialize<Dictionary<string, TitleDataRetentionBonusInfo>>(titleData["RetentionBonusData"]);
retentionBonus.DaysClaimed++; retentionBonus.DaysClaimed++;
retentionBonus.LastClaimTime.Seconds = currentMidnight; retentionBonus.LastClaimTime.Seconds = now;
retentionBonus.ClaimedAll = retentionBonus.DaysClaimed >= 14; retentionBonus.ClaimedAll = retentionBonus.DaysClaimed >= 14;
// TODO: Currently only CB2 bonuses are considered // TODO: Currently only CB2 bonuses are considered
@@ -1,6 +1,4 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Microsoft.Extensions.Options;
using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.CloudScript.Models; using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -29,13 +27,6 @@ public class FYVivoxJoinTokenRequest
[CloudScriptFunction("VivoxJoin")] [CloudScriptFunction("VivoxJoin")]
public class VivoxJoin : ICloudScriptFunction<FYVivoxJoinTokenRequest, object> public class VivoxJoin : ICloudScriptFunction<FYVivoxJoinTokenRequest, object>
{ {
private readonly VivoxConfig _settings;
public VivoxJoin(IOptions<VivoxConfig> settings)
{
_settings = settings.Value;
}
private int ReturnChannelType(string type) { private int ReturnChannelType(string type) {
switch (type) { switch (type) {
case "GLOBAL": return 2; case "GLOBAL": return 2;
@@ -50,7 +41,8 @@ public class VivoxJoin : ICloudScriptFunction<FYVivoxJoinTokenRequest, object>
public async Task<object> ExecuteAsync(FYVivoxJoinTokenRequest request) public async Task<object> ExecuteAsync(FYVivoxJoinTokenRequest request)
{ {
var exp = (int)DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeSeconds(); var exp = (int)DateTimeOffset.UtcNow.AddDays(1).ToUnixTimeSeconds();
var token = TokenGenerator.vxGenerateToken(_settings.Key, _settings.Issuer, exp, "join", request.Username, request.Channel); // TODO: Replace with app configuration
var token = TokenGenerator.vxGenerateToken("key", "issuer", exp, "join", request.Username, request.Channel);
return new FYVivoxJoinData return new FYVivoxJoinData
{ {
@@ -1,6 +1,4 @@
using Microsoft.Extensions.Options; using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.CloudScript.Models;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
@@ -86,18 +84,12 @@ public class TokenGenerator
[CloudScriptFunction("VivoxLogin")] [CloudScriptFunction("VivoxLogin")]
public class VivoxLogin : ICloudScriptFunction<FYVivoxLoginTokenRequest, FYVivoxJoinData> public class VivoxLogin : ICloudScriptFunction<FYVivoxLoginTokenRequest, FYVivoxJoinData>
{ {
private readonly VivoxConfig _settings;
public VivoxLogin(IOptions<VivoxConfig> settings)
{
_settings = settings.Value;
}
public async Task<FYVivoxJoinData> ExecuteAsync(FYVivoxLoginTokenRequest request) public async Task<FYVivoxJoinData> ExecuteAsync(FYVivoxLoginTokenRequest request)
{ {
var channel = $"sip:confctl-g-{_settings.Issuer}.testchannel@mtu1xp.vivox.com"; // e - echo channel, g - global, p - positional // TODO: Replace with app configuration
var channel = "sip:confctl-g-<issuer>.testchannel@mtu1xp.vivox.com"; // e - echo channel, g - global, p - positional
var exp = (int)DateTimeOffset.UtcNow.AddSeconds(90).ToUnixTimeSeconds(); var exp = (int)DateTimeOffset.UtcNow.AddSeconds(90).ToUnixTimeSeconds();
var token = TokenGenerator.vxGenerateToken(_settings.Key, _settings.Issuer, exp, "login", request.Username, channel); var token = TokenGenerator.vxGenerateToken("key", "issuer", exp, "login", request.Username, channel);
return new FYVivoxJoinData return new FYVivoxJoinData
{ {
@@ -1,26 +1,18 @@
using Microsoft.Extensions.Options; using Prospect.Server.Api.Services.CloudScript.Models;
using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
[CloudScriptFunction("VivoxSettings")] [CloudScriptFunction("VivoxSettings")]
public class VivoxSettings : ICloudScriptFunction<FYEmptyRequest, FYVivoxSettingsData> public class VivoxSettings : ICloudScriptFunction<FYEmptyRequest, FYVivoxSettingsData>
{ {
private readonly VivoxConfig _settings;
public VivoxSettings(IOptions<VivoxConfig> settings)
{
_settings = settings.Value;
}
public Task<FYVivoxSettingsData> ExecuteAsync(FYEmptyRequest request) public Task<FYVivoxSettingsData> ExecuteAsync(FYEmptyRequest request)
{ {
return Task.FromResult(new FYVivoxSettingsData return Task.FromResult(new FYVivoxSettingsData
{ {
VivoxDomain = _settings.Domain, // TODO: Replace with app configuration
VivoxIssuer = _settings.Issuer, // VivoxDomain = "",
VivoxServer = _settings.Server // VivoxIssuer = "",
// VivoxServer = ""
}); });
} }
} }
@@ -1,7 +1,14 @@
namespace Prospect.Server.Api.Services.CloudScript.Models.Data; using System.Text.Json.Serialization;
namespace Prospect.Server.Api.Services.CloudScript.Models.Data;
// Envoyé par le client au déploiement (dans FYEnterMatchAzureFunction) : quels items
// de l'inventaire assurer, avec quelle police (voir TitleData "InventoryInsurance").
public class FYPurchaseInventoryInsuranceRequest public class FYPurchaseInventoryInsuranceRequest
{ {
// m_insuranceId [JsonPropertyName("insuranceId")]
// m_itemIds public string InsuranceId { get; set; }
[JsonPropertyName("itemIds")]
public List<string> ItemIds { get; set; }
} }
@@ -43,4 +43,18 @@ public class DbUserService : BaseDbService<PlayFabUser>
return await FindAsync(type, key) ?? return await FindAsync(type, key) ??
await CreateAsync(type, key); await CreateAsync(type, key);
} }
// Resolve several auth keys (e.g. a batch of Steam IDs) to the players that exist on
// this server in a single query. Used by the friends list to keep only the imported
// Steam friends who have actually played here.
public async Task<List<PlayFabUser>> FindManyAsync(PlayFabUserAuthType type, IReadOnlyCollection<string> keys)
{
if (keys == null || keys.Count == 0)
{
return new List<PlayFabUser>();
}
var wanted = new HashSet<string>(keys);
return await Collection.Find(user => user.Auth.Any(auth =>
auth.Type == type && wanted.Contains(auth.Key))).ToListAsync();
}
} }
File diff suppressed because one or more lines are too long
-2
View File
@@ -25,8 +25,6 @@ public class Startup
services.Configure<AuthTokenSettings>(Configuration.GetSection(nameof(AuthTokenSettings))); services.Configure<AuthTokenSettings>(Configuration.GetSection(nameof(AuthTokenSettings)));
services.Configure<DatabaseSettings>(Configuration.GetSection(nameof(DatabaseSettings))); services.Configure<DatabaseSettings>(Configuration.GetSection(nameof(DatabaseSettings)));
services.Configure<PlayFabSettings>(Configuration.GetSection(nameof(PlayFabSettings))); services.Configure<PlayFabSettings>(Configuration.GetSection(nameof(PlayFabSettings)));
services.Configure<VivoxConfig>(Configuration.GetSection(nameof(VivoxConfig)));
services.Configure<SignalRSettings>(Configuration.GetSection(nameof(SignalRSettings)));
services.AddSingleton<AuthTokenService>(); services.AddSingleton<AuthTokenService>();
services.AddSingleton<UserDataService>(); services.AddSingleton<UserDataService>();
+1 -17
View File
@@ -8,27 +8,11 @@
}, },
"PlayFabSettings": { "PlayFabSettings": {
"PublisherId": "850902E5B40508ED", "PublisherId": "850902E5B40508ED",
"TitleId": "2EA46" "TitleId": "2EA46",
},
"SignalRSettings": {
"SignalRURL": "https://127.0.0.1:8443/signalr/?hub=pubsub", "SignalRURL": "https://127.0.0.1:8443/signalr/?hub=pubsub",
"SignalRAccessToken": "TEST" "SignalRAccessToken": "TEST"
}, },
"VivoxConfig": {
"Key": "",
"Issuer": "",
"Domain": "",
"Server": ""
},
"Kestrel": { "Kestrel": {
"Endpoints": {
"Https": {
"Url": "https://127.0.0.1:8443"
},
"Http": {
"Url": "http://127.0.0.1:8000"
}
},
"Certificates": { "Certificates": {
"Default": { "Default": {
"Path": ".\\certificate.pfx" "Path": ".\\certificate.pfx"
+39 -11
View File
@@ -1,4 +1,5 @@
using Prospect.Unreal.Core; using Prospect.Unreal.Core;
using Prospect.Unreal.Net.Actors;
using Prospect.Unreal.Runtime; using Prospect.Unreal.Runtime;
using Serilog; using Serilog;
@@ -25,27 +26,54 @@ internal static class Program
.WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] ({SourceContext,-52}) {Message:lj}{NewLine}{Exception}") .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss} {Level:u3}] ({SourceContext,-52}) {Message:lj}{NewLine}{Exception}")
.CreateLogger(); .CreateLogger();
Logger.Information("Starting Prospect.Server.Game"); // The Cycle: Frontier authoritative game server.
// We start on the station map (the simplest shared space to get two players spawned
// and moving); the raid maps (Bright Sands, …) come once spawn + movement replication
// work end to end. AI and loot are intentionally out of scope for now.
var map = Environment.GetEnvironmentVariable("PROSPECT_MAP") ?? "/Game/Maps/MP/Station/Station_P";
var gameMode = Environment.GetEnvironmentVariable("PROSPECT_GAMEMODE") ?? "/Script/Prospect/YGameMode_Station";
var port = int.TryParse(Environment.GetEnvironmentVariable("PROSPECT_PORT"), out var p) ? p : 7777;
// Prospect: Logger.Information("Starting Prospect.Server.Game — map={Map} gameMode={GameMode} port={Port}", map, gameMode, port);
// Map: /Game/Maps/MP/Station/Station_P
// GameMode: /Script/Prospect/YGameMode_Station
var worldUrl = new FUrl var worldUrl = new FUrl { Map = map, Port = port };
{ worldUrl.Options.Add($"game={gameMode}");
Map = "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap"
};
await using (var world = new ProspectWorld()) await using (var world = new ProspectWorld())
{ {
world.SetGameInstance(new UGameInstance()); world.SetGameInstance(new UGameInstance());
world.SetGameMode(worldUrl); world.SetGameMode(worldUrl);
// The game mode needs a GameSession, otherwise NMT_Join -> SpawnPlayActor -> Login
// fails with "GameSession is null" and the player is never spawned.
var authGameMode = world.GetAuthGameMode();
if (authGameMode != null && authGameMode.GameSession == null)
{
authGameMode.GameSession = new AGameSession();
}
world.InitializeActorsForPlay(worldUrl, true); world.InitializeActorsForPlay(worldUrl, true);
world.Listen();
if (!world.Listen())
{
Logger.Fatal("Failed to start listening (port {Port} already in use?)", port);
return;
}
Logger.Information("Server listening on :{Port}. Waiting for players…", port);
while (await Tick.WaitForNextTickAsync()) while (await Tick.WaitForNextTickAsync())
{ {
world.Tick(TickRate); try
{
world.Tick(TickRate);
}
catch (Exception ex)
{
// A misaligned/incompatible client packet must not kill the whole server
// (R&D: the client's exact net version isn't matched yet). Log and continue.
Logger.Error(ex, "Tick error (bad packet?) — continuing");
}
} }
} }
@@ -129,16 +129,19 @@ public class FNetPacketNotify
{ {
if (!notificationData.Seq.Greater(_inSeq)) if (!notificationData.Seq.Greater(_inSeq))
{ {
Logger.Information("[HS-SEQ] fail1 seq<=inSeq seq={Seq} inSeq={InSeq}", notificationData.Seq.Value, _inSeq.Value);
return 0; return 0;
} }
if (!notificationData.AckedSeq.GreaterEq(_outAckSeq)) if (!notificationData.AckedSeq.GreaterEq(_outAckSeq))
{ {
Logger.Information("[HS-SEQ] fail2 ackedSeq<outAckSeq ackedSeq={Acked} outAckSeq={OutAck}", notificationData.AckedSeq.Value, _outAckSeq.Value);
return 0; return 0;
} }
if (!_outSeq.Greater(notificationData.AckedSeq)) if (!_outSeq.Greater(notificationData.AckedSeq))
{ {
Logger.Information("[HS-SEQ] fail3 outSeq<=ackedSeq outSeq={OutSeq} ackedSeq={Acked}", _outSeq.Value, notificationData.AckedSeq.Value);
return 0; return 0;
} }
@@ -185,6 +185,8 @@ public class StatelessConnectHandlerComponent : HandlerComponent
public override void Incoming(FBitReader packet) public override void Incoming(FBitReader packet)
{ {
var diagTotalBits = packet.GetBitsLeft();
if (_magicHeader.Length > 0) if (_magicHeader.Length > 0)
{ {
// Skip magic header. // Skip magic header.
@@ -192,6 +194,11 @@ public class StatelessConnectHandlerComponent : HandlerComponent
} }
var bHandshakePacket = packet.ReadBit() && !packet.IsError(); var bHandshakePacket = packet.ReadBit() && !packet.IsError();
// [HS-DIAG] Compare the incoming packet against what the stateless handshake expects.
// The Cycle client may use a magic header / different handshake size than 227 bits.
Logger.Information("[HS-DIAG] Incoming totalBits={Total} magicLen={Magic} bHandshake={HS} bitsLeftAfterFlag={Left} expectAfterFlag={Exp}",
diagTotalBits, _magicHeader.Length, bHandshakePacket, packet.GetBitsLeft(), HandshakePacketSizeBits - 1);
if (bHandshakePacket) if (bHandshakePacket)
{ {
var bRestartHandshake = false; var bRestartHandshake = false;
@@ -392,6 +399,7 @@ public class StatelessConnectHandlerComponent : HandlerComponent
} }
var bHandshakePacket = packet.ReadBit() && !packet.IsError(); var bHandshakePacket = packet.ReadBit() && !packet.IsError();
Logger.Information("[HS-DIAG-CL] Incoming flag={HS} bitsLeftAfterFlag={Left} expect={Exp}", bHandshakePacket, packet.GetBitsLeft(), HandshakePacketSizeBits - 1);
_lastChallengeSuccessAddress = null; _lastChallengeSuccessAddress = null;
@@ -404,6 +412,7 @@ public class StatelessConnectHandlerComponent : HandlerComponent
Span<byte> origCookie = stackalloc byte[CookieByteSize]; Span<byte> origCookie = stackalloc byte[CookieByteSize];
bHandshakePacket = ParseHandshakePacket(packet, ref bRestartHandshake, ref secretId, ref timestamp, cookie, origCookie); bHandshakePacket = ParseHandshakePacket(packet, ref bRestartHandshake, ref secretId, ref timestamp, cookie, origCookie);
Logger.Information("[HS-DIAG-CL] parsed ok={Ok} restart={R} secretId={S} timestamp={T}", bHandshakePacket, bRestartHandshake, secretId, timestamp);
if (bHandshakePacket) if (bHandshakePacket)
{ {
@@ -412,6 +421,7 @@ public class StatelessConnectHandlerComponent : HandlerComponent
var bInitialConnect = timestamp == 0.0; var bInitialConnect = timestamp == 0.0;
if (bInitialConnect) if (bInitialConnect)
{ {
Logger.Information("[HS-DIAG-CL] initial -> SendConnectChallenge to {Addr}", address);
SendConnectChallenge(address); SendConnectChallenge(address);
} }
else if (_driver != null) else if (_driver != null)
@@ -430,6 +440,7 @@ public class StatelessConnectHandlerComponent : HandlerComponent
GenerateCookie(address, secretId, timestamp, regenCookie); GenerateCookie(address, secretId, timestamp, regenCookie);
bChallengeSuccess = cookie.SequenceEqual(regenCookie); bChallengeSuccess = cookie.SequenceEqual(regenCookie);
Logger.Information("[HS-DIAG-CL] challenge response cookieMatch={Ok}", bChallengeSuccess);
if (bChallengeSuccess) if (bChallengeSuccess)
{ {
+39 -1
View File
@@ -267,6 +267,8 @@ public abstract class UNetConnection : UPlayer
/// <summary> /// <summary>
/// Full incoming packet index. /// Full incoming packet index.
/// </summary> /// </summary>
private bool _bAdoptedInitialSequence;
public int InPacketId { get; private set; } public int InPacketId { get; private set; }
/// <summary> /// <summary>
@@ -515,6 +517,19 @@ public abstract class UNetConnection : UPlayer
return; return;
} }
// The Cycle client (UE4 R3.5.0) writes one extra bit between the packet's
// ack history and the packet-info payload that stock UE 4.27 (EngineNetVer
// 16) does not. Bit-decoding real client packets showed the header is 65
// bits, not 64: consuming this bit realigns everything downstream — the
// bHasPacketInfoPayload flag, the 10-bit jitter clock and bHasServerFrameTime
// then land exactly, and the first control-channel bunch parses cleanly
// (ChIndex 0, NMT_Hello). Observed always 0 in captures.
var bCycleExtraHeaderBit = reader.ReadBit();
if (bCycleExtraHeaderBit)
{
Logger.Warning("[HS-HDR] Cycle extra header bit was set (expected 0)");
}
var bHasPacketInfoPayload = true; var bHasPacketInfoPayload = true;
if (reader.EngineNetVer() > EEngineNetworkVersionHistory.HISTORY_JITTER_IN_HEADER) if (reader.EngineNetVer() > EEngineNetworkVersionHistory.HISTORY_JITTER_IN_HEADER)
@@ -540,6 +555,25 @@ public abstract class UNetConnection : UPlayer
} }
var packetSequenceDelta = PacketNotify.GetSequenceDelta(header); var packetSequenceDelta = PacketNotify.GetSequenceDelta(header);
// The Cycle client doesn't derive its initial packet sequences from the handshake
// cookie the way stock UE does, so our cookie-based InitSequence disagrees with it
// and every packet looks out-of-order. On the very first packet, adopt the client's
// announced sequences (Seq + AckedSeq) instead of the cookie-derived ones.
if (packetSequenceDelta <= 0 && !_bAdoptedInitialSequence && Driver != null && Driver.IsServer())
{
_bAdoptedInitialSequence = true;
var adoptIn = new SequenceNumber((ushort)(header.Seq.Value - 1));
var adoptOut = new SequenceNumber((ushort)(header.AckedSeq.Value + 1));
PacketNotify.Init(adoptIn, adoptOut);
InPacketId = header.Seq.Value - 1;
OutPacketId = header.AckedSeq.Value + 1;
OutAckPacketId = header.AckedSeq.Value;
LastNotifiedPacketId = OutAckPacketId;
Logger.Information("[HS-SEQ] Adopted client sequences: inSeq={In} outSeq={Out}", header.Seq.Value, header.AckedSeq.Value + 1);
packetSequenceDelta = PacketNotify.GetSequenceDelta(header);
}
if (packetSequenceDelta > 0) if (packetSequenceDelta > 0)
{ {
var bPacketOrderCacheActive = !_bFlushingPacketOrderCache && _packetOrderCache != null; var bPacketOrderCacheActive = !_bFlushingPacketOrderCache && _packetOrderCache != null;
@@ -645,7 +679,10 @@ public abstract class UNetConnection : UPlayer
if (bunch.ChIndex >= MaxChannelSize) if (bunch.ChIndex >= MaxChannelSize)
{ {
throw new Exception("Bunch channel index exceeds channel limit"); // Don't crash the whole server on a malformed/misaligned bunch (e.g. a
// version/bit-alignment mismatch with the client). Log and drop the packet.
Logger.Error("[HS-SEQ] Bunch channel index {Idx} exceeds limit {Max} (netVer={Ver}) — dropping packet", bunch.ChIndex, MaxChannelSize, (int)bunch.EngineNetVer());
return;
} }
} }
@@ -1079,6 +1116,7 @@ public abstract class UNetConnection : UPlayer
public void InitSequence(int incomingSequence, int outgoingSequence) public void InitSequence(int incomingSequence, int outgoingSequence)
{ {
Logger.Information("[HS-SEQ] InitSequence incoming={In} outgoing={Out}", incomingSequence, outgoingSequence);
if (InPacketId == -1) if (InPacketId == -1)
{ {
// Initialize the base UNetConnection packet sequence (not very useful/effective at preventing attacks) // Initialize the base UNetConnection packet sequence (not very useful/effective at preventing attacks)
+29 -6
View File
@@ -286,7 +286,29 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
} }
else else
{ {
throw new NotImplementedException("Encryption"); // R&D WALL — The Cycle mandates encryption on the game connection.
// The client's NMT_Hello carries EncryptionToken = its PlayFab
// user_id (e.g. "92EBCFE8C3EAF3AC"), and the client PacketHandler
// stack is [DTLSHandlerComponent, StatelessConnectHandlerComponent]
// (confirmed from the client's own logs). Encryption is therefore
// DTLS in PSK mode (the shipping exe bundles ECDHE/DHE-PSK-AES256
// cipher suites + a "DTLS.PreSharedKeys" cvar + DTLSPSK*Callback).
//
// Proceeding to the challenge WITHOUT sending NMT_EncryptionAck does
// NOT work: the client requires encryption and closes the control
// channel immediately after the plaintext challenge.
//
// To finish this we'd need (1) a DTLS-PSK server that matches UE's
// DTLSHandlerComponent framing, and (2) the 32-byte PSK the client
// derives per user_id. The derivation lives in the client's code,
// which cannot be recovered statically: the exe is packed/encrypted
// (.text entropy = 8.0), so Ghidra/radare2 see only ciphertext. The
// only route left is a runtime memory dump of the decrypted image.
//
// For now: log and proceed to the challenge so the flow is visible
// in logs; the client will close afterwards.
Logger.Warning("Client requires DTLS-PSK encryption (EncryptionToken={Token}); server-side DTLS not implemented — connection will be dropped by client", encryptionToken);
connection.SendChallengeControlMessage();
} }
} }
break; break;
@@ -434,13 +456,14 @@ public abstract partial class UWorld : FNetworkNotify, IAsyncDisposable
private void WelcomePlayer(UNetConnection connection) private void WelcomePlayer(UNetConnection connection)
{ {
// TODO: Properly fetch level name from CurrentLevel // Tell the client which level + game mode to travel to. Previously hardcoded to the
var levelName = "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap"; // UE ThirdPerson template; now use the world's configured map and the "game=" option
// (set by the host), falling back to the template if unset.
// TODO: Properly fetch from AuthorityGameMode var levelName = string.IsNullOrEmpty(Url.Map) ? "/Game/ThirdPersonCPP/Maps/ThirdPersonExampleMap" : Url.Map;
var gameName = "/Script/ThirdPersonMP.ThirdPersonMPGameMode"; var gameName = Url.GetOption("game=", "/Script/ThirdPersonMP.ThirdPersonMPGameMode") ?? string.Empty;
var redirectUrl = string.Empty; var redirectUrl = string.Empty;
Logger.Information("Welcoming player -> level={Level} game={Game}", levelName, gameName);
NMT_Welcome.Send(connection, levelName, gameName, redirectUrl); NMT_Welcome.Send(connection, levelName, gameName, redirectUrl);
connection.FlushNet(); connection.FlushNet();