36 Commits
Author SHA1 Message Date
neckfire 7dbab1aab8 Merge pull request 'Audit fixes: Fortuna pass level, rename persist, loadout & craft-timer' (#6) from preprod into main
Build & Deploy / build (push) Successful in 26s
2026-07-15 00:57:11 +00:00
neckfireandClaude Opus 4.8 1b4c1b2606 fix(audit): Fortuna pass level, display-name persist, loadout & craft-timer queries
Build & Deploy / build (push) Successful in 39s
Audit of the emulator surface + 4 fixes:
- Fortuna pass level was frozen: nothing ever granted season XP. ClaimActiveContract
  now grants + persists FortunaPass{2,3}_SeasonXp (scaled to the contract's reputation)
  and returns the new total, so the pass level moves as you complete contracts.
- UpdateUserTitleDisplayName never persisted the rename (echoed back, lost on relog).
  Added DbUserService.UpdateDisplayNameAsync and call it.
- GetPlayerSets returned a blank loadout; now reads the persisted LOADOUT key.
- GetCraftingInProgressData was a stub; now returns the persisted CraftingTimer so an
  in-progress craft + remaining time survive a menu reopen/relog.

Remaining known gaps documented in Plane (need client data / multiplayer / out of scope).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:56:47 +02:00
neckfire 078d2f80be Merge pull request 'Fix critique: achats bloqués (sentinelle ContractLockPurchase None)' (#5) from preprod into main
Build & Deploy / build (push) Successful in 24s
2026-07-15 00:15:14 +00:00
neckfireandClaude Opus 4.8 14ab3267f4 fix(shop): don't treat ContractLockPurchase sentinel 'None' as a real lock
Build & Deploy / build (push) Successful in 34s
Regression from the contract-lock feature: the check gated on any non-empty
ContractLockPurchase, but the blueprint data uses the string "None" (not "")
for items with no lock (749/773 items). So virtually every item returned
'Item is locked' before the currency deduction -> players couldn't buy anything
(error shown, K-Marks unchanged). Skip the gate when the value is null/empty/"None".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 02:15:13 +02:00
neckfire c986a98471 Merge pull request 'Promotion: fix sauvegarde des missions en zone (raid client-hosted)' (#4) from preprod into main
Build & Deploy / build (push) Successful in 23s
2026-07-14 23:58:55 +00:00
neckfireandClaude Opus 4.8 dc34233e40 fix(contracts): persist raid-zone objective progress (client-hosted raids)
Build & Deploy / build (push) Successful in 36s
Symptom: missions done in raid zones weren't saved -> contracts stuck on
'Objective not met', no claim possible.

Root cause: client-hosted raids have no dedicated game server, so
UpdatePlayerActiveContracts is never called (confirmed: 0 calls in prod logs).
Only Kills and OwnNumOfItem objectives were handled; DeadDrop/VisitArea/
LootContainer had Progress[i]=0 forever -> unclaimable and not shown at station.

Fix: EYContractObjectiveType.IsRaidRuntime() groups the objectives that can only
be observed in a live raid (Kills/DeadDrop/VisitArea/LootContainer). Auto-credit
them on deploy (EnterMatchmakingMatch, persisted) so the station shows them
complete, and accept them in ClaimActiveContract. OwnNumOfItem still validated
against the real stash; FactionLevel/CompletedMission unchanged. Added a
defensive guard against a Progress array shorter than the objective list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-15 01:54:25 +02:00
neckfireandClaude Opus 4.8 71a2c8a1e9 feat(deploy): optional dedicated-server travel via GAMESERVER_ADDRESS
Build & Deploy / build (push) Successful in 31s
When GAMESERVER_ADDRESS ("host:port") is set, EnterMatchmaking returns
that server (SingleplayerStation=false) and EnterMatchmakingMatch's
deploy signal carries its address, so the client travels to the real
game server instead of the client-hosted station. Unset = unchanged
(normal preprod). Lets a 2nd preprod run the same :preprod image and
differ only by this env var (dedicated-server test bench).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 18:41:20 +02:00
neckfireandClaude Opus 4.8 55a9571958 fix(matchmaking): deploy signal targets only the deploying player
Build & Deploy / build (push) Successful in 32s
EnterMatchmakingMatch broadcast OnSquadMatchmakingSuccess to Clients.All,
so one player deploying pulled every connected player into the match.
Map each player to their SignalR connection (GetSignalRConnection tags the
URL with uid; CycleHub records it via a SignalRConnectionRegistry) and
send the deploy signal only to that connection (broadcast fallback if
unmapped). Also enable m_automaticImportPlatformFriends (Steam friends).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:56:22 +02:00
neckfireandClaude Opus 4.8 8057fcae47 fix(social): disable friends limit (was capping at 0 friends)
Build & Deploy / build (push) Successful in 31s
FeatureToggles had m_isFriendsLimitEnabled=true with no positive limit,
so the client showed 'friend limit reached: 0 friends' and blocked adding
friends / squad invites. Disabled the toggle (squads/social already on).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:31:44 +02:00
neckfireandClaude Opus 4.8 c70f674431 docs(tool): document automatic Proton prefix cert import
Build & Deploy / build (push) Successful in 28s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:50:30 +02:00
neckfireandClaude Opus 4.8 61e45351cd feat(tool): import cert into the Proton prefix on Linux
Build & Deploy / build (push) Successful in 28s
On Linux the tool now actually trusts the server certificate inside the
game's Proton/Wine prefix (the wine certutil tool is broken under Proton):
it fetches the live cert from the server (backend.txt), builds the wine
serialized cert blob (SHA1 + encoded cert) and writes it to the prefix
Root store via `wine reg import`. Auto-detects the Proton wine and lists
compatdata prefixes (most-recent first) so the non-Steam-shortcut prefix
is picked correctly; the chosen prefix is remembered.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:48:49 +02:00
neckfireandClaude Opus 4.8 6d77bb4b82 chore(signalr): log remote IP + resolved URL for diagnostics
Build & Deploy / build (push) Successful in 30s
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:24:49 +02:00
neckfireandClaude Opus 4.8 0089833b74 fix(signalr): return LAN SignalR URL to local clients
Build & Deploy / build (push) Successful in 34s
GetSignalRConnection now serves the SignalR URL on 192.168.1.136 to
clients whose remote IP is on the LAN, and keeps the public domain for
external clients. The real-time WebSocket (libwebsockets, incl. under
Proton/Wine) couldn't reach the public domain from inside the LAN
(hairpin NAT) — it failed right after the intro as "error code 5".
External friends are unaffected (still get the domain).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:18:35 +02:00
neckfireandClaude Opus 4.8 2809033b76 docs(tool): README for ProspectServerSwitcher
Build & Deploy / build (push) Successful in 26s
Documents the cross-platform server switcher: backend.txt mechanism,
domain presets, interactive/CLI usage, cert import (Windows / Proton),
launch, settings location and self-contained publish commands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 16:03:24 +02:00
neckfireandClaude Opus 4.8 2cc90a22e0 feat(tool): use public domains as presets (works LAN + internet)
Build & Deploy / build (push) Successful in 26s
Switcher presets now use the public domains (tc.nfteam.ovh:8443 /
rd-tc.nfteam.ovh:8444) so the same target works for everyone; the
unified TLS cert covers both hostnames. LAN users add a hosts entry to
avoid hairpin NAT.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:57:51 +02:00
neckfireandClaude Opus 4.8 b04621e2a0 feat(tool): cross-platform server switcher (Prospect.Client.Config)
Build & Deploy / build (push) Successful in 25s
New console utility (net8.0, Linux/Proton + Windows) to point a client at
a server by writing backend.txt — the single value the Prospect agent
reads. Presets for PROD/PREPROD LAN and PROD internet, plus custom URL,
certificate import (native on Windows, guided with Proton-prefix
detection on Linux) and game launch. Interactive menu + scriptable CLI
(--folder/--set). Added to the solution; not built by the server CI.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:53:43 +02:00
neckfireandClaude Opus 4.8 0588849e4c feat(squad): in-memory squad/lobby state + wire squad functions
Build & Deploy / build (push) Successful in 31s
Adds SquadService (singleton, process-local) tracking squads by the
squadId the client shares across party members, plus a user->squad index
so TryGetCompleteSquadInfo (no squadId in request) can resolve the
caller's squad. Members are lazily registered on any squad call.

- TryGetCompleteSquadInfo: returns the caller's real squad.
- SquadMemberReadyForMatch: joins the squad, records readiness/map,
  returns the squad + whether everyone is ready.
- SquadMemberSelectedMap: records the member's selected map.
- DbUserService.FindByIdAsync: resolve display name for squad members.

Groundwork for the station lobby; assumes the client drives squads via
these calls with a shared squadId (to confirm with a 2-client session).
Real-time SignalR push (unknown event names) deferred; the squad is
carried in responses + TryGetCompleteSquadInfo for now.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 15:34:43 +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 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 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 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
58 changed files with 1957 additions and 441 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
+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"]
+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.*
+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`.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+404
View File
@@ -0,0 +1,404 @@
using System.Diagnostics;
using System.Security.Cryptography.X509Certificates;
using System.Text;
using System.Text.Json;
namespace Prospect.Client.Config;
// Cross-platform (Linux/Proton + Windows) helper to point a The Cycle: Frontier client at a
// server. The Prospect agent reads the target PlayFab URL from `backend.txt` next to the game
// executable, so switching servers = writing that one file. This tool also imports the server
// certificate and launches the game (natively on Windows, guided on Linux/Proton).
internal static class Program
{
private const string AppId = "868270";
// Presets use the public domains so the same target works for everyone (LAN or internet):
// DNS resolves to the public IP for friends, and the unified TLS cert covers both hostnames.
// On the same LAN, add a hosts entry (domain -> 192.168.1.136) to avoid hairpin NAT.
private static readonly (string Key, string Label, string Url)[] Presets =
{
("prod", "PROD", "https://tc.nfteam.ovh:8443"),
("preprod", "PREPROD", "https://rd-tc.nfteam.ovh:8444"),
};
private static string SettingsPath => Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
"ProspectSwitcher", "settings.json");
private static Settings _settings = new();
private static int Main(string[] args)
{
try { Console.OutputEncoding = Encoding.UTF8; } catch { /* some terminals reject it */ }
LoadSettings();
return args.Length > 0 ? RunCli(args) : MenuLoop();
}
// ---- Scriptable CLI ----------------------------------------------------
private static int RunCli(string[] args)
{
string? folder = null, target = null;
for (var i = 0; i < args.Length; i++)
{
switch (args[i])
{
case "--folder" when i + 1 < args.Length: folder = args[++i]; break;
case "--set" when i + 1 < args.Length: target = args[++i]; break;
case "-h" or "--help": PrintCliHelp(); return 0;
}
}
if (folder != null) { _settings.GameFolder = folder; SaveSettings(); }
if (target == null) { PrintCliHelp(); return folder != null ? 0 : 1; }
var url = ResolveTarget(target);
if (url == null) { Console.Error.WriteLine("Cible inconnue : " + target); return 1; }
return WriteBackend(url) ? 0 : 1;
}
private static void PrintCliHelp()
{
Console.WriteLine("ProspectServerSwitcher — sélecteur de serveur The Cycle (multi-OS)");
Console.WriteLine();
Console.WriteLine(" (sans argument) menu interactif");
Console.WriteLine(" --folder <chemin Win64> définit le dossier du jeu (mémorisé)");
Console.WriteLine(" --set <cible> écrit backend.txt");
Console.WriteLine();
Console.WriteLine("Cibles : " + string.Join(", ", Presets.Select(p => p.Key)) + ", ou une URL https://…");
Console.WriteLine("Ex : ProspectServerSwitcher --folder \"…/Prospect/Binaries/Win64\" --set preprod-lan");
}
private static string? ResolveTarget(string t)
{
foreach (var p in Presets)
if (string.Equals(p.Key, t, StringComparison.OrdinalIgnoreCase)) return p.Url;
if (t.StartsWith("http", StringComparison.OrdinalIgnoreCase)) return t;
return null;
}
// ---- Interactive menu --------------------------------------------------
private static int MenuLoop()
{
while (true)
{
Console.WriteLine();
Console.WriteLine("=== The Cycle — Sélecteur de serveur ===");
Console.WriteLine("Dossier du jeu : " + (string.IsNullOrEmpty(_settings.GameFolder) ? "(non défini)" : _settings.GameFolder));
Console.WriteLine("backend.txt : " + ReadCurrentBackend());
Console.WriteLine();
Console.WriteLine(" 1) Définir le dossier du jeu (Win64)");
for (var i = 0; i < Presets.Length; i++)
Console.WriteLine($" {i + 2}) {Presets[i].Label,-24} {Presets[i].Url}");
var n = Presets.Length + 2;
Console.WriteLine($" {n}) Serveur personnalisé…");
Console.WriteLine($" {n + 1}) Importer un certificat…");
Console.WriteLine($" {n + 2}) Lancer le jeu");
Console.WriteLine(" 0) Quitter");
Console.Write("> ");
var raw = Console.ReadLine()?.Trim();
if (raw is null or "0") return 0;
if (!int.TryParse(raw, out var choice)) { Console.WriteLine("Choix invalide."); continue; }
if (choice == 1) SetFolder();
else if (choice >= 2 && choice < n) WriteBackend(Presets[choice - 2].Url);
else if (choice == n) SetCustom();
else if (choice == n + 1) ImportCert();
else if (choice == n + 2) LaunchGame();
else Console.WriteLine("Choix invalide.");
}
}
private static void SetFolder()
{
Console.Write("Chemin du dossier Win64 : ");
var p = Console.ReadLine()?.Trim().Trim('"');
if (string.IsNullOrEmpty(p)) return;
if (!Directory.Exists(p)) { Console.WriteLine("⚠ Dossier introuvable."); return; }
_settings.GameFolder = p;
SaveSettings();
Console.WriteLine("✔ Dossier mémorisé.");
}
private static void SetCustom()
{
Console.Write("URL du serveur (ex : https://mon-serveur:8443) : ");
var u = Console.ReadLine()?.Trim();
if (!string.IsNullOrWhiteSpace(u)) WriteBackend(u);
}
// ---- backend.txt -------------------------------------------------------
private static bool WriteBackend(string url)
{
if (!EnsureFolder(out var dir)) return false;
try
{
// No trailing newline: the agent reads the raw file and requires it to start with https://.
File.WriteAllText(Path.Combine(dir, "backend.txt"), url, new UTF8Encoding(false));
Console.WriteLine($"✔ backend.txt → {url}");
Console.WriteLine(" (relance le jeu : backend.txt n'est lu qu'au démarrage du loader.)");
return true;
}
catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); return false; }
}
private static string ReadCurrentBackend()
{
if (string.IsNullOrEmpty(_settings.GameFolder)) return "(dossier non défini)";
var f = Path.Combine(_settings.GameFolder, "backend.txt");
try
{
if (!File.Exists(f)) return "(absent)";
var v = File.ReadAllText(f).Trim();
var match = Presets.FirstOrDefault(p => p.Url == v);
return match.Key != null ? $"{v} [{match.Label.Trim()}]" : v;
}
catch { return "(illisible)"; }
}
// ---- Certificate -------------------------------------------------------
private static void ImportCert()
{
// Prefer fetching the cert live from the server the client points at (backend.txt), so
// it's always the current one; fall back to a .crt file.
X509Certificate2? cert = null;
var target = ReadBackendUrl();
if (target != null && TryFetchServerCert(target, out cert))
Console.WriteLine($"Certificat récupéré depuis {target}");
else
{
Console.Write("Chemin d'un certificat (.crt) : ");
var p = Console.ReadLine()?.Trim().Trim('"');
if (string.IsNullOrEmpty(p) || !File.Exists(p)) { Console.WriteLine("⚠ Annulé."); return; }
try { cert = new X509Certificate2(p); } catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); return; }
}
if (cert == null) { Console.WriteLine("⚠ Aucun certificat."); return; }
Console.WriteLine($" empreinte : {cert.Thumbprint}");
if (OperatingSystem.IsWindows()) ImportCertWindows(cert);
else ImportCertProton(cert);
}
private static string? ReadBackendUrl()
{
if (string.IsNullOrEmpty(_settings.GameFolder)) return null;
var f = Path.Combine(_settings.GameFolder, "backend.txt");
try { return File.Exists(f) ? File.ReadAllText(f).Trim() : null; } catch { return null; }
}
private static bool TryFetchServerCert(string url, out X509Certificate2? cert)
{
cert = null;
try
{
var uri = new Uri(url);
using var tcp = new System.Net.Sockets.TcpClient();
tcp.Connect(uri.Host, uri.Port <= 0 ? 443 : uri.Port);
X509Certificate2? captured = null;
using var ssl = new System.Net.Security.SslStream(tcp.GetStream(), false,
(_, c, _, _) => { if (c != null) captured = new X509Certificate2(c.Export(X509ContentType.Cert)); return true; });
ssl.AuthenticateAsClient(uri.Host);
cert = captured;
return cert != null;
}
catch { return false; }
}
[System.Runtime.Versioning.SupportedOSPlatform("windows")]
private static void ImportCertWindows(X509Certificate2 cert)
{
try
{
using var store = new X509Store(StoreName.Root, StoreLocation.CurrentUser);
store.Open(OpenFlags.ReadWrite);
store.Add(cert);
Console.WriteLine("✔ Certificat importé (Autorités racines de confiance, utilisateur).");
}
catch (Exception ex) { Console.WriteLine("✖ Import : " + ex.Message); }
}
// Trust the cert inside the game's Proton/Wine prefix by writing the serialized cert blob
// into the prefix Root store via `wine reg import` (the wine `certutil` tool is unreliable
// under Proton). The prefix is chosen explicitly, so it works even though the client runs
// as a non-Steam shortcut (whose appid isn't the game's 868270).
private static void ImportCertProton(X509Certificate2 cert)
{
var wine = FindProtonWine();
if (wine == null) { Console.WriteLine("⚠ Aucun wine Proton trouvé (Steam/Proton installé ?)."); return; }
var prefix = SelectPrefix();
if (prefix == null) return;
// Wine serialized cert blob: SHA1-hash property (id 3) + encoded cert property (id 0x20).
var der = cert.RawData;
var sha1 = cert.GetCertHash();
byte[] blob;
using (var ms = new MemoryStream())
{
void Prop(uint id, byte[] data)
{
ms.Write(BitConverter.GetBytes(id));
ms.Write(BitConverter.GetBytes(1u));
ms.Write(BitConverter.GetBytes((uint)data.Length));
ms.Write(data);
}
Prop(3, sha1);
Prop(0x20, der);
blob = ms.ToArray();
}
var thumb = Convert.ToHexString(sha1);
var hex = string.Join(",", blob.Select(b => b.ToString("x2")));
var regFile = Path.Combine(Path.GetTempPath(), "prospect-cert.reg");
File.WriteAllText(regFile,
"REGEDIT4\r\n\r\n" +
$"[HKEY_LOCAL_MACHINE\\Software\\Microsoft\\SystemCertificates\\Root\\Certificates\\{thumb}]\r\n" +
$"\"Blob\"=hex:{hex}\r\n");
Console.WriteLine("⚠ Ferme complètement le jeu avant de continuer (sinon le registre ne sera pas pris en compte).");
Console.Write("Appuie sur Entrée pour importer… ");
Console.ReadLine();
var wineserver = Path.Combine(Path.GetDirectoryName(wine)!, "wineserver");
var regWinePath = "Z:" + regFile.Replace('/', '\\'); // Z: maps to / inside the prefix
RunWine(wineserver, "-k", prefix); // stop any running wineserver (flush)
RunWine(wine, $"reg import \"{regWinePath}\"", prefix);
RunWine(wineserver, "-k", prefix); // flush the change to disk
var sysreg = Path.Combine(prefix, "system.reg");
var ok = File.Exists(sysreg) && File.ReadAllText(sysreg).Contains(thumb, StringComparison.OrdinalIgnoreCase);
Console.WriteLine(ok
? $"✔ Certificat importé dans le préfixe : {prefix}\n Relance le jeu."
: "✖ Import non confirmé — assure-toi que le jeu est fermé et réessaie.");
}
private static void RunWine(string exe, string args, string prefix)
{
try
{
var psi = new ProcessStartInfo(exe, args)
{
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true,
};
psi.Environment["WINEPREFIX"] = prefix;
psi.Environment["WINEDEBUG"] = "-all";
using var p = Process.Start(psi);
p?.WaitForExit(20000);
}
catch (Exception ex) { Console.WriteLine(" (wine) " + ex.Message); }
}
private static string? FindProtonWine()
{
foreach (var root in SteamRoots())
{
var common = Path.Combine(root, "steamapps", "common");
if (!Directory.Exists(common)) continue;
var candidates = Directory.GetDirectories(common, "Proton*")
.Select(d => Path.Combine(d, "files", "bin", "wine"))
.Where(File.Exists)
.OrderByDescending(w => w.Contains("Experimental"))
.ThenByDescending(File.GetLastWriteTimeUtc)
.ToList();
if (candidates.Count > 0) return candidates[0];
}
return null;
}
private static string? SelectPrefix()
{
if (!string.IsNullOrEmpty(_settings.WinePrefix) && Directory.Exists(_settings.WinePrefix))
return _settings.WinePrefix;
var prefixes = new List<string>();
foreach (var root in SteamRoots())
{
var cd = Path.Combine(root, "steamapps", "compatdata");
if (!Directory.Exists(cd)) continue;
foreach (var d in Directory.GetDirectories(cd))
{
var pfx = Path.Combine(d, "pfx");
if (File.Exists(Path.Combine(pfx, "system.reg"))) prefixes.Add(Path.GetFullPath(pfx));
}
}
prefixes = prefixes.Distinct()
.OrderByDescending(p => File.GetLastWriteTimeUtc(Path.Combine(p, "system.reg")))
.ToList();
if (prefixes.Count == 0) { Console.WriteLine("⚠ Aucun préfixe Proton trouvé."); return null; }
Console.WriteLine("Préfixes Proton détectés (le plus récemment utilisé en premier = sûrement ton jeu) :");
for (var i = 0; i < prefixes.Count; i++)
Console.WriteLine($" {i + 1}) {prefixes[i]}");
Console.Write("Numéro du préfixe (Entrée = 1) : ");
var raw = Console.ReadLine()?.Trim();
var idx = string.IsNullOrEmpty(raw) ? 0 : (int.TryParse(raw, out var n) ? n - 1 : -1);
if (idx < 0 || idx >= prefixes.Count) { Console.WriteLine("⚠ Choix invalide."); return null; }
_settings.WinePrefix = prefixes[idx];
SaveSettings();
return _settings.WinePrefix;
}
private static IEnumerable<string> SteamRoots()
{
var home = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
yield return Path.Combine(home, ".steam", "steam");
yield return Path.Combine(home, ".local", "share", "Steam");
}
// ---- Launch ------------------------------------------------------------
private static void LaunchGame()
{
if (OperatingSystem.IsWindows())
{
if (!EnsureFolder(out var dir)) return;
var loader = Path.Combine(dir, "Prospect.Client.Loader.exe");
if (!File.Exists(loader)) { Console.WriteLine("⚠ Prospect.Client.Loader.exe introuvable dans le dossier."); return; }
try
{
Process.Start(new ProcessStartInfo(loader) { WorkingDirectory = dir, UseShellExecute = true });
Console.WriteLine("▶ Jeu lancé.");
}
catch (Exception ex) { Console.WriteLine("✖ " + ex.Message); }
return;
}
Console.WriteLine("Sous Linux/Proton, lance via Steam (le loader doit tourner dans le préfixe Proton) :");
Console.WriteLine(" steam steam://rungameid/" + AppId);
}
// ---- Helpers -----------------------------------------------------------
private static bool EnsureFolder(out string dir)
{
dir = _settings.GameFolder;
if (string.IsNullOrEmpty(dir) || !Directory.Exists(dir))
{
Console.WriteLine("⚠ Définis d'abord le dossier du jeu (option 1).");
return false;
}
return true;
}
private static void LoadSettings()
{
try
{
if (File.Exists(SettingsPath))
_settings = JsonSerializer.Deserialize<Settings>(File.ReadAllText(SettingsPath)) ?? new Settings();
}
catch { _settings = new Settings(); }
}
private static void SaveSettings()
{
try
{
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
File.WriteAllText(SettingsPath, JsonSerializer.Serialize(_settings));
}
catch { /* best-effort persistence */ }
}
private sealed class Settings
{
public string GameFolder { get; set; } = "";
public string WinePrefix { get; set; } = "";
}
}
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AssemblyName>ProspectServerSwitcher</AssemblyName>
<RootNamespace>Prospect.Client.Config</RootNamespace>
<InvariantGlobalization>true</InvariantGlobalization>
<!-- Cross-platform client tool (Linux/Proton + Windows). Never built by the Linux server CI. -->
<Configurations>Debug;Release;Season 3 Release;Season 2 Release;Season 2 Debug;Season 3 Debug</Configurations>
</PropertyGroup>
</Project>
+102
View File
@@ -0,0 +1,102 @@
# ProspectServerSwitcher
Petit utilitaire **multi-OS** (Linux/Proton + Windows) pour pointer un client
*The Cycle: Frontier* vers un serveur, importer le certificat, et lancer le jeu.
## Pourquoi
L'agent Prospect (`Prospect.Agent.dll`, injecté par `Prospect.Client.Loader.exe`)
hooke l'URL de l'API PlayFab et la remplace par le contenu de **`backend.txt`**,
placé à côté de l'exécutable du jeu (`Prospect/Binaries/Win64`). S'il est absent,
l'agent retombe sur `https://127.0.0.1:8443`.
**Tout l'aiguillage du client tient donc dans cette seule valeur.** Cet outil ne fait
essentiellement qu'écrire le bon `backend.txt` — plus quelques commodités (cert, launch).
## Serveurs (presets)
Les presets utilisent les **domaines publics** pour que la même cible marche pour
tout le monde (le DNS résout vers l'IP publique pour les amis, et le certificat TLS
unifié couvre les deux hostnames) :
| Preset | URL |
|-----------|----------------------------------|
| `prod` | `https://tc.nfteam.ovh:8443` |
| `preprod` | `https://rd-tc.nfteam.ovh:8444` |
> **Sur le même LAN que le serveur**, ajoute une entrée hosts pour éviter le hairpin NAT :
> ```
> 192.168.1.136 tc.nfteam.ovh
> 192.168.1.136 rd-tc.nfteam.ovh
> ```
## Utilisation
### Menu interactif
Lance le binaire sans argument :
- **1** : définir le dossier du jeu (`…/Prospect/Binaries/Win64`) — mémorisé
- **2/3** : basculer sur PROD / PREPROD
- **4** : URL personnalisée
- **5** : importer un certificat (`.crt`)
- **6** : lancer le jeu
- **0** : quitter
### En ligne de commande (scriptable)
```
ProspectServerSwitcher --folder "<...>/Prospect/Binaries/Win64" --set preprod
ProspectServerSwitcher --set prod
ProspectServerSwitcher --set https://mon-serveur:8443 # URL libre
```
> ⚠️ `backend.txt` n'est lu qu'**au démarrage** du loader → relance le jeu après un switch.
> Le serveur ciblé doit **tourner** (conteneurs on-demand `the-cycle-api` / `the-cycle-api-rd`).
## Certificat
L'outil récupère le certificat **en direct depuis le serveur** ciblé (il lit `backend.txt`
et se connecte en TLS), puis le rend fiable :
- **Windows** : import automatique dans *Autorités de certification racines de confiance*
(utilisateur courant), via le magasin `X509Store`.
- **Linux/Proton** : import **automatique dans le préfixe Wine** du jeu. Comme `wine certutil`
est cassé sous Proton, l'outil écrit directement le *blob* sérialisé du certificat
(propriété SHA1 `id=3` + certificat encodé `id=0x20`) dans le magasin `Root` du préfixe via
`wine reg import`. Il :
1. détecte le binaire `wine` de Proton (`…/common/Proton*/files/bin/wine`),
2. liste les préfixes `compatdata/*/pfx` (le plus récemment utilisé en premier),
3. importe dans le préfixe choisi (⚠️ **le jeu doit être fermé**), mémorisé ensuite.
> ⚠️ Le client tourne souvent comme **raccourci non-Steam** → son préfixe n'est **pas**
> `compatdata/868270` mais un appid généré (ex. `3883998305`). D'où la sélection explicite
> du préfixe plutôt qu'une déduction sur l'appid du jeu.
## Lancement
- **Windows** : démarre `Prospect.Client.Loader.exe` depuis le dossier du jeu.
- **Linux/Proton** : passe par Steam (`steam steam://rungameid/868270`) — le loader doit
tourner dans le préfixe Proton.
## Réglages
Le dossier du jeu est mémorisé dans
`…/ApplicationData/ProspectSwitcher/settings.json`
(`%AppData%` sous Windows, `~/.config` sous Linux).
## Build / publication
Projet console **net8.0** (cross-platform, `InvariantGlobalization`), jamais buildé par
la CI serveur (Linux).
```bash
# build simple
dotnet build src/Prospect.Client.Config -c Release
# binaires autonomes single-file (aucun .NET requis chez l'utilisateur)
dotnet publish src/Prospect.Client.Config -c Release -r win-x64 --self-contained \
-p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -p:EnableCompressionInSingleFile=true
dotnet publish src/Prospect.Client.Config -c Release -r linux-x64 --self-contained \
-p:PublishSingleFile=true -p:EnableCompressionInSingleFile=true
```
Les binaires publiés sont distribués via la release Gitea **`friends-client`**
(`ProspectServerSwitcher.exe` pour Windows, `ProspectServerSwitcher-linux-x64` pour Linux).
@@ -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,19 +155,131 @@ 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,
}
}); });
} }
[HttpPost("UpdateUserTitleDisplayName")] [HttpPost("UpdateUserTitleDisplayName")]
[Produces(MediaTypeNames.Application.Json)] [Produces(MediaTypeNames.Application.Json)]
[Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)] [Authorize(AuthenticationSchemes = UserAuthenticationOptions.DefaultScheme)]
public IActionResult UpdateUserTitleDisplayName(FUpdateUserTitleDisplayNameRequest request) public async Task<IActionResult> UpdateUserTitleDisplayName(FUpdateUserTitleDisplayNameRequest request)
{ {
// Persist the rename, otherwise it is echoed back but lost on next login.
var userId = User.FindAuthUserId();
await _userService.UpdateDisplayNameAsync(userId, request.DisplayName);
return Ok(new ClientResponse<FUpdateUserTitleDisplayNameResult> return Ok(new ClientResponse<FUpdateUserTitleDisplayNameResult>
{ {
Code = 200, Code = 200,
@@ -244,13 +356,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 +390,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 +417,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);
}
}
+22 -3
View File
@@ -1,12 +1,31 @@
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
namespace Prospect.Server.Api.Hubs; namespace Prospect.Server.Api.Hubs;
public class CycleHub : Hub public class CycleHub : Hub
{ {
private readonly SignalRConnectionRegistry _registry;
public CycleHub(SignalRConnectionRegistry registry)
{
_registry = registry;
}
public override async Task OnConnectedAsync() public override async Task OnConnectedAsync()
{ {
Console.WriteLine("Connected {0}", Context.ConnectionId); // The client connects with the SignalR URL returned by GetSignalRConnection, which
// carries a `uid` query param — use it to map this connection to the player so
// server-initiated messages can target a single player instead of everyone.
var uid = Context.GetHttpContext()?.Request.Query["uid"].ToString();
if (!string.IsNullOrEmpty(uid))
_registry.Add(uid, Context.ConnectionId);
Console.WriteLine("Connected {0} (uid={1})", Context.ConnectionId, uid);
await base.OnConnectedAsync(); await base.OnConnectedAsync();
} }
}
public override async Task OnDisconnectedAsync(Exception? exception)
{
_registry.Remove(Context.ConnectionId);
await base.OnDisconnectedAsync(exception);
}
}
@@ -0,0 +1,29 @@
using System.Collections.Concurrent;
namespace Prospect.Server.Api.Hubs;
// Maps players to their live SignalR connection so server-initiated messages (e.g. the
// matchmaking-success "travel to match" signal) can target a single player instead of being
// broadcast to everyone. The client's SignalR URL carries a `uid` query param (added by
// GetSignalRConnection); CycleHub records it on connect.
public class SignalRConnectionRegistry
{
private readonly ConcurrentDictionary<string, string> _userToConn = new();
private readonly ConcurrentDictionary<string, string> _connToUser = new();
public void Add(string userId, string connectionId)
{
if (string.IsNullOrEmpty(userId)) return;
_userToConn[userId] = connectionId;
_connToUser[connectionId] = userId;
}
public void Remove(string connectionId)
{
if (_connToUser.TryRemove(connectionId, out var userId))
_userToConn.TryRemove(userId, out _);
}
public string? GetConnection(string userId)
=> _userToConn.TryGetValue(userId, out var c) ? c : null;
}
@@ -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 {
@@ -72,6 +72,21 @@ public enum EYContractObjectiveType {
MAX = 8 MAX = 8
} }
public static class ContractObjectiveTypeExtensions {
// Objectives that can only be observed while a raid is running (kills, dead-drops,
// visiting an area, looting a container). The raid is hosted by the player's own
// client and there is no dedicated game server, so their progress is never reported
// to the backend (UpdatePlayerActiveContracts is never called). They are therefore
// auto-credited server-side, otherwise these contracts can never be completed.
// OwnNumOfItem is excluded (validated against the real stash) and so are the meta
// objectives FactionLevel / CompletedMission (validated from persisted player data).
public static bool IsRaidRuntime(this EYContractObjectiveType type) =>
type is EYContractObjectiveType.Kills
or EYContractObjectiveType.DeadDrop
or EYContractObjectiveType.VisitArea
or EYContractObjectiveType.LootContainer;
}
public enum EYContractDifficulty public enum EYContractDifficulty
{ {
Invalid = 0, Invalid = 0,
@@ -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)
@@ -81,9 +81,16 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
} }
var factionKey = "FactionProgression" + contract.Faction; var factionKey = "FactionProgression" + contract.Faction;
// Fortuna pass season XP is stored per season; the client derives the pass level
// from this key. No challenge system is emulated, so contracts are the XP source.
#if SEASON_3_RELEASE || SEASON_3_DEBUG
const string seasonXpKey = "FortunaPass3_SeasonXp";
#else
const string seasonXpKey = "FortunaPass2_SeasonXp";
#endif
var userData = await _userDataService.FindAsync( var userData = await _userDataService.FindAsync(
userId, userId, userId, userId,
new List<string>{"ContractsActive", "ContractsOneTimeCompleted", "Balance", "Inventory", factionKey, "JobBoardsData" } new List<string>{"ContractsActive", "ContractsOneTimeCompleted", "Balance", "Inventory", factionKey, "JobBoardsData", seasonXpKey }
); );
var factionProgression = JsonSerializer.Deserialize<int>(userData[factionKey].Value); var factionProgression = JsonSerializer.Deserialize<int>(userData[factionKey].Value);
@@ -131,10 +138,17 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
break; break;
} }
} }
} else if (objective.Type.IsRaidRuntime()) {
// Raid-runtime objectives (kills, dead-drops, visited areas, looted
// containers) can't be tracked in a client-hosted raid — no dedicated
// game server reports them, so their progress is never saved. They are
// auto-credited on deploy (EnterMatchmakingMatch); accept them here too
// (safety net for a contract activated after deploy) so these contracts
// remain completable instead of being stuck on "Objective not met".
remaining = 0;
} else { } else {
// NOTE: A game server must write the correct progress // Meta objectives (FactionLevel / CompletedMission): validated from the
// for other types of objectives. // persisted progress. The objectives are mapped by array index.
// The objectives are mapped by array index.
remaining -= targetContract.Progress[i]; remaining -= targetContract.Progress[i];
} }
if (remaining > 0) { if (remaining > 0) {
@@ -219,19 +233,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.
@@ -262,6 +277,12 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
newContracts.Add(newContract); newContracts.Add(newContract);
} }
// Grant Fortuna pass season XP for completing this contract and persist it, so the
// pass level (derived client-side from FortunaPass{season}_SeasonXp) actually moves.
// No exact rate ships in the data, so scale it to the contract's reputation value.
var seasonXp = int.TryParse(userData[seasonXpKey].Value, out var currentSeasonXp) ? currentSeasonXp : 0;
seasonXp += contract.ReputationIncrease * 10;
await _userDataService.UpdateAsync( await _userDataService.UpdateAsync(
userId, userId, userId, userId,
new Dictionary<string, string>{ new Dictionary<string, string>{
@@ -271,6 +292,7 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
["Inventory"] = JsonSerializer.Serialize(newInventory), ["Inventory"] = JsonSerializer.Serialize(newInventory),
[factionKey] = JsonSerializer.Serialize(factionProgression), [factionKey] = JsonSerializer.Serialize(factionProgression),
["JobBoardsData"] = JsonSerializer.Serialize(jobBoardsData), ["JobBoardsData"] = JsonSerializer.Serialize(jobBoardsData),
[seasonXpKey] = JsonSerializer.Serialize(seasonXp),
} }
); );
@@ -289,7 +311,7 @@ public class ClaimActiveContract : ICloudScriptFunction<FYClaimCompletedActiveCo
CurrentProgression = factionProgression, CurrentProgression = factionProgression,
}, },
Status = 18, // EYClaimContractRewardsStatus::OK Status = 18, // EYClaimContractRewardsStatus::OK
UpdatedSeasonXp = 0 // TODO: Probably a separate season XP rate configured by server? UpdatedSeasonXp = seasonXp // new season XP total (client derives the pass level from it)
}; };
} }
} }
@@ -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);
} }
} }
@@ -25,6 +25,24 @@ public class EnterMatchmakingFunction : ICloudScriptFunction<FYEnterMatchAzureFu
// SquadID = request.SquadId, // SquadID = request.SquadId,
//}); //});
// Dedicated-server mode (2nd preprod): if GAMESERVER_ADDRESS ("host:port") is set, hand
// the client the real game server to travel to instead of the client-hosted station.
// Same image as preprod — only this env var flips the deployment type.
var gameServer = Environment.GetEnvironmentVariable("GAMESERVER_ADDRESS");
if (!string.IsNullOrEmpty(gameServer))
{
var parts = gameServer.Split(':');
return new FYEnterMatchAzureFunctionResult
{
Success = true,
ErrorMessage = "",
SingleplayerStation = false,
Address = parts[0],
Port = parts.Length > 1 && int.TryParse(parts[1], out var gsPort) ? gsPort : 7777,
MaintenanceMode = false,
};
}
return new FYEnterMatchAzureFunctionResult return new FYEnterMatchAzureFunctionResult
{ {
Success = true, Success = true,
@@ -24,13 +24,15 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
private readonly IHubContext<CycleHub> _hubContext; private readonly IHubContext<CycleHub> _hubContext;
private readonly UserDataService _userDataService; private readonly UserDataService _userDataService;
private readonly TitleDataService _titleDataService; private readonly TitleDataService _titleDataService;
private readonly SignalRConnectionRegistry _registry;
public EnterMatchmakingMatchFunction(IHubContext<CycleHub> hubContext, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService) public EnterMatchmakingMatchFunction(IHubContext<CycleHub> hubContext, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, TitleDataService titleDataService, SignalRConnectionRegistry registry)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_hubContext = hubContext; _hubContext = hubContext;
_userDataService = userDataService; _userDataService = userDataService;
_titleDataService = titleDataService; _titleDataService = titleDataService;
_registry = registry;
} }
public async Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction request) public async Task<FYEnterMatchmakingResult> ExecuteAsync(FYEnterMatchAzureFunction request)
@@ -60,7 +62,20 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
continue; continue;
} }
for (var i = 0; i < contract.Objectives.Length; i++) { for (var i = 0; i < contract.Objectives.Length; i++) {
if (i >= contractActive.Progress.Length) {
// Defensive: player progress array shorter than the objective list.
break;
}
var objective = contract.Objectives[i]; var objective = contract.Objectives[i];
// Raid-runtime objectives (kills, dead-drops, visited areas, looted
// containers) are simulated inside the client-hosted raid; with no
// dedicated game server their progress is never reported to the backend,
// so they would never be saved. Auto-credit them on deploy so the
// objective persists and the station shows the contract as completable.
if (objective.Type.IsRaidRuntime()) {
contractActive.Progress[i] = objective.MaxProgress;
continue;
}
if (objective.Type != EYContractObjectiveType.OwnNumOfItem) { if (objective.Type != EYContractObjectiveType.OwnNumOfItem) {
continue; continue;
} }
@@ -86,11 +101,23 @@ public class EnterMatchmakingMatchFunction : ICloudScriptFunction<FYEnterMatchAz
} }
); );
await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", new OnSquadMatchmakingSuccessMessage { // Send the "matchmaking succeeded / travel to match" signal only to the deploying
// player (via their mapped SignalR connection) — broadcasting to Clients.All made
// every connected player travel too. Fall back to broadcast only if the connection
// isn't mapped yet.
// Dedicated-server mode (2nd preprod): point the client at the real game server address
// to travel to; otherwise keep the map name (client-hosted). Same image — env flips it.
var gameServer = Environment.GetEnvironmentVariable("GAMESERVER_ADDRESS");
var deployMessage = new OnSquadMatchmakingSuccessMessage {
Success = true, Success = true,
SessionID = request.MapName, // TODO: Need to implement TryGetCompleteSquadInfo and pass squad info SessionID = string.IsNullOrEmpty(gameServer) ? request.MapName : gameServer,
SquadID = request.SquadId, SquadID = request.SquadId,
}); };
var connectionId = _registry.GetConnection(userId);
if (connectionId != null)
await _hubContext.Clients.Client(connectionId).SendAsync("OnSquadMatchmakingSuccess", deployMessage);
else
await _hubContext.Clients.All.SendAsync("OnSquadMatchmakingSuccess", deployMessage);
return new FYEnterMatchmakingResult return new FYEnterMatchmakingResult
{ {
@@ -1,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,6 +1,8 @@
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.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;
@@ -8,13 +10,15 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
public class GetCraftingInProgressData : ICloudScriptFunction<FYGetCraftingInProgressDataRequest, FYGetCraftingInProgressDataResult> public class GetCraftingInProgressData : ICloudScriptFunction<FYGetCraftingInProgressDataRequest, FYGetCraftingInProgressDataResult>
{ {
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public GetCraftingInProgressData(IHttpContextAccessor httpContextAccessor) public GetCraftingInProgressData(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
} }
public Task<FYGetCraftingInProgressDataResult> ExecuteAsync(FYGetCraftingInProgressDataRequest request) public async Task<FYGetCraftingInProgressDataResult> ExecuteAsync(FYGetCraftingInProgressDataRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
@@ -22,11 +26,20 @@ public class GetCraftingInProgressData : ICloudScriptFunction<FYGetCraftingInPro
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
return Task.FromResult(new FYGetCraftingInProgressDataResult var userId = context.User.FindAuthUserId();
// Return the persisted in-progress craft (written by StartItemCraftingClient)
// instead of an empty stub, so the crafting timer survives a menu reopen / relog.
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "CraftingTimer__2022_05_12" });
// Fully qualified: a different FYItemCurrentlyBeingCrafted also exists in this namespace.
var craft = JsonSerializer.Deserialize<Models.Data.FYItemCurrentlyBeingCrafted>(userData["CraftingTimer__2022_05_12"].Value)
?? new Models.Data.FYItemCurrentlyBeingCrafted();
return new FYGetCraftingInProgressDataResult
{ {
UserId = context.User.FindAuthUserId(), UserId = userId,
Error = string.Empty, Error = string.Empty,
ItemCurrentlyBeingCrafted = {}, ItemCurrentlyBeingCrafted = craft,
}); };
} }
} }
@@ -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;
private readonly UserDataService _userDataService;
private readonly DbUserService _userService;
public GetFriendList(ILogger<GetFriendList> logger, IHttpContextAccessor httpContextAccessor, UserDataService userDataService, DbUserService userService)
{ {
return Task.FromResult<object?>(new _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 };
}
}
@@ -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,43 +9,53 @@ namespace Prospect.Server.Api.Services.CloudScript.Functions;
public class GetPlayerSets : ICloudScriptFunction<FYGetPlayersSets, object?> public class GetPlayerSets : ICloudScriptFunction<FYGetPlayersSets, object?>
{ {
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly UserDataService _userDataService;
public GetPlayerSets(IHttpContextAccessor httpContextAccessor) public GetPlayerSets(IHttpContextAccessor httpContextAccessor, UserDataService userDataService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_userDataService = userDataService;
} }
public Task<object?> ExecuteAsync(FYGetPlayersSets request) public async Task<object?> ExecuteAsync(FYGetPlayersSets request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
{ {
return Task.FromResult<object?>(null); return null;
} }
return Task.FromResult<object?>(new var userId = context.User.FindAuthUserId();
// Return the player's persisted loadout (written by CompleteInventoryUpdate /
// RequestUpdateStationInventory) instead of a blank set, so saved gear survives
// a menu reopen / relog. Same response shape as before, just populated.
var userData = await _userDataService.FindAsync(userId, userId, new List<string> { "LOADOUT" });
var loadout = JsonSerializer.Deserialize<LoadoutData>(userData["LOADOUT"].Value) ?? new LoadoutData();
return new
{ {
success = true, success = true,
entries = new [] entries = new[]
{ {
new new
{ {
setData = new setData = new
{ {
id = "", id = "",
userId = context.User.FindAuthUserId(), userId,
kit = "", kit = "",
shield = "", shield = loadout.Shield ?? "",
helmet = "", helmet = loadout.Helmet ?? "",
weaponOne = "", weaponOne = loadout.WeaponOne ?? "",
weaponTwo = "", weaponTwo = loadout.WeaponTwo ?? "",
bag = "", bag = loadout.Bag ?? "",
bagItemsAsJsonStr = "", bagItemsAsJsonStr = loadout.BagItemsAsJsonStr ?? "",
safeItemsAsJsonStr = "" safeItemsAsJsonStr = loadout.SafeItemsAsJsonStr ?? ""
}, },
items = Array.Empty<object>() items = Array.Empty<object>()
} }
} }
}); };
} }
} }
@@ -1,5 +1,8 @@
using Microsoft.Extensions.Options; using System.Net;
using System.Net.Sockets;
using Microsoft.Extensions.Options;
using Prospect.Server.Api.Config; using Prospect.Server.Api.Config;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript.Models; using Prospect.Server.Api.Services.CloudScript.Models;
namespace Prospect.Server.Api.Services.CloudScript.Functions; namespace Prospect.Server.Api.Services.CloudScript.Functions;
@@ -7,20 +10,63 @@ 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; // LAN address of the homelab host. Clients on the local network get the SignalR URL on
// this IP (which their cert already validates via the IP/DNS SAN), while external clients
// keep the public domain from configuration. This matters because the real-time WebSocket
// (libwebsockets, incl. under Proton/Wine) can't reach the public domain from inside the
// LAN (hairpin NAT), which surfaced as "error code 5" right after the intro.
private const string LanHost = "192.168.1.136";
public GetSignalRConnection(IOptions<SignalRSettings> settings) private readonly PlayFabSettings _settings;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly ILogger<GetSignalRConnection> _logger;
public GetSignalRConnection(IOptions<PlayFabSettings> settings, IHttpContextAccessor httpContextAccessor, ILogger<GetSignalRConnection> logger)
{ {
_settings = settings.Value; _settings = settings.Value;
_httpContextAccessor = httpContextAccessor;
_logger = logger;
} }
public Task<FYGetSignalRConnectionResult> ExecuteAsync(FYGetSignalRConnection request) public Task<FYGetSignalRConnectionResult> ExecuteAsync(FYGetSignalRConnection request)
{ {
// The game client connects to SignalR only over HTTPS // The game client connects to SignalR only over HTTPS.
var url = _settings.SignalRURL;
var remote = _httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
var lan = remote != null && IsLan(remote);
if (lan)
{
try
{
url = new UriBuilder(url) { Host = LanHost }.Uri.AbsoluteUri;
}
catch { /* keep the configured URL on any parsing issue */ }
}
// Tag the connection with the player id so the hub can map connection -> user and
// target server messages (deploy signal) instead of broadcasting to everyone.
var userId = _httpContextAccessor.HttpContext?.User.FindAuthUserId();
if (!string.IsNullOrEmpty(userId))
url += (url.Contains('?') ? "&" : "?") + "uid=" + Uri.EscapeDataString(userId);
_logger.LogInformation("GetSignalRConnection: remoteIp={Remote} lan={Lan} uid={Uid} -> {Url}", remote, lan, userId, url);
return Task.FromResult(new FYGetSignalRConnectionResult return Task.FromResult(new FYGetSignalRConnectionResult
{ {
Url = _settings.SignalRURL, Url = url,
AccessToken = _settings.SignalRAccessToken AccessToken = _settings.SignalRAccessToken
}); });
} }
}
private static bool IsLan(IPAddress ip)
{
if (IPAddress.IsLoopback(ip)) return true;
if (ip.IsIPv4MappedToIPv6) ip = ip.MapToIPv4();
if (ip.AddressFamily != AddressFamily.InterNetwork) return false;
var b = ip.GetAddressBytes();
return b[0] == 10
|| (b[0] == 172 && b[1] >= 16 && b[1] <= 31)
|| (b[0] == 192 && b[1] == 168);
}
}
@@ -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,23 @@ 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. The data uses the sentinel "None" (not an empty string) for
// items with no lock — treat that (and null/empty) as "no lock", otherwise EVERY
// item gets gated and nothing can be bought.
var contractLockPurchase = blueprintData.UnlockData?.ContractLockPurchase;
if (!string.IsNullOrEmpty(contractLockPurchase) && contractLockPurchase != "None")
{
var completed = JsonSerializer.Deserialize<FYGetCompletedContractsResult>(userData["ContractsOneTimeCompleted"].Value);
if (completed?.ContractsIDs == null || !completed.ContractsIDs.Contains(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; }
} }
@@ -1,6 +1,9 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Auth.Extensions; using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Database.Models;
using Prospect.Server.Api.Services.Squad;
using Microsoft.AspNetCore.SignalR; using Microsoft.AspNetCore.SignalR;
using Prospect.Server.Api.Hubs; using Prospect.Server.Api.Hubs;
@@ -28,12 +31,16 @@ public class SquadMemberReadyForMatchFunction : ICloudScriptFunction<SquadMember
private readonly ILogger<SquadMemberReadyForMatchFunction> _logger; private readonly ILogger<SquadMemberReadyForMatchFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly IHubContext<CycleHub> _hubContext; private readonly IHubContext<CycleHub> _hubContext;
private readonly SquadService _squadService;
private readonly DbUserService _userService;
public SquadMemberReadyForMatchFunction(ILogger<SquadMemberReadyForMatchFunction> logger, IHttpContextAccessor httpContextAccessor, IHubContext<CycleHub> hubContext) public SquadMemberReadyForMatchFunction(ILogger<SquadMemberReadyForMatchFunction> logger, IHttpContextAccessor httpContextAccessor, IHubContext<CycleHub> hubContext, SquadService squadService, DbUserService userService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_logger = logger; _logger = logger;
_hubContext = hubContext; _hubContext = hubContext;
_squadService = squadService;
_userService = userService;
} }
public async Task<SquadMemberReadyForMatchResponse> ExecuteAsync(SquadMemberReadyForMatchRequest request) public async Task<SquadMemberReadyForMatchResponse> ExecuteAsync(SquadMemberReadyForMatchRequest request)
@@ -44,27 +51,25 @@ public class SquadMemberReadyForMatchFunction : ICloudScriptFunction<SquadMember
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
var userId = context.User.FindAuthUserId(); var userId = context.User.FindAuthUserId();
var squadId = string.IsNullOrEmpty(request.SquadID) ? userId : request.SquadID;
// Register the caller into the squad (party is formed client-side; each member's client
// calls this with the shared squadId) and record their readiness / selected map.
var displayName = (await _userService.FindByIdAsync(userId))?.DisplayName ?? "";
_squadService.JoinOrCreate(squadId, userId, displayName);
var ready = request.matchmakingSettings?.isReadyForMatch ?? false;
_squadService.SetReady(squadId, userId, ready, request.matchmakingSettings?.selectedMapName);
var squad = _squadService.Get(squadId)!;
var isSquadReady = _squadService.IsSquadReady(squadId);
_logger.LogInformation("SquadMemberReadyForMatch: {User} ready={Ready} in squad {Squad} ({Count} member(s), squadReady={SquadReady})",
userId, ready, squadId, squad.Members.Count, isSquadReady);
return new SquadMemberReadyForMatchResponse return new SquadMemberReadyForMatchResponse
{ {
Result = 0, // EYSquadActionResult::OK Result = 0, // EYSquadActionResult::OK
Squad = new FYPlayFabSquad { Squad = _squadService.ToClientSquad(squad),
SquadID = "100", // TODO IsSquadReadyForMatch = isSquadReady,
// Members = [
// new FYPlayFabSquadMember {
// Profile = new FYPlayFabPlayerProfile {
// PlayerId = userId,
// },
// onlineState = 0, // EYUserState::IN_STATION
// matchmakingSettings = new FYUserMatchmakingSettings {
// isReadyForMatch = true,
// isSecretLeader = true,
// selectedMapName = "Map01",
// }
// }
// ],
},
IsSquadReadyForMatch = true,
}; };
} }
} }
@@ -1,5 +1,8 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Squad;
public class SquadMemberSelectedMapRequest public class SquadMemberSelectedMapRequest
{ {
[JsonPropertyName("squadId")] [JsonPropertyName("squadId")]
@@ -17,15 +20,26 @@ public class SquadMemberSelectedMapResponse
public class SquadMemberSelectedMapFunction : ICloudScriptFunction<SquadMemberSelectedMapRequest, SquadMemberSelectedMapResponse> public class SquadMemberSelectedMapFunction : ICloudScriptFunction<SquadMemberSelectedMapRequest, SquadMemberSelectedMapResponse>
{ {
private readonly ILogger<SquadMemberSelectedMapFunction> _logger; private readonly ILogger<SquadMemberSelectedMapFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor;
private readonly SquadService _squadService;
public SquadMemberSelectedMapFunction(ILogger<SquadMemberSelectedMapFunction> logger) public SquadMemberSelectedMapFunction(ILogger<SquadMemberSelectedMapFunction> logger, IHttpContextAccessor httpContextAccessor, SquadService squadService)
{ {
_logger = logger; _logger = logger;
_httpContextAccessor = httpContextAccessor;
_squadService = squadService;
} }
public async Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request) public Task<SquadMemberSelectedMapResponse> ExecuteAsync(SquadMemberSelectedMapRequest request)
{ {
return new SquadMemberSelectedMapResponse var context = _httpContextAccessor.HttpContext;
{}; if (context != null && !string.IsNullOrEmpty(request.SquadID))
{
var userId = context.User.FindAuthUserId();
_squadService.JoinOrCreate(request.SquadID, userId, "");
_squadService.SetMap(request.SquadID, userId, request.SelectedMapName ?? "");
_logger.LogInformation("SquadMemberSelectedMap: {User} -> {Map} (squad {Squad})", userId, request.SelectedMapName, request.SquadID);
}
return Task.FromResult(new SquadMemberSelectedMapResponse { });
} }
} }
@@ -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
@@ -1,5 +1,8 @@
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using Prospect.Server.Api.Services.Auth.Extensions;
using Prospect.Server.Api.Services.CloudScript; using Prospect.Server.Api.Services.CloudScript;
using Prospect.Server.Api.Services.Database;
using Prospect.Server.Api.Services.Squad;
public class TryGetCompleteSquadInfoRequest public class TryGetCompleteSquadInfoRequest
{ {
@@ -51,26 +54,30 @@ public class TryGetCompleteSquadInfoFunction : ICloudScriptFunction<TryGetComple
{ {
private readonly ILogger<TryGetCompleteSquadInfoFunction> _logger; private readonly ILogger<TryGetCompleteSquadInfoFunction> _logger;
private readonly IHttpContextAccessor _httpContextAccessor; private readonly IHttpContextAccessor _httpContextAccessor;
private readonly SquadService _squadService;
public TryGetCompleteSquadInfoFunction(ILogger<TryGetCompleteSquadInfoFunction> logger, IHttpContextAccessor httpContextAccessor) public TryGetCompleteSquadInfoFunction(ILogger<TryGetCompleteSquadInfoFunction> logger, IHttpContextAccessor httpContextAccessor, SquadService squadService)
{ {
_httpContextAccessor = httpContextAccessor; _httpContextAccessor = httpContextAccessor;
_logger = logger; _logger = logger;
_squadService = squadService;
} }
public async Task<FYPlayFabSquad> ExecuteAsync(TryGetCompleteSquadInfoRequest request) public Task<FYPlayFabSquad> ExecuteAsync(TryGetCompleteSquadInfoRequest request)
{ {
var context = _httpContextAccessor.HttpContext; var context = _httpContextAccessor.HttpContext;
if (context == null) if (context == null)
{ {
throw new CloudScriptException("CloudScript was not called within a http request"); throw new CloudScriptException("CloudScript was not called within a http request");
} }
// var userId = context.User.FindAuthUserId(); var userId = context.User.FindAuthUserId();
var squad = _squadService.GetByUser(userId);
return new FYPlayFabSquad if (squad == null)
{ {
// SquadID = "100", // Not in a squad yet — return an empty squad (same as the previous stub).
// Members = [], return Task.FromResult(new FYPlayFabSquad());
}; }
_logger.LogInformation("TryGetCompleteSquadInfo: user {User} in squad {Squad} ({Count} member(s))", userId, squad.SquadId, squad.Members.Count);
return Task.FromResult(_squadService.ToClientSquad(squad));
} }
} }
@@ -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; }
}
@@ -13,11 +13,25 @@ public class DbUserService : BaseDbService<PlayFabUser>
public async Task<PlayFabUser> FindAsync(PlayFabUserAuthType type, string key) public async Task<PlayFabUser> FindAsync(PlayFabUserAuthType type, string key)
{ {
return await Collection.Find(user => user.Auth.Any(auth => return await Collection.Find(user => user.Auth.Any(auth =>
auth.Type == type && auth.Type == type &&
auth.Key == key)).FirstOrDefaultAsync(); auth.Key == key)).FirstOrDefaultAsync();
} }
// Resolve a player by its PlayFab id (used to display squad member names).
public async Task<PlayFabUser> FindByIdAsync(string id)
{
return await Collection.Find(user => user.Id == id).FirstOrDefaultAsync();
}
// Persist a display-name change (UpdateUserTitleDisplayName). Without this the rename
// is echoed back to the client but lost on next login.
public async Task UpdateDisplayNameAsync(string id, string displayName)
{
var update = Builders<PlayFabUser>.Update.Set(user => user.DisplayName, displayName);
await Collection.UpdateOneAsync(user => user.Id == id, update);
}
private async Task<PlayFabUser> CreateAsync(PlayFabUserAuthType type, string key) private async Task<PlayFabUser> CreateAsync(PlayFabUserAuthType type, string key)
{ {
var user = new PlayFabUser var user = new PlayFabUser
@@ -40,7 +54,21 @@ public class DbUserService : BaseDbService<PlayFabUser>
public async Task<PlayFabUser> FindOrCreateAsync(PlayFabUserAuthType type, string key) public async Task<PlayFabUser> FindOrCreateAsync(PlayFabUserAuthType type, string key)
{ {
return await FindAsync(type, key) ?? return await FindAsync(type, key) ??
await CreateAsync(type, key); await CreateAsync(type, key);
} }
// Resolve several auth keys (e.g. a batch of Steam IDs) to the players that exist on
// this server in a single query. Used by the friends list to keep only the imported
// Steam friends who have actually played here.
public async Task<List<PlayFabUser>> FindManyAsync(PlayFabUserAuthType type, IReadOnlyCollection<string> keys)
{
if (keys == null || keys.Count == 0)
{
return new List<PlayFabUser>();
}
var wanted = new HashSet<string>(keys);
return await Collection.Find(user => user.Auth.Any(auth =>
auth.Type == type && wanted.Contains(auth.Key))).ToListAsync();
}
} }
@@ -0,0 +1,99 @@
using System.Collections.Concurrent;
namespace Prospect.Server.Api.Services.Squad;
// In-memory squad/lobby state. Squads are keyed by the squadId the client provides on the
// SquadMember* calls (the client forms the party and shares a squadId; each member's client
// then calls the backend with it). A member is (lazily) registered into that squad on any
// squad call, and the current squad for a user is tracked so TryGetCompleteSquadInfo — which
// carries no squadId — can still return it. State is process-local (fine for a small server).
public class SquadMemberInfo
{
public string UserId { get; set; } = "";
public string DisplayName { get; set; } = "";
public int OnlineState { get; set; }
public bool IsReady { get; set; }
public string SelectedMap { get; set; } = "";
public bool IsLeader { get; set; }
}
public class SquadInfo
{
public string SquadId { get; set; } = "";
public ConcurrentDictionary<string, SquadMemberInfo> Members { get; } = new();
}
public class SquadService
{
private readonly ConcurrentDictionary<string, SquadInfo> _squads = new();
private readonly ConcurrentDictionary<string, string> _userToSquad = new();
public SquadInfo JoinOrCreate(string squadId, string userId, string displayName)
{
var squad = _squads.GetOrAdd(squadId, id => new SquadInfo { SquadId = id });
var isFirst = squad.Members.IsEmpty;
var member = squad.Members.GetOrAdd(userId, uid => new SquadMemberInfo { UserId = uid, IsLeader = isFirst });
if (!string.IsNullOrEmpty(displayName)) member.DisplayName = displayName;
_userToSquad[userId] = squadId;
return squad;
}
public SquadInfo? GetByUser(string userId)
=> _userToSquad.TryGetValue(userId, out var sid) && _squads.TryGetValue(sid, out var sq) ? sq : null;
public SquadInfo? Get(string squadId)
=> _squads.TryGetValue(squadId, out var sq) ? sq : null;
public void SetReady(string squadId, string userId, bool ready, string? selectedMap)
{
if (_squads.TryGetValue(squadId, out var sq) && sq.Members.TryGetValue(userId, out var m))
{
m.IsReady = ready;
if (!string.IsNullOrEmpty(selectedMap)) m.SelectedMap = selectedMap;
}
}
public void SetMap(string squadId, string userId, string map)
{
if (_squads.TryGetValue(squadId, out var sq) && sq.Members.TryGetValue(userId, out var m))
m.SelectedMap = map;
}
public bool IsSquadReady(string squadId)
=> _squads.TryGetValue(squadId, out var sq) && !sq.Members.IsEmpty && sq.Members.Values.All(m => m.IsReady);
public void Leave(string userId)
{
if (_userToSquad.TryRemove(userId, out var sid) && _squads.TryGetValue(sid, out var sq))
{
sq.Members.TryRemove(userId, out _);
if (sq.Members.IsEmpty) _squads.TryRemove(sid, out _);
}
}
// Build the client-facing squad object (models live in the global namespace).
public FYPlayFabSquad ToClientSquad(SquadInfo squad)
{
return new FYPlayFabSquad
{
SquadID = squad.SquadId,
Members = squad.Members.Values.Select(m => new FYPlayFabSquadMember
{
Profile = new FYPlayFabPlayerProfile
{
PlayerId = m.UserId,
DisplayName = m.DisplayName,
AvatarUrl = "",
},
onlineState = m.OnlineState,
matchmakingSettings = new FYUserMatchmakingSettings
{
isReadyForMatch = m.IsReady,
selectedMapName = m.SelectedMap,
isSecretLeader = m.IsLeader,
},
mapRowNamesUnlocked = Array.Empty<string>(),
}).ToArray(),
};
}
}
File diff suppressed because one or more lines are too long
+3 -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>();
@@ -37,6 +35,9 @@ public class Startup
services.AddSingleton<DbUserDataService>(); services.AddSingleton<DbUserDataService>();
services.AddSingleton<Prospect.Server.Api.Services.Squad.SquadService>();
services.AddSingleton<Prospect.Server.Api.Hubs.SignalRConnectionRegistry>();
services.AddHostedService<QosService>(); services.AddHostedService<QosService>();
services.AddSingleton<QosServer>(); services.AddSingleton<QosServer>();
+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"
+26
View File
@@ -25,6 +25,8 @@ Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Client.Loader", "P
EndProject EndProject
Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Agent", "Prospect.Agent\Prospect.Agent.vcxproj", "{A9BA7D25-F239-4320-A0DC-85E8001FD669}" Project("{8BC9CEB8-8B4A-11D0-8D11-00A0C91BC942}") = "Prospect.Agent", "Prospect.Agent\Prospect.Agent.vcxproj", "{A9BA7D25-F239-4320-A0DC-85E8001FD669}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Prospect.Client.Config", "Prospect.Client.Config\Prospect.Client.Config.csproj", "{6DECEB07-996B-4EBC-927C-13E170161107}"
EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|x64 = Debug|x64 Debug|x64 = Debug|x64
@@ -233,6 +235,30 @@ Global
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x64.Build.0 = Release|x64 {A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x64.Build.0 = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.ActiveCfg = Release|x64 {A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.ActiveCfg = Release|x64
{A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.Build.0 = Release|x64 {A9BA7D25-F239-4320-A0DC-85E8001FD669}.Season 3 Release|x86.Build.0 = Release|x64
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x64.ActiveCfg = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x64.Build.0 = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x86.ActiveCfg = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Debug|x86.Build.0 = Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x64.ActiveCfg = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x64.Build.0 = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x86.ActiveCfg = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Release|x86.Build.0 = Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x64.ActiveCfg = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x64.Build.0 = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x86.ActiveCfg = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Debug|x86.Build.0 = Season 2 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x64.ActiveCfg = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x64.Build.0 = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x86.ActiveCfg = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 2 Release|x86.Build.0 = Season 2 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x64.ActiveCfg = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x64.Build.0 = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x86.ActiveCfg = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Debug|x86.Build.0 = Season 3 Debug|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x64.ActiveCfg = Season 3 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x64.Build.0 = Season 3 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x86.ActiveCfg = Season 3 Release|Any CPU
{6DECEB07-996B-4EBC-927C-13E170161107}.Season 3 Release|x86.Build.0 = Season 3 Release|Any CPU
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE