Compare commits

...

21 Commits

Author SHA1 Message Date
d21c1f24b7 Release Template files 2026-07-13 11:37:58 +02:00
e673b1954c v1.0.13 — Section « Arguments de lancement » indépendante du mode auto
Feature request : jusqu'à v1.0.12, les CLI args passés à PROSERVE_UE_*.exe
n'étaient utilisés QUE dans _config.AutoMode.Args, et seulement pour la
version désignée AUTO. Un opérateur qui voulait des flags globaux appliqués
à TOUS les lancements manuels (ex : -nosplash, -log, -fps=90) devait soit
activer le mode auto sur chaque version manuellement, soit modifier le
raccourci Windows (perdu au prochain install).

Fix : nouvelle section « Arguments de lancement » dans Settings → Avancés,
au-dessus du bloc « Mode auto ». Ces args sont concaténés en PREMIER dans
la ligne de commande passée à Unreal ; les args du mode auto s'ajoutent
DERRIÈRE quand la version courante est celle marquée AUTO.

Sémantique de merge :
  • Non-auto (comportement neuf) : cliArgs = DefaultLaunchArgs
  • Auto (comportement étendu)    : cliArgs = DefaultLaunchArgs + AutoMode.Args
  • Doublons de clé : Unreal FParse prend la dernière occurrence — les
    auto args écrasent silencieusement un default homonyme. Voulu (ex :
    -fps=90 en default, -fps=120 en auto).

Impl :
  • Model : LocalConfig.DefaultLaunchArgs (List<AutoModeArg>, reuse du type
    existant). Empty par défaut → rétro-compat total.
  • MainViewModel.LaunchVersion : composition de argList (default puis auto).
  • SettingsViewModel : nouvelle ObservableCollection DefaultLaunchArgs +
    AddDefaultLaunchArgCommand + Save/Load persistence.
  • SettingsDialog.xaml : nouvelle carte au-dessus d'AutoMode, patron
    identique (Key + Value + Remove par ligne + bouton Ajouter).
  • Strings.cs : SettingsLaunchArgs + SettingsLaunchArgsHelp (FR/EN/CN/TH/
    AR/ES/DE).

Rétro-compat : configs existantes qui n'ont pas le field DefaultLaunchArgs
en JSON → deserialize en List vide → cliArgs = null sur launch manuel (=
comportement d'avant, identique).

Bump : 1.0.12 → 1.0.13 (nouvelle feature UI).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-09 12:30:06 +02:00
f8af338faf v1.0.12 — Badge channel en violet pour se distinguer du BETA orange
Feedback UX post-1.0.11 : le badge channel bleu (#3B82F6) était trop
proche du bleu du statut « Available » — pas assez de contraste
sémantique pour distinguer instantanément « quel channel ? » de
« quel état ? ». Passage sur violet (#8B5CF6) qui n'entre en conflit
ni avec le BETA orange (#F59E0B) ni avec les brushes de statut.

Deux sites (compact row + FeaturedVersion), aucune autre modif.

Bump : 1.0.11 → 1.0.12 (patch cosmétique).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 17:20:12 +02:00
d845edb90f v1.0.11 — /manifest : opt-in multiChannel pour ne plus dédupliquer par version
Bug rapporté après v1.0.10 : client sur license channel=full avec les deux
entries v1.5.4.32 dans le backoffice (Entry #1 tagged firefighter+full,
Entry #2 tagged full), le launcher n'affichait toujours qu'une seule ligne.

Root cause côté serveur, PAS côté client cette fois. Manifest.php faisait
un « group by version + pick most specific » dans filterVersions() —
comportement historique introduit pour ne pas faire crasher les vieux
clients qui déduisaient par ToDictionary(v.Version). Résultat : Entry #2
était filtrée avant même d'atteindre le launcher.

Ce filtrage était nécessaire à l'époque (vieux clients) mais bloque tous
les fixes multi-channels client (v1.0.8-1.0.10) qui avaient rendu le
client capable d'afficher plusieurs entries au même numéro.

Fix : opt-in via query param sur /manifest?multiChannel=1

Server (Manifest.php) :
  • filterVersions() prend un `bool $multiChannel = false`.
  • Si true → skip group-by, retourne toutes les entries visibles.
  • Si false (défaut, vieux clients) → comportement historique préservé.
  • Query param `multiChannel` lu depuis $_GET, transmis à filterVersions.

Client (ManifestService.FetchFromOvhAsync) :
  • Ajoute `?multiChannel=1` inconditionnellement. Un serveur ancien
    ignore silencieusement le param (pas de header d'échec).
  • Combiné avec &channel=X quand la license a un channel.

Rétro-compat :
  • Vieux client (v1.0.9-) + serveur nouveau : n'envoie pas multiChannel=1,
    serveur dédupe comme avant, launcher ne crash pas.
  • Client nouveau (v1.0.11+) + serveur ancien : le param est ignoré,
    même comportement qu'avant (dédup côté serveur, une seule row visible).
  • Client nouveau + serveur nouveau (config voulue) : les deux entries
    remontent, le row-key-par-Id de v1.0.10 fait le reste.

Bump : 1.0.10 → 1.0.11 (fix ciblé serveur+client).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 17:16:58 +02:00
dc72e7eee6 v1.0.10 — Fix v1.0.8/9 : row identity par entry Id, pas par folder name
Bug rapporté : après update client vers 1.0.9, un opérateur ne voyait qu'UNE
ligne alors que le manifest en contenait deux au même numéro. Root cause :
mon refactor v1.0.8 keyait les rows par folder name résolu via
GetInstallFolderName(). Sur les manifests existants (où l'opérateur n'avait
pas encore migré vers un installFolderTemplate distinct par channel), les
deux entries résolvent au même dossier « PROSERVE v1.5.4.32 » — le dico
TryAdd droppait la seconde silencieusement (juste un log warn).

Fix : row identity passe sur l'Id de l'entrée manifest (unique par entry,
auto-généré server-side depuis longtemps via generate_entry_id()). Le folder
name reste utilisé pour le matching installed ↔ remote quand l'entryId
manque (installs d'avant v1.0.8 qui n'ont pas encore été re-installés).

Bénéfices :
  • Les deux entries s'affichent MÊME si elles partagent un
    installFolderTemplate. L'install guard côté client bloquera l'écrasement
    au moment de l'install avec le message clair habituel.
  • Migration transparente : les installs existants continuent d'être matchés
    par folder name tant qu'ils n'ont pas d'EntryId dans leur meta. Au
    ré-install, le meta reçoit son EntryId et le matching devient canonique.

Détails :

── Model ─────────────────────────────────────────────────────────────
• InstalledVersion : nouveau champ optionnel EntryId (default null pour la
  rétro-compat des call sites existants).

── Registry ──────────────────────────────────────────────────────────
• Scan() populate EntryId via TryReadEntryId(dir). null si le fichier meta
  n'existe pas ou si la clé est absente (install antérieur à v1.0.8).

── MainViewModel.RebuildList ─────────────────────────────────────────
• remoteByRowKey : keyé par Id de l'entry (fallback folder name si le
  manifest est très vieux et n'a pas d'id).
• installedByRowKey : keyé par EntryId lu du meta ; fallback = folder name
  ; fallback ultime = héritage de l'Id d'un remote match si un install
  legacy pointe vers un remote qui, lui, a un Id.
• Tri VersionOrder : lookup version + isBeta par rowKey via deux dicos
  rawByRowKey/installedByRowKey (au lieu de folder name).

── VersionRowViewModel ───────────────────────────────────────────────
• RowKey : priorité (Remote.Id → Installed.EntryId → folder name → Version).
  Aligné avec la logique RebuildList pour que les lookups par RowKey
  trouvent la row correcte.

Bump : 1.0.9 → 1.0.10 (fix critique du refactor v1.0.8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 17:11:13 +02:00
53c9e0519c v1.0.9 — Badge channel affiché uniquement en cas de conflit de version
Feedback UX post-1.0.8 : le badge channel bleu était toujours visible dès
qu'une entrée manifest avait un channel non-default. Sur les setups
mono-channel (99 % des cas, y compris les clients firefighter/police qui
ne voient QUE leur channel + les entries default), ça polluait chaque row
avec un badge sans valeur informative — le badge ne sert qu'à distinguer
deux rows partageant un numéro de version.

Fix : nouveau flag HasVersionConflict sur VersionRowViewModel, positionné
par RebuildList après construction (group by Version, count >= 2 → true).
HasChannelBadge devient (ChannelBadge non vide AND HasVersionConflict) —
badge visible seulement quand une AUTRE row visible partage le même numéro.

Résultat :
  • Client firefighter voit v1.5.4.32 (default) → pas de badge (row unique)
  • Client firefighter voit v1.5.4.32 (default) + v1.5.4.32 (firefighter)
    → les DEUX rows affichent leur badge (« default » n'a pas de badge par
    convention, mais firefighter a le sien → distingue visuellement)
  • Client dev/opérateur voit tous les channels → badge apparaît sur toute
    row en conflit

Notification via [NotifyPropertyChangedFor(HasChannelBadge)] sur la
property auto-générée HasVersionConflict — les bindings XAML se rafraîchissent
correctement quand le flag change entre deux RebuildList (ex : upload
d'une entrée concurrente côté serveur puis refresh manifest client).

Bump : 1.0.8 → 1.0.9 (patch UX).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 17:02:26 +02:00
28c5ca5877 v1.0.8 — Multi-channels : afficher plusieurs entries au même numéro + install guard anti-collision
Contexte : après le fix v1.0.5 (/download-url disambigué par filename), un
opérateur peut avoir un manifest avec deux entries partageant un numéro de
version sur des channels différents (ex : proserve-firefighter-1.5.4.32 vs
proserve-full-1.5.4.32). Deux problèmes restants :

1. Le client n'affichait qu'UNE row : `remote.ToDictionary(v.Version)` dans
   RebuildList crashait sur duplicate key.
2. À l'install, les deux entries résolvaient au même dossier via le default
   `installFolderTemplate = "PROSERVE v{version}"` → l'install le plus récent
   écrasait silencieusement le précédent (ZipInstaller rename en .bak-{ts}
   puis delete en background).

Solution end-to-end :

── Client ─────────────────────────────────────────────────────────────
• RebuildList refactor : index par folder name (résolu via GetInstallFolder-
  Name()) au lieu de par version. Deux entries au même numéro deviennent
  visibles dès qu'elles ont des templates distincts. Warning log si deux
  entries résolvent au même folder.
• VersionRowViewModel : nouveau RowKey (basename du folder ou fallback
  Version), ChannelBadge (premier channel non-default). Sites de lookup
  (DL-in-flight preservation, 404 retry) migrés sur RowKey.
• MainWindow.xaml : badge bleu channel affiché à côté du badge BÊTA, dans
  la row compact ET dans FeaturedVersion.
• Install guard : refuse une install si le dossier cible contient déjà un
  .proserve-meta.json avec un entryId différent. Le meta stocke maintenant
  l'entryId à chaque WriteInstallMetadataAsync. Message clair localisé
  (FR/EN/CN/TH/AR/ES/DE) qui pointe l'opérateur vers le backoffice.
• VersionManifest client model : nouveau champ optionnel `Id` (mappé sur
  le champ serveur existant), utilisé pour identifier l'entrée source.
• Registry regex broadened : accepte `PROSERVE(-<channel>)? v...` en plus
  du `PROSERVE v...` legacy. Les folders custom par channel sont scannés.

── Serveur admin (versions.php) ──────────────────────────────────────
• Nouveau champ éditable `install_folder_template` dans le formulaire
  d'ajout ET dans edit_meta. Validation regex (contient {version}, charset
  whitelisted).
• Default intelligent à la création : si un seul channel non-default est
  coché, pré-remplit avec "PROSERVE-<channel> v{version}". Sinon garde
  "PROSERVE v{version}" (legacy).
• Validation croisée : refuse la save si deux entries résolvent au même
  dossier, avec un message clair qui suggère un template alternatif.

── Rétro-compat ──────────────────────────────────────────────────────
• Vieux installs (sans entryId dans meta) : install guard fail-open, se
  laisse écraser à la ré-install et retrofit l'entryId.
• Vieux manifests (sans `id` sur les entries) : `Id` est null côté client,
  l'install guard reste passif, comportement identique à v1.0.7.
• Vieux serveurs (sans `install_folder_template` éditable) : le manifest
  reste avec le default généré par generate_entry_id, aucune breaking
  change. Le badge channel s'affiche quand même si `channels` est renseigné.
• Setups mono-channel (99 % des cas) : aucun changement visible, sort et
  matching identiques.

Bump : 1.0.7 → 1.0.8.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 16:54:44 +02:00
f9820620dd v1.0.7 — UpdateChecker filtre isBeta selon canSeeBetas de la license
Fix latent identifié en marge du v1.0.6 : UpdateChecker.CheckAsync
retournait toujours la plus haute version disponible sans filtrer par
isBeta, indépendamment des droits de la license courante. Conséquence
possible : un client sans droits beta pouvait recevoir un popup
« Nouvelle version disponible : 1.5.4.32 » puis ne pas voir cette
version dans la liste (RebuildList applique le filtre canSeeBetas), et
au clic il n'aurait pas pu la télécharger (entitlement backend). UX
incohérente + confusion opérationnelle.

Fix : IUpdateChecker.CheckAsync prend maintenant un paramètre
`bool canSeeBetas`. UpdateChecker filtre les entrées isBeta=true de la
sélection LatestRemote quand ce flag est false. Le manifest complet
reste retourné dans le UpdateCheckResult pour le rendu ultérieur —
seule la variable "quelle version est proposée comme MAJ ?" est
concernée.

Côté caller (MainViewModel.CheckForUpdatesAsync), on passe
`_license?.CanSeeBetas ?? false` — même défaut que RebuildList,
comportement cohérent bout-en-bout.

Rétrocompat : aucun autre caller de CheckAsync dans le codebase (grep
vérifié). Le nouveau param est requis mais n'a qu'un site d'appel.

Bump : 1.0.6 → 1.0.7 (petit fix isolé, mérite d'être tracé séparément
puisque la sémantique change).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 15:44:13 +02:00
c20d4603a2 v1.0.6 — Version finale non-beta > dernière beta au même préfixe 3-digit
Bug rapporté : après avoir itéré des builds beta 1.5.4.30 / .31 / .32
(isBeta=true, 4-digit), publier 1.5.4 (isBeta=false, 3-digit) comme
release finale ne rendait PAS 1.5.4 la version "courante" côté launcher.
Deux cas d'échec :

  1. Client canSeeBetas=true (opérateur/testeur) : les deux visibles,
     1.5.4.32 restait en tête du tri (SemVer strict : 1.5.4 == 1.5.4.0
     < 1.5.4.32).
  2. Client canSeeBetas=false avec 1.5.4.32 déjà installé : l'install
     locale n'est jamais filtrée par isBeta, donc restait en tête aussi.

Root cause : SemVer.CompareTo() traite le 4ᵉ digit comme un patch
post-release (documenté ainsi dans SemVer.cs pour supporter les itérations
de test 1.5.4.13 alignées sur leur release stable 1.5.4). Cette sémantique
casse quand le 4-digit est en fait un "pré-release" beta destiné à être
supplanté par la 3-digit finale.

Fix : nouveau helper VersionOrder.Compare(versionA, isBetaA, versionB,
isBetaB) qui ajoute une règle par-dessus SemVer :

  Au MÊME préfixe 3-digit (X.Y.Z égaux) ET statut beta différent, la
  version isBeta=false l'emporte, indépendamment du 4ᵉ digit.

Hors ce cas exact (préfixes 3-digit différents, ou même statut beta des
deux côtés) : SemVer strict, aucune régression sur les scénarios existants
(1.5.4.30 beta < 1.5.4.32 beta reste vrai, 1.5.4 < 1.5.5 reste vrai, etc.).

Application aux deux hotspots :

  • UpdateChecker.CheckAsync — tri par VersionOrder au lieu de SemVer,
    ET pour la comparaison isNewer, retrouve l'isBeta d'origine de l'install
    locale via lookup dans manifest.Versions (si l'entrée existe encore).
  • MainViewModel.RebuildList — tri combiné installé/remote via VersionOrder.
    L'isBeta est lookupé dans le RAW remote (avant le filtre canSeeBetas),
    sinon un client sans droits beta ayant installé une beta perdrait
    l'info et retomberait sur SemVer strict.

Migration : aucune côté data. Les versions publiées comme beta restent
identifiées par leur flag isBeta ; le launcher les considère automatiquement
comme pré-release dès qu'une non-beta au même préfixe 3-digit apparaît
dans le manifest. Publish 1.5.4 (isBeta=false) → devient la version featured
même sur les postes ayant 1.5.4.32 installé.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 15:38:20 +02:00
d52e29151e v1.0.5 — Fix /download-url sur manifests multi-channels (same-version)
Bug rapporté : sur un manifest avec deux entrées partageant le numéro de
version 1.5.4.32 (channels proserve-firefighter vs proserve-full), le
client se prenait au démarrage de l'install le garde-fou :

  « Incohérence serveur : l'endpoint /download-url retourne un nom de
    fichier différent du manifest. Manifest : proserve-full-1.5.4.32.zip.
    Signé : proserve-firefighter-1.5.4.32.zip. → DownloadUrl.php côté
    serveur doit lire le filename depuis manifest.download.url, pas via
    un template hardcodé. »

Root cause : DownloadUrl.php faisait un `foreach ... if version match {
break; }` — il retournait donc TOUJOURS la première entrée matchant le
numéro, quel que soit le channel réellement cliqué côté client. Pareil
que le bug de sync_one (même famille de problèmes), mais côté endpoint
runtime du client.

Fix côté serveur : /download-url accepte maintenant un query param optionnel
`?filename=proserve-full-1.5.4.32.zip`. Si présent, le foreach filtre sur
(numéro version AND basename(download.url) == filename attendu). Whitelist
défensive sur le filename (path traversal). Rétro-compat : sans param, le
1er match par numéro gagne comme avant.

Fix côté client : le client extrait le filename attendu de `row.Remote.
Download.Url` (déjà connu, signé Ed25519) et le passe à l'endpoint. Deux
sites d'appel modifiés : le call initial dans InstallVersionAsync + le
callback RefreshUrlAsync (utilisé quand un segment reçoit 403/410 mid-DL
et qu'il faut re-signer). Sans le refresh à jour, un DL long sur ADSL
tomberait au 1er refresh forcé.

Extension d'interface : ILicenseService.GetSignedDownloadUrlAsync prend
maintenant un `string? expectedFilename` en 2e param. Callers qui passent
null continuent de fonctionner comme avant (utile pour les tests).

Bump : 1.0.4 → 1.0.5 (bug fix ciblé sur les setups multi-channel).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 13:49:22 +02:00
b0ca082b52 sync_one : hash uniquement l'entrée cliquée, pas toutes celles du même numéro
Bug : sur un manifest avec deux entrées partageant le même numéro de version
(channels multiples — ex proserve-firefighter-1.5.4.32.zip ET proserve-full-
1.5.4.32.zip, deux builds distincts pour firefighter vs full), cliquer le
bouton « 🔁 Hash » d'une ligne déclenchait le hash des DEUX ZIPs dans la
même requête HTTP. Résultat 2 × 14 Go = 28 Go dans une seule requête, ce
qui dépasse le timeout front d'OVH mutualisé (hard-limit invisible côté
PHP même avec set_time_limit(0)) → 500 Internal Server Error.

C'est pour ça que « des hashs de la même taille passaient sans problème
avant » : c'est le cas 28 Go des DEUX ZIPs en une requête qui est nouveau
(depuis l'introduction des channels), pas la taille du single 14 Go qui
passait déjà.

Fix : nouveau paramètre $onlyEntryId sur SignManifest::run(). Prend le pas
sur $onlyVersion. L'admin action sync_one passe l'entryId (unique par
ligne, généré par generate_entry_id()) au lieu du numéro de version. Une
ligne cliquée = un ZIP hashé, point. Le bouton « 🔁 Sync (all versions) »
(action sync_versions) continue de tout hasher — c'est ce que l'opérateur
demande explicitement.

Rétro-compat : si $onlyEntryId est null (CLI, cron, anciens callers),
$onlyVersion filtre comme avant.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 13:42:39 +02:00
aae339287b SignManifest : capture les erreurs dans un log accessible via SFTP
Sur OVH mutualisé les logs Apache (error_log Apache) ne sont accessibles
que via le manager web — pas grep-able en SFTP, donc difficile de
diagnostiquer un 500 quand ça se reproduit.

Fix : redirige log_errors vers manifest/.signmanifest-error.log dès l'entrée
de run(). Trois canaux capturés :

1. ini_set error_log → tout ce que PHP loggue habituellement va aussi
   dans notre fichier (warnings inclus).
2. register_shutdown_function → attrape les E_ERROR / E_PARSE / fatal
   errors qui court-circuitent l'exécution avant tout catch normal.
3. try/catch autour de getOrComputeSha256() par version, avec un START/DONE
   log de chaque étape. Si le process meurt, on saura pile lequel des ZIPs
   du manifest était en cours au moment du crash.

L'opérateur peut maintenant SFTP le fichier après un 500 et coller le
contenu ici pour identifier la cause réelle (l'hypothèse timeout du commit
précédent était probablement fausse — l'opérateur a rapporté que plein de
hashs de la même taille avaient marché avant).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 13:40:02 +02:00
46d7c461c5 Fix SignManifest 500 sur ZIPs de 14 Go via OVH mutualisé
Bug rapporté depuis le backoffice : cliquer « 🔁 Hash » sur une nouvelle
version renvoyait un 500 Internal Server Error générique (« Please contact
the server administrator at postmaster@asterionvr.com »). L'opérateur ne
pouvait donc plus signer une release après upload SFTP du ZIP.

Root cause : hash_file('sha256', $zip) sur un fichier de 14 Go via le SAN
mutualisé OVH prend 5-10 min. Le max_execution_time PHP par défaut (30-60s)
tue le process bien avant. Apache remonte alors 500 avec son boilerplate
par défaut, sans log utile pour l'opérateur.

Fix en défense en profondeur :

1. set_time_limit(0) + ignore_user_abort(true) au début de run(). Couvre
   TOUS les callers (admin web, cron, CLI). ignore_user_abort évite qu'un
   refresh de l'onglet backoffice interrompe un hash en cours (10 min = ~un
   café — l'opérateur peut être tenté de refresh).

2. Passage de hash_file() → hash_init + hash_update_stream en boucle
   16 Mo par chunk. Deux bénéfices :
   • flush() entre chaque chunk = heartbeat pour le proxy Apache/OVH front,
     évite un timeout côté serveur web même si PHP a le droit de continuer.
   • set_time_limit(300) glissant à chaque chunk = si un chunk prend +5 min
     c'est vraiment un disque HS, pas juste un gros fichier — on n'est pas
     bloqué sur un unique timer géant.

Mémoire : hash_update_stream() ne buffere pas, streaming pur, aucun risque
d'OOM même sur 14 Go.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-07 13:32:01 +02:00
ff05edbe7e v1.0.4 — Conserver les sauvegardes : user-data wins over ZIP defaults
Bug rapporté par l'opérateur : upgrade 1.5.4.30 → 1.5.4.32 sur un poste,
les paramètres customisés dans 1.5.4.30 n'étaient pas visibles au premier
lancement de 1.5.4.32 alors que la case « Conserver les sauvegardes » était
cochée.

Root cause : le ZIP de la nouvelle version bundle des .sav par défaut (typ.
PROSERVE_UE_5_7/Saved/SaveGames/GeneralSettings.sav = réglages usine).
Après extraction, ce fichier existe déjà dans le dossier cible. Ma logique
« non-destructive » précédente skippait alors la copie depuis la version
précédente pour « ne pas écraser un fichier bundlé » — mais c'est
exactement l'inverse qu'on veut : les données utilisateur (progression,
réglages persos, replays) DOIVENT primer sur les defaults du ZIP.

Fix : mode « USER-DATA WINS » — on écrase toujours le fichier cible s'il
existe. La version précédente contient soit la même valeur (no-op), soit
la valeur customisée par l'opérateur (recherchée). Aucune raison légitime
de préserver les defaults bundlés au détriment de user data.

Logging bumpé Debug → Information sur les branches critiques de
CopyPreviousSaveGamesAsync (nombre de versions scannées, path source
résolu, count trouvé par sous-dossier). Facilite le diagnostic de ce genre
de régression à l'avenir sans obliger l'opérateur à activer un mode verbose.

Bump : 1.0.3 → 1.0.4 (bug fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-07-06 14:55:10 +02:00
8d8cf32e12 Bump WebView2 SDK 1.0.3792.45 → 1.0.3800.47 (clear NU1603 warning)
L'ancien build sortait :
  NU1603: PS_Launcher dépend de Microsoft.Web.WebView2 (>= 1.0.3792.45)
  mais Microsoft.Web.WebView2 1.0.3792.45 est introuvable.
  Microsoft.Web.WebView2 1.0.3800.47 a été résolu à la place.

Microsoft a purgé 1.0.3792.45 du feed NuGet entre temps (révoqué pour
sécurité ou simplement nettoyé). NuGet remontait à la version supérieure
qui était de toute façon le minimum acceptable.

Pas de changement de comportement — la SDK reste un wrapper du même
WebView2 Runtime (qu'on installe via le bootstrapper Microsoft).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 10:35:31 +02:00
e2d9171df1 v1.0.3 — Render fallback software pour environnements sans accel GPU
Bug rapporté : sur un poste client fraîchement installé, la fenêtre du
launcher reste TOUT BLANCHE. Aucune exception loguée, services en
arrière-plan fonctionnent (manifest fetché, LAN discovery active,
hosting alive), juste rien à l'écran. WebView2 confirmé installé, donc
ce n'était pas la cause.

Diagnostique : c'est un mode de panne classique de WPF sur certains
environnements où l'accélération matérielle DirectX échoue silencieusement.
La fenêtre est créée, la message loop tourne, mais rien ne se rend
visuellement. Aucune exception ne remonte parce que le pipeline graphique
échoue en dehors du try/catch managé.

Environnements à risque :
  - Sessions Bureau à distance (RDP) — accélération matérielle limitée
  - Machines virtuelles sans hardware accel (Hyper-V Gen 1, VirtualBox,
    VMware basic)
  - GPU Intel HD anciens avec drivers obsolètes
  - Windows Server sans Desktop Experience
  - PCs corporate avec GPU émulé (RDS, Citrix)

Fix : check au démarrage et fallback vers RenderMode.SoftwareOnly dans
3 cas :
  1. RenderCapability.Tier == 0 — WPF lui-même dit "pas d'accel possible"
  2. GetSystemMetrics(SM_REMOTESESSION) != 0 — session RDP détectée
  3. Variable d'env PSLAUNCHER_SOFTWARE_RENDER=1 — override manuel

Le mode choisi est logué au démarrage (Tier=X RDP=Y EnvOverride=Z →
SoftwareOnly=W), donc en cas de re-rapport on saura immédiatement si
c'est ce code qui s'est déclenché.

Trade-off : rendu software ~30% plus lent sur le scroll et les
animations, mais visible vs INVISIBLE. Le bon trade-off.

Pour l'utilisateur déjà bloqué AVANT cette release : set la var
PSLAUNCHER_SOFTWARE_RENDER=1 dans les variables d'environnement Windows
de la session user (Paramètres → Système → À propos → Paramètres
système avancés → Variables d'environnement), puis relancer.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 10:32:31 +02:00
5b489dcfc5 Installer v1.0.3 : bundle WebView2 Runtime Bootstrapper
Bug rapporté : sur un poste client fraîchement installé sans Microsoft
Edge WebView2 Runtime (typique sur Windows Server, Win10 sans MAJ
récente, ou install corporate stripped down), la fenêtre du launcher
restait toute blanche au démarrage. Les contrôles WebView2 (utilisés
pour les onglets Reports et Documentation) tentent de s'initialiser
au render de MainWindow, échouent en silence faute de runtime, et la
fenêtre WPF reste vide visuellement.

Fix : on bundle le bootstrapper WebView2 (MicrosoftEdgeWebview2Setup.exe,
1.6 MB, redistribuable Microsoft) dans le setup. À l'install, un check
registre (3 emplacements : HKLM x64, HKLM x86, HKCU) détecte si le
runtime est déjà présent. S'il manque, on lance le bootstrapper en
silencieux qui télécharge ~150 MB depuis Microsoft et installe.

Si le téléchargement échoue (poste offline, firewall corporate qui
bloque go.microsoft.com), on ne fail PAS le setup global — le launcher
sera installé sans WebView2, le user verra le bug visuel et pourra
installer manuellement le runtime depuis Microsoft. Trade-off vs forcer
l'install qui pourrait bloquer indéfiniment des admins en offline.

Téléchargé depuis : https://go.microsoft.com/fwlink/p/?LinkId=2124703

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-26 08:35:39 +02:00
a6ea9b11af v1.0.2 — Conserver les sauvegardes et replays entre versions
Nouvelle option dans le popup « Mise à jour disponible » (case cochée par
défaut, décochable) : « Conserver les sauvegardes et replays de la version
précédente ». Après extraction du ZIP de la nouvelle version, le launcher
copie :
  • PROSERVE_UE_*/Saved/SaveGames/*.sav   (profils + progression Unreal)
  • PROSERVE_UE_*/Saved/Demos/*.replay    (replays de session pour debrief)

Source = version installée au plus haut SemVer autre que celle qu'on
vient d'installer (couvre upgrade ET ré-install). Le dossier projet UE est
déduit du nom de l'exe (PROSERVE_UE_5_7.exe → PROSERVE_UE_5_7/) — supporte
le passage UE_5_5 → UE_5_7 (renommage transparent : on copie du dossier
source vers le dossier cible, peu importe leur numéro UE).

Sémantique non-destructive : si la nouvelle install contient déjà un
fichier au même chemin (profil/replay bundlé dans le ZIP), il n'est PAS
écrasé. La version la plus à jour côté installer prime pour ce slot
précis ; les autres fichiers créés par l'opérateur en cours d'utilisation
sont copiés normalement. Liste des sous-dossiers + globs en table statique
(PreservedSavedSubdirs) pour faciliter l'ajout futur (Logs/Config user…).

Best-effort : exceptions IO loggées en warn mais l'install n'échoue pas
pour une copie qui foire (handle verrouillé, accès refusé). Si l'option
est décochée OU si aucune version précédente n'est installée, no-op
silencieux.

Côté UI : checkbox au-dessus des boutons Plus tard / Télécharger, avec
tooltip détaillant les deux chemins. État remonté via dialog.PreserveSaveGames
et lu par MainViewModel APRÈS l'écriture du .proserve-meta.json (donc
avant les redists & SteamVR merge). Pour les resumes de DL interrompus,
le défaut est TRUE (l'utilisateur a déjà confirmé la première fois).

i18n complète : FR/EN/CN/TH/AR/ES/DE pour le libellé de la case, le
tooltip et le StatusMessage « Copie des sauvegardes et replays depuis
vX.Y.Z (N fichiers)… ».

Bump : 1.0.1 → 1.0.2 (feature patch).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 18:03:57 +02:00
b3844bfe16 no message 2026-06-22 15:33:20 +02:00
43a6070a65 v1.0.1 — Fix SteamVR merge : scope aux blocs system.generated.* uniquement
Bug : à chaque install, la popup « Vérification de la santé système / X
blocs vont être mis à jour » apparaissait alors que la source `_steamvr/
steamvr.vrsettings` n'avait pas changé entre les sous-versions. L'opérateur
voyait le launcher toucher au fichier SteamVR de manière répétitive et
inutile.

Root cause : le diff comparait TOUS les blocs racine du source vs target.
Or le fichier source contient (légitimement, pour la doc opérateur) des
blocs user-/machine-spécifiques copiés depuis une machine de référence :
  • DesktopUI       (position fenêtres SteamVR — varie par poste)
  • GpuSpeed        (calibration GPU — RTX 3080 chez l'opérateur vs autre
                     GPU chez le client)
  • LastKnown       (HMD info — Focus3 ou autre)
  • dashboard, steamvr (settings UI — installID utilisateur, etc.)
  • trackers        (mapping device — déjà configuré côté client)
Ces blocs DIFFÈRENT toujours entre la machine de référence (où le source
a été capturé) et chaque poste client → faux positif de diff systématique.

Fix : on restreint la diff + le push aux blocs racine `system.generated.*`
(typiquement system.generated.openxr.proserve_ue_5_5.proserve_ue_5_5.exe,
etc.) qui contiennent les bindings tracker workshop URLs — la VRAIE config
que le launcher est censé pousser. Tout le reste du fichier source est
maintenant ignoré.

Sémantique précise pour un bloc system.generated.* :
  • ABSENT côté target → push complet (les 4 leaf keys CurrentURL,
    PreviousURL, AutosaveURL, NeedToUpdateAutosave)
  • PRÉSENT côté target → check sur les seules leaf keys *_CurrentURL_openxr
    et *_PreviousURL_openxr. Si elles matchent → skip silencieux. Si elles
    diffèrent (= opérateur a updaté la binding workshop) → réécriture des
    2 leafs ciblées, les autres (AutosaveURL, NeedToUpdate) sont laissées
    intactes (gérées par SteamVR).

Cleanup : helpers `DeepMergeInto` et `WouldDeepMergeChange` retirés (plus
référencés). Nouveau helpers ciblés `OpenXrBindingUrlsDiffer` (check) et
`ReplaceOpenXrBindingUrls` (write). Doc XML mise à jour côté interface.

Bump : 1.0.0 → 1.0.1 (patch fix).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-22 14:13:18 +02:00
73d084a703 v1.0.0 🎉 — Production release. i18n Espagnol + Allemand (launcher + emails).
Première release MAJEURE après ~30 itérations 0.x. Le système est complet
et stable pour la production : distribution PROSERVE multi-channels, install
launcher cross-fleet, monitoring santé VR, sécurité (signatures Ed25519,
HMAC URLs présignées, settings lock per-license, RFC1918 filter cache LAN).

i18n nouvelles langues (Espagnol + Allemand) :
 - Strings.cs : signature T() étendue avec 2 params optionnels (es?/de?)
   en plus des 5 obligatoires. Fallback automatique sur l'anglais quand
   non traduit → infrastructure prête immédiatement, traduction progressive
   sur les ~317 strings du launcher selon les besoins terrain.
 - ~30 strings critiques traduites maintenant (top bar, body, status badges,
   actions principales Launch/Install/Cancel/Yes/No, MessageBox titles).
 - Available[] inclut es/Español + de/Deutsch ; auto-détection Windows
   pour es-* / de-* funcionne au premier launch.
 - Init() whitelist mise à jour.

Emails release announce localisés par license :
 - Migration 005 : ajout `language VARCHAR(8) NULL` sur licenses (whitelist
   fr/en/es/de/zh/th/ar, NULL = fallback English).
 - getEmailStrings() PHP étendu : dictionnaire 7 langues × 14 strings.
   Traduction complète es + de (gérable : 28 nouvelles strings, vs 634
   pour traduire le launcher en intégralité).
 - notify_release boucle par license : récupère language + locale fallback
   'en' + render avec la bonne locale → chaque destinataire reçoit son
   email dans la langue de SA license, indépendamment des autres clients.
 - Subject localisé aussi (« PROSERVE v1.5.4 ya está disponible » /
   « PROSERVE v1.5.4 ist jetzt verfügbar »).
 - Release notes body reste TOUJOURS en anglais (évite d'avoir à maintenir
   N traductions du Markdown des notes).
 - Direction RTL préservée pour l'arabe (dir="rtl" sur <html> + <table>).

Admin UI : dropdown langue dans la modal Contacts d'une license + whitelist
backend (set_contact_emails accepte fr/en/es/de/zh/th/ar).

Bumps : 0.29.10 → 1.0.0 — milestone V1, premier release majeur en prod.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 18:01:50 +02:00
270 changed files with 41363 additions and 236 deletions

View File

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher"
#define MyAppVersion "0.29.10"
#define MyAppVersion "1.0.13"
#define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe"
@@ -65,6 +65,14 @@ Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription
Source: "..\src\PSLauncher.App\bin\Release\net8.0-windows10.0.17763.0\win-x64\publish\{#MyAppExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\src\PSLauncher.Updater\bin\Release\net8.0-windows\win-x64\publish\{#MyUpdaterExeName}"; DestDir: "{app}"; Flags: ignoreversion
Source: "..\docs\PS_Launcher-Guide-Utilisateur.pptx"; DestDir: "{app}\docs"; Flags: ignoreversion
; WebView2 Evergreen Bootstrapper (Microsoft, redistribuable). 1.6 MB.
; Téléchargé depuis https://go.microsoft.com/fwlink/p/?LinkId=2124703
; Exécuté en silencieux à l'install SI WebView2 Runtime n'est pas déjà présent
; (vérification registre via la fonction Pascal IsWebView2Installed ci-dessous).
; Sans ça, sur un Win10/Server sans WebView2 préinstallé, la fenêtre du launcher
; reste toute blanche au démarrage parce que les contrôles WebView2 (Reports +
; Documentation) ne peuvent pas s'initialiser et plantent en silence le rendu UI.
Source: "redists\MicrosoftEdgeWebview2Setup.exe"; DestDir: "{tmp}"; Flags: deleteafterinstall; Check: not IsWebView2Installed
[Icons]
Name: "{group}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"
@@ -73,6 +81,14 @@ Name: "{group}\{cm:UninstallProgram,{#MyAppName}}"; Filename: "{uninstallexe}"
Name: "{autodesktop}\{#MyAppName}"; Filename: "{app}\{#MyAppExeName}"; Tasks: desktopicon
[Run]
; Installe WebView2 Runtime si pas déjà présent. /silent /install = install
; non-interactif. Le bootstrapper télécharge ~150 MB depuis Microsoft (1-2 min
; selon la connexion). StatusMsg pour que l'utilisateur voie ce qu'il se passe.
; Code exit attendu : 0 (déjà installé OU install réussie). On ne fail pas le
; setup global même si ça échoue — le launcher logue ensuite l'absence si ça
; pose problème, plutôt que de bloquer toute l'installation pour ça.
Filename: "{tmp}\MicrosoftEdgeWebview2Setup.exe"; Parameters: "/silent /install"; StatusMsg: "Installation du composant Microsoft Edge WebView2 Runtime (~150 MB, peut prendre 1-2 min)..."; Check: not IsWebView2Installed; Flags: waituntilterminated
; Règles firewall pour le cache LAN P2P. Profile=private,domain (PAS public) =
; safety net : même si l'utilisateur connecte le PC à un Wi-Fi public, le port
; reste fermé. Le filtre RFC1918 dans LanCacheServer.cs est la première barrière,
@@ -92,3 +108,28 @@ Filename: "{sys}\netsh.exe"; Parameters: "advfirewall firewall delete rule name=
; binaires installés et le dossier d'install s'il est vide après.
Type: filesandordirs; Name: "{app}\*.bak"
Type: dirifempty; Name: "{app}"
[Code]
// Détecte si Microsoft Edge WebView2 Runtime est déjà installé sur le système.
// Référence : https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution
//
// 3 emplacements registre possibles selon le contexte d'install du runtime :
// - HKLM 64-bit (machine-wide, install par admin)
// - HKLM 32-bit (machine-wide, parfois utilisé par les versions plus anciennes)
// - HKCU (user-only, install par utilisateur sans admin)
//
// La clé contient une valeur 'pv' avec la version installée. Présence non vide
// = runtime présent. Le check Edge classique (HKLM\...\Edge\BLBeacon) ne suffit
// pas parce qu'Edge stable ≠ WebView2 Runtime (deux composants distincts).
function IsWebView2Installed(): Boolean;
var
PV: String;
begin
Result := False;
if RegQueryStringValue(HKLM, 'SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
Result := True
else if RegQueryStringValue(HKLM, 'SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
Result := True
else if RegQueryStringValue(HKCU, 'Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}', 'pv', PV) and (PV <> '') and (PV <> '0.0.0.0') then
Result := True;
end;

Binary file not shown.

1
server/admin/.gitkeep Normal file
View File

@@ -0,0 +1 @@
# Placeholder — les vrais ZIPs (proserve-X.Y.Z.zip) sont uploadés en SFTP, pas en git.

View File

@@ -169,25 +169,34 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
}
}
elseif ($action === 'set_contact_emails') {
// Met à jour la liste des emails de contact pour les notifications
// de nouvelles versions. L'admin saisit en texte libre (1 par ligne,
// virgules, point-virgules — peu importe), on parse + valide via
// FILTER_VALIDATE_EMAIL et on stocke en CSV. Vide / aucun email
// valide → on stocke NULL (= pas de notif possible pour cette
// license, juste skip silencieux à l'envoi).
// Met à jour la liste des emails de contact + la langue préférée
// pour les notifications de nouvelles versions. L'admin saisit en
// texte libre (1 par ligne, virgules, point-virgules — peu importe),
// on parse + valide via FILTER_VALIDATE_EMAIL et on stocke en CSV.
// Vide / aucun email valide → on stocke NULL (= pas de notif possible
// pour cette license, juste skip silencieux à l'envoi).
//
// Language : whitelist stricte fr/en/zh/th/ar (= ce que le launcher
// supporte côté Strings.cs). NULL ou inconnu → fallback English.
$id = (int)($_POST['id'] ?? 0);
$raw = (string)($_POST['contact_emails'] ?? '');
$lang = trim((string)($_POST['language'] ?? ''));
if ($id <= 0) {
throw new Exception('License id invalide.');
}
$allowedLangs = ['fr', 'en', 'es', 'de', 'zh', 'th', 'ar'];
if ($lang !== '' && !in_array($lang, $allowedLangs, true)) {
throw new Exception("Code langue invalide : '{$lang}'. Attendu : " . implode(', ', $allowedLangs) . ' ou vide.');
}
$parsed = Mailer::parseEmails($raw);
$stored = Mailer::joinForStorage($parsed);
$db->prepare('UPDATE licenses SET contact_emails = ? WHERE id = ?')
->execute([$stored, $id]);
$db->prepare('UPDATE licenses SET contact_emails = ?, language = ? WHERE id = ?')
->execute([$stored, $lang !== '' ? $lang : null, $id]);
$count = count($parsed);
$langDisplay = $lang !== '' ? $lang : 'en (défaut)';
$message = $count === 0
? "License #{$id} : contacts de notification supprimés."
: "License #{$id} : {$count} contact(s) email enregistré(s) pour les notifications de release.";
? "License #{$id} : contacts de notification supprimés. Langue email : {$langDisplay}."
: "License #{$id} : {$count} contact(s) email enregistré(s), langue email : {$langDisplay}.";
}
elseif ($action === 'reset_machines') {
$id = (int)($_POST['id'] ?? 0);
@@ -546,17 +555,45 @@ Layout::header('Licenses', 'licenses');
version de PROSERVE accessible à cette license (matching channel +
entitlement). Format libre : une adresse par ligne, virgules, ou
point-virgules. Adresses invalides ignorées silencieusement.
La langue choisie ci-dessous est utilisée pour le contenu de l'email
(greeting + instructions) — les release notes elles-mêmes restent
toujours en anglais.
</p>
<?php
// Affichage en mode "1 par ligne" pour la lisibilité, peu importe
// comment c'est stocké en DB (CSV).
$currentEmails = Mailer::parseEmails($l['contact_emails'] ?? '');
$currentDisplay = implode("\n", $currentEmails);
$currentLang = trim((string)($l['language'] ?? ''));
$langOptions = [
'' => 'English (defaut)',
'fr' => 'Français',
'en' => 'English',
'es' => 'Español',
'de' => 'Deutsch',
'zh' => '中文 (Chinese)',
'th' => 'ภาษาไทย (Thai)',
'ar' => 'العربية (Arabic)',
];
?>
<form method="post">
<?= Layout::csrfField() ?>
<input type="hidden" name="action" value="set_contact_emails">
<input type="hidden" name="id" value="<?= $l['id'] ?>">
<div class="field">
<label>Langue de l'email</label>
<select name="language">
<?php foreach ($langOptions as $code => $label):
$sel = ($currentLang === $code) ? 'selected' : '';
?>
<option value="<?= htmlspecialchars($code) ?>" <?= $sel ?>><?= htmlspecialchars($label) ?></option>
<?php endforeach; ?>
</select>
<p class="hint" style="margin: 4px 0 0;">
Langue dans laquelle l'email de notification sera rédigé.
Le contenu des release notes reste toujours en anglais.
</p>
</div>
<div class="field">
<label>Adresses email <span class="muted" style="font-weight: normal;">(<?= count($currentEmails) ?> actuellement)</span></label>
<textarea name="contact_emails" rows="6"

View File

@@ -102,33 +102,212 @@ function find_entry_index_by_id(array $manifest, string $id): ?int
}
/**
* Construit le corps HTML de l'email de release announce. Message en anglais
* (= langue par défaut du parc client international VR PROSERVE), inclut les
* release notes brutes en Markdown (sans rendu — les MUA modernes Markdown
* basique en clair reste très lisible et évite la dépendance Markdig PHP).
* Le owner_name de la license est utilisé pour personnaliser le greeting.
* Le logo (optionnel via <c>notifications.logo_url</c>) est inséré dans le
* bandeau d'en-tête bleu — hotlinkable depuis n'importe quel MUA (pas de CID
* embed pour rester compatible avec Gmail/Outlook qui bloquent les data URIs).
* Strings localisées pour l'email de release announce. Couvre les 5 langues
* supportées par le launcher (Strings.cs côté .NET) : fr, en, zh, th, ar.
* Le release notes BODY lui-même reste TOUJOURS en anglais — seul le texte
* d'accompagnement (greeting, instructions, CTAs) est localisé. Permet de
* notifier des clients internationaux dans leur langue sans avoir à
* maintenir N traductions du Markdown des notes.
*
* Placeholders dans les chaînes :
* {version} → numéro de version (ex. "1.5.4.16")
* {owner} → nom du client (ex. "Asterion VR")
*
* Fallback : si la locale demandée n'est pas en table → 'en' (= comportement
* conservateur, le marché VR international a globalement l'anglais comme
* lingua franca tech).
*
* @return array<string, string> dictionnaire de strings prêtes à interpoler
*/
function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = ''): string
function getEmailStrings(string $locale): array
{
static $cache = null;
if ($cache !== null) {
return $cache[$locale] ?? $cache['en'];
}
$cache = [
'en' => [
'subject' => 'PROSERVE v{version} is now available',
'title' => 'PROSERVE v{version} is available!',
'subtitle' => 'A new version is ready to install via your PROSERVE Launcher.',
'greeting' => 'Hi {owner},',
'intro' => 'We\'re happy to let you know that <strong>PROSERVE version {version}</strong> has just been published and is now available for download.',
'instructions' => 'If you already have the <strong>PROSERVE Launcher</strong> installed, simply open it on any of your machines, click <em>« Check for updates »</em>, and install the new version with one click.',
'firstInstallTitle' => 'First install or new workstation?',
'firstInstallBody' => 'If this is your first time, or you\'re setting up a new PC, download the PROSERVE Launcher installer below. <strong>You\'ll need to run it on EACH workstation</strong> where PROSERVE will be used.',
'downloadButton' => '⬇ Download PROSERVE Launcher (Windows)',
'directLink' => 'Direct link:',
'releaseNotesTitle' => 'Release notes',
'releaseNotesNone' => 'No release notes for this version.',
'support' => 'As always, if you run into any issue (download failure, install error, anything weird in-game), don\'t hesitate to reach out — we\'re here to help.',
'closing' => 'Happy training!',
'signature' => 'The ASTERION VR team',
'footer' => 'This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.<br/>To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.',
],
'fr' => [
'subject' => 'PROSERVE v{version} est disponible',
'title' => 'PROSERVE v{version} est disponible !',
'subtitle' => 'Une nouvelle version est prête à installer via votre PROSERVE Launcher.',
'greeting' => 'Bonjour {owner},',
'intro' => 'Nous avons le plaisir de vous annoncer que <strong>la version {version} de PROSERVE</strong> vient d\'être publiée et est désormais disponible au téléchargement.',
'instructions' => 'Si vous avez déjà le <strong>PROSERVE Launcher</strong> installé, ouvrez-le simplement sur n\'importe lequel de vos postes, cliquez sur <em>« Vérifier les mises à jour »</em> et installez la nouvelle version en un clic.',
'firstInstallTitle' => 'Première installation ou nouveau poste ?',
'firstInstallBody' => 'S\'il s\'agit de votre première installation, ou si vous configurez un nouveau PC, téléchargez le PROSERVE Launcher ci-dessous. <strong>Il doit être lancé sur CHAQUE poste</strong> où PROSERVE sera utilisé.',
'downloadButton' => '⬇ Télécharger PROSERVE Launcher (Windows)',
'directLink' => 'Lien direct :',
'releaseNotesTitle' => 'Notes de version',
'releaseNotesNone' => 'Pas de notes de version pour cette release.',
'support' => 'Comme toujours, en cas de problème (échec de téléchargement, erreur d\'installation, comportement inattendu en VR), n\'hésitez pas à nous contacter — nous sommes là pour vous aider.',
'closing' => 'Bon entraînement !',
'signature' => 'L\'équipe ASTERION VR',
'footer' => 'Cette notification automatique a été envoyée parce que votre email est enregistré comme contact sur l\'une de vos licenses PROSERVE.<br/>Pour ne plus recevoir ces emails, demandez à votre gestionnaire de compte ASTERION VR de retirer votre adresse des contacts license.',
],
'zh' => [
'subject' => 'PROSERVE v{version} 现已发布',
'title' => 'PROSERVE v{version} 已发布!',
'subtitle' => '新版本已准备好通过您的 PROSERVE Launcher 安装。',
'greeting' => '你好 {owner}',
'intro' => '我们很高兴地通知您,<strong>PROSERVE 版本 {version}</strong> 刚刚发布,现已可供下载。',
'instructions' => '如果您已经安装了 <strong>PROSERVE Launcher</strong>,只需在任何一台机器上打开它,单击<em>「检查更新」</em>,然后单击「安装」即可完成新版本的安装。',
'firstInstallTitle' => '首次安装或新工作站?',
'firstInstallBody' => '如果这是您的第一次使用,或者您正在配置一台新 PC请在下方下载 PROSERVE Launcher 安装程序。<strong>您需要在使用 PROSERVE 的每台工作站上运行它</strong>。',
'downloadButton' => '⬇ 下载 PROSERVE Launcher (Windows)',
'directLink' => '直接链接:',
'releaseNotesTitle' => '版本说明',
'releaseNotesNone' => '此版本没有版本说明。',
'support' => '一如既往,如果您遇到任何问题(下载失败、安装错误、游戏中出现异常情况),请随时联系我们 — 我们随时为您提供帮助。',
'closing' => '训练愉快!',
'signature' => 'ASTERION VR 团队',
'footer' => '此自动通知已发送,因为您的联系电子邮件已注册到您的某个 PROSERVE 许可证中。<br/>要停止接收这些通知,请要求您的 ASTERION VR 客户经理从许可证联系人中删除您的地址。',
],
'th' => [
'subject' => 'PROSERVE v{version} พร้อมใช้งานแล้ว',
'title' => 'PROSERVE v{version} พร้อมใช้งาน!',
'subtitle' => 'เวอร์ชันใหม่พร้อมติดตั้งผ่าน PROSERVE Launcher ของคุณ',
'greeting' => 'สวัสดี {owner},',
'intro' => 'เรายินดีที่จะแจ้งให้คุณทราบว่า <strong>PROSERVE เวอร์ชัน {version}</strong> เพิ่งได้รับการเผยแพร่และพร้อมให้ดาวน์โหลดแล้ว',
'instructions' => 'หากคุณติดตั้ง <strong>PROSERVE Launcher</strong> ไว้แล้ว เพียงเปิดบนเครื่องใดก็ได้ของคุณ คลิก <em>« ตรวจสอบการอัปเดต »</em> แล้วติดตั้งเวอร์ชันใหม่ด้วยคลิกเดียว',
'firstInstallTitle' => 'ติดตั้งครั้งแรก หรือเวิร์กสเตชันใหม่?',
'firstInstallBody' => 'หากเป็นครั้งแรกของคุณ หรือคุณกำลังตั้งค่า PC ใหม่ ดาวน์โหลด PROSERVE Launcher ด้านล่าง <strong>คุณต้องเรียกใช้บนเวิร์กสเตชันทุกเครื่อง</strong>ที่จะใช้ PROSERVE',
'downloadButton' => '⬇ ดาวน์โหลด PROSERVE Launcher (Windows)',
'directLink' => 'ลิงก์โดยตรง:',
'releaseNotesTitle' => 'บันทึกการเผยแพร่',
'releaseNotesNone' => 'ไม่มีบันทึกการเผยแพร่สำหรับเวอร์ชันนี้',
'support' => 'เช่นเคย หากคุณพบปัญหาใดๆ (ดาวน์โหลดล้มเหลว ติดตั้งผิดพลาด อะไรแปลกๆ ในเกม) อย่าลังเลที่จะติดต่อ — เราพร้อมช่วยเหลือ',
'closing' => 'ฝึกซ้อมให้สนุก!',
'signature' => 'ทีม ASTERION VR',
'footer' => 'การแจ้งเตือนอัตโนมัตินี้ถูกส่งเนื่องจากอีเมลติดต่อของคุณถูกลงทะเบียนกับหนึ่งในใบอนุญาต PROSERVE ของคุณ<br/>เพื่อหยุดรับการแจ้งเตือนเหล่านี้ ขอให้ผู้จัดการบัญชี ASTERION VR ของคุณลบที่อยู่ของคุณออกจากผู้ติดต่อใบอนุญาต',
],
'ar' => [
'subject' => 'PROSERVE v{version} متوفر الآن',
'title' => 'PROSERVE v{version} متوفر!',
'subtitle' => 'إصدار جديد جاهز للتثبيت عبر PROSERVE Launcher الخاص بك.',
'greeting' => 'مرحباً {owner}،',
'intro' => 'يسعدنا أن نعلمك بأن <strong>الإصدار {version} من PROSERVE</strong> قد تم نشره للتو وأصبح متاحاً للتنزيل.',
'instructions' => 'إذا كان لديك <strong>PROSERVE Launcher</strong> مثبتاً بالفعل، فما عليك سوى فتحه على أي من أجهزتك، والنقر فوق <em>«التحقق من التحديثات»</em>، وتثبيت الإصدار الجديد بنقرة واحدة.',
'firstInstallTitle' => 'تثبيت لأول مرة أو محطة عمل جديدة؟',
'firstInstallBody' => 'إذا كانت هذه المرة الأولى لك، أو إذا كنت تعد جهاز كمبيوتر جديداً، فقم بتنزيل PROSERVE Launcher أدناه. <strong>ستحتاج إلى تشغيله على كل محطة عمل</strong> سيتم استخدام PROSERVE فيها.',
'downloadButton' => '⬇ تنزيل PROSERVE Launcher (Windows)',
'directLink' => 'رابط مباشر:',
'releaseNotesTitle' => 'ملاحظات الإصدار',
'releaseNotesNone' => 'لا توجد ملاحظات إصدار لهذه النسخة.',
'support' => 'كما هو الحال دائماً، إذا واجهت أي مشكلة (فشل التنزيل، خطأ في التثبيت، أي شيء غريب في اللعبة)، فلا تتردد في التواصل معنا — نحن هنا للمساعدة.',
'closing' => 'تدريب سعيد!',
'signature' => 'فريق ASTERION VR',
'footer' => 'تم إرسال هذا الإشعار التلقائي لأن بريدك الإلكتروني للاتصال مسجل في أحد تراخيص PROSERVE الخاصة بك.<br/>للتوقف عن تلقي هذه الإشعارات، اطلب من مدير حسابك في ASTERION VR إزالة عنوانك من جهات اتصال الترخيص.',
],
'es' => [
'subject' => 'PROSERVE v{version} ya está disponible',
'title' => '¡PROSERVE v{version} ya está disponible!',
'subtitle' => 'Una nueva versión está lista para instalar a través de tu PROSERVE Launcher.',
'greeting' => 'Hola {owner},',
'intro' => 'Nos complace informarte que <strong>la versión {version} de PROSERVE</strong> acaba de publicarse y ya está disponible para descargar.',
'instructions' => 'Si ya tienes el <strong>PROSERVE Launcher</strong> instalado, simplemente ábrelo en cualquiera de tus máquinas, haz clic en <em>«Buscar actualizaciones»</em> e instala la nueva versión con un solo clic.',
'firstInstallTitle' => '¿Primera instalación o nueva estación de trabajo?',
'firstInstallBody' => 'Si es la primera vez, o si estás configurando un PC nuevo, descarga el instalador de PROSERVE Launcher abajo. <strong>Deberás ejecutarlo en CADA estación de trabajo</strong> donde se utilice PROSERVE.',
'downloadButton' => '⬇ Descargar PROSERVE Launcher (Windows)',
'directLink' => 'Enlace directo:',
'releaseNotesTitle' => 'Notas de versión',
'releaseNotesNone' => 'No hay notas de versión para esta release.',
'support' => 'Como siempre, si encuentras algún problema (fallo de descarga, error de instalación, comportamiento inesperado en el juego), no dudes en contactarnos — estamos aquí para ayudar.',
'closing' => '¡Buen entrenamiento!',
'signature' => 'El equipo ASTERION VR',
'footer' => 'Esta notificación automática se envió porque tu dirección de correo está registrada como contacto en una de tus licencias PROSERVE.<br/>Para dejar de recibir estos correos, pide a tu gestor de cuenta ASTERION VR que elimine tu dirección de los contactos de la licencia.',
],
'de' => [
'subject' => 'PROSERVE v{version} ist jetzt verfügbar',
'title' => 'PROSERVE v{version} ist verfügbar!',
'subtitle' => 'Eine neue Version ist bereit zur Installation über deinen PROSERVE Launcher.',
'greeting' => 'Hallo {owner},',
'intro' => 'Wir freuen uns, dir mitzuteilen, dass <strong>die Version {version} von PROSERVE</strong> soeben veröffentlicht wurde und jetzt zum Download bereitsteht.',
'instructions' => 'Wenn du den <strong>PROSERVE Launcher</strong> bereits installiert hast, öffne ihn einfach auf einer beliebigen deiner Maschinen, klicke auf <em>«Updates suchen»</em> und installiere die neue Version mit einem Klick.',
'firstInstallTitle' => 'Erstinstallation oder neue Arbeitsstation?',
'firstInstallBody' => 'Wenn dies das erste Mal ist oder du einen neuen PC einrichtest, lade den PROSERVE Launcher-Installer unten herunter. <strong>Du musst ihn auf JEDER Arbeitsstation ausführen</strong>, auf der PROSERVE verwendet wird.',
'downloadButton' => '⬇ PROSERVE Launcher herunterladen (Windows)',
'directLink' => 'Direkter Link:',
'releaseNotesTitle' => 'Versionshinweise',
'releaseNotesNone' => 'Keine Versionshinweise für diese Version.',
'support' => 'Wie immer, wenn du auf Probleme stößt (Download-Fehler, Installationsfehler, irgendetwas Seltsames im Spiel), zögere nicht, uns zu kontaktieren — wir sind hier, um zu helfen.',
'closing' => 'Viel Spaß beim Training!',
'signature' => 'Das ASTERION VR Team',
'footer' => 'Diese automatische Benachrichtigung wurde gesendet, weil deine Kontakt-E-Mail bei einer deiner PROSERVE-Lizenzen registriert ist.<br/>Um diese E-Mails nicht mehr zu erhalten, bitte deinen ASTERION VR Account Manager, deine Adresse aus den Lizenz-Kontakten zu entfernen.',
],
];
return $cache[$locale] ?? $cache['en'];
}
/**
* Construit le corps HTML de l'email de release announce, dans la langue
* <c>$locale</c> demandée (fallback 'en'). Le contenu de la release note
* elle-même (Markdown) reste en anglais — seul le texte d'accompagnement
* (greeting, instructions, CTAs) est localisé via <see cref="getEmailStrings"/>.
* Le owner_name personnalise le greeting. Le logo (optionnel via CID embed
* <c>notifications.logo_path</c>) est inséré dans le bandeau d'en-tête navy.
*
* Pour l'arabe : on ajoute <c>dir="rtl"</c> sur le wrapper table → Outlook,
* Gmail et Apple Mail rendent correctement le texte droit→gauche, et les
* sous-éléments inline (HTML tags, code, URLs) restent affichés LTR comme
* attendu (mixed bidi handling natif).
*/
function renderReleaseAnnounceEmail(string $version, string $ownerName, string $notesMarkdown, string $baseUrl, string $logoSrc = '', string $locale = 'en'): string
{
// Récupère le dictionnaire de strings localisées + interpolation simple
// des placeholders {version} et {owner}. On échappe APRÈS interpolation
// pour ne pas double-encoder les < > de balises HTML déjà dans les
// strings (cf. <strong> dans les templates de getEmailStrings).
$s = getEmailStrings($locale);
$versionEsc = htmlspecialchars($version, ENT_QUOTES, 'UTF-8');
$ownerEsc = htmlspecialchars($ownerName !== '' ? $ownerName : 'team', ENT_QUOTES, 'UTF-8');
$notesEsc = htmlspecialchars(trim($notesMarkdown), ENT_QUOTES, 'UTF-8');
// $logoSrc peut être une URL https://… (hotlink) OU un cid:xxx (inline embed).
// Le caller décide selon ce qu'il préfère ; on échappe juste pour mettre dans src=.
$logoEsc = htmlspecialchars($logoSrc, ENT_QUOTES, 'UTF-8');
// Direction RTL pour l'arabe. Les autres langues sont LTR par défaut.
// Note : on POSITIONNE rtl sur la table racine — les sub-blocks restent
// align="center" / align="left" selon le contexte (les tags HTML inline
// gardent leur direction naturelle via bidi-isolate du browser).
$dirAttr = ($locale === 'ar') ? ' dir="rtl"' : '';
// Helper d'interpolation : remplace {version} et {owner} dans une string
// localisée. NOTE : on ne réencode PAS — les strings dans getEmailStrings
// contiennent volontairement des <strong>, <em>, etc. à rendre comme HTML.
$tr = function (string $key) use ($s, $versionEsc, $ownerEsc): string {
return strtr($s[$key] ?? '', ['{version}' => $versionEsc, '{owner}' => $ownerEsc]);
};
// Wrap les release notes en <pre> pour préserver le Markdown formatting
// (indentation, listes, line breaks). Pas de rendu HTML — l'admin sait que
// ses notes sont en Markdown et l'opérateur peut lire le format brut.
// IMPORTANT : le titre "Release notes" est localisé MAIS le contenu reste
// toujours en anglais (= ce que l'admin a saisi dans la modal Notes).
$notesBlock = $notesEsc !== ''
? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">Release notes</h3>\n"
? "<h3 style=\"color: #1F2937; margin: 24px 0 8px;\">" . htmlspecialchars($s['releaseNotesTitle'], ENT_QUOTES, 'UTF-8') . "</h3>\n"
. "<pre style=\"background: #F3F4F6; padding: 16px; border-radius: 6px; "
. "font-family: 'Cascadia Code', Consolas, monospace; font-size: 13px; "
. "white-space: pre-wrap; word-wrap: break-word; line-height: 1.5; "
. "direction: ltr; text-align: left; "
. "border-left: 3px solid #3B82F6;\">{$notesEsc}</pre>"
: '<p style="color: #6B7280; font-style: italic;">No release notes for this version.</p>';
: '<p style="color: #6B7280; font-style: italic;">' . htmlspecialchars($s['releaseNotesNone'], ENT_QUOTES, 'UTF-8') . '</p>';
// Logo block (optionnel). Image hotlinkée + alt fallback : si le MUA bloque
// les images (Outlook protégé par défaut), l'alt "ASTERION VR" s'affiche
@@ -158,38 +337,50 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
: 'https://asterionvr.com/PS_Launcher/installer/PS_Launcher-Setup.exe';
$installerUrlEsc = htmlspecialchars($installerUrl, ENT_QUOTES, 'UTF-8');
// Toutes les strings utilisateur sont passées par $tr() qui interpole
// {version}/{owner} dans le template localisé pour la $locale demandée.
$titleStr = $tr('title');
$subtitleStr = $tr('subtitle');
$greetingStr = $tr('greeting');
$introStr = $tr('intro');
$instructionsStr = $tr('instructions');
$fiTitleStr = $tr('firstInstallTitle');
$fiBodyStr = $tr('firstInstallBody');
$downloadBtnStr = $tr('downloadButton');
$directLinkStr = $tr('directLink');
$supportStr = $tr('support');
$closingStr = $tr('closing');
$signatureStr = $tr('signature');
$footerStr = $tr('footer');
return <<<HTML
<!DOCTYPE html>
<html>
<html{$dirAttr}>
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"></head>
<body style="margin:0; padding:24px 12px; background-color:#F3F4F6; font-family:'Segoe UI',Arial,sans-serif; color:#1F2937;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" align="center" width="640"
style="max-width:640px; margin:0 auto; border-collapse:collapse;">
style="max-width:640px; margin:0 auto; border-collapse:collapse;"{$dirAttr}>
<tr>
<td bgcolor="#0F172A" align="center"
style="background-color:#0F172A; padding:32px 24px; color:#FFFFFF; border-radius:8px 8px 0 0;">
<div style="margin-bottom:16px;">{$logoBlock}</div>
<h1 style="margin:0; font-size:24px; color:#FFFFFF; font-weight:600;">
PROSERVE v{$versionEsc} is available!
{$titleStr}
</h1>
<p style="margin:8px 0 0; color:#94A3B8; font-size:14px;">
A new version is ready to install via your PROSERVE Launcher.
{$subtitleStr}
</p>
</td>
</tr>
<tr>
<td bgcolor="#FFFFFF"
style="background-color:#FFFFFF; padding:28px 28px; border:1px solid #E5E7EB; border-top:none; border-radius:0 0 8px 8px;">
<p style="margin:0 0 14px;">Hi {$ownerEsc},</p>
<p style="margin:0 0 14px;">{$greetingStr}</p>
<p style="margin:0 0 14px;">
We're happy to let you know that
<strong>PROSERVE version {$versionEsc}</strong>
has just been published and is now available for download.
{$introStr}
</p>
<p style="margin:0 0 14px;">
If you already have the <strong>PROSERVE Launcher</strong> installed,
simply open it on any of your machines, click
<em>« Check for updates »</em>, and install the new version with one click.
{$instructionsStr}
</p>
<!-- Section « Don't have the launcher yet ? » : bouton de téléchargement
@@ -200,13 +391,10 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
<tr>
<td style="padding:18px 0;">
<p style="margin:0 0 12px; font-weight:600; color:#1F2937;">
First install or new workstation?
{$fiTitleStr}
</p>
<p style="margin:0 0 16px; color:#4B5563;">
If this is your first time, or you're setting up a new PC,
download the PROSERVE Launcher installer below.
<strong>You'll need to run it on EACH workstation</strong>
where PROSERVE will be used.
{$fiBodyStr}
</p>
<!-- Bouton stylé via table + bgcolor (vs CSS button) pour
compat Outlook qui ne supporte ni border-radius CSS sur
@@ -222,13 +410,13 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
color:#FFFFFF; text-decoration:none;
font-family:'Segoe UI',Arial,sans-serif;
font-size:14px; font-weight:600;">
⬇ Download PROSERVE Launcher (Windows)
{$downloadBtnStr}
</a>
</td>
</tr>
</table>
<p style="margin:12px 0 0; color:#9CA3AF; font-size:11px; text-align:center;">
Direct link:
{$directLinkStr}
<a href="{$installerUrlEsc}" style="color:#3B82F6; text-decoration:underline; word-break:break-all;">{$installerUrlEsc}</a>
</p>
</td>
@@ -237,19 +425,17 @@ function renderReleaseAnnounceEmail(string $version, string $ownerName, string $
{$notesBlock}
<p style="margin:24px 0 14px;">
As always, if you run into any issue (download failure, install error,
anything weird in-game), don't hesitate to reach out — we're here to help.
{$supportStr}
</p>
<p style="margin:0;">
Happy training!<br/>
<strong>The ASTERION VR team</strong>
{$closingStr}<br/>
<strong>{$signatureStr}</strong>
</p>
</td>
</tr>
<tr>
<td align="center" style="padding:16px 8px; color:#9CA3AF; font-size:11px; line-height:1.5;">
This automated notification was sent because your contact email is registered with one of your PROSERVE licenses.<br/>
To stop receiving these, ask your ASTERION VR account manager to remove your address from the license contacts.
{$footerStr}
</td>
</tr>
</table>
@@ -264,6 +450,26 @@ HTML;
* - assure le suffixe .zip
* - retombe sur la valeur par défaut si l'input est vide ou inexploitable
*/
/**
* Valide un installFolderTemplate côté admin. Contraintes :
* - doit contenir {version} (sinon deux entries au même template résolvent
* au même dossier une fois substitué, cassant l'anti-collision).
* - charset filename-safe : [A-Za-z0-9_.-] + espaces. Pas de séparateur de
* chemin (/, \) ni de caractères spéciaux qui casseraient un basename
* côté client (Path.GetFileName).
* - non vide, longueur raisonnable (backoffice UI n'accepte pas les URLs).
*/
function ps_is_valid_install_folder_template(string $tpl): bool
{
$tpl = trim($tpl);
if ($tpl === '' || strlen($tpl) > 120) return false;
if (!str_contains($tpl, '{version}')) return false;
// On check le "reste" du template (partie non-{version}) — le placeholder
// {version} lui-même contient `{` et `}` qui ne sont pas dans notre whitelist.
$stripped = str_replace('{version}', '', $tpl);
return (bool)preg_match('/^[A-Za-z0-9 _.\-]*$/', $stripped);
}
function ps_normalize_zip_filename(string $raw, string $version): string
{
$default = "proserve-{$version}.zip";
@@ -335,12 +541,43 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!preg_match('/^[A-Za-z0-9_.\-]+\.exe$/', $exeName)) {
$exeName = 'PROSERVE_UE_5_7.exe';
}
// installFolderTemplate — nom du sous-dossier créé côté client par le
// launcher. Doit contenir {version} et être unique par (version, channel)
// pour éviter l'écrasement silencieux à l'install quand deux entries
// partagent un numéro. Default intelligent : si un channel non-default
// est sélectionné, on préfixe avec "PROSERVE-<channel>". Sinon on garde
// le legacy "PROSERVE v{version}".
$installFolderTemplateInput = trim((string)($_POST['install_folder_template'] ?? ''));
if ($installFolderTemplateInput === '') {
$nonDefaultChannels = array_values(array_filter($channels, fn($c) => $c !== 'default'));
$installFolderTemplateInput = count($nonDefaultChannels) === 1
? "PROSERVE-{$nonDefaultChannels[0]} v{version}"
: "PROSERVE v{version}";
}
if (!ps_is_valid_install_folder_template($installFolderTemplateInput)) {
throw new Exception(
"InstallFolderTemplate invalide « {$installFolderTemplateInput} » : doit contenir {version} et n'utiliser que [A-Za-z0-9_.-] et espaces."
);
}
// Validation anti-collision cross-entries : résous le template avec la
// version courante, refuse si une autre entrée résoud au même dossier.
$installFolderResolved = str_replace('{version}', $version, $installFolderTemplateInput);
foreach ($manifest['versions'] as $vExisting) {
$existingTpl = (string)($vExisting['installFolderTemplate'] ?? 'PROSERVE v{version}');
$existingResolved = str_replace('{version}', $vExisting['version'] ?? '', $existingTpl);
if ($existingResolved === $installFolderResolved) {
throw new Exception(
"Collision de dossier d'install : v" . htmlspecialchars($vExisting['version'] ?? '?')
. " utilise déjà le dossier « {$installFolderResolved} ». Choisis un installFolderTemplate distinct (ex : « PROSERVE-{$version}-<channel> v{version} » ou en dur « PROSERVE-<channel> v{version} »)."
);
}
}
$entry = [
'id' => $entryId,
'version' => $version,
'releasedAt' => $releasedAtIso,
'executable' => $exeName,
'installFolderTemplate' => 'PROSERVE v{version}',
'installFolderTemplate' => $installFolderTemplateInput,
'channels' => $channels,
'download' => [
'url' => "{$base}/builds/{$zipFilename}",
@@ -408,6 +645,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
$manifest['versions'][$idx]['download']['sizeBytes'] = 0;
}
// installFolderTemplate — édition optionnelle. Laisser vide = ne pas
// changer. Valide + anti-collision cross-entries obligatoire.
$newFolderTpl = trim((string)($_POST['install_folder_template'] ?? ''));
if ($newFolderTpl !== '') {
if (!ps_is_valid_install_folder_template($newFolderTpl)) {
throw new Exception(
"InstallFolderTemplate invalide « {$newFolderTpl} » : doit contenir {version} et n'utiliser que [A-Za-z0-9_.-] et espaces."
);
}
$newFolderResolved = str_replace('{version}', $version, $newFolderTpl);
foreach ($manifest['versions'] as $other) {
if (($other['id'] ?? '') === $entryId) continue;
$otherTpl = (string)($other['installFolderTemplate'] ?? 'PROSERVE v{version}');
$otherResolved = str_replace('{version}', $other['version'] ?? '', $otherTpl);
if ($otherResolved === $newFolderResolved) {
throw new Exception(
"Collision de dossier d'install : v" . htmlspecialchars($other['version'] ?? '?')
. " utilise déjà le dossier « {$newFolderResolved} ». Choisis un installFolderTemplate distinct."
);
}
}
$manifest['versions'][$idx]['installFolderTemplate'] = $newFolderTpl;
}
saveManifest($manifestPath, $manifest);
$message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.');
}
@@ -485,11 +745,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root);
$force = !empty($_POST['force']);
// SignManifest::run filtre par numéro de version ; en cas d'entries
// de même version sur des channels différents, le hash sera recalculé
// pour toutes celles qui matchent (chacune pointe sur son propre ZIP,
// donc le résultat est correct, juste un peu plus de boulot).
$result = $signer->run('versions', $force, $version);
// Filtre par entryId (pas par numéro de version). Sinon, quand deux
// entrées partagent le même numéro (channels firefighter vs full sur
// 1.5.4.32 p.ex.), les DEUX ZIPs sont hashés dans la même requête HTTP
// → 2 × 14 Go = risque de dépasser le timeout front OVH sur mutualisé.
$result = $signer->run('versions', $force, null, $entryId);
$forceLabel = $force ? ' [FORCE]' : '';
$message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error';
@@ -547,10 +807,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$notesFile = "$notesDir/{$entryId}.md";
$notesBody = is_file($notesFile) ? file_get_contents($notesFile) : '';
// Récupère les licenses éligibles
// Récupère les licenses éligibles. On inclut maintenant `language`
// pour pouvoir localiser l'email — fallback 'en' si NULL ou inconnu.
$db = Db::get($config);
$stmt = $db->prepare(
'SELECT id, owner_name, channel, can_see_betas, contact_emails, download_entitlement_until
'SELECT id, owner_name, channel, can_see_betas, contact_emails, language, download_entitlement_until
FROM licenses
WHERE revoked_at IS NULL
AND download_entitlement_until >= ?
@@ -603,7 +864,15 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (empty($emails)) continue;
$licsTouched++;
$subject = "PROSERVE v{$version} is now available";
// Locale par license : whitelist contre les langues supportées,
// fallback 'en' pour les licenses sans préférence définie.
$locale = trim((string)($lic['language'] ?? ''));
if (!in_array($locale, ['fr', 'en', 'zh', 'th', 'ar'], true)) {
$locale = 'en';
}
// Sujet localisé via le même dictionnaire que le corps.
$subjectTemplate = getEmailStrings($locale)['subject'] ?? 'PROSERVE v{version} is now available';
$subject = str_replace('{version}', $version, $subjectTemplate);
// Logo via CID inline si fichier disponible sur disque, sinon
// chaîne vide → le template tombera sur le fallback texte
// "ASTERION VR" en blanc sur le bandeau dark navy. On NE
@@ -617,7 +886,8 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$lic['owner_name'] ?? '',
$notesBody,
$config['base_url'] ?? '',
$logoSrc
$logoSrc,
$locale
);
foreach ($emails as $email) {
@@ -735,6 +1005,11 @@ Layout::header('Versions', 'versions');
<label>Nom de l'exécutable <span class="muted" style="font-weight: normal; font-size: 11px;">(le .exe à lancer dans le dossier d'install — varie selon la version d'Unreal Engine du build)</span></label>
<input type="text" name="executable" placeholder="PROSERVE_UE_5_7.exe" value="PROSERVE_UE_5_7.exe" required pattern="[A-Za-z0-9_.\-]+\.exe" maxlength="120">
</div>
<div class="field">
<label>Dossier d'install côté client <span class="muted" style="font-weight: normal; font-size: 11px;">(optionnel, defaut <code>PROSERVE v{version}</code> ; DOIT contenir <code>{version}</code>. Utilise un nom distinct par channel si tu as plusieurs entrées au même numéro de version — ex. <code>PROSERVE-firefighter v{version}</code>)</span></label>
<input type="text" name="install_folder_template" placeholder="PROSERVE v{version}" maxlength="120" pattern="[A-Za-z0-9 _.\-\{\}]+"
title="Nom du sous-dossier créé par le launcher dans le InstallRoot. DOIT contenir {version}. Sur un channel non-default, préfixe avec le nom du channel pour éviter d'écraser une install d'un autre channel au même numéro de version.">
</div>
<div class="field">
<label>Channels qui voient cette version <span class="muted" style="font-weight: normal; font-size: 11px;">(coche au moins « default » pour rendre publique, ou un channel privé pour la cibler)</span></label>
<div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;">
@@ -965,6 +1240,15 @@ Layout::header('Versions', 'versions');
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer">
<p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p>
</div>
<div class="field">
<label>Dossier d'install côté client
<span class="muted" style="font-weight: normal;">
(actuel : <code><?= htmlspecialchars($v['installFolderTemplate'] ?? 'PROSERVE v{version}') ?></code>)
</span>
</label>
<input type="text" name="install_folder_template" placeholder="laisse vide pour ne rien changer" maxlength="120">
<p class="muted" style="font-size: 11px;">DOIT contenir <code>{version}</code>. Change-le si deux entries au même numéro se retrouvent à cibler le même dossier (ex : <code>PROSERVE-firefighter v{version}</code>).</p>
</div>
<div class="modal-footer" style="margin: 20px -20px -20px;">
<button type="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Méta</button>

View File

@@ -75,12 +75,36 @@ final class DownloadUrl
Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
}
$manifest = json_decode(file_get_contents($manifestPath), true);
// Filename attendu, envoyé par le client (extrait de son propre manifest signé).
// Sert à disambiguer les manifestes multi-channels où plusieurs entrées
// partagent le même numéro de version (ex : proserve-firefighter-1.5.4.32 et
// proserve-full-1.5.4.32 sur v1.5.4.32). Sans ce filtre, le foreach ci-dessous
// retournait la 1re entrée matchant le numéro → l'URL signée pointait vers le
// MAUVAIS ZIP, et le client détectait le mismatch filename manifest vs signé
// et abortait (garde-fou côté MainViewModel.InstallVersionAsync). Optionnel
// pour rétro-compat avec les vieux clients (v1.0.4-) qui ne l'envoient pas ;
// dans ce cas la 1re entrée gagne, comme avant.
$expectedFilename = trim((string)($_GET['filename'] ?? ''));
// Whitelist défensive — même règle que la validation du filename lu du manifest
// plus bas dans cette route. Bloque path traversal via query param.
if ($expectedFilename !== '' && !preg_match('/^[a-zA-Z0-9._-]+\.zip$/', $expectedFilename)) {
Response::error('invalid_filename', "Query filename invalide : '{$expectedFilename}'", 400);
}
$entry = null;
foreach ($manifest['versions'] ?? [] as $v) {
if (($v['version'] ?? '') === $version) { $entry = $v; break; }
if (($v['version'] ?? '') !== $version) continue;
if ($expectedFilename !== '') {
$entryFilename = basename(parse_url((string)($v['download']['url'] ?? ''), PHP_URL_PATH) ?: '');
if ($entryFilename !== $expectedFilename) continue;
}
$entry = $v;
break;
}
if (!$entry) {
Response::error('version_not_found', "Version {$version} absente du manifest", 404);
$suffix = $expectedFilename !== '' ? " (filename attendu : {$expectedFilename})" : '';
Response::error('version_not_found', "Version {$version} absente du manifest{$suffix}", 404);
}
// Vérif droits téléchargement

View File

@@ -40,9 +40,17 @@ final class Manifest
$clientChannel = null;
}
// Multi-channel opt-in : les clients v1.0.10+ savent afficher plusieurs
// entries au même numéro de version (channels distincts). Ils passent
// ?multiChannel=1 pour signaler la capabilité. Les clients v1.0.9- (et
// avant refactor row-by-Id) crasheraient sur ToDictionary(v.Version) →
// le serveur reste en mode dédup pour eux (comportement historique).
$multiChannel = !empty($_GET['multiChannel']);
$manifest['versions'] = self::filterVersions(
$manifest['versions'] ?? [],
$clientChannel
$clientChannel,
$multiChannel
);
// Re-signature à la volée. La clé privée Ed25519 est dans config.php
@@ -85,7 +93,7 @@ final class Manifest
* @param array<int, array<string,mixed>> $versions
* @return list<array<string,mixed>>
*/
private static function filterVersions(array $versions, ?string $clientChannel): array
private static function filterVersions(array $versions, ?string $clientChannel, bool $multiChannel = false): array
{
// Étape 1 : filter visible par ce client
$visible = [];
@@ -102,7 +110,18 @@ final class Manifest
}
}
// Étape 2 : group by version, pick most specific per group
// Client v1.0.10+ : bypass la dédup, retourne toutes les entries visibles.
// Le client sait maintenant afficher plusieurs rows au même numéro (keyage
// par entryId côté RebuildList) et bloque les collisions d'install côté
// guard. Le badge channel n'apparaît que si plusieurs entries partagent
// le numéro (v1.0.9+) → UX propre.
if ($multiChannel) {
return array_map(fn($item) => $item['entry'], $visible);
}
// Étape 2 (clients v1.0.9-) : group by version, pick most specific per
// group. Comportement historique pour ne pas crasher les vieux clients
// qui font ToDictionary(v.Version) et exploseraient sur des duplicates.
$byVersion = [];
foreach ($visible as $item) {
$key = (string)($item['entry']['version'] ?? '?');

View File

@@ -0,0 +1,11 @@
-- PS_Launcher schema v5
-- Ajoute language sur la table licenses : code de langue 2-letter (fr/en/zh/th/ar)
-- utilisé pour localiser les emails de notification de release. NULL = English
-- (defaut conservateur pour les licenses existantes qui n'ont pas été éditées).
-- Le contenu de la release note elle-même reste TOUJOURS en anglais — seul le
-- texte d'accompagnement (greeting, instructions, CTAs) est localisé.
--
-- À jouer après 004_license_contact_emails.sql.
-- migrate.php attrape "Duplicate column" comme idempotent.
ALTER TABLE licenses ADD COLUMN language VARCHAR(8) NULL AFTER contact_emails;

View File

@@ -83,7 +83,37 @@ final class SignManifest
}
$start = microtime(true);
$sha = hash_file('sha256', $path);
// Streamed hash en chunks 16 Mo. Deux bénéfices vs hash_file() atomique :
// 1. On peut envoyer un heartbeat (flush()) entre chunks pour que le
// proxy Apache/OVH ne timeout pas la requête pendant les ~5-10 min
// que prend un SHA-256 sur un ZIP de 14 Go via SAN mutualisé.
// 2. On peut relever set_time_limit() à chaque chunk (fenêtre glissante)
// au lieu de faire un unique set_time_limit(0) à la caller.
// Mémoire : hash_update_stream() ne buffere pas — c'est du streaming pur.
$fp = @fopen($path, 'rb');
if ($fp === false) {
return ['sha256' => '', 'fromCache' => false, 'durationMs' => 0];
}
try {
$ctx = hash_init('sha256');
// 16 Mo = compromis entre nombre d'appels PHP et pression CPU par read()
$chunkBytes = 16 * 1024 * 1024;
while (!feof($fp)) {
hash_update_stream($ctx, $fp, $chunkBytes);
// Fenêtre glissante : autorise ~5 min de plus avant que PHP ne
// timeout. Sur un fichier de 14 Go / chunks 16 Mo = ~900 itérations,
// donc si un chunk prend >5 min c'est vraiment que le disque est HS.
@set_time_limit(300);
// Heartbeat côté front — évite Apache RequestTimeout / OVH proxy
// timeout sur les grosses requêtes. Silencieux si output buffering
// est actif (pas fatal).
@ob_flush();
@flush();
}
$sha = hash_final($ctx);
} finally {
fclose($fp);
}
$duration = (int)((microtime(true) - $start) * 1000);
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha];
@@ -109,11 +139,57 @@ final class SignManifest
* @param bool $force Si true, ignore le cache et recalcule tous les hashs
* @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
* versions[] (les autres restent inchangées). Le scope 'launcher'
* est ignoré dans ce cas.
* est ignoré dans ce cas. ATTENTION : si plusieurs entrées
* partagent le même numéro de version (channels firefighter
* vs full sur 1.5.4.32 p.ex.), TOUTES sont hashées — d'où
* $onlyEntryId ci-dessous pour cibler une seule ligne.
* @param ?string $onlyEntryId Si non null, ne touche QUE l'entrée avec cet id (généré par
* generate_entry_id() au backoffice). Prend le pas sur
* $onlyVersion. Utilisé par le bouton « 🔁 Hash » d'une
* ligne isolée pour éviter de re-hasher les autres channels
* qui partagent le même numéro de version (2 × 14 Go dans une
* requête HTTP → risque de timeout front OVH).
* @return array{ok:bool, log:string[]}
*/
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null): array
public function run(string $scope = 'all', bool $force = false, ?string $onlyVersion = null, ?string $onlyEntryId = null): array
{
// Sur OVH mutualisé, un SHA-256 d'un ZIP de 14 Go peut prendre 5-10 min via
// le SAN partagé. Le max_execution_time par défaut (30-60s) tue le process
// → Apache retourne 500 Internal Server Error avec le boilerplate
// postmaster@… — c'est le mode d'échec principal du bouton « Hash » au
// backoffice. On désactive la limite ici (couvre AUSSI les callers CLI et
// cron, pas seulement l'admin web). ignore_user_abort évite qu'un refresh
// ou une fermeture d'onglet côté opérateur interrompe un hash en cours.
@set_time_limit(0);
@ignore_user_abort(true);
// Capture toutes les erreurs PHP (warnings + fatals + exceptions non-catchées)
// dans un fichier accessible via SFTP, à côté du manifest. Sur OVH mutualisé
// les logs Apache ne sont accessibles que via le manager web — pas pratique
// pour un diagnostic rapide de 500. Ce fichier permet à l'opérateur de le
// grep post-clic sans passer par le manager.
$errorLogPath = dirname($this->manifestPath) . '/.signmanifest-error.log';
@ini_set('log_errors', '1');
@ini_set('error_log', $errorLogPath);
@error_reporting(E_ALL);
$ts = date('Y-m-d H:i:s');
@file_put_contents($errorLogPath,
"[{$ts}] --- SignManifest::run(scope={$scope}, force=" . ($force?'1':'0')
. ", onlyVersion=" . ($onlyVersion ?? 'null')
. ", onlyEntryId=" . ($onlyEntryId ?? 'null') . ") ---\n",
FILE_APPEND);
// Fatal errors → capturés par un shutdown handler. Sinon Apache renvoie
// juste 500 sans qu'on sache ce qui a claqué.
register_shutdown_function(function () use ($errorLogPath) {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR, E_USER_ERROR], true)) {
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] FATAL " . $err['type']
. " : {$err['message']} @ {$err['file']}:{$err['line']}\n",
FILE_APPEND);
}
});
if (!is_file($this->manifestPath)) {
$this->out("Manifest introuvable : {$this->manifestPath}");
return ['ok' => false, 'log' => $this->log];
@@ -127,9 +203,9 @@ final class SignManifest
$doVersions = ($scope === 'all' || $scope === 'versions');
$doLauncher = ($scope === 'all' || $scope === 'launcher');
if ($onlyVersion !== null) {
// Mode "hash une seule version" : on ne touche pas au launcher,
// et on ne hash que la version demandée dans la section versions[].
if ($onlyVersion !== null || $onlyEntryId !== null) {
// Mode "hash une seule entrée / version" : on ne touche pas au launcher,
// et on ne hash que ce qui correspond au filtre demandé dans versions[].
$doLauncher = false;
$doVersions = true;
}
@@ -141,7 +217,15 @@ final class SignManifest
$hashedVersions = [];
if ($doVersions) foreach ($manifest['versions'] as &$v) {
$version = $v['version'] ?? '?';
if ($onlyVersion !== null && $version !== $onlyVersion) {
$entryId = (string)($v['id'] ?? '');
// Filtre par id d'entrée prioritaire sur filtre par version (cas
// channels multiples partageant un numéro de version identique —
// ex : proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32).
// Sinon, filtre par numéro de version (rétro-compat).
if ($onlyEntryId !== null && $entryId !== $onlyEntryId) {
continue;
}
if ($onlyEntryId === null && $onlyVersion !== null && $version !== $onlyVersion) {
continue; // skip silently les autres versions
}
$url = $v['download']['url'] ?? '';
@@ -202,7 +286,26 @@ final class SignManifest
}
}
$r = $this->getOrComputeSha256($zip, $cache, $force);
// Log préalable au hash — utile pour identifier QUEL ZIP a fait
// planter le process si on retombe sur le 500. Sans ça, l'error log
// dit juste "PHP Fatal…" sans savoir si c'est le 1er ou le Nième ZIP.
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] START hash $version$zip ($size octets)\n",
FILE_APPEND);
try {
$r = $this->getOrComputeSha256($zip, $cache, $force);
} catch (\Throwable $e) {
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] EXCEPTION during hash of $version : "
. get_class($e) . " : {$e->getMessage()} @ {$e->getFile()}:{$e->getLine()}\n"
. $e->getTraceAsString() . "\n",
FILE_APPEND);
$this->out(" [ERROR] $version : hash failed — " . $e->getMessage());
continue;
}
@file_put_contents($errorLogPath,
"[" . date('Y-m-d H:i:s') . "] DONE hash $version → sha256={$r['sha256']} ({$r['durationMs']} ms, fromCache=" . ($r['fromCache']?'1':'0') . ")\n",
FILE_APPEND);
$cacheChanged = true;
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");

View File

@@ -120,6 +120,19 @@ public partial class App : Application
Log.Information("PSLauncher starting (logs in {Path})", LogsDirectory);
// RENDER MODE : sur certains environnements (RDP, VMs sans hardware accel,
// GPU émulé, Intel HD anciens, Windows Server sans Desktop Experience…)
// l'accélération matérielle WPF plante en silence : la fenêtre s'affiche
// toute blanche, aucun contenu ne rend, aucune exception ne remonte. C'est
// un bug classique non rattrapable côté .NET parce que le pipeline graphique
// tombe entre les mailles du try/catch. On force le rendu software dans 3 cas :
// 1. RenderCapability.Tier == 0 (WPF lui-même détecte aucune accel possible)
// 2. Session Bureau à distance (RDP) détectée
// 3. Var d'env PSLAUNCHER_SOFTWARE_RENDER=1 (override manuel pour debug)
// Trade-off : rendu software plus lent (~30% impact sur le scrolling de la
// liste des versions), mais au moins ça affiche QUELQUE CHOSE.
ApplyRenderModeFallback();
AppDomain.CurrentDomain.UnhandledException += (_, args) =>
{
// CRITIQUE : on flush AVANT que le process meure. Sans ça, sur les
@@ -317,6 +330,55 @@ public partial class App : Application
window.Show();
}
/// <summary>
/// Décide entre rendu hardware (par défaut WPF) et rendu software, en fonction
/// de l'environnement détecté + override par variable d'environnement. Log
/// la décision pour faciliter le debug si l'utilisateur reporte un écran blanc.
/// </summary>
private static void ApplyRenderModeFallback()
{
try
{
// Override manuel : PSLAUNCHER_SOFTWARE_RENDER=1 force le software.
// Le user peut le set dans les variables d'environnement Windows OU dans
// le raccourci .lnk (champ Cible : "cmd /c set PSLAUNCHER_SOFTWARE_RENDER=1 && start PS_Launcher.exe")
var envOverride = Environment.GetEnvironmentVariable("PSLAUNCHER_SOFTWARE_RENDER");
var manualSoftware = !string.IsNullOrEmpty(envOverride)
&& (envOverride == "1" || envOverride.Equals("true", StringComparison.OrdinalIgnoreCase));
// RenderCapability.Tier renvoie un int packed : (tier << 16). Tier 0 = pas
// d'accélération matérielle possible. Tier 1 = partielle. Tier 2 = complète.
var tier = System.Windows.Media.RenderCapability.Tier >> 16;
// GetSystemMetrics(SM_REMOTESESSION) = 1 si la session est une session RDP.
// SM_REMOTESESSION constant = 0x1000 (4096).
var isRdp = GetSystemMetrics(0x1000) != 0;
var shouldUseSoftware = manualSoftware || tier == 0 || isRdp;
Log.Information("Render env : Tier={Tier} RDP={Rdp} EnvOverride={EnvOverride} → SoftwareOnly={Sw}",
tier, isRdp, manualSoftware, shouldUseSoftware);
if (shouldUseSoftware)
{
System.Windows.Media.RenderOptions.ProcessRenderMode =
System.Windows.Interop.RenderMode.SoftwareOnly;
Log.Warning("WPF render mode forced to SoftwareOnly (cause: " +
(manualSoftware ? "env override" : tier == 0 ? "Tier=0 no GPU accel" : "RDP session") + ")");
}
}
catch (Exception ex)
{
// Pas critique : si le fallback échoue, on continue avec le default
// hardware accel et on espère que ça marche. Le user verra un écran
// blanc si non, mais au moins l'app aura pas crashé.
Log.Warning(ex, "Failed to evaluate render-mode fallback, keeping default");
}
}
[DllImport("user32.dll")]
private static extern int GetSystemMetrics(int nIndex);
protected override void OnExit(ExitEventArgs e)
{
Log.Information("PSLauncher shutting down");

View File

@@ -18,9 +18,9 @@
<Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace>
<Version>0.29.10</Version>
<AssemblyVersion>0.29.10.0</AssemblyVersion>
<FileVersion>0.29.10.0</FileVersion>
<Version>1.0.13</Version>
<AssemblyVersion>1.0.13.0</AssemblyVersion>
<FileVersion>1.0.13.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile>
@@ -47,7 +47,7 @@
Le runtime est pré-installé sur Win11 et auto-mis-à-jour via Windows
Update sur Win10 récent. Si absent, le contrôle affiche une erreur
et on aiguille l'user vers https://go.microsoft.com/fwlink/p/?LinkId=2124703 -->
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2792.45" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3800.47" />
</ItemGroup>
<ItemGroup>

View File

@@ -543,7 +543,14 @@ public sealed partial class MainViewModel : ObservableObject
var preserveActive = oldActive is not null
&& oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying;
var installed = _registry.Scan().ToDictionary(v => v.Version);
// Scan disque. Chaque install a un FolderPath (basename unique par install)
// et éventuellement un EntryId (lu depuis .proserve-meta.json — présent
// pour les installs faits par v1.0.8+, null pour les plus anciens).
var installedList = _registry.Scan();
// Index par folder name pour le matching de fallback (installs anciens
// sans EntryId, ou remote entries sans Id).
var installedByFolderName = installedList
.ToDictionary(v => Path.GetFileName(v.FolderPath), StringComparer.OrdinalIgnoreCase);
// Filtrage BÊTA : si la license n'a pas le flag canSeeBetas, on cache
// les versions taggées isBeta=true. Les installations locales déjà
@@ -555,23 +562,95 @@ public sealed partial class MainViewModel : ObservableObject
var remote = canSeeBetas
? rawRemote
: rawRemote.Where(v => !v.IsBeta).ToList();
var remoteByVer = remote.ToDictionary(v => v.Version);
var allVersions = installed.Keys.Union(remoteByVer.Keys)
.OrderByDescending(v => SemVer.Parse(v))
.ToList();
// Row identity : Id de l'entrée manifest quand dispo (unique par entry,
// survit à un rename de folder), sinon fallback sur le folder name
// résolu. Ce système survit au cas où deux entries partagent un
// installFolderTemplate identique (bug d'admin) — on garde les deux
// rows dans l'UI, l'install guard client bloquera l'écrasement au
// moment de l'install.
static string RemoteRowKey(VersionManifest v)
=> !string.IsNullOrEmpty(v.Id) ? v.Id! : v.GetInstallFolderName();
var rows = new List<VersionRowViewModel>();
foreach (var ver in allVersions)
var remoteByRowKey = new Dictionary<string, VersionManifest>(StringComparer.OrdinalIgnoreCase);
foreach (var v in remote)
{
VersionRowViewModel row;
if (installed.TryGetValue(ver, out var inst))
var key = RemoteRowKey(v);
if (!remoteByRowKey.TryAdd(key, v))
{
row = VersionRowViewModel.ForInstalled(inst, remoteByVer.GetValueOrDefault(ver));
_logger.LogWarning(
"Manifest duplicate row key « {Key} » (versions {V1} and {V2}). Second entry dropped.",
key, remoteByRowKey[key].Version, v.Version);
}
}
// Match installed ↔ remote — priorité à l'EntryId (identifie l'entrée
// exacte même si le folder a été renommé côté opérateur), fallback sur
// le folder name. Sur les installs anciens sans EntryId dans le meta,
// seul le folder name est utilisé (comportement legacy).
var installedByRowKey = new Dictionary<string, InstalledVersion>(StringComparer.OrdinalIgnoreCase);
foreach (var inst in installedList)
{
var folderName = Path.GetFileName(inst.FolderPath);
string rowKey;
if (!string.IsNullOrEmpty(inst.EntryId))
{
// Meta contient l'entryId — clé canonique.
rowKey = inst.EntryId!;
}
else
{
row = VersionRowViewModel.ForRemote(remoteByVer[ver]);
// Legacy install : essaie de retrouver l'entrée remote par folder
// name (unique dans le manifest sur setup sain). Si match, on
// hérite de son Id comme rowKey. Sinon, c'est un install orphelin
// → clé = folder name.
var remoteMatch = remote.FirstOrDefault(r =>
string.Equals(r.GetInstallFolderName(), folderName, StringComparison.OrdinalIgnoreCase));
rowKey = remoteMatch is not null && !string.IsNullOrEmpty(remoteMatch.Id)
? remoteMatch.Id!
: folderName;
}
installedByRowKey.TryAdd(rowKey, inst);
}
// Tri combiné — on ordonne par VersionOrder (SemVer + isBeta), en
// lookupant version + isBeta depuis les deux dicos par rowKey.
// isBeta est lu depuis `rawRemote` (non filtré par canSeeBetas) pour
// que la comparaison reste correcte même si une beta a été retirée
// de la vue courante.
var rawByRowKey = rawRemote
.GroupBy(RemoteRowKey, StringComparer.OrdinalIgnoreCase)
.ToDictionary(g => g.Key, g => g.First(), StringComparer.OrdinalIgnoreCase);
(string Version, bool IsBeta) VersionAndBeta(string rowKey)
{
if (rawByRowKey.TryGetValue(rowKey, out var r)) return (r.Version, r.IsBeta);
if (installedByRowKey.TryGetValue(rowKey, out var i)) return (i.Version, false);
return (rowKey, false);
}
var allRowKeys = installedByRowKey.Keys
.Union(remoteByRowKey.Keys, StringComparer.OrdinalIgnoreCase)
.OrderByDescending(k => k, Comparer<string>.Create((a, b) =>
{
var (va, ba) = VersionAndBeta(a);
var (vb, bb) = VersionAndBeta(b);
return VersionOrder.Compare(va, ba, vb, bb);
}))
.ToList();
var rows = new List<VersionRowViewModel>();
foreach (var rowKey in allRowKeys)
{
VersionRowViewModel row;
var hasInst = installedByRowKey.TryGetValue(rowKey, out var inst);
var hasRem = remoteByRowKey.TryGetValue(rowKey, out var rem);
if (hasInst)
{
row = VersionRowViewModel.ForInstalled(inst!, rem);
}
else
{
row = VersionRowViewModel.ForRemote(rem!);
}
WireRowHandlers(row);
rows.Add(row);
@@ -598,7 +677,9 @@ public sealed partial class MainViewModel : ObservableObject
// bon objet et que la barre de progress de la row avance live.
if (preserveActive && oldActive is not null)
{
var matching = rows.FirstOrDefault(r => r.Version == oldActive.Version);
// Match par RowKey (folder name) plutôt que Version : sur du multi-
// channel deux rows peuvent partager le numéro de version.
var matching = rows.FirstOrDefault(r => r.RowKey == oldActive.RowKey);
if (matching is not null)
{
matching.State = oldActive.State;
@@ -609,6 +690,15 @@ public sealed partial class MainViewModel : ObservableObject
}
}
// Marque les rows qui partagent un numéro de version avec au moins UNE
// autre row visible → active leur badge channel (sinon caché pour ne pas
// polluer l'UI mono-channel). Group by Version, count >= 2 → conflict.
foreach (var grp in rows.GroupBy(r => r.Version, StringComparer.OrdinalIgnoreCase))
{
bool hasConflict = grp.Count() >= 2;
foreach (var r in grp) r.HasVersionConflict = hasConflict;
}
// Featured : plus haute installée, sinon plus haute distante
var featured = rows.FirstOrDefault(r => r.IsInstalled) ?? rows.FirstOrDefault();
FeaturedVersion = featured;
@@ -747,7 +837,13 @@ public sealed partial class MainViewModel : ObservableObject
// on garde le cache silencieusement (pas de crash).
await RefreshLicenseFromServerAsync(CancellationToken.None);
var result = await _updateChecker.CheckAsync(CancellationToken.None);
// Passe canSeeBetas depuis la license courante — UpdateChecker exclut les
// betas de la sélection LatestRemote quand le client n'a pas les droits.
// Évite le popup « MAJ dispo : 1.5.4.32 beta » pour un client qui ne peut
// pas voir cette version dans la liste après filtrage RebuildList.
var result = await _updateChecker.CheckAsync(
_license?.CanSeeBetas ?? false,
CancellationToken.None);
if (result.Error is not null)
{
StatusMessage = Strings.StatusError(result.Error);
@@ -1090,13 +1186,22 @@ public sealed partial class MainViewModel : ObservableObject
try
{
// Si on lance la version désignée comme "auto", on passe les CLI args
// configurés (ex: -autoconnect -login=Jerome2 -password=… -ipserver=10.0.4.100).
// Pour les lancements manuels d'autres versions, args = null (comportement standard).
// Composition des CLI args passés à PROSERVE_UE_*.exe :
// 1. _config.DefaultLaunchArgs — appliqués à CHAQUE lancement (auto ou manuel).
// Contenu type : -nosplash, -log, -fps=90.
// 2. _config.AutoMode.Args — appliqués UNIQUEMENT si la version lancée est la
// version auto désignée. Contenu type : -autoconnect, -login=Jerome,
// -password=…, -ipserver=10.0.4.100.
// Concaténation dans cet ordre → Unreal FParse lit dans l'ordre, la dernière
// occurrence d'une clé gagne : les auto args écrasent les default args sur un
// même key (comportement intentionnel : un opérateur peut avoir -fps=90 en
// default et -fps=120 en auto sans conflit).
var isAutoVersion = _config.AutoMode.FeatureEnabled
&& !string.IsNullOrEmpty(_config.AutoMode.SelectedVersion)
&& string.Equals(_config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal);
string[]? cliArgs = isAutoVersion ? BuildAutoModeCliArgs(_config.AutoMode.Args) : null;
var argList = new List<AutoModeArg>(_config.DefaultLaunchArgs);
if (isAutoVersion) argList.AddRange(_config.AutoMode.Args);
string[]? cliArgs = argList.Count > 0 ? BuildAutoModeCliArgs(argList) : null;
var proc = _processLauncher.Launch(row.Installed, cliArgs);
_runningProserve = proc;
@@ -1441,8 +1546,14 @@ public sealed partial class MainViewModel : ObservableObject
var target = _config.AutoMode.SelectedVersion;
if (string.IsNullOrEmpty(target)) return;
var row = (FeaturedVersion?.Version == target ? FeaturedVersion : null)
?? OtherVersions.FirstOrDefault(r => r.Version == target);
// AutoMode config stocke le numéro de version (pas le RowKey). Sur un
// setup correctement configuré (multi-channel avec installFolderTemplate
// distincts par channel), au plus UNE row installée peut partager ce
// numéro à la fois — l'install guard client (v1.0.8+) empêche l'écrasement.
// Donc la comparaison par Version est safe. Si l'invariant est violé
// (setup manuel bidouillé), le premier match par ordre de tri gagne.
var row = (FeaturedVersion?.Version == target && FeaturedVersion.IsInstalled ? FeaturedVersion : null)
?? OtherVersions.FirstOrDefault(r => r.Version == target && r.IsInstalled);
if (row is null || !row.IsInstalled)
{
_logger.LogInformation("Auto-launch skipped: v{Version} not installed locally", target);
@@ -1473,10 +1584,49 @@ public sealed partial class MainViewModel : ObservableObject
try
{
// ------------------------------------------------------------------
// Install guard anti-collision multi-channels : refuser AVANT de DL
// 14 Go si le dossier cible contient déjà un install d'une AUTRE
// entrée manifest (ex : firefighter et full à même version qui
// pointent tous deux sur "PROSERVE v1.5.4.32"). Sans ça, le ZIP
// installer renomme l'existant en .bak-{ts} puis le supprime en
// arrière-plan quelques secondes plus tard — le premier install
// disparaît silencieusement.
//
// Décision de collision : dossier cible existe ET son
// .proserve-meta.json a un entryId ET cet entryId ≠ celui qu'on
// s'apprête à installer. Si l'un des deux entryId est absent
// (install antérieur à cette feature, ou manifest sans id), on
// ne peut pas prouver la collision → on laisse passer (fail-open,
// le ré-install écrase mais l'opérateur peut retrofit ensuite).
// ------------------------------------------------------------------
var targetForGuard = Path.Combine(_config.InstallRoot, row.Remote.GetInstallFolderName());
if (Directory.Exists(targetForGuard) && !string.IsNullOrEmpty(row.Remote.Id))
{
var existingEntryId = _registry.TryReadEntryId(targetForGuard);
if (existingEntryId is not null && existingEntryId != row.Remote.Id)
{
_logger.LogError(
"Install guard : collision on {Target} — existing entryId={Existing}, new entryId={New} (v{Version})",
targetForGuard, existingEntryId, row.Remote.Id, row.Version);
ThemedMessageBox.Show(
Strings.MsgInstallCollision(row.Version, row.Remote.GetInstallFolderName()),
Strings.MsgBoxError,
MessageBoxButton.OK, MessageBoxImage.Error);
return;
}
}
// Si reprise d'un DL interrompu, on saute la popup de release notes :
// l'utilisateur a déjà confirmé sa décision la première fois.
var isResume = row.HasResumableDownload;
// Préservation des savegames : par défaut TRUE (option cochée dans la
// popup). Si l'install est un resume, l'utilisateur a déjà confirmé sa
// décision la première fois — on garde le défaut (préserver) plutôt que
// de re-bother avec un dialog.
bool preserveSaveGames = true;
if (!isResume)
{
// 1) Récupère release notes (best effort).
@@ -1503,6 +1653,7 @@ public sealed partial class MainViewModel : ObservableObject
var dialog = new UpdateAvailableDialog(row.Remote, notes) { Owner = Application.Current.MainWindow };
dialog.ShowDialog();
if (!dialog.DownloadRequested) return;
preserveSaveGames = dialog.PreserveSaveGames;
}
// 3) Download — résolution d'URL en deux temps :
@@ -1524,7 +1675,15 @@ public sealed partial class MainViewModel : ObservableObject
else
{
_currentPeerHost = null;
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, ct);
// Extrait le filename attendu depuis l'URL du manifest signé pour le
// passer à /download-url. Nécessaire sur les manifestes multi-channels
// où plusieurs entrées partagent un numéro de version (firefighter vs
// full sur v1.5.4.32) : sans ça le serveur retourne l'URL du 1er match
// par numéro → mismatch filename → abort avec l'erreur InvalidOperation
// ci-dessous. Rétro-compat : un serveur qui ignore ?filename= retombe
// sur son comportement historique.
var expectedFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath);
var signed = await _licenseService.GetSignedDownloadUrlAsync(row.Version, expectedFilename, ct);
var urlString = signed ?? row.Remote.Download.Url;
url = new Uri(urlString);
@@ -1566,7 +1725,10 @@ public sealed partial class MainViewModel : ObservableObject
async Task<Uri?> RefreshUrlAsync(CancellationToken c)
{
if (peerSrc is not null) return peerSrc.ZipUrl;
var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, c);
// Passe le filename attendu — le refresh doit re-signer la même
// ligne channel qu'à l'origine, pas la 1re entrée par version.
var refreshFilename = Path.GetFileName(new Uri(row.Remote.Download.Url).AbsolutePath);
var fresh = await _licenseService.GetSignedDownloadUrlAsync(row.Version, refreshFilename, c);
return fresh is null ? null : new Uri(fresh);
}
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256)
@@ -1654,13 +1816,38 @@ public sealed partial class MainViewModel : ObservableObject
try
{
if (!string.IsNullOrWhiteSpace(row.Remote.Executable))
await _registry.WriteInstallMetadataAsync(target, row.Remote.Executable, ct);
await _registry.WriteInstallMetadataAsync(target, row.Remote.Executable, row.Remote.Id, ct);
}
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to write install metadata (will fall back to glob detection)");
}
// Préservation des savegames — copie les .sav (PROSERVE_UE_*/Saved/SaveGames/*)
// depuis la version installée la plus récente AUTRE que celle qu'on vient
// d'installer vers le nouveau dossier d'install. Best-effort : si la
// copie échoue (handle verrouillé, accès refusé), on log un warn mais on
// ne bloque pas l'install — l'utilisateur peut copier manuellement.
// L'option est cochée par défaut dans le dialog mais l'utilisateur peut
// la décocher (cas : premier install propre, ou release qui invalide
// explicitement les anciennes saves).
if (preserveSaveGames)
{
try
{
await CopyPreviousSaveGamesAsync(target, row.Version, ct);
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex, "Save games copy threw an unhandled exception (continuing install)");
}
}
else
{
_logger.LogInformation("Save games preservation disabled by user for v{Version}", row.Version);
}
// Installation des redists Unreal (VC_redist, UEPrereqSetup, etc.) si la
// version contient un dossier `_redist/`. Run silent + UAC élévation par
// installer. Track via metadata pour ne pas re-runner à chaque réinstall.
@@ -2118,8 +2305,10 @@ public sealed partial class MainViewModel : ObservableObject
// Récupère la row fraîche depuis le rebuild (l'instance peut
// avoir changé) pour relire la nouvelle URL du manifest.
var freshRow = (FeaturedVersion?.Version == row.Version ? FeaturedVersion : null)
?? OtherVersions.FirstOrDefault(r => r.Version == row.Version);
// Match par RowKey (folder name) plutôt que Version pour supporter
// les setups multi-channels où deux entries partagent un numéro.
var freshRow = (FeaturedVersion?.RowKey == row.RowKey ? FeaturedVersion : null)
?? OtherVersions.FirstOrDefault(r => r.RowKey == row.RowKey);
var urlAfter = freshRow?.Remote?.Download.Url;
_logger.LogInformation(
"Manifest URL for v{Version} — before: {Before}, after refresh: {After}",
@@ -2307,6 +2496,178 @@ public sealed partial class MainViewModel : ObservableObject
}
}
/// <summary>
/// Liste des sous-dossiers <c>PROSERVE_UE_*/Saved/{sub}/</c> à préserver
/// entre versions, avec le glob filtre par sous-dossier. La copie est
/// récursive (<c>AllDirectories</c>) et non destructive (les fichiers
/// déjà présents dans la nouvelle install ne sont jamais écrasés).
///
/// <list type="bullet">
/// <item><b>SaveGames / *.sav</b> — profils Unreal, progression
/// d'entraînement, données opérateur.</item>
/// <item><b>Demos / *.replay</b> — replays Unreal (enregistrements de
/// session, extension <c>.replay</c> dans PROSERVE). Précieux pour le
/// debriefing post-formation.</item>
/// </list>
/// </summary>
private static readonly (string SubDir, string Glob)[] PreservedSavedSubdirs =
{
("SaveGames", "*.sav"),
("Demos", "*.replay"),
};
/// <summary>
/// Copie les fichiers utilisateur (.sav + .demo) depuis les sous-dossiers
/// <c>PROSERVE_UE_*/Saved/SaveGames/</c> et <c>PROSERVE_UE_*/Saved/Demos/</c>
/// de la version installée la plus récente (autre que celle qu'on vient
/// d'installer) vers le même chemin relatif dans le nouveau dossier d'install.
///
/// Cas d'usage : l'opérateur installe v1.5.5 par-dessus v1.5.4 — sans ça, les
/// .sav Unreal (progression d'entraînement, profils) et les .demo (replays
/// de session) de v1.5.4 ne seraient pas visibles depuis v1.5.5 car ces
/// fichiers vivent dans le dossier d'install du jeu, pas dans %LocalAppData%.
/// Activé par défaut (case cochée dans <see cref="UpdateAvailableDialog"/>),
/// décochable.
///
/// Détails :
/// <list type="bullet">
/// <item>Source = version installée au plus haut SemVer, autre que
/// <paramref name="newVersion"/>. Si l'opérateur fait une réinstall ou un
/// downgrade, on copie quand même depuis la plus récente DISPONIBLE.</item>
/// <item>Dossier projet UE détecté via le nom de l'exe
/// (<c>PROSERVE_UE_5_7.exe</c> → <c>PROSERVE_UE_5_7/</c>). Compatible avec
/// les passages d'une version UE à l'autre (UE_5_5 → UE_5_7) — on copie
/// du dossier UE source vers le dossier UE cible (renommage transparent).</item>
/// <item>Mode USER-DATA WINS : les <c>.sav</c>/<c>.replay</c> sont des
/// données utilisateur (progression, réglages, replays de session). Si
/// le nouveau ZIP livre un fichier au même chemin (ex : profil par
/// défaut bundlé <c>GeneralSettings.sav</c>), on l'ÉCRASE avec la
/// version de l'utilisateur — sinon les réglages custom de la version
/// précédente seraient perdus au profit des defaults du ZIP.</item>
/// <item>Best-effort : exceptions IO loggées en warn mais non remontées —
/// l'install ne doit pas échouer pour une copie de saves.</item>
/// </list>
/// </summary>
private async Task CopyPreviousSaveGamesAsync(string newInstallDir, string newVersion, CancellationToken ct)
{
var installed = _registry.Scan();
_logger.LogInformation(
"SaveGames copy for v{New} — scanning {Count} installed version(s): [{Versions}]",
newVersion, installed.Count, string.Join(", ", installed.Select(v => v.Version)));
var previous = installed
.Where(v => !string.Equals(v.Version, newVersion, StringComparison.OrdinalIgnoreCase))
.FirstOrDefault();
if (previous is null)
{
_logger.LogInformation(
"SaveGames copy skipped : no previous version found other than v{New} (aucune version antérieure à copier)",
newVersion);
return;
}
// Dossier projet UE = nom de l'exe sans extension. L'exe live à la racine
// de l'install, le projet UE dans un sous-dossier homonyme :
// <install>/PROSERVE_UE_5_7.exe
// <install>/PROSERVE_UE_5_7/Saved/SaveGames/*.sav
// <install>/PROSERVE_UE_5_7/Saved/Demos/*.replay
var srcProjectName = Path.GetFileNameWithoutExtension(previous.ExecutablePath);
if (string.IsNullOrWhiteSpace(srcProjectName))
{
_logger.LogWarning(
"SaveGames copy aborted : cannot resolve project name from previous exe {Exe}",
previous.ExecutablePath);
return;
}
var srcSavedRoot = Path.Combine(previous.FolderPath, srcProjectName, "Saved");
_logger.LogInformation(
"SaveGames copy source resolved : v{From} at {Root} (project = {Proj})",
previous.Version, srcSavedRoot, srcProjectName);
// Pre-scan : on liste TOUS les fichiers à copier (savegames + demos) AVANT
// de toucher quoi que ce soit, pour pouvoir afficher un count total dans
// le StatusMessage et early-return si vraiment rien à faire.
var plan = new List<(string SubDir, string SrcFile)>();
foreach (var (subDir, glob) in PreservedSavedSubdirs)
{
var srcSubDir = Path.Combine(srcSavedRoot, subDir);
if (!Directory.Exists(srcSubDir))
{
_logger.LogInformation(
"SaveGames copy : source subdir absent, skipping — {Path}", srcSubDir);
continue;
}
var found = Directory.GetFiles(srcSubDir, glob, SearchOption.AllDirectories);
_logger.LogInformation(
"SaveGames copy : {Count} × {Glob} found in {Path}", found.Length, glob, srcSubDir);
foreach (var file in found)
plan.Add((subDir, file));
}
if (plan.Count == 0)
{
_logger.LogInformation(
"SaveGames copy skipped : previous v{Version} has no matching files in {Root}",
previous.Version, srcSavedRoot);
return;
}
// Côté cible, on résout le NOUVEAU dossier UE via la même règle : lit la
// metadata fraîchement écrite (qui contient l'exe du manifest), tombe sur
// glob PROSERVE_UE_*.exe sinon. Le dossier projet peut différer du source
// (downgrade UE, ou bump UE_5_5 → UE_5_7).
var newExe = Directory.GetFiles(newInstallDir, "PROSERVE_UE_*.exe", SearchOption.TopDirectoryOnly)
.OrderBy(p => p, StringComparer.OrdinalIgnoreCase)
.FirstOrDefault();
if (newExe is null)
{
_logger.LogWarning("Cannot find PROSERVE_UE_*.exe in {Dir} — skipping save games copy", newInstallDir);
return;
}
var dstProjectName = Path.GetFileNameWithoutExtension(newExe);
var dstSavedRoot = Path.Combine(newInstallDir, dstProjectName, "Saved");
StatusMessage = Strings.StatusCopyingSaveGames(previous.Version, plan.Count);
ProgressDetail = null;
_logger.LogInformation(
"Copying {Count} user file(s) from v{From} ({SrcProj}) to v{To} ({DstProj}) — savegames+demos",
plan.Count, previous.Version, srcProjectName, newVersion, dstProjectName);
int copied = 0, overwritten = 0;
foreach (var (subDir, srcFile) in plan)
{
ct.ThrowIfCancellationRequested();
var srcSubDir = Path.Combine(srcSavedRoot, subDir);
var relPath = Path.GetRelativePath(srcSubDir, srcFile);
var dstFile = Path.Combine(dstSavedRoot, subDir, relPath);
try
{
// Mode USER-DATA WINS : on ÉCRASE toujours le fichier cible s'il
// existe déjà. Le ZIP peut livrer des defaults (ex :
// GeneralSettings.sav avec les réglages usine) mais la version
// customisée par l'utilisateur dans l'install précédente prime —
// sinon les réglages custom seraient perdus à chaque upgrade au
// profit des defaults bundlés dans le ZIP.
bool wasOverwrite = File.Exists(dstFile);
Directory.CreateDirectory(Path.GetDirectoryName(dstFile)!);
await using (var src = new FileStream(srcFile, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true))
await using (var dst = new FileStream(dstFile, FileMode.Create, FileAccess.Write, FileShare.None, 81920, useAsync: true))
{
await src.CopyToAsync(dst, ct).ConfigureAwait(false);
}
if (wasOverwrite) overwritten++; else copied++;
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex)
{
_logger.LogWarning(ex, "Failed to copy user file {Src} → {Dst}", srcFile, dstFile);
}
}
_logger.LogInformation(
"User data copy complete : {Copied} new + {Overwritten} overwritten (ZIP defaults replaced by user data)",
copied, overwritten);
}
private async Task UninstallVersionAsync(VersionRowViewModel row)
{
if (row.Installed is null) return;

View File

@@ -129,6 +129,10 @@ public sealed partial class SettingsViewModel : ObservableObject
public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new();
public bool HasLanDiscoveredPeers => LanDiscoveredPeers.Count > 0;
// ----- Args par défaut (chaque lancement) -----
/// <summary>Args édités dans la UI. Persiste vers <c>_config.DefaultLaunchArgs</c> au Save.</summary>
public System.Collections.ObjectModel.ObservableCollection<AutoModeArgRowViewModel> DefaultLaunchArgs { get; } = new();
// ----- Mode auto -----
[ObservableProperty] private bool _autoModeFeatureEnabled;
[ObservableProperty] private int _autoModeGraceSeconds;
@@ -276,6 +280,9 @@ public sealed partial class SettingsViewModel : ObservableObject
Environment.NewLine,
config.SteamVr.ProcessesToKill ?? new List<string>());
foreach (var a in config.DefaultLaunchArgs ?? new List<AutoModeArg>())
DefaultLaunchArgs.Add(new AutoModeArgRowViewModel(a.Key, a.Value, RemoveDefaultLaunchArg));
_autoModeFeatureEnabled = config.AutoMode.FeatureEnabled;
_autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds;
_autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty;
@@ -465,6 +472,15 @@ public sealed partial class SettingsViewModel : ObservableObject
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
_config.DefaultLaunchArgs = DefaultLaunchArgs
.Select(r => new AutoModeArg
{
Key = (r.Key ?? string.Empty).Trim(),
Value = string.IsNullOrEmpty(r.Value) ? null : r.Value.Trim(),
})
.Where(a => !string.IsNullOrWhiteSpace(a.Key))
.ToList();
_config.AutoMode.FeatureEnabled = AutoModeFeatureEnabled;
_config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60);
_config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion)
@@ -1057,6 +1073,19 @@ public sealed partial class SettingsViewModel : ObservableObject
}
}
// ===================== DEFAULT LAUNCH ARGS =====================
[RelayCommand]
private void AddDefaultLaunchArg()
{
DefaultLaunchArgs.Add(new AutoModeArgRowViewModel(string.Empty, null, RemoveDefaultLaunchArg));
}
private void RemoveDefaultLaunchArg(AutoModeArgRowViewModel row)
{
DefaultLaunchArgs.Remove(row);
}
// ===================== AUTO MODE ARGS =====================
[RelayCommand]

View File

@@ -26,6 +26,55 @@ public sealed partial class VersionRowViewModel : ObservableObject
public VersionManifest? Remote { get; }
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null;
/// <summary>
/// Clé stable qui identifie une row au-delà du simple <see cref="Version"/>,
/// pour distinguer deux entrées manifest partageant le même numéro sur des
/// channels différents (ex : proserve-firefighter-1.5.4.32 vs proserve-full-
/// 1.5.4.32). Priorité :
/// <list type="number">
/// <item>Basename du dossier d'install (côté installed, ou côté remote
/// résolu via <c>InstallFolderTemplate</c>) — unique par convention car
/// deux entries au même version DOIVENT avoir des dossiers distincts
/// (validé backoffice + install guard client).</item>
/// <item>Fallback : le <see cref="Version"/> seul (mono-channel, comportement
/// identique à avant).</item>
/// </list>
/// Utilisé pour identifier une row lors des lookups par état actif (DL en
/// cours, install courant), au lieu de matcher par <c>Version</c> qui devient
/// ambigu sur les setups multi-channels.
/// </summary>
public string RowKey { get; }
/// <summary>
/// Channel principal à afficher comme badge sur la row (null / vide → pas
/// de badge). Sélection : le premier channel non-« default » de
/// <see cref="VersionManifest.Channels"/>, en <c>OrdinalIgnoreCase</c>. Pour
/// une row installée sans remote (orpheline), c'est null. Pour une entrée
/// remote sans Channels ou uniquement « default », c'est null.
/// </summary>
public string? ChannelBadge => Remote?.Channels?
.FirstOrDefault(c => !string.IsNullOrWhiteSpace(c)
&& !string.Equals(c, "default", StringComparison.OrdinalIgnoreCase));
/// <summary>
/// True si au moins une AUTRE row visible sur ce launcher partage le même
/// numéro de version. Positionné par <c>MainViewModel.RebuildList</c> après
/// construction des rows (group by Version, count &gt;= 2 → conflict).
/// Le badge channel n'est utile QUE dans ce cas — sinon la row est
/// univoque et le badge ajoute du bruit visuel.
/// </summary>
[ObservableProperty]
[NotifyPropertyChangedFor(nameof(HasChannelBadge))]
private bool _hasVersionConflict;
/// <summary>
/// Le badge n'est visible QUE quand la row a un channel spécifique ET qu'une
/// autre row visible partage le même numéro de version. Ça garde l'UI propre
/// sur les setups mono-channel (99 % des cas) et affiche le distingueur
/// seulement quand il est réellement nécessaire.
/// </summary>
public bool HasChannelBadge => !string.IsNullOrEmpty(ChannelBadge) && HasVersionConflict;
/// <summary>
/// True si la version est taggée BÊTA dans le manifest (champ
/// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge
@@ -197,6 +246,19 @@ public sealed partial class VersionRowViewModel : ObservableObject
Installed = installed;
Remote = remote;
_state = initialState;
// RowKey — priorité : (1) Id de l'entrée manifest (unique par entry, survit
// à un rename de folder), (2) EntryId lu depuis le .proserve-meta.json de
// l'install, (3) folder name basename, (4) version en dernier recours.
// Doit matcher la logique de MainViewModel.RebuildList pour que les lookups
// par RowKey trouvent la row correcte.
var folderName = !string.IsNullOrEmpty(folderPath)
? System.IO.Path.GetFileName(folderPath)
: remote?.GetInstallFolderName();
RowKey = !string.IsNullOrEmpty(remote?.Id) ? remote!.Id!
: !string.IsNullOrEmpty(installed?.EntryId) ? installed!.EntryId!
: !string.IsNullOrWhiteSpace(folderName) ? folderName!
: version;
}
// Les commandes sont câblées par le MainViewModel après instanciation

View File

@@ -96,6 +96,20 @@
FontSize="10" FontWeight="Bold"
Foreground="#1A0A00" />
</Border>
<!-- Badge CHANNEL : violet, à côté des autres badges. Affiché
quand la row est sur un channel spécifique (non-default) et
qu'une autre row visible partage le même numéro de version.
Couleur violette #8B5CF6 pour se distinguer nettement du
badge BETA orange et du bleu du statut "Available". -->
<Border CornerRadius="8" Padding="6,2" Margin="10,0,0,0"
VerticalAlignment="Center"
Background="#8B5CF6"
ToolTip="{Binding ChannelBadge}"
Visibility="{Binding HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding ChannelBadge}"
FontSize="10" FontWeight="Bold"
Foreground="White" />
</Border>
<Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
VerticalAlignment="Center">
<Border.Style>
@@ -518,6 +532,20 @@
FontSize="11" FontWeight="Bold"
Foreground="#1A0A00" />
</Border>
<!-- Badge CHANNEL : pill violette. Visible sur les entrées
manifest taggées avec un channel non-default (firefighter,
police, etc.) quand une autre row partage le numéro de
version. Violet #8B5CF6 pour se distinguer du orange BETA
et du bleu du statut "Available". -->
<Border CornerRadius="10" Padding="8,3" Margin="12,8,0,0"
VerticalAlignment="Center"
Background="#8B5CF6"
ToolTip="{Binding FeaturedVersion.ChannelBadge}"
Visibility="{Binding FeaturedVersion.HasChannelBadge, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="{Binding FeaturedVersion.ChannelBadge}"
FontSize="11" FontWeight="Bold"
Foreground="White" />
</Border>
<Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
VerticalAlignment="Center">
<Border.Style>

View File

@@ -696,6 +696,68 @@
</StackPanel>
</Border>
<!-- Args par défaut : appliqués à CHAQUE lancement de PROSERVE, mode
auto ou pas. Utile pour des flags globaux (ex : -nosplash, -log,
-fps=90) qui doivent s'appliquer quelle que soit la version lancée
manuellement. Les args du mode auto (bloc suivant) s'ajoutent en
plus, uniquement quand la version courante est celle désignée AUTO. -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"
CornerRadius="6" Padding="20" Margin="0,0,0,16">
<StackPanel>
<TextBlock Text="{x:Static loc:Strings.SettingsLaunchArgs}"
FontSize="11" FontWeight="Bold"
Foreground="{StaticResource Brush.Text.Secondary}"
Margin="0,0,0,8" />
<TextBlock Text="{x:Static loc:Strings.SettingsLaunchArgsHelp}"
FontSize="12"
Foreground="{StaticResource Brush.Text.Secondary}"
TextWrapping="Wrap"
Margin="0,0,0,10" />
<!-- Liste éditable identique à celle du mode auto : Key + Value + Remove. -->
<ItemsControl ItemsSource="{Binding DefaultLaunchArgs}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Grid Margin="0,0,0,4">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBox Grid.Column="0"
Text="{Binding Key, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas"
Tag="{x:Static loc:Strings.SettingsAutoModeArgKey}"
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgKey}" />
<TextBox Grid.Column="1"
Text="{Binding Value, UpdateSourceTrigger=PropertyChanged}"
Background="#1A1A20" Foreground="{StaticResource Brush.Text.Primary}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" Padding="8"
FontFamily="Consolas"
Margin="6,0,6,0"
Tag="{x:Static loc:Strings.SettingsAutoModeArgValue}"
ToolTip="{x:Static loc:Strings.SettingsAutoModeArgValue}" />
<Button Grid.Column="2"
Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsAutoModeArgRemove}"
Command="{Binding RemoveCommand}"
Padding="10,6" />
</Grid>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<Button Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.SettingsAutoModeArgAdd}"
Command="{Binding AddDefaultLaunchArgCommand}"
HorizontalAlignment="Left"
Margin="0,4,0,0" Padding="12,6" />
</StackPanel>
</Border>
<!-- Mode auto : auto-launch + auto-relaunch d'une version désignée -->
<Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"

View File

@@ -14,6 +14,7 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0"
@@ -37,7 +38,16 @@
Background="Transparent" />
</Border>
<StackPanel Grid.Row="3" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,18,0,0">
<CheckBox Grid.Row="3"
x:Name="PreserveSaveGamesCheckBox"
Content="{x:Static loc:Strings.UpdatePreserveSaveGames}"
ToolTip="{x:Static loc:Strings.UpdatePreserveSaveGamesTooltip}"
IsChecked="True"
Foreground="{StaticResource Brush.Text.Primary}"
Margin="0,14,0,0"
HorizontalAlignment="Left" />
<StackPanel Grid.Row="4" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Style="{StaticResource SecondaryButton}"
Content="{x:Static loc:Strings.ActionLater}"
IsCancel="True"

View File

@@ -24,9 +24,18 @@ public partial class UpdateAvailableDialog : Window
public bool DownloadRequested { get; private set; }
/// <summary>
/// État de la case « Conserver les sauvegardes de la version précédente ».
/// Cochée par défaut, lue par MainViewModel APRÈS l'extraction du ZIP pour
/// décider s'il faut copier les .sav depuis l'install précédente vers la
/// nouvelle (voir CopyPreviousSaveGamesAsync).
/// </summary>
public bool PreserveSaveGames { get; private set; } = true;
private void OnDownload(object sender, RoutedEventArgs e)
{
DownloadRequested = true;
PreserveSaveGames = PreserveSaveGamesCheckBox.IsChecked == true;
DialogResult = true;
Close();
}

View File

@@ -11,15 +11,26 @@ public interface IInstallationRegistry
/// <summary>
/// Écrit le metadata <c>.proserve-meta.json</c> dans le dossier d'install avec
/// le nom du .exe à lancer (récupéré du manifest serveur). À appeler par
/// <c>MainViewModel</c> après l'extraction du ZIP, pour que le futur <c>Scan</c>
/// trouve le bon exe sans avoir besoin du manifest en mémoire.
/// le nom du .exe à lancer (récupéré du manifest serveur) + l'identifiant
/// d'entrée manifest (<paramref name="entryId"/>) pour détecter les
/// collisions inter-channels. À appeler par <c>MainViewModel</c> après
/// l'extraction du ZIP, pour que le futur <c>Scan</c> trouve le bon exe et
/// pour que l'install guard puisse comparer l'entryId d'un dossier existant
/// avant écrasement.
/// </summary>
Task WriteInstallMetadataAsync(string installDir, string executableName, CancellationToken ct);
Task WriteInstallMetadataAsync(string installDir, string executableName, string? entryId, CancellationToken ct);
/// <summary>Marque les redists comme installés (timestamp UTC) dans la metadata.</summary>
Task MarkRedistInstalledAsync(string installDir, CancellationToken ct);
/// <summary>Vrai si la metadata indique que les redists ont déjà été installés.</summary>
bool IsRedistInstalled(string installDir);
/// <summary>
/// Lit l'<c>entryId</c> stocké dans <c>.proserve-meta.json</c> du dossier
/// d'install. Retourne null si le fichier n'existe pas, si la clé n'a
/// jamais été écrite (installs antérieurs à ce feature), ou en cas
/// d'erreur de parse. Utilisé pour l'install guard anti-collision.
/// </summary>
string? TryReadEntryId(string installDir);
}

View File

@@ -26,7 +26,16 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
// nouveaux "PROSERVE vX.Y.Z" (changement de casse de la marque). Le 4ᵉ digit
// optionnel (?:\.\d+)? permet aux itérations dev/test (1.5.4.13) de
// cohabiter avec leur release stable (1.5.4) dans deux dossiers distincts.
[GeneratedRegex(@"^PROSERVE v(?<v>\d+\.\d+\.\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase)]
//
// Le groupe optionnel `(-<channel>)?` permet de discriminer plusieurs installs
// au même numéro de version mais sur des channels différents :
// PROSERVE v1.5.4.32
// PROSERVE-firefighter v1.5.4.32
// PROSERVE-full v1.5.4.32
// Le channel n'est PAS capturé ici — c'est juste un suffixe autorisé dans le
// nom de dossier. L'association exacte channel ↔ dossier passe par le lookup
// <see cref="VersionManifest.GetInstallFolderName"/> dans MainViewModel.
[GeneratedRegex(@"^PROSERVE(-[a-z0-9_-]+)? v(?<v>\d+\.\d+\.\d+(?:\.\d+)?)$", RegexOptions.IgnoreCase)]
private static partial Regex VersionFolderRegex();
private readonly ILogger<InstallationRegistry> _logger;
@@ -67,7 +76,8 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
FolderPath: dir,
ExecutablePath: exe,
InstalledAt: info.CreationTimeUtc,
SizeBytes: SafeDirectorySize(dir)));
SizeBytes: SafeDirectorySize(dir),
EntryId: TryReadEntryId(dir)));
}
return results
@@ -123,14 +133,44 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
/// <summary>
/// Écrit le fichier metadata <c>.proserve-meta.json</c> dans le dossier d'install
/// fraîchement extrait. Appelé par <see cref="MainViewModel"/> après le ZipInstaller,
/// avec l'exe déclaré dans le manifest (<c>VersionManifest.Executable</c>).
/// avec l'exe déclaré dans le manifest (<c>VersionManifest.Executable</c>) et
/// l'<paramref name="entryId"/> de l'entrée manifest source. L'entryId sert au
/// pre-install guard : un futur install qui vise le même dossier avec un id
/// différent (= une autre entrée manifest sur un channel différent partageant
/// le numéro de version) sera refusé pour éviter l'écrasement silencieux.
/// </summary>
public Task WriteInstallMetadataAsync(string installDir, string executableName, CancellationToken ct)
public async Task WriteInstallMetadataAsync(string installDir, string executableName, string? entryId, CancellationToken ct)
{
var path = Path.Combine(installDir, MetadataFileName);
var meta = new InstallMetadata { Executable = executableName };
// Préserve un éventuel RedistInstalledAt existant (relance install sans
// ré-installer les redists → on ne veut pas perdre le timestamp).
InstallMetadata? existing = null;
if (File.Exists(path))
{
try { existing = JsonSerializer.Deserialize<InstallMetadata>(await File.ReadAllTextAsync(path, ct).ConfigureAwait(false)); }
catch { existing = null; }
}
var meta = new InstallMetadata
{
Executable = executableName,
EntryId = entryId,
RedistInstalledAt = existing?.RedistInstalledAt,
};
var json = JsonSerializer.Serialize(meta, new JsonSerializerOptions { WriteIndented = true });
return File.WriteAllTextAsync(path, json, ct);
await File.WriteAllTextAsync(path, json, ct).ConfigureAwait(false);
}
/// <inheritdoc/>
public string? TryReadEntryId(string installDir)
{
var path = Path.Combine(installDir, MetadataFileName);
if (!File.Exists(path)) return null;
try
{
var meta = JsonSerializer.Deserialize<InstallMetadata>(File.ReadAllText(path));
return string.IsNullOrWhiteSpace(meta?.EntryId) ? null : meta.EntryId;
}
catch { return null; }
}
/// <summary>
@@ -174,6 +214,14 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
{
public string Executable { get; set; } = string.Empty;
public DateTime? RedistInstalledAt { get; set; }
/// <summary>
/// Id de l'entrée manifest source (<see cref="VersionManifest.Id"/>). Null
/// pour les installs antérieurs à cette feature (rétro-compat). Utilisé
/// par l'install guard : refuse un install si un autre entryId est déjà
/// présent dans le dossier cible.
/// </summary>
public string? EntryId { get; set; }
}
public InstalledVersion? Get(string version) =>

View File

@@ -0,0 +1,55 @@
using PSLauncher.Models;
namespace PSLauncher.Core.Installations;
/// <summary>
/// Comparaison de versions PROSERVE qui prend en compte le flag <c>isBeta</c>
/// du manifest en plus du SemVer numérique.
///
/// Règle : au même préfixe 3-digit <c>X.Y.Z</c>, une version <c>isBeta=false</c>
/// est considérée SUPÉRIEURE à une version <c>isBeta=true</c>, quel que soit le
/// 4ᵉ digit. Cas d'usage typique : après avoir itéré des builds beta <c>1.5.4.30
/// .31 .32</c>, l'opérateur publie <c>1.5.4</c> non-beta comme release finale.
/// Sous SemVer pur, <c>1.5.4 == 1.5.4.0 &lt; 1.5.4.32</c> donc le launcher
/// resterait pointé sur la beta ; ce helper inverse la comparaison pour ce
/// cas précis afin que la release finale soit considérée comme LA version
/// courante.
///
/// Hors ce cas (préfixes 3-digit différents, ou même statut beta des deux
/// côtés), on retombe sur <see cref="SemVer.CompareTo"/> — donc :
/// <list type="bullet">
/// <item><c>1.5.4 (non-beta)</c> vs <c>1.5.4.32 (beta)</c> → 1.5.4 gagne (règle spéciale)</item>
/// <item><c>1.5.4.30 (beta)</c> vs <c>1.5.4.32 (beta)</c> → 1.5.4.32 gagne (SemVer)</item>
/// <item><c>1.5.4 (non-beta)</c> vs <c>1.5.5.10 (beta)</c> → 1.5.5.10 gagne (SemVer, préfixes différents)</item>
/// <item><c>1.5.4</c> vs <c>1.5.5</c> → 1.5.5 gagne (SemVer)</item>
/// </list>
/// </summary>
public static class VersionOrder
{
/// <summary>
/// Retourne &lt;0 si A &lt; B, 0 si égales, &gt;0 si A &gt; B. Voir la doc de
/// <see cref="VersionOrder"/> pour la règle isBeta.
/// </summary>
public static int Compare(string versionA, bool isBetaA, string versionB, bool isBetaB)
{
var svA = SemVer.Parse(versionA);
var svB = SemVer.Parse(versionB);
// Cas spécial : même préfixe 3-digit ET statut beta différent.
// La version non-beta l'emporte, indépendamment du 4ᵉ digit (traité
// comme un suffixe pré-release dans ce contexte particulier).
if (svA.Major == svB.Major
&& svA.Minor == svB.Minor
&& svA.Patch == svB.Patch
&& isBetaA != isBetaB)
{
return isBetaA ? -1 : 1;
}
return svA.CompareTo(svB);
}
/// <summary>Overload pratique pour une <see cref="VersionManifest"/>.</summary>
public static int Compare(VersionManifest a, VersionManifest b)
=> Compare(a.Version, a.IsBeta, b.Version, b.IsBeta);
}

View File

@@ -16,8 +16,14 @@ public interface ILicenseService
/// Demande au serveur une URL HMAC-signée valide 1 h pour le ZIP de cette version.
/// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
/// auquel cas l'appelant retombe sur l'URL publique du manifest.
/// <paramref name="expectedFilename"/> est le nom du fichier attendu, extrait du
/// <c>download.url</c> côté manifest client. Sert au serveur à disambiguer les
/// entrées quand plusieurs channels partagent le même numéro de version
/// (proserve-firefighter-1.5.4.32 vs proserve-full-1.5.4.32 sur v1.5.4.32).
/// Si null ou vide, le serveur retombe sur "1re entrée matchant le numéro"
/// (rétro-compat avec les vieux serveurs qui ignorent ce paramètre).
/// </summary>
Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct);
Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct);
/// <summary>
/// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null

View File

@@ -266,7 +266,7 @@ public sealed class LicenseService : ILicenseService
return license.CanDownload(version);
}
public async Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct)
public async Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct)
{
var key = GetDecryptedKey();
if (string.IsNullOrEmpty(key)) return null;
@@ -276,6 +276,13 @@ public sealed class LicenseService : ILicenseService
// PHP. La requête reste en HTTPS donc la clé n'est pas exposée sur le câble.
var encodedKey = Uri.EscapeDataString(key);
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey;
// Passe le filename attendu pour disambiguer les channels sur les manifests
// multi-entrée par version. Si null/empty, le serveur retombe sur "1re entrée
// matchant le numéro" (rétro-compat avec les vieux serveurs).
if (!string.IsNullOrWhiteSpace(expectedFilename))
{
url += "&filename=" + Uri.EscapeDataString(expectedFilename);
}
try
{
// Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas

View File

@@ -8,7 +8,11 @@ namespace PSLauncher.Core.Localization;
/// session. Tout changement de langue nécessite un redémarrage du launcher
/// (les bindings XAML x:Static ne refresh pas dynamiquement).
///
/// Langues supportées : fr (par défaut), en, zh (Simplified), th, ar.
/// Langues supportées :
/// • fr (par défaut), en, zh (Simplified), th, ar : traduction complète historique
/// • es, de : ajoutées en v0.29.11, traduction progressive — les strings non encore
/// traduites tombent automatiquement sur l'anglais via T() (cf. fallback dans
/// <see cref="T(string,string,string,string,string,string?,string?)"/>).
/// Pour Arabic, FlowDirection doit aussi être basculé en RTL côté Window.
/// </summary>
public static class Strings
@@ -27,6 +31,8 @@ public static class Strings
("auto", "Automatique (langue du système)"),
("fr", "Français"),
("en", "English"),
("es", "Español"),
("de", "Deutsch"),
("zh", "中文"),
("th", "ไทย"),
("ar", "العربية"),
@@ -39,7 +45,7 @@ public static class Strings
/// </summary>
public static void Init(string? configValue)
{
var supported = new[] { "fr", "en", "zh", "th", "ar" };
var supported = new[] { "fr", "en", "es", "de", "zh", "th", "ar" };
var v = configValue?.Trim().ToLowerInvariant() ?? "auto";
if (v == "auto" || string.IsNullOrEmpty(v) || Array.IndexOf(supported, v) < 0)
@@ -62,9 +68,21 @@ public static class Strings
catch { /* culture inconnue, on garde le défaut */ }
}
private static string T(string fr, string en, string zh, string th, string ar) => _lang switch
/// <summary>
/// Helper de localisation. Les 5 premiers paramètres (fr/en/zh/th/ar) sont
/// requis = chaque string DOIT exister dans ces langues (traduction historique
/// complète). Les 2 derniers (es, de) sont OPTIONNELS — si non fournis, on
/// retombe sur l'anglais. Stratégie progressive : on traduit en es/de
/// uniquement les strings les plus visibles (top bar, actions principales,
/// états licenses, errors common) ; le reste reste en anglais pour les
/// utilisateurs es/de tant qu'on n'a pas traduit. Mieux qu'un crash ou
/// une chaîne vide, et l'anglais est globalement lisible par ces locuteurs.
/// </summary>
private static string T(string fr, string en, string zh, string th, string ar, string? es = null, string? de = null) => _lang switch
{
"en" => en,
"es" => es ?? en, // fallback English si pas encore traduit
"de" => de ?? en, // idem
"zh" => zh,
"th" => th,
"ar" => ar,
@@ -72,34 +90,36 @@ public static class Strings
};
// ==================== TOP BAR ====================
public static string TopBarCheckUpdates => T("🔄 Vérifier les MAJ", "🔄 Check for updates", "🔄 检查更新", "🔄 ตรวจสอบการอัปเดต", "🔄 التحقق من التحديثات");
public static string TopBarSettings => T("⚙ Paramètres", "⚙ Settings", "⚙ 设置", "⚙ ตั้งค่า", "⚙ الإعدادات");
public static string TopBarCheckUpdates => T("🔄 Vérifier les MAJ", "🔄 Check for updates", "🔄 检查更新", "🔄 ตรวจสอบการอัปเดต", "🔄 التحقق من التحديثات", "🔄 Buscar actualizaciones", "🔄 Updates suchen");
public static string TopBarSettings => T("⚙ Paramètres", "⚙ Settings", "⚙ 设置", "⚙ ตั้งค่า", "⚙ الإعدادات", "⚙ Ajustes", "⚙ Einstellungen");
// ==================== BODY ====================
public static string BodyVersions => T("Versions", "Versions", "版本", "เวอร์ชัน", "النسخ");
public static string BodyVersions => T("Versions", "Versions", "版本", "เวอร์ชัน", "النسخ", "Versiones", "Versionen");
public static string BodyVersionsHint => T(
"Lance, installe ou supprime une version. Les versions installées et celles disponibles sur le serveur cohabitent.",
"Launch, install or remove a version. Installed and available versions can coexist.",
"启动、安装或删除版本。已安装的版本与服务器上可用的版本可以共存。",
"เปิด ติดตั้ง หรือลบเวอร์ชัน เวอร์ชันที่ติดตั้งและที่มีในเซิร์ฟเวอร์อยู่ร่วมกันได้",
"تشغيل أو تثبيت أو إزالة نسخة. النسخ المثبتة والمتاحة على الخادم يمكن أن تتعايش."
"تشغيل أو تثبيت أو إزالة نسخة. النسخ المثبتة والمتاحة على الخادم يمكن أن تتعايش.",
"Inicia, instala o elimina una versión. Las versiones instaladas y las disponibles en el servidor pueden coexistir.",
"Starte, installiere oder entferne eine Version. Installierte und verfügbare Versionen können koexistieren."
);
public static string FeaturedCurrent => T("VERSION COURANTE", "CURRENT VERSION", "当前版本", "เวอร์ชันปัจจุบัน", "النسخة الحالية");
public static string SectionOther => T("AUTRES VERSIONS", "OTHER VERSIONS", "其他版本", "เวอร์ชันอื่นๆ", "نسخ أخرى");
public static string ReleasedOn => T("Sortie le ", "Released ", "发布于 ", "เผยแพร่เมื่อ ", "صدر في ");
public static string FeaturedCurrent => T("VERSION COURANTE", "CURRENT VERSION", "当前版本", "เวอร์ชันปัจจุบัน", "النسخة الحالية", "VERSIÓN ACTUAL", "AKTUELLE VERSION");
public static string SectionOther => T("AUTRES VERSIONS", "OTHER VERSIONS", "其他版本", "เวอร์ชันอื่นๆ", "نسخ أخرى", "OTRAS VERSIONES", "ANDERE VERSIONEN");
public static string ReleasedOn => T("Sortie le ", "Released ", "发布于 ", "เผยแพร่เมื่อ ", "صدر في ", "Publicado el ", "Veröffentlicht am ");
// ==================== STATUS BADGES ====================
public static string StatusInstalled => T("● Installée", "● Installed", "● 已安装", "● ติดตั้งแล้ว", "● مثبتة");
public static string StatusAvailable => T("○ Disponible", "○ Available", "○ 可用", "○ พร้อมใช้งาน", "○ متاحة");
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…");
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…");
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…");
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…");
public static string StatusInstalled => T("● Installée", "● Installed", "● 已安装", "● ติดตั้งแล้ว", "● مثبتة", "● Instalada", "● Installiert");
public static string StatusAvailable => T("○ Disponible", "○ Available", "○ 可用", "○ พร้อมใช้งาน", "○ متاحة", "○ Disponible", "○ Verfügbar");
public static string StatusDownloading => T("⬇ Téléchargement…", "⬇ Downloading…", "⬇ 下载中…", "⬇ กำลังดาวน์โหลด…", "⬇ جارٍ التنزيل…", "⬇ Descargando…", "⬇ Wird heruntergeladen…");
public static string StatusInstalling => T("📦 Installation…", "📦 Installing…", "📦 安装中…", "📦 กำลังติดตั้ง…", "📦 جارٍ التثبيت…", "📦 Instalando…", "📦 Wird installiert…");
public static string StatusUninstalling => T("🗑 Suppression…", "🗑 Uninstalling…", "🗑 卸载中…", "🗑 กำลังถอนการติดตั้ง…", "🗑 جارٍ الإزالة…", "🗑 Eliminando…", "🗑 Wird entfernt…");
public static string StatusVerifying => T("🔍 Vérification…", "🔍 Verifying…", "🔍 校验中…", "🔍 กำลังตรวจสอบ…", "🔍 جارٍ التحقق…", "🔍 Verificando…", "🔍 Wird überprüft…");
// ==================== BETA BADGE ====================
/// <summary>Texte affiché dans la pill orange à côté de la version. Court et localisé.</summary>
public static string BetaBadgeLabel => T("BÊTA", "BETA", "测试版", "เบต้า", "تجريبي");
public static string BetaBadgeLabel => T("BÊTA", "BETA", "测试版", "เบต้า", "تجريبي", "BETA", "BETA");
/// <summary>Tooltip par défaut quand la version est tag BÊTA mais sans note des testeurs.</summary>
public static string BetaBadgeTooltipDefault => T(
@@ -120,17 +140,17 @@ public static class Strings
);
// ==================== ACTIONS ====================
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل");
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل");
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت");
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء");
public static string ActionOk => T("OK", "OK", "确定", "ตกลง", "موافق");
public static string ActionYes => T("Oui", "Yes", "是", "ใช่", "نعم");
public static string ActionNo => T("Non", "No", "否", "ไม่", "لا");
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً");
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ");
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق");
public static string ActionLaunch => T("▶ Lancer", "▶ Launch", "▶ 启动", "▶ เปิด", "▶ تشغيل", "▶ Iniciar", "▶ Starten");
public static string ActionLaunchBig => T("▶ LANCER", "▶ LAUNCH", "▶ 启动", "▶ เปิด", "▶ تشغيل", "▶ INICIAR", "▶ STARTEN");
public static string ActionInstall => T("⬇ Installer", "⬇ Install", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت", "⬇ Instalar", "⬇ Installieren");
public static string ActionInstallBig => T("⬇ INSTALLER", "⬇ INSTALL", "⬇ 安装", "⬇ ติดตั้ง", "⬇ تثبيت", "⬇ INSTALAR", "⬇ INSTALLIEREN");
public static string ActionCancel => T("Annuler", "Cancel", "取消", "ยกเลิก", "إلغاء", "Cancelar", "Abbrechen");
public static string ActionOk => T("OK", "OK", "确定", "ตกลง", "موافق", "OK", "OK");
public static string ActionYes => T("Oui", "Yes", "是", "ใช่", "نعم", "Sí", "Ja");
public static string ActionNo => T("Non", "No", "否", "ไม่", "لا", "No", "Nein");
public static string ActionLater => T("Plus tard", "Later", "稍后", "ภายหลัง", "لاحقاً", "Más tarde", "Später");
public static string ActionSave => T("Enregistrer", "Save", "保存", "บันทึก", "حفظ", "Guardar", "Speichern");
public static string ActionClose => T("Fermer", "Close", "关闭", "ปิด", "إغلاق", "Cerrar", "Schließen");
// Affiché sur le bouton install d'une version dont le minLicenseDate dépasse l'entitlement de la license.
// Concrètement : ta license expire le 2024-12-31 mais cette version a été sortie le 2025-03-15 → tu ne peux pas la télécharger.
// Tu peux toujours installer/lancer une version antérieure couverte par ta période de license.
@@ -258,6 +278,24 @@ public static class Strings
// ==================== UPDATE ====================
public static string UpdateAvailableTitle => T("Mise à jour disponible", "Update available", "有可用更新", "มีการอัปเดต", "تحديث متاح");
public static string UpdateDownload => T("⬇ Télécharger", "⬇ Download", "⬇ 下载", "⬇ ดาวน์โหลด", "⬇ تنزيل");
public static string UpdatePreserveSaveGames => T(
"Conserver les sauvegardes et replays de la version précédente",
"Keep save games and replays from the previous version",
"保留上一版本的存档和录像",
"เก็บไฟล์เซฟและรีเพลย์จากเวอร์ชันก่อนหน้า",
"الاحتفاظ بحفظات اللعبة والإعادات من النسخة السابقة",
"Conservar las partidas guardadas y repeticiones de la versión anterior",
"Spielstände und Replays der vorherigen Version beibehalten"
);
public static string UpdatePreserveSaveGamesTooltip => T(
"Copie les fichiers .sav (PROSERVE_UE_*/Saved/SaveGames) ET les replays .replay (PROSERVE_UE_*/Saved/Demos) de la version la plus récente déjà installée vers la nouvelle installation, après extraction. Si aucune version précédente n'est trouvée, l'option est sans effet.",
"Copies .sav files (PROSERVE_UE_*/Saved/SaveGames) AND .replay files (PROSERVE_UE_*/Saved/Demos) from the most recent already-installed version into the new install, after extraction. If no previous version is found, the option has no effect.",
"在解压后将最近已安装版本的 .sav 存档 (PROSERVE_UE_*/Saved/SaveGames) 和 .replay 录像 (PROSERVE_UE_*/Saved/Demos) 复制到新安装中。如果未找到之前的版本,则此选项无效。",
"คัดลอกไฟล์ .sav (PROSERVE_UE_*/Saved/SaveGames) และไฟล์ .replay (PROSERVE_UE_*/Saved/Demos) จากเวอร์ชันที่ติดตั้งล่าสุดไปยังการติดตั้งใหม่ หลังจากการแตกไฟล์ หากไม่พบเวอร์ชันก่อนหน้า ตัวเลือกนี้จะไม่มีผล",
"ينسخ ملفات .sav (PROSERVE_UE_*/Saved/SaveGames) وملفات .replay (PROSERVE_UE_*/Saved/Demos) من أحدث نسخة مثبتة بالفعل إلى التثبيت الجديد، بعد الاستخراج. إذا لم يتم العثور على نسخة سابقة، فلن يكون لهذا الخيار أي تأثير.",
"Copia los archivos .sav (PROSERVE_UE_*/Saved/SaveGames) y los archivos .replay (PROSERVE_UE_*/Saved/Demos) de la versión más reciente ya instalada en la nueva instalación, tras la extracción. Si no se encuentra una versión anterior, la opción no surte efecto.",
"Kopiert die .sav-Dateien (PROSERVE_UE_*/Saved/SaveGames) UND .replay-Dateien (PROSERVE_UE_*/Saved/Demos) der zuletzt installierten Version nach dem Entpacken in die neue Installation. Wird keine vorherige Version gefunden, hat die Option keine Wirkung."
);
public static string LauncherUpdateTitle => T("Mise à jour du launcher", "Launcher update", "启动器更新", "อัปเดต Launcher", "تحديث المُشغِّل");
public static string LauncherUpdateAvailable => T("Mise à jour du launcher disponible", "Launcher update available", "启动器更新可用", "มีการอัปเดต Launcher", "تحديث المُشغِّل متاح");
public static string LauncherUpdateBody => T(
@@ -270,15 +308,25 @@ public static class Strings
public static string LauncherUpdateNow => T("⬇ Mettre à jour", "⬇ Update now", "⬇ 立即更新", "⬇ อัปเดตเดี๋ยวนี้", "⬇ تحديث الآن");
// ==================== MESSAGEBOX TITLES ====================
public static string MsgBoxError => T("Erreur", "Error", "错误", "ข้อผิดพลาด", "خطأ");
public static string MsgBoxLaunchError => T("Erreur de lancement", "Launch error", "启动错误", "เปิดใช้งานล้มเหลว", "خطأ في التشغيل");
public static string MsgBoxConfirm => T("Confirmer", "Confirm", "确认", "ยืนยัน", "تأكيد");
public static string MsgBoxInfo => T("Information", "Information", "信息", "ข้อมูล", "معلومة");
public static string MsgBoxPatience => T("Patience", "Please wait", "请稍候", "โปรดรอ", "يرجى الانتظار");
public static string MsgBoxLanguageChange => T("Changement de langue", "Language change", "更改语言", "เปลี่ยนภาษา", "تغيير اللغة");
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار");
public static string MsgBoxError => T("Erreur", "Error", "错误", "ข้อผิดพลาด", "خطأ", "Error", "Fehler");
public static string MsgBoxLaunchError => T("Erreur de lancement", "Launch error", "启动错误", "เปิดใช้งานล้มเหลว", "خطأ في التشغيل", "Error al iniciar", "Startfehler");
public static string MsgBoxConfirm => T("Confirmer", "Confirm", "确认", "ยืนยัน", "تأكيد", "Confirmar", "Bestätigen");
public static string MsgBoxInfo => T("Information", "Information", "信息", "ข้อมูล", "معلومة", "Información", "Information");
public static string MsgBoxPatience => T("Patience", "Please wait", "请稍候", "โปรดรอ", "يرجى الانتظار", "Por favor espera", "Bitte warten");
public static string MsgBoxLanguageChange => T("Changement de langue", "Language change", "更改语言", "เปลี่ยนภาษา", "تغيير اللغة", "Cambio de idioma", "Sprachänderung");
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار", "Notas de versión", "Versionshinweise");
// ==================== MESSAGEBOX MESSAGES ====================
public static string MsgInstallCollision(string version, string folderName) => T(
$"Impossible d'installer v{version} : le dossier « {folderName} » est déjà occupé par une autre édition (channel différent) de cette version.\n\nPour éviter d'écraser silencieusement l'install existante, configure un « installFolderTemplate » distinct côté backoffice pour cette entrée (ex : « PROSERVE-firefighter v{{version}} »), puis relance l'install.",
$"Cannot install v{version}: the folder \"{folderName}\" is already used by another edition (different channel) of this version.\n\nTo avoid silently overwriting the existing install, configure a distinct \"installFolderTemplate\" for this entry in the backoffice (e.g. \"PROSERVE-firefighter v{{version}}\"), then retry the install.",
$"无法安装 v{version}:文件夹 \"{folderName}\" 已被此版本的另一个版本(不同频道)占用。\n\n为避免静默覆盖现有安装请在后台为该条目配置不同的 \"installFolderTemplate\"(例如 \"PROSERVE-firefighter v{{version}}\"),然后重试安装。",
$"ไม่สามารถติดตั้ง v{version}: โฟลเดอร์ \"{folderName}\" ถูกใช้แล้วโดยเวอร์ชันอื่น (channel ต่างกัน) ของเวอร์ชันนี้\n\nเพื่อหลีกเลี่ยงการเขียนทับการติดตั้งที่มีอยู่โดยไม่แจ้ง โปรดกำหนดค่า \"installFolderTemplate\" ที่แตกต่างกันสำหรับรายการนี้ในหลังบ้าน (เช่น \"PROSERVE-firefighter v{{version}}\") จากนั้นลองติดตั้งใหม่",
$"لا يمكن تثبيت v{version}: المجلد \"{folderName}\" مستخدم بالفعل بواسطة إصدار آخر (قناة مختلفة) من هذه النسخة.\n\nلتجنب الكتابة فوق التثبيت الموجود دون تنبيه، قم بإعداد \"installFolderTemplate\" مميز لهذا الإدخال في لوحة الإدارة (مثال \"PROSERVE-firefighter v{{version}}\")، ثم أعد المحاولة.",
$"No se puede instalar v{version}: la carpeta «{folderName}» ya está ocupada por otra edición (canal distinto) de esta versión.\n\nPara evitar sobrescribir la instalación existente sin aviso, configura un «installFolderTemplate» distinto para esta entrada en el backoffice (por ejemplo, «PROSERVE-firefighter v{{version}}»), y luego reintenta la instalación.",
$"v{version} kann nicht installiert werden: Der Ordner „{folderName}\" wird bereits von einer anderen Ausgabe (anderer Channel) dieser Version verwendet.\n\nUm ein stilles Überschreiben der bestehenden Installation zu vermeiden, konfigurieren Sie im Backoffice ein anderes installFolderTemplate\" für diesen Eintrag (z. B. „PROSERVE-firefighter v{{version}}\") und wiederholen Sie die Installation."
);
public static string MsgBusy => T(
"Une autre opération est déjà en cours.",
"Another operation is already in progress.",
@@ -545,6 +593,16 @@ public static class Strings
$"تم تثبيت v{version} بنجاح"
);
public static string StatusCopyingSaveGames(string fromVersion, int fileCount) => T(
$"Copie des sauvegardes et replays depuis v{fromVersion} ({fileCount} fichier{(fileCount > 1 ? "s" : "")})…",
$"Copying save games and replays from v{fromVersion} ({fileCount} file{(fileCount > 1 ? "s" : "")})…",
$"正在从 v{fromVersion} 复制存档和录像({fileCount} 个文件)…",
$"กำลังคัดลอกไฟล์เซฟและรีเพลย์จาก v{fromVersion} ({fileCount} ไฟล์)…",
$"جارٍ نسخ حفظات اللعبة والإعادات من v{fromVersion} ({fileCount} ملف)…",
$"Copiando partidas guardadas y repeticiones desde v{fromVersion} ({fileCount} archivo{(fileCount > 1 ? "s" : "")})…",
$"Spielstände und Replays werden von v{fromVersion} kopiert ({fileCount} Datei{(fileCount > 1 ? "en" : "")})…"
);
public static string StatusUninstallingVersion(string version) => T(
$"Suppression v{version}…",
$"Uninstalling v{version}…",
@@ -860,7 +918,7 @@ public static class Strings
);
// ---- Mode auto (auto-launch + auto-relaunch après exit) ----
public static string ActionAuto => T("AUTO", "AUTO", "自动", "อัตโนมัติ", "تلقائي");
public static string ActionAuto => T("AUTO", "AUTO", "自动", "อัตโนมัติ", "تلقائي", "AUTO", "AUTO");
public static string AutoModeTooltipActive => T(
"Cette version est désignée pour le lancement automatique. Clique pour désactiver.",
"This version is set for auto-launch. Click to disable.",
@@ -932,6 +990,26 @@ public static class Strings
"هناك نسخة من PROSERVE قيد التشغيل بالفعل. يرفض المشغل بدء نسخة ثانية لتجنب التعارضات (الجلسات، المنافذ، الملفات المقفلة).\n\nأغلق النسخة الحالية قبل بدء أخرى."
);
// ---- Section Settings → Avancés → Arguments de lancement (défauts appliqués partout) ----
public static string SettingsLaunchArgs => T(
"ARGUMENTS DE LANCEMENT",
"LAUNCH ARGUMENTS",
"启动参数",
"อาร์กิวเมนต์การเปิด",
"وسائط التشغيل",
"ARGUMENTOS DE INICIO",
"STARTARGUMENTE"
);
public static string SettingsLaunchArgsHelp => T(
"Arguments CLI appliqués à CHAQUE lancement de PROSERVE (mode auto ou manuel). Les arguments du mode auto ci-dessous s'ajoutent en plus, uniquement pour la version désignée AUTO.",
"CLI arguments applied to EVERY PROSERVE launch (auto or manual). Auto mode arguments below are added on top, only for the version marked AUTO.",
"应用于每次 PROSERVE 启动的 CLI 参数(自动或手动)。下方的自动模式参数仅对标记为 AUTO 的版本额外附加。",
"อาร์กิวเมนต์ CLI ที่ใช้กับการเปิด PROSERVE ทุกครั้ง (อัตโนมัติหรือด้วยตนเอง) อาร์กิวเมนต์โหมดอัตโนมัติด้านล่างจะเพิ่มเฉพาะสำหรับเวอร์ชันที่ตั้งเป็น AUTO",
"وسائط CLI مطبقة على كل تشغيل لـ PROSERVE (تلقائي أو يدوي). وسائط الوضع التلقائي أدناه تُضاف فقط للنسخة المحددة AUTO.",
"Argumentos CLI aplicados a CADA inicio de PROSERVE (auto o manual). Los argumentos del modo auto de abajo se añaden encima solo para la versión marcada AUTO.",
"CLI-Argumente, die bei JEDEM Start von PROSERVE angewendet werden (Auto oder manuell). Die Auto-Modus-Argumente unten werden nur für die als AUTO markierte Version zusätzlich angehängt."
);
// ---- Section Settings → Avancés → Mode auto ----
public static string SettingsAutoMode => T("MODE AUTO", "AUTO MODE", "自动模式", "โหมดอัตโนมัติ", "الوضع التلقائي");
public static string SettingsAutoModeFeatureEnabled => T(

View File

@@ -146,9 +146,16 @@ public sealed class ManifestService : IManifestService
// Whitelist côté client aussi pour éviter des chars exotiques qui casseraient l'URL.
var channel = _channelProvider();
var baseUrl = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
var url = !string.IsNullOrWhiteSpace(channel) && System.Text.RegularExpressions.Regex.IsMatch(channel!, "^[a-z0-9_-]{1,64}$")
? $"{baseUrl}?channel={Uri.EscapeDataString(channel!)}"
: baseUrl;
// Query params : ?channel=X (filtrage par channel license) + &multiChannel=1
// (opt-in au comportement v1.0.10+ : le serveur envoie plusieurs entries au
// même numéro de version sans dédup, le client sait afficher chacune sur
// sa row). Toujours envoyé — les serveurs anciens l'ignorent silencieusement.
var qs = new System.Text.StringBuilder("?multiChannel=1");
if (!string.IsNullOrWhiteSpace(channel) && System.Text.RegularExpressions.Regex.IsMatch(channel!, "^[a-z0-9_-]{1,64}$"))
{
qs.Append("&channel=").Append(Uri.EscapeDataString(channel!));
}
var url = baseUrl + qs.ToString();
using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
ovhCts.CancelAfter(OvhFetchTimeoutMs);
using var req = new HttpRequestMessage(HttpMethod.Get, url);

View File

@@ -1,20 +1,26 @@
namespace PSLauncher.Core.SteamVr;
/// <summary>
/// Fusionne récursivement le <c>steamvr.vrsettings</c> bundled dans le ZIP
/// PROSERVE (sous-dossier <c>_steamvr/</c>) dans le fichier SteamVR de l'utilisateur.
/// Fusionne le <c>steamvr.vrsettings</c> bundled dans le ZIP PROSERVE
/// (sous-dossier <c>_steamvr/</c>) dans le fichier SteamVR de l'utilisateur.
///
/// Sémantique de merge (deep, récursive) :
/// Sémantique de merge — SCOPED aux blocs racine <c>system.generated.*</c> :
/// <list type="bullet">
/// <item>Clé absente côté cible → ajoutée (clone profond du source)</item>
/// <item>Clé présente des deux côtés, valeurs JSON objet → <b>recursivement fusionnées</b>
/// (les sous-clés cible non touchées par le source sont préservées —
/// critique pour des blocs comme <c>"trackers"</c> qui contiennent
/// potentiellement déjà d'autres devices)</item>
/// <item>Clé présente, valeurs primitives ou de types différents (string,
/// bool, number, array) → la valeur source écrase celle de cible</item>
/// <item>Clés racine côté cible qui ne sont PAS dans le source → 100 %
/// préservées (on ne touche QUE ce que le source apporte)</item>
/// <item>SEULS les blocs racine <c>system.generated.*</c> sont considérés.
/// Tous les autres blocs (<c>DesktopUI</c>, <c>GpuSpeed</c>, <c>LastKnown</c>,
/// <c>dashboard</c>, <c>steamvr</c>, <c>trackers</c>, …) sont user-/machine-
/// spécifiques et VARIENT légitimement entre postes — le launcher ne
/// les touche jamais (sinon faux positifs de diff à chaque install).</item>
/// <item>Bloc <c>system.generated.*</c> ABSENT côté cible → push complet du
/// bloc tel quel (les 4 leaf keys fournies par l'installer dans le ZIP).</item>
/// <item>Bloc PRÉSENT côté cible → réécriture des leaf keys
/// <c>*_CurrentURL_openxr</c> et <c>*_PreviousURL_openxr</c> uniquement.
/// Les autres leaf keys (<c>_AutosaveURL_openxr</c>,
/// <c>_NeedToUpdateAutosave_openxr</c>) restent intactes — gérées par
/// SteamVR.</item>
/// <item>La décision "merge nécessaire" repose UNIQUEMENT sur la diff des
/// CurrentURL/PreviousURL (ou l'absence du bloc). Pas de popup si l'install
/// est ré-effectué et que ces valeurs n'ont pas bougé.</item>
/// </list>
///
/// Tue préalablement SteamVR + Vive Business Streaming pour éviter qu'ils ne

View File

@@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
using Microsoft.Extensions.Logging;
using Microsoft.Win32;
using PSLauncher.Models;
@@ -17,6 +18,32 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
/// <summary>Nom canonique du fichier de settings SteamVR (côté source ET cible).</summary>
private const string SettingsFileName = "steamvr.vrsettings";
/// <summary>
/// Pattern d'identification d'un bloc auto-généré par SteamVR pour une
/// application OpenXR donnée. Ex :
/// <c>system.generated.openxr.proserve_ue_5_5.proserve_ue_5_5.exe</c>.
///
/// Le launcher ne considère QUE ces blocs pour la diff + le push. Tous les
/// autres blocs racine du source (<c>DesktopUI</c>, <c>GpuSpeed</c>,
/// <c>LastKnown</c>, <c>dashboard</c>, <c>steamvr</c>, <c>trackers</c>, …)
/// sont user-/machine-spécifiques et VARIENT légitimement entre postes —
/// les checker provoquerait des faux positifs de diff à chaque install.
/// </summary>
private static readonly Regex SystemGeneratedKey =
new(@"^system\.generated\.", RegexOptions.Compiled);
/// <summary>
/// Leaf keys dans un bloc <c>system.generated.*</c> qui pilotent la diff.
/// Si l'une ou l'autre diffère entre source et target → le bloc est marqué
/// "à mettre à jour". Les autres leaf keys (<c>_AutosaveURL_openxr</c>,
/// <c>_NeedToUpdateAutosave_openxr</c>) ne sont PAS considérées pour la
/// décision de diff, mais elles sont quand même écrites lors du push initial
/// d'un bloc absent du target.
/// </summary>
private static bool IsRelevantOpenXrLeaf(string leafKey) =>
leafKey.EndsWith("_CurrentURL_openxr", StringComparison.Ordinal)
|| leafKey.EndsWith("_PreviousURL_openxr", StringComparison.Ordinal);
/// <summary>
/// Délai d'attente entre le kill des processus et l'ouverture du fichier.
/// SteamVR garde un handle sur steamvr.vrsettings et le flush ses changements
@@ -111,27 +138,36 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
$"Cible JSON invalide : {ex.Message}");
}
// 7) Deep-compare. On compte les clés RACINE qui changeraient (added ou
// modified) — c'est le compteur lisible pour l'opérateur. Les sous-clés
// d'un bloc qui change ne sont pas comptées individuellement.
// 7) Diff scoped : SEULS les blocs racine `system.generated.*` sont
// considérés. Les autres (DesktopUI, GpuSpeed, LastKnown, dashboard,
// steamvr, trackers, …) sont user-/machine-spécifiques et varient
// légitimement entre postes — les checker générerait des faux positifs
// de diff à chaque install. Cf. SystemGeneratedKey.
//
// Pour chaque bloc system.generated.* :
// • ABSENT côté target → on push le bloc entier au merge (= compte
// comme un "change")
// • PRÉSENT côté target → on compare uniquement les leaf keys
// `*_CurrentURL_openxr` et `*_PreviousURL_openxr`. Si l'une diffère,
// c'est un "change" (l'opérateur a édité ses bindings). Sinon, skip.
int rootKeysChanging = 0;
foreach (var (key, srcValue) in sourceObj)
{
// Whitelist : on n'examine QUE les blocs system.generated.*
if (!SystemGeneratedKey.IsMatch(key)) continue;
if (!targetObj.ContainsKey(key))
{
rootKeysChanging++;
continue;
}
var tgtValue = targetObj[key];
if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
{
if (WouldDeepMergeChange(tgtChild, srcChild))
if (OpenXrBindingUrlsDiffer(tgtChild, srcChild))
rootKeysChanging++;
}
else if (!JsonNodesEqual(tgtValue, srcValue))
{
rootKeysChanging++;
}
}
var outcome = rootKeysChanging > 0
@@ -281,28 +317,38 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
// Les compteurs top-level reflètent uniquement les clés RACINE
// (remplacées ou ajoutées) parce que c'est le niveau visible dans
// les logs et le seul niveau que l'opérateur a besoin de monitorer.
// Merge scoped : on n'agit QUE sur les blocs racine system.generated.*
// (cf. justification dans CheckMergeNeededAsync).
// • ABSENT côté target → push complet du bloc tel quel
// (= ce que l'installer fournit, avec ses 4 leaf keys)
// • PRÉSENT côté target → écrase les leaf keys CurrentURL +
// PreviousURL avec les valeurs du source. Les autres leaf keys
// (AutosaveURL, NeedToUpdate) restent comme côté target — gérées
// par SteamVR, pas touchées par le launcher.
int replaced = 0, added = 0;
foreach (var (key, srcValue) in sourceObj.ToList())
{
if (!SystemGeneratedKey.IsMatch(key)) continue;
if (!targetObj.ContainsKey(key))
{
// Bloc absent côté target → push complet du source
// (les 4 leaf keys, telles que fournies par l'installer).
targetObj[key] = srcValue?.DeepClone();
added++;
continue;
}
// Clé présente des deux côtés. Si les deux valeurs sont des
// objets JSON, on deep-merge ; sinon le source remplace.
if (srcValue is JsonObject srcChild
&& targetObj[key] is JsonObject tgtChild)
{
DeepMergeInto(tgtChild, srcChild);
// Bloc présent → on n'écrit QUE les leaf keys CurrentURL +
// PreviousURL depuis source (replace). Si elles matchent déjà,
// c'est un no-op au final (mais on tag quand même replaced
// car le check a déclenché un merge).
int leafTouched = ReplaceOpenXrBindingUrls(tgtChild, srcChild);
if (leafTouched > 0) replaced++;
}
else
{
targetObj[key] = srcValue?.DeepClone();
}
replaced++;
}
// 8. Write atomique via .tmp + rename. SteamVR utilise un JSON
@@ -321,6 +367,7 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
_logger.LogInformation(
"SteamVR settings merge OK: replaced={Replaced}, added={Added}, target={Path}",
replaced, added, targetPath);
return new SteamVrMergeResult(
SteamVrMergeStatus.Merged, replaced, added, targetPath, killed);
}
@@ -431,43 +478,6 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
/// L'ordre des entrées dans la liste compte : Vive Business Streaming
/// doit être en TÊTE car il relance SteamVR sinon.
/// </summary>
/// <summary>
/// Fusionne récursivement <paramref name="source"/> dans <paramref name="target"/>.
/// Règles par clé :
/// • absente côté cible → ajout (clone profond de la valeur source)
/// • présente des deux côtés ET les deux valeurs sont des objets JSON → recurse
/// • sinon → la valeur source écrase celle de cible
///
/// Les arrays JSON sont traités comme des "primitives" (= replace complet,
/// pas de merge par index ni dédup). SteamVR n'utilise pas d'arrays dans
/// ses settings de toute façon, c'est juste pour avoir un comportement
/// prévisible si jamais.
/// </summary>
/// <summary>
/// Dry-run récursif : applique mentalement le même algo que
/// <see cref="DeepMergeInto"/> et retourne true dès qu'au moins UNE clé du
/// source serait ajoutée ou modifiée côté target. Short-circuit pour ne
/// pas parcourir le JSON entier inutilement quand on a déjà trouvé un diff.
/// </summary>
private static bool WouldDeepMergeChange(JsonObject target, JsonObject source)
{
foreach (var (key, srcValue) in source)
{
if (!target.ContainsKey(key)) return true;
var tgtValue = target[key];
if (srcValue is JsonObject srcChild && tgtValue is JsonObject tgtChild)
{
if (WouldDeepMergeChange(tgtChild, srcChild)) return true;
}
else if (!JsonNodesEqual(tgtValue, srcValue))
{
return true;
}
}
return false;
}
/// <summary>
/// Comparaison d'égalité structurelle entre deux JsonNode (objects, arrays,
/// primitives). System.Text.Json n'expose pas <c>DeepEquals</c> avant .NET 9 ;
@@ -513,26 +523,49 @@ public sealed class SteamVrSettingsDeployer : ISteamVrSettingsDeployer
return false;
}
private static void DeepMergeInto(JsonObject target, JsonObject source)
/// <summary>
/// Dry-run pour un bloc <c>system.generated.*</c> déjà présent côté target.
/// Retourne true si une des leaf keys pertinentes (<c>_CurrentURL_openxr</c>
/// ou <c>_PreviousURL_openxr</c>) DIFFÈRE entre source et target. Les autres
/// leaf keys (AutosaveURL, NeedToUpdate, …) sont ignorées pour la décision.
/// </summary>
private static bool OpenXrBindingUrlsDiffer(JsonObject target, JsonObject source)
{
foreach (var (key, srcValue) in source.ToList())
foreach (var (leafKey, srcValue) in source)
{
if (!target.ContainsKey(key))
if (!IsRelevantOpenXrLeaf(leafKey)) continue;
if (!target.TryGetPropertyValue(leafKey, out var tgtValue)) return true; // absent du target
if (!JsonNodesEqual(tgtValue, srcValue)) return true; // valeur différente
}
return false;
}
/// <summary>
/// Merge effectif sur un bloc <c>system.generated.*</c> déjà présent côté
/// target. Réécrit les leaf keys CurrentURL et PreviousURL depuis le source,
/// laisse les autres leaf keys (<c>_AutosaveURL_openxr</c>,
/// <c>_NeedToUpdateAutosave_openxr</c>) intactes — c'est SteamVR qui les
/// gère, le launcher ne s'en mêle pas.
/// Retourne le nombre de leaf keys effectivement touchées (= 0 si source et
/// target ont déjà les mêmes URLs, sinon &gt; 0).
/// </summary>
private static int ReplaceOpenXrBindingUrls(JsonObject target, JsonObject source)
{
int touched = 0;
foreach (var (leafKey, leafValue) in source.ToList())
{
if (!IsRelevantOpenXrLeaf(leafKey)) continue;
// Skip si target a déjà la même valeur exacte (no-op silencieux,
// pas la peine de marquer touched).
if (target.TryGetPropertyValue(leafKey, out var existing)
&& JsonNodesEqual(existing, leafValue))
{
target[key] = srcValue?.DeepClone();
continue;
}
if (srcValue is JsonObject srcChild
&& target[key] is JsonObject tgtChild)
{
DeepMergeInto(tgtChild, srcChild);
}
else
{
target[key] = srcValue?.DeepClone();
}
target[leafKey] = leafValue?.DeepClone();
touched++;
}
return touched;
}
private async Task<int> KillProcessesAsync(

View File

@@ -4,7 +4,14 @@ namespace PSLauncher.Core.Updates;
public interface IUpdateChecker
{
Task<UpdateCheckResult> CheckAsync(CancellationToken ct);
/// <param name="canSeeBetas">
/// Si false, les versions marquées <see cref="VersionManifest.IsBeta"/> sont
/// exclues de la sélection du <c>LatestRemote</c> — pas de popup « MAJ dispo »
/// pour une beta que le client ne pourrait de toute façon pas voir dans la
/// liste après filtrage license côté <c>MainViewModel.RebuildList</c>. Le
/// manifest complet est quand même retourné pour le rendu ultérieur.
/// </param>
Task<UpdateCheckResult> CheckAsync(bool canSeeBetas, CancellationToken ct);
}
public sealed record UpdateCheckResult(

View File

@@ -21,29 +21,56 @@ public sealed class UpdateChecker : IUpdateChecker
_logger = logger;
}
public async Task<UpdateCheckResult> CheckAsync(CancellationToken ct)
public async Task<UpdateCheckResult> CheckAsync(bool canSeeBetas, CancellationToken ct)
{
try
{
var manifest = await _manifestService.FetchAsync(ct).ConfigureAwait(false);
// On ignore le champ `manifest.Latest` (trop facile à oublier au serveur).
// On prend toujours la plus haute version SemVer disponible et téléchargeable.
var latest = manifest.Versions
.Where(v => v.AvailableForDownload)
.OrderByDescending(v => SemVer.Parse(v.Version))
// On prend toujours la plus haute version disponible et téléchargeable, en
// ordonnant via VersionOrder — ça respecte la règle « non-beta > beta au
// même préfixe 3-digit » (ex : 1.5.4 non-beta > 1.5.4.32 beta), sinon
// c'est du SemVer strict.
//
// Filtre isBeta : les clients sans droits beta ne doivent JAMAIS voir une
// beta comme LatestRemote — sinon le popup « MAJ dispo » leur propose une
// version qu'ils ne pourront pas installer (filtrée en aval par RebuildList).
// Le filtre est appliqué avant le tri VersionOrder pour éviter qu'une beta
// ne se glisse comme "latest" chez un client non-autorisé, même si elle
// aurait été détrônée par une non-beta au même préfixe 3-digit.
var candidates = manifest.Versions.Where(v => v.AvailableForDownload);
if (!canSeeBetas) candidates = candidates.Where(v => !v.IsBeta);
var latest = candidates
.OrderByDescending(v => v, Comparer<VersionManifest>.Create(VersionOrder.Compare))
.FirstOrDefault();
var installed = _registry.Scan();
// Pour les installs locales on ne connaît pas leur statut beta d'origine
// (l'entrée manifest a pu disparaître entre-temps). On les compare en
// SemVer pur — c'est acceptable parce que le vrai check « faut-il updater »
// ci-dessous confronte l'install LA PLUS HAUTE au meilleur remote, qui
// lui embarque son isBeta.
var latestInstalled = installed
.OrderByDescending(v => SemVer.Parse(v.Version))
.FirstOrDefault();
var isLatestInstalled = latest is not null && latestInstalled is not null
&& latestInstalled.Version == latest.Version;
// Pour « isNewer », on doit décider si `latest` (remote) supersede
// `latestInstalled` (local). On cherche l'entrée manifest correspondante
// à l'install locale pour retrouver son isBeta d'origine. Si absente
// (typiquement : install locale d'une ancienne beta que l'opérateur a
// supprimée du manifest), on la traite comme non-beta pour ne pas
// fausser la comparaison en défaveur de l'install locale.
var installedRemote = latestInstalled is null
? null
: manifest.Versions.FirstOrDefault(v => v.Version == latestInstalled.Version);
var installedIsBeta = installedRemote?.IsBeta ?? false;
var isNewer = latest is not null
&& (latestInstalled is null
|| SemVer.Parse(latest.Version).CompareTo(SemVer.Parse(latestInstalled.Version)) > 0);
|| VersionOrder.Compare(latest.Version, latest.IsBeta,
latestInstalled.Version, installedIsBeta) > 0);
return new UpdateCheckResult(manifest, latest, latestInstalled, isLatestInstalled, isNewer, null);
}

View File

@@ -1,8 +1,14 @@
namespace PSLauncher.Models;
/// <param name="EntryId">
/// Id de l'entrée manifest source, lu depuis <c>.proserve-meta.json</c>. Null
/// pour les installs antérieurs à v1.0.8 (rétro-compat). Sert au matching
/// installed ↔ remote quand deux entries partagent un folder name résolu.
/// </param>
public sealed record InstalledVersion(
string Version,
string FolderPath,
string ExecutablePath,
DateTime InstalledAt,
long SizeBytes);
long SizeBytes,
string? EntryId = null);

View File

@@ -7,6 +7,17 @@ public sealed class LocalConfig
public string InstallRoot { get; set; } = string.Empty;
public string? LastLaunchedVersion { get; set; }
/// <summary>
/// Arguments CLI appliqués à CHAQUE lancement de PROSERVE (mode auto ou manuel).
/// Format identique à <see cref="AutoModeConfig.Args"/> : flag <c>-{Key}</c> si
/// <c>Value</c> null/vide, sinon <c>-{Key}={Value}</c>. Concaténés en premier ;
/// si la version lancée est aussi la version auto, les <see cref="AutoModeConfig.Args"/>
/// s'ajoutent DERRIÈRE (Unreal <c>FParse</c> lit dans l'ordre — la dernière occurrence
/// gagne sur un même key). Éditable dans Settings → « Arguments de lancement ».
/// Exemples typiques : <c>-nosplash</c>, <c>-log</c>, <c>-fps=90</c>.
/// </summary>
public List<AutoModeArg> DefaultLaunchArgs { get; set; } = new();
/// <summary>
/// Code de langue de l'UI : "auto" (suit la langue Windows), "fr", "en",
/// "zh", "th" ou "ar". Tout changement nécessite un redémarrage du launcher.

View File

@@ -46,6 +46,16 @@ public sealed class LauncherInfo
public sealed class VersionManifest
{
/// <summary>
/// Identifiant unique de l'entrée manifest, généré par le backoffice
/// (<c>generate_entry_id()</c>). Optionnel côté client : les vieux
/// manifests sans <c>id</c> continuent de fonctionner (rétro-compat).
/// Utilisé principalement pour le row-key côté UI et pour l'install guard
/// anti-collision (deux entries partageant version + folder → refuse).
/// </summary>
[JsonPropertyName("id")]
public string? Id { get; set; }
[JsonPropertyName("version")]
public string Version { get; set; } = string.Empty;

View File

@@ -0,0 +1,55 @@
<?php
// define constants for table names
// "data"
define("USERS_TABLE_NAME", "users");
define("SESSIONS_TABLE_NAME", "sessions");
define("PARTICIPATES_TABLE_NAME", "participates");
define("TRIGGEREVENTS_TABLE_NAME", "triggerevents");
define("REACTEVENTS_TABLE_NAME", "reactevents");
// types
define("TRIGGEREVENTTYPES_TABLE_NAME", "triggereventtypes");
define("REACTEVENTTYPES_TABLE_NAME", "reacteventtypes");
define("REACTEVENTMODES_TABLE_NAME", "reacteventmodes");
define("SESSIONTYPES_TABLE_NAME", "sessiontypes");
define("USERSTATUS_TABLE_NAME", "userstatus");
define("USERROLES_TABLE_NAME", "userroles");
// views
define("SESSIONDEBRIEFS_VIEW_NAME", "sessiondebriefs");
//////////////////////////////////////// query strings for SESSIONDEBRIEFS_VIEW ////////////////////////////////////////
// SELECT clause
define("SESSIONDEBRIEFS_VIEW_QUERY_SELECT",
"SELECT S.id AS SessionId, S.sessionType AS SessionTypeId, ST.displayName AS SessionType, S.sessionName AS SessionName, S.sessionDate AS SessionDate, " .
"S.mapName AS MapName, S.scenarioName AS ScenarioName, S.success AS SessionSuccessful, S.timeToFinish AS SessionDuration, " .
"COALESCE(T.type, -1) AS TriggerTypeId, COALESCE(TT.displayName, '') AS TriggerType, (SELECT IFNULL(T.srcUserId, PS.userId)) AS ShooterId, US.username AS ShooterName, " .
"(SELECT IFNULL(PS.role, 3)) AS ShooterRoleId, (SELECT R.displayName FROM userroles R WHERE R.id = ShooterRoleId) AS ShooterRole, " .
"COALESCE(T.indexCount, -1) AS ShotIndex, COALESCE(R.id, -1) AS ReactId, COALESCE(R.reactMode, 2) AS ReactModeId, COALESCE(RM.displayName, '') AS ReactMode, " .
"COALESCE(R.reactType, -1) AS ReactTypeId, COALESCE(RT.displayName, '') AS ReactType, COALESCE(R.hitUserId, -1) AS TargetUserId, " .
"COALESCE(UH.username, '') AS TargetUserName, (SELECT IFNULL(PH.role, 3)) AS TargetRoleId, (SELECT R.displayName FROM userroles R WHERE R.id = TargetRoleId) AS TargetRole, " .
"COALESCE(R.hitTargetName, '') AS TargetName, COALESCE(R.hitBoneName, '') AS TargetBoneName, COALESCE(R.targetKilled, 0) AS TargetKilled, " .
"COALESCE(R.objectHitLocationX, 0) AS HitLocationX, COALESCE(R.objectHitLocationY, 0) AS HitLocationY, COALESCE(R.objectHitTagLocation, '') AS HitLocationTag, " .
"COALESCE(R.hitPrecision, 0) AS HitPrecision, COALESCE(R.distance, 0) AS HitTargetDistance, COALESCE(R.reactTime, 0) AS ReactionTime, " .
"COALESCE(R.timeStamp, 0) AS TimeStamp, COUNT(DISTINCT R.id) AS NbHit, COUNT(DISTINCT RK.srcEventIndex,RK.hitTargetName) AS NbKilled");
// FROM clause
define ("SESSIONDEBRIEFS_VIEW_QUERY_FROM",
" FROM " . SESSIONS_TABLE_NAME . " S LEFT JOIN ". PARTICIPATES_TABLE_NAME . " PS ON (S.id = PS.sessionId) " .
"LEFT JOIN ". PARTICIPATES_TABLE_NAME . " PH ON (S.id = PH.sessionId) LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " T ON (S.id = T.sessionId) " .
"LEFT JOIN " . TRIGGEREVENTTYPES_TABLE_NAME . " TT ON (TT.id = T.type) LEFT JOIN " . SESSIONTYPES_TABLE_NAME . " ST ON (ST.id = S.sessionType) " .
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " R ON ( T.indexCount = R.srcEventIndex AND T.sessionId = R.srcEventSessionId ) " .
"LEFT JOIN " . USERS_TABLE_NAME . " UH ON (UH.id = R.hitUserId) LEFT JOIN " . USERS_TABLE_NAME . " US ON (US.id = T.srcUserId OR US.id = PS.userId ) " .
"LEFT JOIN " . REACTEVENTTYPES_TABLE_NAME . " RT ON (RT.id = R.reactType) LEFT JOIN " . REACTEVENTMODES_TABLE_NAME . " RM ON (RM.id = COALESCE(R.reactMode, 2)) " .
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " RK ON (R.id = RK.id AND RK.targetKilled = 1)");
// GROUP BY clause
define("SESSIONDEBRIEFS_VIEW_QUERY_GROUPBY", " GROUP BY SessionId,ShooterId,ShotIndex,TargetName");
// ORDER BY clause
define("SESSIONDEBRIEFS_VIEW_QUERY_ORDERBY", " ORDER BY SessionId,ShooterId,ShotIndex,ReactId");
// overall query string for SESSIONDEBRIEFS_VIEW (concatenation of SELECT, FROM, GROUP BY, ORDER BY clauses)
define("SESSIONDEBRIEFS_VIEW_QUERY", SESSIONDEBRIEFS_VIEW_QUERY_SELECT . SESSIONDEBRIEFS_VIEW_QUERY_FROM . SESSIONDEBRIEFS_VIEW_QUERY_GROUPBY . SESSIONDEBRIEFS_VIEW_QUERY_ORDERBY);
?>

View File

@@ -0,0 +1,28 @@
<?php
class Database
{
// specify your own database credentials
private $host = "localhost"; //Server
private $db_name = "ProserveAPI"; //Database Name
private $username = "root"; //UserName of Phpmyadmin
private $password = ""; //Password associated with username
public $conn;
// get the database connection
public function getConnection()
{
$this->conn = null;
try
{
$this->conn = new PDO("mysql:host=" . $this->host . ";dbname=" . $this->db_name, $this->username, $this->password);
$this->conn->exec("set names utf8");
}
catch(PDOException $exception)
{
echo "Connection error: " . $exception->getMessage();
}
return $this->conn;
}
}
?>

View File

@@ -0,0 +1,25 @@
<?php
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// set custom error handler
set_error_handler("json_error_handler");
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function missing_parameter_error ($param_name)
{
$error_message = "Missing_Parameter:" . $param_name;
return $error_message;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function json_error_handler ($err_level, $err_message)
{
$err_arr=array (
"status" => false,
"level" => $err_level,
"message" => $err_message
);
//if ($err_level != 2)
print_r(json_encode($err_arr));
}
?>

View File

@@ -0,0 +1,20 @@
<?php
// CORS headers for web interface
header("Access-Control-Allow-Origin: *");
header("Access-Control-Allow-Methods: POST, GET, OPTIONS");
header("Access-Control-Allow-Headers: Content-Type");
header("Content-Type: application/json; charset=UTF-8");
// Handle preflight OPTIONS request
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
http_response_code(200);
exit();
}
// include database and object files
include_once '../config/database.php';
// get database connection
$database = new Database();
$db = $database->getConnection();
?>

View File

@@ -0,0 +1,25 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_participates.php';
// prepare user object
$participation = new UserInGameSession($db);
$participation->sessionId = 1458;
$participation->userId = 6;
$participation->endStatus = 0;
// user leaves session
if ($participation->userLeaves())
{
// update score and averages
if ($participation->updateUserScores())
{
$participation_arr = $participation->getResultArray(true, "User_Left_OK");
print_r(json_encode($participation_arr)); // OK
}
else trigger_error("Calculate_Score_Failed");
}
else trigger_error("Leave_Session_Failed");
?>

View File

@@ -0,0 +1,47 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_reactevent.php';
// prepare user object
$event = new ReactEvent($db);
// read mandatory $_POST properties
// ensure sessionId, trigger index and event type are passed in $_POST parameters
if (isset($_POST['srcEventIndex']) && $_POST['srcEventIndex'] >= 0)
$event->srcEventIndex = $_POST['srcEventIndex'];
else trigger_error( missing_parameter_error("srcEventIndex") );
if (isset($_POST['srcEventSessionId']) && $_POST['srcEventSessionId'] > 0)
$event->srcEventSessionId = $_POST['srcEventSessionId'];
else trigger_error( missing_parameter_error("srcEventSessionId") );
if (isset($_POST['type']))
$event->reactType = $_POST['type'];
else trigger_error( missing_parameter_error("type") );
// read other $_POST properties
$event->hitUserId = $_POST['hitUserId'];
$event->hitTargetName = $_POST['targetName'];
$event->hitBoneName = $_POST['boneName'];
$event->damage = $_POST['damage'];
$event->targetKilled = (strcasecmp( ($_POST['targetKilled'] ?? false), "true" ) == 0) ? 1 : 0;
$event->objectHitLocationX = $_POST['objectHitLocationX'];
$event->objectHitLocationY = $_POST['objectHitLocationY'];
$event->objectHitTagLocation = $_POST['objectHitTagLocation'];
$event->hitPrecision = $_POST['hitPrecision'];
$event->distance = $_POST['distance'];
$event->reactTime = $_POST['reactTime'];
$event->reactMode = $_POST['reactMode'];
$event->timeStamp = $_POST['timestamp'];
// create reactevent
if ($event->record())
{
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
print_r(json_encode($evt_arr)); // OK
}
else trigger_error("Error_Occured");
?>

View File

@@ -0,0 +1,36 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_triggerevent.php';
// prepare user object
$event = new TriggerEvent($db);
// read mandatory $_POST properties
// ensure sessionId, userId and trigger index are passed in $_POST parameters
if (isset($_POST['session']) && $_POST['session'] > 0)
$event->sessionId = $_POST['session'];
else trigger_error( missing_parameter_error("session") );
if (isset($_POST['user']) && $_POST['user'] >= 0)
$event->srcUserId = $_POST['user'];
else trigger_error( missing_parameter_error("user") );
if (isset($_POST['index']) && $_POST['index'] >= 0)
$event->indexCount = $_POST['index'];
else trigger_error( missing_parameter_error("index") );
// read other $_POST properties
$event->type = $_POST['type'];
$event->timeStamp = $_POST['timestamp'];
$event->successful = $_POST['success'];
// create trigger event
if ($event->record())
{
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
print_r(json_encode($evt_arr)); // OK
}
else trigger_error("Error_Occured");
?>

View File

@@ -0,0 +1,14 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$typeId = isset($_POST['typeId']) ? $_POST['typeId'] : -1;
$stats = new StatsObject($db);
$stats->getSessionsForUser(-1, $typeId);
$stats_arr = $stats->getResultArray(true, "All_Sessions_List_OK");
print_r(json_encode($stats_arr));
?>

View File

@@ -0,0 +1,12 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$stats = new StatsObject($db);
$stats->getUsersInSession(-1);
$stats_arr = $stats->getResultArray(true, "All_Users_List_OK");
print_r(json_encode($stats_arr));
?>

View File

@@ -0,0 +1,15 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$userId = isset($_POST['userId']) ? $_POST['userId'] : -1;
$typeId = isset($_POST['typeId']) ? $_POST['typeId'] : -1;
$stats = new StatsObject($db);
$stats->getSessionsForUser($userId, $typeId);
$stats_arr = $stats->getResultArray(true, "User_Sessions_List_OK");
print_r(json_encode($stats_arr)); // OK
?>

View File

@@ -0,0 +1,14 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$sessionId = isset($_POST['sessionId']) ? $_POST['sessionId'] : -1;
$stats = new StatsObject($db);
$stats->getUsersInSession($sessionId);
$stats_arr = $stats->getResultArray(true, "Session_Users_List_OK");
print_r(json_encode($stats_arr)); // OK
?>

View File

@@ -0,0 +1,56 @@
<?php
include_once '../objects/db_table_object.php';
class DBObjectType extends DBTableObject
{
// database connection and table name
//private $conn;
//private $table_name = "sessioneventtypes";
protected $array_key = "type";
// object properties
public $id = -1;
public $displayName = "";
public $description = "";
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"id" => $this->id,
"displayName" => $this->displayName,
"description" => $this->description
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function save ()
{
// query to insert record of new user signup
$query = "INSERT INTO " . $this->table_name . " SET displayName=:displayName, description=:description";
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->displayName=htmlspecialchars(strip_tags($this->displayName));
$this->description=htmlspecialchars(strip_tags($this->description));
// bind values
$stmt->bindParam(":displayName", $this->displayName);
$stmt->bindParam(":description", $this->description);
// execute query
if($stmt->execute())
{
$this->id = $this->conn->lastInsertId();
return true;
}
return false;
}
}
?>

View File

@@ -0,0 +1,591 @@
<?php
include_once "../objects/db_table_object.php";
include_once "../objects/db_participates_results.php";
include_once '../score/score_algo1.php'; // for score calculation
class UserInGameSession extends DBTableObject
{
// database connection and table name
//private $conn;
protected $table_name = PARTICIPATES_TABLE_NAME;
protected $array_key = "participation";
// object properties
public int $userId = -1;
public int $sessionId = 0;
public float $score = 0;
public float $firePrecision = 0.0;
public float $reactionTime = 0.0;
public int $nbEnemyHit = 0;
public int $nbCivilsHit = 0;
public int $damageTaken = 0;
public $endStatus = 0;
public $avatar = "";
public $weapon = "";
public int $role = 0;
public $results = "";
//public $replayFileName = "";
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"userId" => (int)$this->userId,
"sessionId" => (int)$this->sessionId,
"score" => (float)$this->score ?? 0.0,
"firePrecision" => (float)$this->firePrecision ?? 0.0,
"reactionTime" => (float)$this->reactionTime ?? 0.0,
"nbEnemyHit" => (int)$this->nbEnemyHit ?? 0,
"nbCivilsHit" => (int)$this->nbCivilsHit ?? 0,
"damageTaken" => (int)$this->damageTaken ?? 0,
"endStatus" => (int)$this->endStatus ?? 0,
"avatar" => $this->avatar ?? "",
"weapon" => $this->weapon ?? "",
"roleId" => $this->role,
"resultsAsString" => $this->results
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function readRow (array $row)
{
$this->userId = (int)$row['userId'];
$this->sessionId = (int)$row['sessionId'];
$this->score = (int)$row['score'];
$this->firePrecision = (float)$row['firePrecision'];
$this->reactionTime = (float)$row['reactionTime'];
$this->nbEnemyHit = (int)$row['nbEnemyHit'];
$this->nbCivilsHit = (int)$row['nbCivilsHit'];
$this->damageTaken = (int)$row['damageTaken'];
$this->endStatus = (int)$row['endStatus'];
$this->avatar = $row['avatar'];
$this->weapon = $row['weapon'];
$this->role = $row['role'];
$this->results = $row['results'];
//$this->replayFileName = $row['replayFileName'];
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function sanitize ()
{
$this->userId=htmlspecialchars(strip_tags($this->userId));
$this->sessionId=htmlspecialchars(strip_tags($this->sessionId));
$this->avatar=htmlspecialchars(strip_tags($this->avatar));
$this->weapon=htmlspecialchars(strip_tags($this->weapon));
$this->role=htmlspecialchars(strip_tags($this->role));
$this->results=htmlspecialchars(strip_tags($this->results));
//$this->nbCivilsHit=htmlspecialchars(strip_tags($this->nbCivilsHit));
//$this->nbEnemyHit=htmlspecialchars(strip_tags($this->nbEnemyHit));
//$this->score=htmlspecialchars(strip_tags($this->score));
//$this->firePrecision=htmlspecialchars(strip_tags($this->firePrecision));
//$this->damageTaken=htmlspecialchars(strip_tags($this->damageTaken));
//$this->replayFileName=htmlspecialchars(strip_tags($this->replayFileName));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function load ()
{
// select all query with user inputed username and password
$query = "SELECT * FROM " . $this->table_name . " WHERE userId='" . $this->userId . "' AND sessionId='" . $this->sessionId . "'";
// prepare query statement
$stmt = $this->conn->prepare($query);
// execute query
$stmt->execute();
if($stmt->rowCount() > 0)
{
// get retrieved row
$row = $stmt->fetch(PDO::FETCH_ASSOC);
// retrieve session values
$this->readRow($row);
return true;
}
return false;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function registerUser ()
{
//$this->sessionId = $session;
//$this->userId = $user;
//$this->avatar = $avatar;
//$this->weapon = $weapon;
if ($this->alreadyRegistered())
return false;
// query to insert record of new user signup
$query = "INSERT INTO " . $this->table_name . " SET sessionId=:sessionId, userId=:userId, avatar=:avatar, weapon=:weapon, role=:role";
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->sanitize();
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
$stmt->bindParam(":avatar", $this->avatar);
$stmt->bindParam(":weapon", $this->weapon);
$stmt->bindParam(":role", $this->role);
//$stmt->bindParam(":nbCivilsHit", $this->nbCivilsHit);
//$stmt->bindParam(":nbEnemyHit", $this->nbEnemyHit);
//$stmt->bindParam(":score", $this->score);
//$stmt->bindParam(":firePrecision", $this->firePrecision);
//$stmt->bindParam(":damageTaken", $this->damageTaken);
//$stmt->bindParam(":replayFileName", $this->replayFileName);
// execute query
return $stmt->execute();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function alreadyRegistered ()
{
$query = "SELECT * FROM " . $this->table_name . " WHERE userId=:userId AND sessionId=:sessionId";
// prepare query statement
$stmt = $this->conn->prepare($query);
// sanitize
$this->userId=htmlspecialchars(strip_tags($this->userId));
$this->sessionId=htmlspecialchars(strip_tags($this->sessionId));
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
// execute query
$stmt->execute();
return $stmt->rowCount() > 0;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function updateUserScores ()
{
//$this->sessionId = $sessionId;
//$this->userId = $userId;
// load current values
$this->load();
$this->score = 0;
$query = "SELECT sessionType FROM Sessions WHERE id='" . $this->sessionId . "'";
// prepare query
$stmt = $this->conn->prepare($query);
$stmt->execute();
if($stmt->rowCount() > 0)
{
$row = $stmt->fetch(PDO::FETCH_ASSOC);
if ($row['sessionType'] == 0) // FireRange
{
$query = "SELECT R.id, R.hitPrecision, R.objectHitLocationX, R.objectHitLocationY, R.distance, R.objectHitTagLocation " . "
FROM " . REACTEVENTS_TABLE_NAME . " R, " . TRIGGEREVENTS_TABLE_NAME . " T " . "
WHERE R.srcEventSessionId=T.sessionId AND R.srcEventIndex=T.indexCount AND T.srcUserId=:userId AND T.sessionId=:sessionId";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
//$stmt->bindParam(":score", $this->score);
//$stmt->bindParam(":role", $role);
// execute query
$stmt->execute();
$totalPrecision=0;
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
$totalPrecision += $row['hitPrecision'];
$this->score = calculate_firerange_v1($totalPrecision);
}
else
{
$query = "SELECT R.id, R.hitPrecision, R.distance, R.reactType, P.nbCivilsHit " . "
FROM " . REACTEVENTS_TABLE_NAME . " R, " . TRIGGEREVENTS_TABLE_NAME . " T, " . $this->table_name . " P " . "
WHERE R.srcEventIndex=T.indexCount AND R.srcEventSessionId=T.sessionId AND P.sessionId=R.srcEventSessionId AND P.userId=T.srcUserId " . "
AND R.srcEventSessionId=:sessionId AND T.srcUserId=:userId AND (R.reactType=0 OR R.reactType=4 OR R.reactType=5)";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
//$stmt->bindParam(":score", $this->score);
//$stmt->bindParam(":role", $role);
// execute query
$stmt->execute();
if ($stmt->rowCount() > 0)
{
$totalPrecision=0;
$nbCivilHits=0;
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
$nbCivilHits = $row['nbCivilsHit'];
if ($row['reactType']==0) // todo : to keep ?
$totalPrecision += $row['hitPrecision'];
}
$this->score = calculate_v1($totalPrecision, $nbCivilHits);
}
}
}
$query = "UPDATE " . $this->table_name . " SET score=:score WHERE sessionId=:sessionId AND userId=:userId";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
$stmt->bindParam(":score", $this->score);
// update overall averages for participation user
$query_Precision = "SELECT X.UserId AS UserId, SUM(X.NbShotsFired) AS nbFire, SUM(Y.HitPrecision) AS precisionTotal FROM " . "
(SELECT P.userID AS UserId, P.sessionId AS SessionId, TE.indexCount AS IndexCount, COUNT(DISTINCT TE.sessionId, TE.indexCount) AS NbShotsFired
FROM " . PARTICIPATES_TABLE_NAME . " P, " . TRIGGEREVENTS_TABLE_NAME . " TE " . "
WHERE P.sessionId=TE.sessionId AND TE.srcUserId=P.UserId GROUP BY P.UserId, P.sessionId, TE.indexCount) AS X
LEFT JOIN
(SELECT P.userID AS UserId, P.sessionId AS SessionId, TE.indexCount AS IndexCount, MAX(RE.hitPrecision) AS HitPrecision
FROM " . PARTICIPATES_TABLE_NAME . " P, " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE
WHERE P.sessionId=TE.sessionId AND P.sessionId=RE.srcEventSessionId AND TE.srcUserId=P.UserId AND RE.srcEventIndex=TE.indexCount GROUP BY P.UserId, P.sessionId, TE.indexCount) AS Y
ON (X.UserId=Y.UserId AND X.SessionId=Y.SessionId AND X.IndexCount=Y.IndexCount) WHERE X.UserId=:userId
GROUP BY UserId";
$query_ReactionTime = "SELECT P.userID AS UserId, SUM(CASE WHEN RE.reactTime>0 THEN 1 ELSE 0 END) AS nbHits, SUM(RE.reactTime) AS reactTimeTotal
FROM " . PARTICIPATES_TABLE_NAME . " P, " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE
WHERE P.sessionId=TE.sessionId AND P.sessionId=RE.srcEventSessionId AND TE.srcUserId=P.UserId AND RE.srcEventIndex=TE.indexCount AND P.UserId=:userId GROUP BY P.UserId";
$stmt_Precision = $this->conn->prepare($query_Precision);
$stmt_ReactionTime = $this->conn->prepare($query_ReactionTime);
$stmt_Precision->bindParam(":userId", $this->userId);
$stmt_ReactionTime->bindParam(":userId", $this->userId);
$stmt_Precision->execute();
$stmt_ReactionTime->execute();
$res_UpdatePrecision = true;
$res_UpdateReactionTime = true;
if ($stmt_Precision->rowCount() > 0 && $this->userId > 0)
{
// get retrieved row
$row = $stmt_Precision->fetch(PDO::FETCH_ASSOC);
$shotFired = $row['nbFire'];
// retrieve average precision per shot
$userPrecision = $row['precisionTotal']/($shotFired == 0 ? 1 : $shotFired);
$updatePrecision = "UPDATE " . USERS_TABLE_NAME . " SET avgPrecision=" . $userPrecision . " WHERE id=" . $this->userId;
$stmt_UpdatePrecision = $this->conn->prepare($updatePrecision);
$res_UpdatePrecision = $stmt_UpdatePrecision->execute();
}
if ($stmt_ReactionTime->rowCount() > 0 && $this->userId > 0)
{
// get retrieved row
$row = $stmt_ReactionTime->fetch(PDO::FETCH_ASSOC);
$shotHits = $row['nbHits'];
// retrieve average reactionTime per shot
$userReactionTime = $row['reactTimeTotal']/($shotHits == 0 ? 1 : $shotHits);
$updateReactionTime = "UPDATE " . USERS_TABLE_NAME . " SET avgReaction=" . $userReactionTime . " WHERE id=" . $this->userId;
$stmt_UpdateReactionTime = $this->conn->prepare($updateReactionTime);
$res_UpdateReactionTime = $stmt_UpdateReactionTime->execute();
}
return $stmt->execute() && $res_UpdatePrecision && $res_UpdateReactionTime;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function updateObjectives ()
{
// load current values
//$this->load();
// sanitize JSON string
//$this->results=htmlspecialchars(strip_tags($this->results));
// update participation with objective results in database
$query = "UPDATE " . $this->table_name . " SET results=:results WHERE sessionId=:sessionId AND userId=:userId";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
$stmt->bindParam(":results", $this->results);
return $stmt->execute();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function userLeaves ()
{
//$this->userId = $userId;
//$this->sessionId = $sessionId;
// load current values
$this->load();
// queries to update user stats
$query_NbShotFired = "SELECT COUNT(DISTINCT TE.indexCount) AS nbFire FROM " . TRIGGEREVENTS_TABLE_NAME . " TE WHERE TE.sessionId=:sessionId AND TE.srcUserId=:userId";
$query_NbEnemyHit = "SELECT COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS nbEnemyHits FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId=:sessionId AND RE.srcEventSessionId=TE.sessionId AND RE.srcEventIndex=TE.indexCount AND TE.srcUserId=:userId AND RE.reactType=0";
$query_NbCivilHit = "SELECT COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS nbCivilHits FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId=:sessionId AND RE.srcEventSessionId=TE.sessionId AND RE.srcEventIndex=TE.indexCount AND TE.srcUserId=:userId AND RE.reactType=1";
$query_PrecisionAndReactionTime = "SELECT SUM(X.precisionByShot) AS precisionTotal, SUM(X.reactTimeByShot) AS reactTimeTotal, COUNT(CASE WHEN X.reactTime > 0 THEN 1 ELSE 0 END) AS shots
FROM (SELECT COALESCE(RE.hitPrecision,0) AS precisionByShot, COALESCE(RE.reactTime,0) AS reactTimeByShot, RE.reactTime AS reactTime FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId=:sessionId AND TE.srcUserId=:userId AND TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND (RE.reactType=0 OR RE.reactType=4 OR RE.reactType=5) GROUP BY TE.sessionId, TE.srcUserId, TE.indexCount) AS X";
// prepare queries
$stmt_NbShotFired = $this->conn->prepare($query_NbShotFired);
$stmt_NbEnemyHit = $this->conn->prepare($query_NbEnemyHit);
$stmt_NbCivilHit = $this->conn->prepare($query_NbCivilHit);
$stmt_PrecisionAndReactionTime = $this->conn->prepare($query_PrecisionAndReactionTime);
// bind values
$stmt_NbShotFired->bindParam(":sessionId", $this->sessionId);
$stmt_NbShotFired->bindParam(":userId", $this->userId);
$stmt_NbEnemyHit->bindParam(":sessionId", $this->sessionId);
$stmt_NbEnemyHit->bindParam(":userId", $this->userId);
$stmt_NbCivilHit->bindParam(":sessionId", $this->sessionId);
$stmt_NbCivilHit->bindParam(":userId", $this->userId);
$stmt_PrecisionAndReactionTime->bindParam(":sessionId", $this->sessionId);
$stmt_PrecisionAndReactionTime->bindParam(":userId", $this->userId);
// execute queries
$stmt_NbShotFired->execute();
$stmt_NbEnemyHit->execute();
$stmt_NbCivilHit->execute();
$stmt_PrecisionAndReactionTime->execute();
$shotFired=1;
if ($stmt_NbShotFired->rowCount() > 0)
{
// get retrieved row
$row = $stmt_NbShotFired->fetch(PDO::FETCH_ASSOC);
// retrieve nbFire value
$shotFired = $row['nbFire'];
}
if ($stmt_NbEnemyHit->rowCount() > 0)
{
// get retrieved row
$row = $stmt_NbEnemyHit->fetch(PDO::FETCH_ASSOC);
// retrieve nbEnemyHits value
$this->nbEnemyHit = $row['nbEnemyHits'];
}
if ($stmt_NbCivilHit->rowCount() > 0)
{
// get retrieved row
$row = $stmt_NbCivilHit->fetch(PDO::FETCH_ASSOC);
// retrieve nbCivilHits value
$this->nbCivilsHit = $row['nbCivilHits'];
}
if ($stmt_PrecisionAndReactionTime->rowCount() > 0)
{
// get retrieved row
$row = $stmt_PrecisionAndReactionTime->fetch(PDO::FETCH_ASSOC);
$shotsWithReactTime = (int)$row['shots'];
if ($shotsWithReactTime <= 0)
$shotsWithReactTime = 1;
// retrieve average precision per shot
$this->firePrecision = $row['precisionTotal']/($shotFired == 0 ? 1 : $shotFired);
// retrieve average reactionTime per shot
$this->reactionTime = $row['reactTimeTotal']/$shotsWithReactTime;
}
return $this->update() && $stmt_NbShotFired->rowCount() > 0 && $stmt_NbEnemyHit->rowCount() > 0 && $stmt_NbCivilHit->rowCount() > 0 && $stmt_PrecisionAndReactionTime->rowCount() > 0 /* && $stmt_Precision->rowCount() > 0 && $stmt_ReactionTime->rowCount() > 0*/;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function update ()
{
$query = "UPDATE " . $this->table_name . "
SET nbEnemyHit=:nbEnemyHit, nbCivilsHit=:nbCivilsHit, firePrecision=:firePrecision, reactionTime=:reactionTime " . "
WHERE sessionId='".$this->sessionId."' AND userId='".$this->userId."'";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":nbEnemyHit", $this->nbEnemyHit);
$stmt->bindParam(":nbCivilsHit", $this->nbCivilsHit);
$stmt->bindParam(":firePrecision", $this->firePrecision);
$stmt->bindParam(":reactionTime", $this->reactionTime);
// execute query
if ($stmt->execute())
{
// once it has been updated, we have to update the averagePrecision field in the USERS_TABLE_NAME table
$query = "UPDATE " . USERS_TABLE_NAME . "
SET avgPrecision=(SELECT AVG(firePrecision) FROM " . PARTICIPATES_TABLE_NAME . " P WHERE P.userId='".$this->userId."'),
avgReaction=(SELECT AVG(reactionTime) FROM " . PARTICIPATES_TABLE_NAME . " P WHERE P.userId='".$this->userId."')
WHERE users.id='".$this->userId."'";
// prepare query
$stmt = $this->conn->prepare($query);
// execute query
return $stmt->execute();
}
return false;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function updateScore ()
{
$query = "UPDATE " . $this->table_name . " SET score=:score WHERE sessionId=:sessionId AND userId=:userId";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":userId", $this->userId);
$stmt->bindParam(":score", $this->score);
return $stmt->execute();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function getAllUsersInSession ()
{
$query = "SELECT DISTINCT userId FROM " . $this->table_name . " WHERE sessionId=:sessionId";
// prepare query
$stmt = $this->conn->prepare($query);
// bind values
$stmt->bindParam(":sessionId", $this->sessionId);
// execute query
$stmt->execute();
$usersIdForSession = array();
// loop through results
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
// retrieve user ids
array_push( $usersIdForSession, $row['userId'] );
}
return $usersIdForSession;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function copy (UserInGameSession $otherParticipation)
{
$this->userId = $otherParticipation->userId;
$this->sessionId = $otherParticipation->sessionId;
$this->score = $otherParticipation->score; // need to call calculateAverages manually
$this->firePrecision = $otherParticipation->firePrecision; // need to call calculateAverages manually
$this->reactionTime = $otherParticipation->reactionTime; // need to call calculateAverages manually
$this->nbEnemyHit = $otherParticipation->nbEnemyHit; // total
$this->nbCivilsHit = $otherParticipation->nbCivilsHit; // total
$this->damageTaken = $otherParticipation->damageTaken; // total
$this->endStatus = $otherParticipation->endStatus; // TODO : mixed ?
$this->avatar = $otherParticipation->avatar;
$this->weapon = $otherParticipation->weapon;
$this->role = $otherParticipation->role; // TODO
if ($this->results != null && $otherParticipation->results != null)
$this->results = $otherParticipation->results;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function add (UserInGameSession $otherParticipation)
{
if ($this->userId != $otherParticipation->userId)
$this->userId = -1;
if ($this->sessionId != $otherParticipation->sessionId)
$this->sessionId = -1;
$this->score += $otherParticipation->score; // need to call calculateAverages manually
$this->firePrecision += $otherParticipation->firePrecision; // need to call calculateAverages manually
$this->reactionTime += $otherParticipation->reactionTime; // need to call calculateAverages manually
$this->nbEnemyHit += $otherParticipation->nbEnemyHit; // total
$this->nbCivilsHit += $otherParticipation->nbCivilsHit; // total
$this->damageTaken += $otherParticipation->damageTaken; // total
$this->endStatus = $this->endStatus; // TODO : mixed ?
if ($this->avatar != $otherParticipation->avatar)
$this->avatar = "various avatars";
if ($this->weapon != $otherParticipation->weapon)
$this->weapon = "various weapons";
$this->role = $this->role; // TODO
$this->addResults($otherParticipation);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function addResults (UserInGameSession $otherParticipation)
{
// add resultsObject debriefs
if ($this->results != null && $otherParticipation->results != null)
{
$resultsObject = UserInGameSessionResults::fromJsonString($this->results);
$otherParticipationResultsObject = UserInGameSessionResults::fromJsonString($otherParticipation->results);
if ($resultsObject != null && $otherParticipationResultsObject)
{
$resultsObject->add($otherParticipationResultsObject);
// update results string
$this->results = json_encode($resultsObject);
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function calculateAverages ($nb)
{
if ($nb > 0)
{
$this->score /= $nb; // average
$this->firePrecision /= $nb; // average
$this->reactionTime /= $nb; // average
$this->nbEnemyHit = $this->nbEnemyHit; // total
$this->nbCivilsHit = $this->nbCivilsHit; // total
$this->damageTaken = $this->damageTaken; // total
// calculate averages for scores of resultsObject
$resultsObject = UserInGameSessionResults::fromJsonString($this->results);
$resultsObject->calculateAverages($nb);
// update results string
$this->results = json_encode($resultsObject);
}
}
}
?>

View File

@@ -0,0 +1,118 @@
<?php
// Single ObjectiveDebrief
class ObjectiveDebrief
{
public $id = "";
public $description = "";
public float $score = 0.0;
public float $weight = 0.0;
public int $completed = 0;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function add (ObjectiveDebrief $otherDebrief)
{
if ($this->id == $otherDebrief->id)
{
// only add stats with same id
$this->score += $otherDebrief->score;
//$this->weight += $otherDebrief->weight; // weight should be the same if ids are the same
if ($this->completed != 1)
$this->completed = $otherDebrief->completed == 1 ? 1 : $this->completed;
}
}
}
// General GameSession debrief (multiple ObjectiveDebrief)
class UserInGameSessionResults
{
public ObjectiveDebrief $civilian;
public ObjectiveDebrief $time;
public ObjectiveDebrief $enemy;
public ObjectiveDebrief $health;
public ObjectiveDebrief $precision;
public ObjectiveDebrief $reactTime;
public ObjectiveDebrief $ammoLimit;
public ObjectiveDebrief $target;
public ObjectiveDebrief $overall;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function __construct ()
{
$this->civilian = new ObjectiveDebrief();
$this->time = new ObjectiveDebrief();
$this->enemy = new ObjectiveDebrief();
$this->health = new ObjectiveDebrief();
$this->precision = new ObjectiveDebrief();
$this->reactTime = new ObjectiveDebrief();
$this->ammoLimit = new ObjectiveDebrief();
$this->target = new ObjectiveDebrief();
$this->overall = new ObjectiveDebrief();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public static function fromJsonString (string $jsonResults)
{
$instance = new self();
if (!empty($jsonResults))
$instance->createFromJsonArray( json_decode($jsonResults, true) );
return $instance;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
private function createFromJsonArray ($missionDebriefData)
{
// Parse all objectiveDebrief JSON entries in data (civilian, time, enemy, etc.)
foreach ($missionDebriefData as $objectiveDebriefKey => $objectiveDebriefValue)
{
// Create an ObjectiveDebrief variable for each objectiveDebrief JSON entry
$debrief = new ObjectiveDebrief();
foreach ($objectiveDebriefValue as $debriefKey => $debriefValue)
{
// Read all JSON properties and assign them to the ObjectiveDebrief variable
$debrief->{$debriefKey} = $debriefValue;
}
// Assign ObjectiveDebrief variable to the corresponding property in UserInGameSessionResults
$this->{$objectiveDebriefKey} = $debrief;
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function add (UserInGameSessionResults $otherParticipationResults)
{
$this->civilian->add($otherParticipationResults->civilian);
$this->time->add($otherParticipationResults->time);
$this->enemy->add($otherParticipationResults->enemy);
$this->health->add($otherParticipationResults->health);
$this->precision->add($otherParticipationResults->precision);
$this->reactTime->add($otherParticipationResults->reactTime);
$this->ammoLimit->add($otherParticipationResults->ammoLimit);
$this->target->add($otherParticipationResults->target);
$this->overall->add($otherParticipationResults->overall);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function calculateAverages ($nb)
{
$this->civilian->score /= $nb;
$this->time->score /= $nb;
$this->enemy->score /= $nb;
$this->health->score /= $nb;
$this->precision->score /= $nb;
$this->reactTime->score /= $nb;
$this->ammoLimit->score /= $nb;
$this->target->score /= $nb;
$this->overall->score /= $nb;
}
}
?>

View File

@@ -0,0 +1,149 @@
<?php
include_once '../objects/db_table_object.php';
include_once '../objects/db_triggerevent.php';
class ReactEvent extends DBTableObject
{
// database connection and table name
//private $conn;
protected $table_name = REACTEVENTS_TABLE_NAME;
protected $array_key = "reactevent";
// object properties
public int $id = -1;
public int $srcEventIndex = -1;
public int $srcEventSessionId = -1;
public int $reactType = -1;
public int $reactMode = -1;
public int $hitUserId = -1;
public string $hitTargetName = "";
public string $hitBoneName = "";
public float $damage = 0.0;
public int $targetKilled = 0;
public float$objectHitLocationX = 0.0;
public float $objectHitLocationY = 0.0;
public string $objectHitTagLocation = "";
public float $hitPrecision = 0.0;
public float $distance = 0.0;
public float $reactTime = 0.0;
public float $timeStamp = 0.0;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"id" => (int)$this->id,
"srcEventIndex" => (int)$this->srcEventIndex,
"srcEventSessionId" => (int)$this->srcEventSessionId,
"reactTypeAsInt" => (int)$this->reactType,
"hitUserId" => (int)$this->hitUserId,
"hitTargetName" => $this->hitTargetName ?? "",
"hitBoneName" => $this->hitBoneName ?? "",
"damage" => (float)$this->damage ?? 0.0,
"targetKilled" => ($this->targetKilled ?? 0) == 1 ? true : false,
"objectHitLocationX" => (float)$this->objectHitLocationX ?? 0.0,
"objectHitLocationY" => (float)$this->objectHitLocationY ?? 0.0,
"objectHitTagLocation" => $this->objectHitTagLocation ?? "",
"hitPrecision" => (float)$this->hitPrecision ?? 0.0,
"timestamp" => (float)$this->timeStamp ?? 0.0,
"distance" => (float)$this->distance ?? 0.0,
"reactTime" => (float)$this->reactTime ?? 0.0,
"reactModeAsInt" => (int)$this->reactMode ?? 0
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function sanitize ()
{
$this->hitUserId=htmlspecialchars(strip_tags($this->hitUserId));
$this->srcEventIndex=htmlspecialchars(strip_tags($this->srcEventIndex));
$this->srcEventSessionId=htmlspecialchars(strip_tags($this->srcEventSessionId));
$this->reactType=htmlspecialchars(strip_tags($this->reactType));
$this->hitTargetName=htmlspecialchars(strip_tags($this->hitTargetName));
$this->hitBoneName=htmlspecialchars(strip_tags($this->hitBoneName));
$this->damage=htmlspecialchars(strip_tags($this->damage));
$this->targetKilled=htmlspecialchars(strip_tags($this->targetKilled));
$this->objectHitLocationX=htmlspecialchars(strip_tags($this->objectHitLocationX));
$this->objectHitLocationY=htmlspecialchars(strip_tags($this->objectHitLocationY));
$this->objectHitTagLocation=htmlspecialchars(strip_tags($this->objectHitTagLocation));
$this->hitPrecision=htmlspecialchars(strip_tags($this->hitPrecision));
$this->distance=htmlspecialchars(strip_tags($this->distance));
$this->reactTime=htmlspecialchars(strip_tags($this->reactTime));
$this->reactMode=htmlspecialchars(strip_tags($this->reactMode));
$this->timeStamp=htmlspecialchars(strip_tags($this->timeStamp));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function record ()
{
$canAddReactEvent = true;
// check that given srcEventId exists, otherwise create it
$query = "SELECT T.indexCount, T.sessionId FROM " . TRIGGEREVENTS_TABLE_NAME . " T WHERE T.indexCount=" . $this->srcEventIndex . " AND T.sessionId=" . $this->srcEventSessionId;
// prepare query
$stmt = $this->conn->prepare($query);
// execute query
$stmt->execute();
if($stmt->rowCount() == 0)
{
$canAddReactEvent = false;
$event = new TriggerEvent($this->conn);
$event->indexCount = $this->srcEventIndex;
$event->sessionId = $this->srcEventSessionId;
$event->srcUserId = 0;
$event->type = 0; // Fire
$event->successful = 1;
$event->timeStamp = $this->timeStamp;
if ($event->recordFromReact())
$canAddReactEvent = true;
}
if ($canAddReactEvent)
{
// query to insert record of new user signup
$query = "INSERT INTO " . $this->table_name . "
SET srcEventIndex=:srcEventIndex, srcEventSessionId=:srcEventSessionId, reactType=:reactType, reactMode=:reactMode, " . "
hitUserId=:hitUserId, hitTargetName=:hitTargetName, hitBoneName=:hitBoneName, damage=:damage, targetKilled=:targetKilled, " . "
objectHitLocationX=:objectHitLocationX, objectHitLocationY=:objectHitLocationY, objectHitTagLocation=:objectHitTagLocation, " . "
hitPrecision=:hitPrecision, distance=:distance, timeStamp=:timeStamp, reactTime=:reactTime";
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->sanitize();
// bind values
$stmt->bindParam(":hitUserId", $this->hitUserId);
$stmt->bindParam(":srcEventIndex", $this->srcEventIndex);
$stmt->bindParam(":srcEventSessionId", $this->srcEventSessionId);
$stmt->bindParam(":reactType", $this->reactType);
$stmt->bindParam(":hitTargetName", $this->hitTargetName);
$stmt->bindParam(":hitBoneName", $this->hitBoneName);
$stmt->bindParam(":damage", $this->damage);
$stmt->bindParam(":targetKilled", $this->targetKilled);
$stmt->bindParam(":objectHitTagLocation", $this->objectHitTagLocation);
$stmt->bindParam(":objectHitLocationX", $this->objectHitLocationX);
$stmt->bindParam(":objectHitLocationY", $this->objectHitLocationY);
$stmt->bindParam(":hitPrecision", $this->hitPrecision);
$stmt->bindParam(":distance", $this->distance);
$stmt->bindParam(":reactTime", $this->reactTime);
$stmt->bindParam(":reactMode", $this->reactMode);
$stmt->bindParam(":timeStamp", $this->timeStamp);
// execute query
if ($stmt->execute())
{
$this->id = $this->conn->lastInsertId();
return true;
}
return false;
}
}
}
?>

View File

@@ -0,0 +1,11 @@
<?php
include_once '../objects/db_event_type.php';
class ReactEventType extends DBObjectType
{
// database connection and table name
//private $conn;
protected $table_name = REACTEVENTMODES_TABLE_NAME;
//protected $array_key = "type";
}
?>

View File

@@ -0,0 +1,11 @@
<?php
include_once '../objects/db_event_type.php';
class ReactEventType extends DBObjectType
{
// database connection and table name
//private $conn;
protected $table_name = REACTEVENTTYPES_TABLE_NAME;
//protected $array_key = "type";
}
?>

View File

@@ -0,0 +1,200 @@
<?php
include_once "../objects/db_table_object.php";
include_once "../objects/db_participates.php";
class GameSession extends DBTableObject
{
// database connection and table name
//private $conn;
protected $table_name = SESSIONS_TABLE_NAME;
protected $array_key = "session";
// object properties
public int $id = -1;
public $sessionType = 0;
public string $sessionName = "";
public $sessionDate = "";
public string $mapName = "";
public string $scenarioName = "";
public $success = 0;
public float $timeToFinish = 0.0;
public $score = 0;
public int $nbEnemyHit = 0;
public int $nbCivilsHit = 0;
public float $damageTaken = 0.0;
public $replayFileName = "";
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"id" => (int)$this->id,
"sessionTypeAsInt" => (int)$this->sessionType ?? 0,
"sessionName" => $this->sessionName ?? "",
"sessionDateAsString" => $this->sessionDate,
"mapName" => $this->mapName ?? "",
"scenarioName" => $this->scenarioName ?? "",
"success" => $this->success == 1 ? true : false,
"timeToFinish" => (float)$this->timeToFinish ?? 0.0,
"score" => (int)$this->score ?? 0,
"nbEnemyHit" => (int)$this->nbEnemyHit ?? 0,
"nbCivilsHit" => (int)$this->nbCivilsHit ?? 0,
"damageTaken" => (float)$this->damageTaken ?? 0.0,
"replayFileName" => $this->replayFileName ?? ""
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public static function withRow ($db, array $row)
{
$instance = new self($db);
$instance->readRow($row);
return $instance;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function readRow (array $row)
{
$this->id = (int)$row['id'];
$this->sessionType = $row['sessionType'];
$this->sessionName = $row['sessionName'];
$this->sessionDate = $row['sessionDate'];
$this->mapName = $row['mapName'];
$this->scenarioName = $row['scenarioName'];
$this->success = $row['success'];
$this->timeToFinish = (float)$row['timeToFinish'];
$this->score = $row['score'];
$this->nbEnemyHit = (int)$row['nbEnemyHit'];
$this->nbCivilsHit = (int)$row['nbCivilsHit'];
$this->damageTaken = (float)$row['damageTaken'];
$this->replayFileName = $row['replayFileName'];
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function load ()
{
// select all query with user inputed username and password
$query = "SELECT * FROM " . $this->table_name . " WHERE id='" . $this->id . "'";
// prepare query statement
$stmt = $this->conn->prepare($query);
// execute query
$stmt->execute();
if($stmt->rowCount() > 0)
{
// get retrieved row
$row = $stmt->fetch(PDO::FETCH_ASSOC);
// retrieve user values
$this->readRow($row);
return true;
}
return false;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function sanitize ()
{
$this->sessionType=htmlspecialchars(strip_tags($this->sessionType));
$this->sessionName=htmlspecialchars(strip_tags($this->sessionName));
$this->sessionDate=htmlspecialchars(strip_tags($this->sessionDate));
$this->mapName=htmlspecialchars(strip_tags($this->mapName));
$this->scenarioName=htmlspecialchars(strip_tags($this->scenarioName));
$this->replayFileName=htmlspecialchars(strip_tags($this->replayFileName));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function sanitize_stop()
{
$this->success=htmlspecialchars(strip_tags($this->success));
$this->timeToFinish=htmlspecialchars(strip_tags($this->timeToFinish));
$this->score=htmlspecialchars(strip_tags($this->score));
//$this->id=htmlspecialchars(strip_tags($this->id));
//$this->nbEnemyHit=htmlspecialchars(strip_tags($this->nbEnemyHit));
//$this->nbCivilsHit=htmlspecialchars(strip_tags($this->nbCivilsHit));
//$this->damageTaken=htmlspecialchars(strip_tags($this->damageTaken));
//$this->replayFileName=htmlspecialchars(strip_tags($this->replayFileName));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function start ()
{
// query to insert record of new user signup
$query = "INSERT INTO " . $this->table_name . "
SET sessionType=:sessionType, sessionName=:sessionName, sessionDate=:sessionDate, mapName=:mapName, scenarioName=:scenarioName, " . "
replayFileName=:replayFileName";
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->sanitize();
// bind values
$stmt->bindParam(":sessionType", $this->sessionType);
$stmt->bindParam(":sessionName", $this->sessionName);
$stmt->bindParam(":sessionDate", $this->sessionDate);
$stmt->bindParam(":mapName", $this->mapName);
$stmt->bindParam(":scenarioName", $this->scenarioName);
$stmt->bindParam(":replayFileName", $this->replayFileName);
// execute query
if($stmt->execute())
{
$this->id = $this->conn->lastInsertId();
return true;
}
return false;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function stop ()
{
//if (this->load())
{
$query = "UPDATE " . $this->table_name . "
SET success=:success, timeToFinish=:timeToFinish, score=:score, " . "
nbEnemyHit=(SELECT COUNT(DISTINCT Re.srcEventIndex, Re.hitTargetName) FROM " . REACTEVENTS_TABLE_NAME . " Re WHERE Re.srcEventSessionId=:id AND Re.reactType=0), " . "
nbCivilsHit=(SELECT COUNT(DISTINCT Rc.srcEventIndex, Rc.hitTargetName) FROM " . REACTEVENTS_TABLE_NAME . " Rc WHERE Rc.srcEventSessionId=:id AND Rc.reactType=1) " . "
WHERE id=:id";
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->sanitize_stop();
// bind values
$stmt->bindParam(":id", $this->id);
$stmt->bindParam(":success", $this->success);
$stmt->bindParam(":timeToFinish", $this->timeToFinish);
$stmt->bindParam(":score", $this->score);
//$stmt->bindParam(":nbEnemyHit", $this->nbEnemyHit);
//$stmt->bindParam(":nbCivilsHit", $this->nbCivilsHit);
//$stmt->bindParam(":damageTaken", $this->damageTaken);
//$stmt->bindParam(":replayFileName", $this->replayFileName);
// execute query
return $stmt->execute();
}
return false;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function getUsers ()
{
$query = "SELECT U.* FROM " . USERS_TABLE_NAME . " U, " . PARTICIPATES_TABLE_NAME . " P WHERE U.id=P.userId AND P.sessionId = '" . $this->id . "'";
// prepare query
$stmt = $this->conn->prepare($query);
// execute query
return $stmt->execute();
}
}
?>

View File

@@ -0,0 +1,11 @@
<?php
include_once '../objects/db_event_type.php';
class SessionType extends DBObjectType
{
// database connection and table name
//private $conn;
protected $table_name = SESSIONTYPES_TABLE_NAME;
//protected $array_key = "type";
}
?>

View File

@@ -0,0 +1,42 @@
<?php
include_once '../config/constants.php';
class DBTableObject
{
// database connection and table name
protected $conn;
protected $table_name = "";
protected $array_key = "";
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// constructor with $db as database connection
public function __construct($db)
{
$this->conn = $db;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function getResultArray ($status, $message) : array
{
return array (
"status" => $status,
"message" => $message,
$this->array_key => $this->toArray()
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function executeQuery ($query)
{
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt;
}
}
?>

View File

@@ -0,0 +1,96 @@
<?php
include_once '../objects/db_table_object.php';
class TriggerEvent extends DBTableObject
{
// database connection and table name
//private $conn;
protected $table_name = TRIGGEREVENTS_TABLE_NAME;
protected $array_key = "triggerevent";
// object properties
public int $indexCount = -1;
public int $srcUserId = -1;
public int $sessionId = -1;
public int $type = -1;
public $successful = 0;
public float $timeStamp = 0.0;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"indexCount" => (int)$this->indexCount,
"sessionId" => (int)$this->sessionId,
"srcUserId" => (int)$this->srcUserId,
"typeAsInt" => (int)$this->type,
"timeStamp" => (float)$this->timeStamp,
"successful" => $this->successful == 1 ? true : false
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function sanitize ()
{
$this->indexCount=htmlspecialchars(strip_tags($this->indexCount));
$this->sessionId=htmlspecialchars(strip_tags($this->sessionId));
$this->srcUserId=htmlspecialchars(strip_tags($this->srcUserId));
$this->timeStamp=htmlspecialchars(strip_tags($this->timeStamp));
$this->successful=htmlspecialchars(strip_tags($this->successful));
$this->type=htmlspecialchars(strip_tags($this->type));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function record ()
{
// query to insert record of new user signup
$query = "INSERT INTO " . $this->table_name . "
SET sessionId=:sessionId, indexCount=:indexCount, srcUserId=:srcUserId, type=:type, timeStamp=:timeStamp, successful=:successful
ON DUPLICATE KEY UPDATE srcUserId=:srcUserId, type=:type, timeStamp=:timeStamp, successful=:successful";
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->sanitize();
// bind values
$stmt->bindParam(":indexCount", $this->indexCount);
$stmt->bindParam(":timeStamp", $this->timeStamp);
$stmt->bindParam(":successful", $this->successful);
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":srcUserId", $this->srcUserId);
$stmt->bindParam(":type", $this->type);
// execute query
return $stmt->execute();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function recordFromReact ()
{
// query to insert record of new user signup
$query = "INSERT INTO " . $this->table_name . "
SET sessionId=:sessionId, indexCount=:indexCount, srcUserId=:srcUserId, type=:type, timeStamp=:timeStamp, successful=:successful
ON DUPLICATE KEY UPDATE indexCount=:indexCount";
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->sanitize();
// bind values
$stmt->bindParam(":indexCount", $this->indexCount);
$stmt->bindParam(":timeStamp", $this->timeStamp);
$stmt->bindParam(":successful", $this->successful);
$stmt->bindParam(":sessionId", $this->sessionId);
$stmt->bindParam(":srcUserId", $this->srcUserId);
$stmt->bindParam(":type", $this->type);
// execute query
return $stmt->execute();
}
}
?>

View File

@@ -0,0 +1,11 @@
<?php
include_once '../objects/db_event_type.php';
class TriggerEventType extends DBObjectType
{
// database connection and table name
//private $conn;
protected $table_name = TRIGGEREVENTTYPES_TABLE_NAME;
//protected $array_key = "type";
}
?>

View File

@@ -0,0 +1,300 @@
<?php
include_once '../objects/db_table_object.php';
class User extends DBTableObject
{
// database connection and table name
//private $conn;
protected $table_name = USERS_TABLE_NAME;
protected $array_key = "user";
// object properties
public int $id = -1;
public string $username = "";
public string $password = "";
public string $firstName = "";
public string $lastName = "";
public $created = "";
public int $leftHanded = 0;
public int $maleGender = 1;
public string $charSkinAssetName = "";
public string $weaponAssetName = "";
public $lastConnection = "";
public float $avgPrecision = 0.0;
public float $avgReaction = 0.0;
public float $avgFault = 0.0;
public float $avgRapidity = 0.0;
public int $size = 0;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public static function withRow ($db, array $row)
{
$instance = new self($db);
$instance->readRow($row);
return $instance;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function readRow (array $row)
{
$this->id = (int)$row['id'];
$this->username = $row['username'];
$this->firstName = $row['firstName'];
$this->lastName = $row['lastName'];
$this->leftHanded = $row['leftHanded'];
$this->maleGender = $row['maleGender'];
$this->charSkinAssetName = $row['charSkinAssetName'];
$this->weaponAssetName = $row['weaponAssetName'];
$this->lastConnection = date('Y-m-d H:i:s');
$this->avgPrecision = (float)$row['avgPrecision'] ?? 0.0;
$this->avgReaction = (float)$row['avgReaction'] ?? 0.0;
$this->avgFault = (float)$row['avgFault'] ?? 0.0;
$this->avgRapidity = (float)$row['avgRapidity'] ?? 0.0;
$this->size = (int)$row['size'];
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"id" => (int)$this->id,
"username" => $this->username ?? "",
"firstName" => $this->firstName ?? "",
"lastName" => $this->lastName ?? "",
"leftHanded" => ($this->leftHanded ?? 0) == 1 ? true : false,
"maleGender" => ($this->maleGender ?? 1) == 1 ? true : false,
"charSkinAssetName" => $this->charSkinAssetName ?? "",
"weaponAssetName" => $this->weaponAssetName ?? "",
"lastConnection" => $this->lastConnection,
"avgPrecision" => (float)$this->avgPrecision ?? 0.0,
"avgReaction" => (float)$this->avgReaction ?? 0.0,
"avgFault" => (float)$this->avgFault ?? 0.0,
"avgRapidity" => (float)$this->avgRapidity ?? 0.0,
"size" => (int)$this->size
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function sanitize ()
{
$this->username=htmlspecialchars(strip_tags($this->username));
$this->password=htmlspecialchars(strip_tags($this->password));
$this->created=htmlspecialchars(strip_tags($this->created));
$this->lastConnection=htmlspecialchars(strip_tags($this->lastConnection));
//$this->firstName=htmlspecialchars(strip_tags($this->firstName));
//$this->lastName=htmlspecialchars(strip_tags($this->lastName));
//$this->leftHanded=htmlspecialchars(strip_tags($this->leftHanded));
//$this->maleGender=htmlspecialchars(strip_tags($this->maleGender));
//$this->charSkinAssetName=htmlspecialchars(strip_tags($this->charSkinAssetName));
//$this->weaponAssetName=htmlspecialchars(strip_tags($this->weaponAssetName));
//$this->avgPrecision=htmlspecialchars(strip_tags($this->avgPrecision));
//$this->avgReaction=htmlspecialchars(strip_tags($this->avgReaction));
//$this->avgFault=htmlspecialchars(strip_tags($this->avgFault));
//$this->avgRapidity=htmlspecialchars(strip_tags($this->avgRapidity));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function sanitize_update ()
{
//$this->username=htmlspecialchars(strip_tags($this->username));
//$this->password=htmlspecialchars(strip_tags($this->password));
//$this->avgPrecision=htmlspecialchars(strip_tags($this->avgPrecision));
//$this->avgReaction=htmlspecialchars(strip_tags($this->avgReaction));
//$this->avgFault=htmlspecialchars(strip_tags($this->avgFault));
//$this->avgRapidity=htmlspecialchars(strip_tags($this->avgRapidity));
$this->firstName=htmlspecialchars(strip_tags($this->firstName));
$this->lastName=htmlspecialchars(strip_tags($this->lastName));
$this->leftHanded=htmlspecialchars(strip_tags($this->leftHanded));
$this->maleGender=htmlspecialchars(strip_tags($this->maleGender));
$this->charSkinAssetName=htmlspecialchars(strip_tags($this->charSkinAssetName));
$this->weaponAssetName=htmlspecialchars(strip_tags($this->weaponAssetName));
$this->lastConnection=htmlspecialchars(strip_tags($this->lastConnection));
$this->size=htmlspecialchars(strip_tags($this->size));
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function load ()
{
// select all query with user inputed username and password
$query = "SELECT * FROM " . $this->table_name . " WHERE id='" . $this->id . "'";
// prepare query statement
$stmt = $this->conn->prepare($query);
// execute query
$stmt->execute();
if($stmt->rowCount() > 0)
{
// get retrieved row
$row = $stmt->fetch(PDO::FETCH_ASSOC);
// retrieve user values
$this->readRow($row);
return true;
}
return false;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//user signup method
function signup ()
{
if ($this->isAlreadyExist()) return false;
// query to insert record of new user signup
$query = "INSERT INTO " . $this->table_name . " SET username=:username, password=:password, created=:created, lastConnection=:lastConnection";
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->sanitize();
// bind values
$stmt->bindParam(":username", $this->username);
$stmt->bindParam(":password", $this->password);
$stmt->bindParam(":created", $this->created);
$stmt->bindParam(":lastConnection", $this->lastConnection);
//$stmt->bindParam(":firstName", $this->firstName);
//$stmt->bindParam(":lastName", $this->lastName);
//$stmt->bindParam(":leftHanded", $this->leftHanded);
//$stmt->bindParam(":maleGender", $this->maleGender);
//$stmt->bindParam(":charSkinAssetName", $this->charSkinAssetName);
//$stmt->bindParam(":weaponAssetName", $this->weaponAssetName);
//$stmt->bindParam(":avgPrecision", $this->avgPrecision);
//$stmt->bindParam(":avgReaction", $this->avgReaction);
//$stmt->bindParam(":avgFault", $this->avgFault);
//$stmt->bindParam(":avgRapidity", $this->avgRapidity);
// execute query
if ($stmt->execute())
{
$this->id = $this->conn->lastInsertId();
return $this->load();
}
return false;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// login user method
function login ()
{
// select all query with user inputed username and password
$query = "SELECT * FROM " . $this->table_name . " WHERE BINARY username='".$this->username."' AND BINARY password='".$this->password."'";
// prepare query statement
$stmt = $this->conn->prepare($query);
// execute query
$stmt->execute();
return $stmt;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//Update user infos
function refreshConnectionDate ()
{
// select all query with user inputed username and password
$query = "UPDATE " . $this->table_name . " SET lastConnection = '" . date('Y-m-d H:i:s') . "' WHERE id='" . $this->id . "'";
// prepare query statement
$stmt = $this->conn->prepare($query);
// execute query
return $stmt->execute();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//Notify if User with given username Already exists during SignUp
function isAlreadyExist ()
{
$query = "SELECT * FROM " . $this->table_name . " WHERE BINARY username='".$this->username."'";
// prepare query statement
$stmt = $this->conn->prepare($query);
// execute query
$stmt->execute();
return ($stmt->rowCount() > 0);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//Update user infos
function update ()
{
$query = "UPDATE " . $this->table_name . "
SET firstName=:firstName, lastName=:lastName, leftHanded=:leftHanded, size=:size, maleGender=:maleGender, charSkinAssetName=:charSkinAssetName, " . "
weaponAssetName=:weaponAssetName, lastConnection=:lastConnection " . "
WHERE id=".$this->id;
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->sanitize_update();
// bind values
//$stmt->bindParam(":username", $this->username);
//$stmt->bindParam(":password", $this->password);
//$stmt->bindParam(":avgPrecision", $this->avgPrecision);
//$stmt->bindParam(":avgReaction", $this->avgReaction);
//$stmt->bindParam(":avgFault", $this->avgFault);
//$stmt->bindParam(":avgRapidity", $this->avgRapidity);
$stmt->bindParam(":firstName", $this->firstName);
$stmt->bindParam(":lastName", $this->lastName);
$stmt->bindParam(":leftHanded", $this->leftHanded);
$stmt->bindParam(":maleGender", $this->maleGender);
$stmt->bindParam(":charSkinAssetName", $this->charSkinAssetName);
$stmt->bindParam(":weaponAssetName", $this->weaponAssetName);
$stmt->bindParam(":lastConnection", $this->lastConnection);
$stmt->bindParam(":size", $this->size);
// execute query
return $stmt->execute();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
//user reset password methods
function resetPassword ()
{
if ($this->id < 1)
{
// find userId for this username
$query = "SELECT id FROM " . $this->table_name . " WHERE BINARY username='".$this->username."'";
// prepare query
$stmt = $this->conn->prepare($query);
// execute query
$stmt->execute();
if ($stmt->rowCount() != 1)
{
// no user found for this username (or multiple users, but this should not happen)
return false;
}
else
{
// get retrieved row
$row = $stmt->fetch(PDO::FETCH_ASSOC);
// retrieve user values
$this->id = (int)$row['id'];
}
}
// now that we have an id, run the update method
$query = "UPDATE " . $this->table_name . " SET password=:password WHERE id=".$this->id;
// prepare query
$stmt = $this->conn->prepare($query);
// sanitize
$this->password=htmlspecialchars(strip_tags($this->password));
// bind values
$stmt->bindParam(":password", $this->password);
// execute query
$stmt->execute();
// load user after password update
return $this->load();
}
}

View File

@@ -0,0 +1,11 @@
<?php
include_once '../objects/db_event_type.php';
class UserRole extends DBObjectType
{
// database connection and table name
//private $conn;
protected $table_name = USERROLES_TABLE_NAME;
//protected $array_key = "type";
}
?>

View File

@@ -0,0 +1,11 @@
<?php
include_once '../objects/db_event_type.php';
class SessionType extends DBObjectType
{
// database connection and table name
//private $conn;
protected $table_name = USERSTATUS_TABLE_NAME;
protected $array_key = "status";
}
?>

View File

@@ -0,0 +1,216 @@
<?php
class SessionDebriefRow
{
public $sessionId = -1;
public int $sessionTypeId = -1;
public $sessionType = "";
public $sessionName = "";
public $sessionDate = "";
public $mapName = "";
public $scenarioName = "";
public bool $sessionSuccessful = false;
public float $sessionDuration = 0.0;
public int $triggerTypeId = -1;
public $triggerType = "";
public int $shooterId = -1;
public $shooterName = "";
public int $shooterRoleId = -1;
public $shooterRole = "";
public int $shotIndex = -1;
public int $reactId = -1;
public int $reactModeId = -1;
public $reactMode = "";
public int $reactTypeId = -1;
public $reactType = "";
public int $targetUserId = -1;
public $targetUserName = "";
public int $targetRoleId = -1;
public $targetRole = "";
public $targetName = "";
public $targetBoneName = "";
public bool $targetKilled = false;
public float $hitLocationX = 0.0;
public float $hitLocationY = 0.0;
public $hitLocationTag = "";
public float $hitPrecision = 0.0;
public float $hitTargetDistance = 0.0;
public float $reactTime = 0.0;
public float $timeStamp = 0.0;
public int $nbHit = 0;
public int $nbKilled = 0;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public static function withRow (array $row)
{
$instance = new self();
$instance->readRow($row);
return $instance;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function readRow (array $row)
{
$this->sessionId = $row['SessionId'];
$this->sessionTypeId = $row['SessionTypeId'];
$this->sessionType = $row['SessionType'];
$this->sessionName = $row['SessionName'];
$this->sessionDate = $row['SessionDate'];
$this->mapName = $row['MapName'];
$this->scenarioName = $row['ScenarioName'];
$this->sessionSuccessful = $row['SessionSuccessful'] == 1;
$this->sessionDuration = (float)$row['SessionDuration'];
$this->triggerTypeId = (int)$row['TriggerTypeId'];
$this->triggerType = $row['TriggerType'];
$this->shooterId = (int)$row['ShooterId'];
$this->shooterName = $row['ShooterName'];
$this->shooterRoleId = (int)$row['ShooterRoleId'];
$this->shooterRole = $row['ShooterRole'];
$this->shotIndex = (int)$row['ShotIndex'];
$this->reactId = (int)$row['ReactId'];
$this->reactModeId = (int)$row['ReactModeId'];
$this->reactMode = $row['ReactMode'];
$this->reactTypeId = (int)$row['ReactTypeId'];
$this->reactType = $row['ReactType'];
$this->targetUserId = (int)$row['TargetUserId'];
$this->targetUserName = $row['TargetUserName'];
$this->targetRoleId = (int)$row['TargetRoleId'];
$this->targetRole = $row['TargetRole'];
$this->targetName = $row['TargetName'];
$this->targetBoneName = $row['TargetBoneName'];
$this->targetKilled = $row['TargetKilled'] == 1;
$this->hitLocationX = (float)$row['HitLocationX'];
$this->hitLocationY = (float)$row['HitLocationY'];
$this->hitLocationTag = $row['HitLocationTag'];
$this->hitPrecision = (float)$row['HitPrecision'];
$this->hitTargetDistance = (float)$row['HitTargetDistance'];
$this->reactTime = (float)$row['ReactionTime'];
$this->timeStamp = (float)$row['TimeStamp'];
$this->nbHit = (int)$row['NbHit'];
$this->nbKilled = (int)$row['NbKilled'];
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"SessionId" => (int)$this->sessionId,
"SessionTypeId" => (int)$this->sessionTypeId,
"SessionTypeAsString" => $this->sessionType,
"SessionName" => $this->sessionName,
"SessionDateAsString" => $this->sessionDate,
"MapName" => $this->mapName,
"ScenarioName" => $this->scenarioName,
"SessionSuccessful" => $this->sessionSuccessful,
"SessionDuration" => (float)$this->sessionDuration,
"TriggerTypeId" => (int)$this->triggerTypeId,
"TriggerTypeAsString" => $this->triggerType,
"ShooterId" => (int)$this->shooterId,
"ShooterName" => $this->shooterName,
"ShooterRoleId" => (int)$this->shooterRoleId,
"ShooterRoleAsString" => $this->shooterRole,
"ShotIndex" => (int)$this->shotIndex,
"ReactId" => (int)$this->reactId,
"ReactModeId" => (int)$this->reactModeId,
"ReactModeAsString" => $this->reactMode,
"ReactTypeId" => (int)$this->reactTypeId,
"ReactTypeAsString" => $this->reactType,
"TargetUserId" => (int)$this->targetUserId,
"TargetUserName" => $this->targetUserName,
"TargetRoleId" => (int)$this->targetRoleId,
"TargetRoleAsString" => $this->targetRole,
"TargetName" => $this->targetName,
"TargetBoneName" => $this->targetBoneName,
"TargetKilled" => $this->targetKilled,
"HitLocationX" => (float)$this->hitLocationX,
"HitLocationY" => (float)$this->hitLocationY,
"HitLocationTag" => $this->hitLocationTag,
"HitPrecision" => (float)$this->hitPrecision,
"HitTargetDistance" => (float)$this->hitTargetDistance,
"ReactionTime" => (float)$this->reactTime,
"TimeStamp" => (float)$this->timeStamp,
"NbHit" => (int)$this->nbHit,
"NbKilled" => (int)$this->nbKilled
);
}
}
class SessionDebriefRowWithTotals
{
public SessionDebriefRow $sessionRow;
public int $nbFiredShotsByUser = 0;
public int $nbEnemyHitsByUser = 0;
public int $nbCivilHitsByUser = 0;
public int $nbPoliceHitsByUser = 0;
public int $nbMissedShotsByUser = 0;
//public int $nbFiredShotsByIA = 0;
public int $nbEnemyHitsByIA = 0;
public int $nbCivilHitsByIA = 0;
public int $nbPoliceHitsByIA = 0;
//public int $nbMissedShotsByIA = 0;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public static function withRow (array $row)
{
$instance = new self();
$instance->readRow($row);
return $instance;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function readRow (array $row)
{
$this->sessionRow = SessionDebriefRow::withRow($row);
if ($row['ReactTypeId'] == -1)
$this->nbMissedShotsByUser = (int)$row['NbShotsOfType'];
else if ($row['ShooterRoleId'] == 0)
{
$this->nbFiredShotsByUser = (int)$row['NbShotsOfType'];
switch ($row['ReactTypeId'])
{
case 0 : $this->nbEnemyHitsByUser = (int)$row['NbShotsOfType']; break;
case 1 : $this->nbCivilHitsByUser = (int)$row['NbShotsOfType']; break;
case 2 : $this->nbPoliceHitsByUser = (int)$row['NbShotsOfType']; break;
}
}
else if ($row['ShooterRoleId'] == 3) // IA
{
//$this->nbFiredShotsByIA = (int)$row['NbShotsOfType'];
switch ($row['ReactTypeId'])
{
case 0 : $this->nbEnemyHitsByIA = (int)$row['NbShotsOfType']; break;
case 1 : $this->nbCivilHitsByIA = (int)$row['NbShotsOfType']; break;
case 2 : $this->nbPoliceHitsByIA = (int)$row['NbShotsOfType']; break;
}
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"session" => $this->sessionRow->toArray(),
"NbFiredShotsByUser" => (int)$this->nbFiredShotsByUser,
"NbEnemyHitsByUser" => (int)$this->nbEnemyHitsByUser,
"NbCivilHitsByUser" => (int)$this->nbCivilHitsByUser,
"NbPoliceHitsByUser" => (int)$this->nbPoliceHitsByUser,
"NbMissedShotsByUser" => (int)$this->nbMissedShotsByUser,
//"NbFiredShotsByIA" => (int)$this->nbFiredShotsByIA,
"NbEnemyHitsByIA" => (int)$this->nbEnemyHitsByIA,
"NbCivilHitsByIA" => (int)$this->nbCivilHitsByIA,
"NbPoliceHitsByIA" => (int)$this->nbPoliceHitsByIA
//"NbMissedShotsByIA" => (int)$this->nbMissedShotsByIA
);
}
}
?>

View File

@@ -0,0 +1,747 @@
<?php
//include_once "../objects/db_table_object.php";
include_once '../objects/db_session.php';
include_once '../objects/db_user.php';
include_once '../objects/db_view_sessiondebrief_row.php';
include_once '../objects/stats_user_globals.php';
//include_once '../objects/stats_user_in_session_row.php';
class StatsObject extends DBTableObject
{
// database connection and table name
//protected $conn;
protected $array_key = "stats";
protected $elements = array();
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
protected $excludeCalibrationSessionsFromStats = true;
protected function getCalibrationSessionsConstraint ($sessionsTableIndex="SD") : string
{
return $this->excludeCalibrationSessionsFromStats == true ? " AND " . $sessionsTableIndex . ".ScenarioName NOT LIKE '%Calibration%'" : "";
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
protected $minimumSessionDuration = 10;
protected function getSessionDurationConstraint ($sessionsTableIndex="SD", $allowZero=false) : string
{
return $this->minimumSessionDuration > 0 ? " AND (" . $sessionsTableIndex . ".timeToFinish > " . $this->minimumSessionDuration . ($allowZero ? " OR " . $sessionsTableIndex . ".timeToFinish = 0" : "") . ")" : "";
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
protected function getSessionsWithUserConstraint ($userId) : string
{
return $userId > 0 ? "SELECT DISTINCT UP.SessionId FROM " . PARTICIPATES_TABLE_NAME . " UP WHERE UP.userId=" . $userId : "";
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return $this->elements;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// get users who took place in given session
public function fixDurations ()
{
$query = "SELECT S.id AS SessionId FROM " . SESSIONS_TABLE_NAME . " S, " . TRIGGEREVENTS_TABLE_NAME . " TE WHERE S.timeToFinish=0 AND S.id=TE.sessionId";
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
$nb = 0;
// get max time for trigger events in these sessions
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
$subQuery = "SELECT COALESCE(MAX(TE.timeStamp),0) AS LastTriggerEvent FROM " . TRIGGEREVENTS_TABLE_NAME . " TE WHERE TE.sessionId=" . $row['SessionId'];
// prepare and execute query
$subStmt = $this->conn->prepare($subQuery);
$subStmt->execute();
while ($subRow = $subStmt->fetch(PDO::FETCH_ASSOC)) // should be only 1 row
{
if ($subRow['LastTriggerEvent'] != 0) // should always be true
{
$subQuery = "UPDATE " . SESSIONS_TABLE_NAME . " SET timeToFinish=" . $subRow['LastTriggerEvent'] . " WHERE id=" . $row['SessionId'];
// prepare and execute query
$subStmt = $this->conn->prepare($subQuery);
$subStmt->execute();
$nb++;
}
}
}
return $nb;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// get users who took place in given session
public function getRawUsers ($sessionId)
{
// Check if we are looking for a specific session
$sessionConstraint = $sessionId > 0 ? " AND P.sessionId=" . $sessionId : "";
$query = "SELECT DISTINCT U.* FROM " . USERS_TABLE_NAME . " U LEFT JOIN " . PARTICIPATES_TABLE_NAME . " P ON U.id = P.userId " .
"LEFT JOIN " . SESSIONS_TABLE_NAME . " SD ON P.sessionId=SD.id " .
"WHERE U.id > 0" . $sessionConstraint . $this->getSessionDurationConstraint("SD");
//$query = "SELECT DISTINCT U.* FROM " . USERS_TABLE_NAME . " U, " . PARTICIPATES_TABLE_NAME . " P WHERE U.id = P.userId AND U.id > 0" . $sessionConstraint;
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// get users who took place in given session
public function getUsersInSession ($sessionId)
{
// Check if we are looking for a specific session
$stmt = $this->getRawUsers($sessionId);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
//$sess = new GameSession($this->conn);
//$sess->readRow($row);
$user = User::withRow($this->conn, $row);
array_push( $this->elements, $user->toArray() );
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// get sessions of given type that given user has taken part in
public function getRawSessions ($userId=-1, $typeId=-1)
{
// Check if we are looking for a specific user and/or type of session
$userConstraint = $userId > 0 ? " AND P.userId=" . $userId : "";
$typeConstraint = $typeId >= 0 ? " AND SD.sessionType=" . $typeId : "";
// Order results from newest to oldest session
$orderConstraint = " ORDER BY SD.id DESC";
//$query = "SELECT DISTINCT S.* FROM " . SESSIONS_TABLE_NAME . " S LEFT JOIN " . PARTICIPATES_TABLE_NAME . " P ON S.id = P.sessionId WHERE 1 " .
$query = "SELECT DISTINCT SD.* FROM " . SESSIONS_TABLE_NAME . " SD, " . PARTICIPATES_TABLE_NAME . " P WHERE SD.id = P.sessionId " .
$this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $userConstraint . $typeConstraint . $orderConstraint;
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// get sessions that given user has taken part in
public function getSessionsForUser ($userId, $typeId=-1)
{
// Check if we are looking for a specific user and/or session
$stmt = $this->getRawSessions($userId, $typeId);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
//$sess = new GameSession($this->conn);
//$sess->readRow($row);
$sess = GameSession::withRow($this->conn, $row);
array_push( $this->elements, $sess->toArray() );
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// debrief mission : get stats for given session
public function getRawStatsForSession ($sessionId, $userId, $fromUser=-1)
{
$sessionStats = array();
// first create expected rows for results, so that every user will get stats (even if user didn't fire or received any shot)
if ($sessionId > 0 && $userId > 0)
{
// we want stats for a specific user in a specific session
// => we only need to create one default row, just in case user didn't fire or received any shot
$defaultRow = new UserStatsInSessionRow();
$defaultRow->createSessionAndUser($this->conn, $sessionId, $userId);
array_push( $sessionStats, $defaultRow );
}
else if ($userId == -1)
{
// we want stats for all users in a specific session
// => we need to create one default row for each user
$stmt_usersInSession = $this->getRawUsers($sessionId);
while ($row = $stmt_usersInSession->fetch(PDO::FETCH_ASSOC))
{
$defaultRow = new UserStatsInSessionRow();
$defaultRow->createSessionAndUser($this->conn, $sessionId, $row['id']);
array_push( $sessionStats, $defaultRow );
}
}
else if ($sessionId == -1)
{
// we want stats for a specific user in all sessions he took place in
// => we need to create one default row for each user
$stmt_sessionsForUser = $this->getRawSessions($userId);
while ($row = $stmt_sessionsForUser->fetch(PDO::FETCH_ASSOC))
{
$defaultRow = new UserStatsInSessionRow();
$defaultRow->createSessionAndUser($this->conn, $row['id'], $userId);
array_push( $sessionStats, $defaultRow );
}
}
else
{
// both $sessionId and $userId are set to -1
// => do nothing, this will (should) not happen
}
// Get fired shots
$stmt_firedShots = $this->getShotsFiredByUser($sessionId, $userId, $fromUser);
while ($row = $stmt_firedShots->fetch(PDO::FETCH_ASSOC))
{
// Only create stats row for shooters with id > 0
if ((int)$row['UserId'] > 0)
{
$statsRow = $this->findStats( $sessionStats, (int)$row['UserId'], (int)$row['SessionId'] );
if ($statsRow == null)
{
$statsRow = UserStatsInSessionRow::withRow($this->conn, $row);
$statsRow->setFiredShots($row);
array_push( $sessionStats, $statsRow );
}
else
$statsRow->setFiredShots($row);
}
}
// Get received shots
$stmt_receivedShots = $this->getReceivedHitsForUser($sessionId, $userId, $fromUser);
while ($row = $stmt_receivedShots->fetch(PDO::FETCH_ASSOC))
{
// Only create stats row for hit users with id > 0
if ((int)$row['UserId'] > 0)
{
$statsRow = $this->findStats ( $sessionStats, (int)$row['UserId'], (int)$row['SessionId'] );
if ($statsRow == null)
{
$statsRow = UserStatsInSessionRow::withRow($this->conn, $row);
$statsRow->setReceivedHits($row);
array_push( $sessionStats, $statsRow );
}
else
$statsRow->setReceivedHits($row);
}
}
// Get average precision and reaction time
$stmt_averages = $this->getPrecisionAndReactionTimeForUser($sessionId, $userId, $fromUser);
while ($row = $stmt_averages->fetch(PDO::FETCH_ASSOC))
{
// Only create stats row for hit users with id > 0
if ((int)$row['UserId'] > 0)
{
$statsRow = $this->findStats ( $sessionStats, (int)$row['UserId'], (int)$row['SessionId'] );
if ($statsRow == null)
{
$statsRow = UserStatsInSessionRow::withRow($this->conn, $row);
$statsRow->setPrecisionAndReactionTime($row);
array_push( $sessionStats, $statsRow );
}
else
$statsRow->setPrecisionAndReactionTime($row);
}
}
// Get total of targets killed in the session
$stmt_targetKilled = $this->getTargetKilledInSession($sessionId);
while ($row = $stmt_targetKilled->fetch(PDO::FETCH_ASSOC))
{
// Add totals stats to all rows of the same session
foreach ($sessionStats as $statsRow)
{
if ($statsRow->sessionId == (int)$row['SessionId'])
$statsRow->setTotalKills($row);
}
}
return $sessionStats;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// debrief mission : get stats for given session
public function getStatsForSession ($sessionId, $userId, $fromUser=-1)
{
$sessionStats = $this->getRawStatsForSession($sessionId, $userId, $fromUser);
// Parse workingStats array to output results
foreach ($sessionStats as $outputStats)
array_push( $this->elements, $outputStats->toArray() );
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// find a row of stats for given user and stats
function findStats (array $statsArray, int $userId, int $sessionId)
{
foreach ($statsArray as $row)
{
if (call_user_func_array(array($row, 'isForUserInSession'), array($userId, $sessionId)) === true)
return $row;
}
return null;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// get stats : main call for getting stats from Unreal
public function get ($sessionId, $userId, $sessionType, $fromUserId=-1)
{
if ($sessionType == 0 || $sessionType == 1 || $sessionType == 7) // Firerange, Challenge or Long Range
$this->getResultsForSession ($sessionId, $userId, $fromUserId);
else if ($sessionId > 0 || $userId > 0)
$this->getStatsForSession ($sessionId, $userId, $fromUserId);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// user history : get user "all-time" totals
public function getUserHistory ($userId, $sessionId, $quickMode)
{
$userGlobals = new UserGlobalStats();
if ($quickMode == 1 && $userId > 0)
{
$userGlobals->totals->createSessionAndUser($this->conn, $sessionId, $userId);
$userGlobals->totals->averagePrecision = $userGlobals->totals->user->avgPrecision;
$userGlobals->totals->averageReactionTime = $userGlobals->totals->user->avgReaction;
$userSubConstraint = $userId >= 0 ? " AND P.UserId = " . $userId : ""; // always true
// Calculate date of first and last sessions
$query = "SELECT P.userID AS UserId, MIN(SD.SessionDate) AS MinDate, MAX(SD.SessionDate) AS MaxDate, " . "
COUNT(DISTINCT P.UserId, P.sessionId) AS NbSessions, SUM(SD.timeToFinish) AS TotalDuration " . "
FROM " . PARTICIPATES_TABLE_NAME . " P, " . SESSIONS_TABLE_NAME . " SD WHERE SD.id=P.sessionId " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $userSubConstraint . " GROUP BY P.UserId";
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) // should be only 1 row
{
$userGlobals->setSessionTotals($row);
//$userGlobals->nbSessions = $row['NbSessions'];
//$userGlobals->totalDuration = $row['TotalDuration'];
//$userGlobals->firstSession = $row['MinDate'];
//$userGlobals->lastSession = $row['MaxDate'];
}
// Calculate shots fired
$userConstraint = $userId > 0 ? " AND SDTE.srcUserId=" . $userId : "";
$query = "SELECT COALESCE(SDTE.srcUserId,-1) AS UserId,
COALESCE(X.NbShotsFired,0) AS NbShotsFired,
COALESCE(X.NbShotsFired,0) - COALESCE(SUM(CASE WHEN Y.ReactTypeId>=0 AND Y.ReactTypeId<6 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END),0) AS NbMissedShots,
SUM(CASE WHEN Y.ReactTypeId=0 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbEnemyHits,
SUM(CASE WHEN Y.ReactTypeId=1 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbCivilHits,
SUM(CASE WHEN Y.ReactTypeId=2 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbPoliceHits,
SUM(CASE WHEN (Y.ReactTypeId=4 OR Y.ReactTypeId=5) AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbObjectHits,
SUM(CASE WHEN Y.ReactTypeId=6 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbDeadBodyHits
FROM " . SESSIONS_TABLE_NAME . " SD LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " SDTE ON (SDTE.sessionId=SD.id)
LEFT JOIN " . REACTEVENTS_TABLE_NAME . " SDRE ON (SDTE.sessionId=SDRE.srcEventSessionId AND SDTE.indexCount=SDRE.srcEventIndex)
LEFT JOIN " . "
(SELECT TE.srcUserId AS ShooterId, COUNT(DISTINCT TE.sessionId, TE.indexCount) AS NbShotsFired FROM " . TRIGGEREVENTS_TABLE_NAME . " TE GROUP BY TE.srcUserId) AS X
ON X.ShooterId=SDTE.srcUserId
LEFT JOIN " . "
(SELECT TE.srcUserId AS ShooterId, RE.ReactType AS ReactTypeId, COUNT(DISTINCT TE.sessionId, TE.indexCount, RE.hitTargetName) AS NbHits, RE.id AS ReactId, RE.hitPrecision AS ReactPrecision
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE
WHERE TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 GROUP BY ShooterId, ReactTypeId, ReactID) AS Y
ON (Y.ShooterId=SDTE.srcUserId AND Y.ReactId=SDRE.id)
LEFT JOIN " . "
(SELECT TE1.srcUserId AS ShooterId, COALESCE(COUNT(DISTINCT TE1.sessionId, TE1.indexCount),0) AS NbMissedShots FROM " . TRIGGEREVENTS_TABLE_NAME . " TE1, " . REACTEVENTS_TABLE_NAME . " RE1
WHERE TE1.sessionId=RE1.srcEventSessionId AND TE1.indexCount NOT IN (SELECT RE2.srcEventIndex FROM " . REACTEVENTS_TABLE_NAME . " RE2 WHERE RE2.srcEventSessionId=RE1.srcEventSessionId)) AS Z
ON (Z.ShooterId=SDTE.srcUserId) " . "
WHERE 1 " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $userConstraint . " GROUP BY SDTE.srcUserId";
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
$userGlobals->totals->setFiredShots($row);
//$userGlobals->totals->nbFiredShotsByUser = $row['NbShotsFired'];
//$userGlobals->totals->nbMissedShotsByUser = $row['NbMissedShots'];
//$userGlobals->totals->nbEnemyHitsByUser = $row['NbEnemyHits'];
//$userGlobals->totals->nbCivilHitsByUser = $row['NbCivilHits'];
//$userGlobals->totals->nbPoliceHitsByUser = $row['NbPoliceHits'];
//$userGlobals->totals->nbObjectHitsByUser = $row['NbObjectHits'];
//$userGlobals->totals->nbDeadBodyHitsByUser = $row['NbDeadBodyHits'];
}
// Calculate received hits
$userConstraint = $userId > 0 ? " AND X.TargetUserId=" . $userId : "";
$query = "SELECT COALESCE(X.TargetUserId, -1) AS UserId,
SUM(CASE WHEN X.ShooterRoleId=3 THEN X.NbHits ELSE 0 END) AS NbEnemyShotsIA,
SUM(CASE WHEN X.ShooterRoleId=1 THEN X.NbHits ELSE 0 END) AS NbEnemyShotsUser,
SUM(CASE WHEN X.ShooterRoleId=0 THEN X.NbHits ELSE 0 END) AS NbPoliceHits
FROM (SELECT TE.SessionId AS SessionId, RE.hitUserId AS TargetUserId,
(SELECT IFNULL((SELECT P.role FROM " . PARTICIPATES_TABLE_NAME . " P WHERE P.sessionId=TE.sessionId AND P.userId=TE.srcUserId),3)) AS ShooterRoleId,
COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS NbHits
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE, " . SESSIONS_TABLE_NAME . " SD " .
"WHERE SD.id=TE.sessionId AND TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 " .
$this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") .
"GROUP BY SessionId, TargetUserId, ShooterRoleId) AS X
WHERE 1 " . $userConstraint . " GROUP BY X.TargetUserId";
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
$userGlobals->totals->setReceivedHits($row);
}
// Calculate totals hits by type
$sessionConstraint = $sessionId > 0 ? " AND SDTE.SessionId = " . $sessionId : "";
$userConstraint = $userId > 0 ? " AND SDTE.SessionId IN (" . $this->getSessionsWithUserConstraint($userId) . ")" : "";
$query = "SELECT SUM(CASE WHEN X.ReactTypeId=0 THEN X.NbHits ELSE 0 END) AS NbEnemyKilled,
SUM(CASE WHEN X.ReactTypeId=1 THEN X.NbHits ELSE 0 END) AS NbCivilKilled,
SUM(CASE WHEN X.ReactTypeId=2 THEN X.NbHits ELSE 0 END) AS NbPoliceKilled
FROM " . SESSIONS_TABLE_NAME . " SD LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " SDTE ON (SD.id = SDTE.sessionId)
LEFT JOIN
(SELECT TE.SessionId AS SessionId, RE.hitUserId AS TargetUserId, TE.srcUserId AS ShooterRoleId, COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS NbHits, RE.srcEventIndex AS TriggerIndex, RE.ReactType AS ReactTypeId
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 AND RE.TargetKilled=1
GROUP BY SessionId, TargetUserId, ShooterRoleId, ReactTypeId) AS X
ON (X.SessionId=SDTE.SessionId AND X.TriggerIndex=SDTE.indexCount)
WHERE 1 " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $userConstraint;
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
$userGlobals->totals->setTotalKills($row);
}
$query = "SELECT X.*, " .
"SUM(X.HitPrecision)/COALESCE(COUNT(DISTINCT X.SessionId, X.ShotIndex),1) AS AvgPrecision, " .
"SUM(X.ReactionTime)/COALESCE(SUM(CASE WHEN X.ReactionTime > 0 THEN CEILING(X.HitPrecision) ELSE 0 END),1) AS AvgReactTime " .
"FROM " .
"(SELECT TE.sessionId AS SessionId, S.sessionType AS SessionTypeId, ST.displayName AS SessionType, '' AS SessionName, '' AS SessionDate, '' AS MapName, S.scenarioName AS ScenarioName, " .
"0 AS SessionSuccessful, 0 AS SessionDuration, 0 AS TriggerTypeId, 'Fire' AS TriggerType, TE.srcUserId AS ShooterId, '' AS ShooterName, -1 AS ShooterRoleId, '' AS ShooterRole, " .
"TE.indexCount AS ShotIndex, RE.id AS ReactId, -1 AS ReactModeId, '' AS ReactMode, -1 AS ReactTypeId, '' AS ReactType, RE.hitUserId AS TargetUserId, " .
"'' AS TargetUserName, -1 AS TargetRoleId, '' AS TargetRole, RE.hitTargetName AS TargetName, RE.hitBoneName AS TargetBoneName, 0 AS TargetKilled, 0 AS HitLocationX, 0 AS HitLocationY, " .
"'' AS HitLocationTag, RE.hitPrecision AS HitPrecision, 0 AS HitTargetDistance, RE.reactTime as ReactionTime, 0 AS TimeStamp, 0 AS NbHit, 0 AS NbKilled " .
"FROM " . SESSIONS_TABLE_NAME . " S " .
"LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " TE ON (S.id=TE.sessionId) " .
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " RE ON (TE.sessionId=RE.srcEventSessionId AND TE.indexCount=RE.srcEventIndex) " .
"LEFT JOIN " . SESSIONTYPES_TABLE_NAME . " ST ON (S.sessionType=ST.id) " .
"WHERE 1 " . $this->getSessionDurationConstraint("S") . $this->getCalibrationSessionsConstraint("S") .
" GROUP BY ShooterId, SessionId, TE.indexCount) AS X " .
"WHERE X.ShooterId = " . $userId . " AND X.SessionId IN (" . $this->getSessionsWithUserConstraint($userId) . ") GROUP BY X.ShooterId, X.SessionId ORDER BY X.SessionId ASC";
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
$row['HitPrecision'] = $row['AvgPrecision'];
$row['ReactionTime'] = $row['AvgReactTime'];
$stats = SessionDebriefRow::withRow($row);
array_push( $userGlobals->sessionDebriefRows, $stats->toArray() );
}
}
else
{
// create SQL constraints for query
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId = " . $sessionId : "";
$userConstraint = $userId >= 0 ? " AND SD.ShooterId = " . $userId : "";
$userSubConstraint = $userId >= 0 ? " AND P.UserId = " . $userId : "";
// get raw results from query
$sessionStats = $this->getRawStatsForSession ($sessionId, $userId, -1);
$newNbFiredShotsForReactionTime = 0;
$nbSessions = 0;
foreach ($sessionStats as $row)
{
if ( ($userId == -1 || $row->userId == $userId) && ($sessionId == -1 || $row->sessionId == $sessionId) )
{
// Increase number of sessions for this user
$nbSessions++;
// Update precision average
$newPrecision = $userGlobals->totals->averagePrecision*$userGlobals->totals->nbFiredShotsByUser + $row->averagePrecision*$row->nbFiredShotsByUser;
$userGlobals->totals->averagePrecision = $newPrecision/max($userGlobals->totals->nbFiredShotsByUser + $row->nbFiredShotsByUser, 1.0);
// Update reactionTime average
if ($row->averageReactionTime > 0)
{
$newReactionTime = $userGlobals->totals->averageReactionTime*$newNbFiredShotsForReactionTime + $row->averageReactionTime*$row->nbFiredShotsByUser;
$newNbFiredShotsForReactionTime += $row->nbFiredShotsByUser;
$userGlobals->totals->averageReactionTime = $newReactionTime/max($newNbFiredShotsForReactionTime, 1.0);
}
// Update other fields
$userGlobals->totals->nbFiredShotsByUser = $userGlobals->totals->nbFiredShotsByUser + $row->nbFiredShotsByUser;
$userGlobals->totals->nbEnemyHitsByUser += $row->nbEnemyHitsByUser;
$userGlobals->totals->nbCivilHitsByUser += $row->nbCivilHitsByUser;
$userGlobals->totals->nbPoliceHitsByUser += $row->nbPoliceHitsByUser;
$userGlobals->totals->nbObjectHitsByUser += $row->nbObjectHitsByUser;
$userGlobals->totals->nbMissedShotsByUser += $row->nbMissedShotsByUser;
$userGlobals->totals->nbDeadBodyHitsByUser += $row->nbDeadBodyHitsByUser;
$userGlobals->totals->nbReceivedHitsFromEnemyIA += $row->nbReceivedHitsFromEnemyIA;
$userGlobals->totals->nbReceivedHitsFromEnemyUser += $row->nbReceivedHitsFromEnemyUser;
$userGlobals->totals->nbReceivedHitsFromPoliceUser +=$row->nbReceivedHitsFromPoliceUser;
$userGlobals->totals->totalEnemyKilled += $row->totalEnemyKilled;
$userGlobals->totals->totalCivilKilled += $row->totalCivilKilled;
$userGlobals->totals->totalPoliceKilled += $row->totalPoliceKilled;
// Update duration
$userGlobals->totalDuration += $row->session->timeToFinish;
// Update user and session ids
$userGlobals->totals->userId = $row->userId;
$userGlobals->totals->sessionId = $row->sessionId;
}
}
// Get number of sessions user took part in
$userGlobals->nbSessions = $nbSessions;
// Calculate averages for precision and reaction time
$query = "SELECT SD.*, SUM(SD.HitPrecision) AS TotalPrecision, COUNT(DISTINCT SD.ShotIndex, SD.TargetName) AS TotalShots" .
", SUM(SD.ReactionTime) AS TotalReactionTime, SUM(CEILING(SD.HitPrecision)) AS TotalHits, X.MinDate AS MinDate, X.MaxDate AS MaxDate" .
" FROM " . SESSIONDEBRIEFS_VIEW_NAME . " SD, " .
" (SELECT P.userID AS UserId, MIN(S.SessionDate) AS MinDate, MAX(S.SessionDate) AS MaxDate" .
//" FROM " . PARTICIPATES_TABLE_NAME . " P INNER JOIN " . SESSIONS_TABLE_NAME . " S ON S.id=P.sessionId WHERE 1 " . $userSubConstraint . " GROUP BY P.userId) AS X" .
" FROM " . PARTICIPATES_TABLE_NAME . " P, " . SESSIONS_TABLE_NAME . " S WHERE S.id=P.sessionId " . $userSubConstraint . " GROUP BY P.userId) AS X" .
" WHERE 1 " . $sessionConstraint . $userConstraint . " GROUP BY SD.SessionId, SD.ShooterId";
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
// read MIN and MAX dates
$userGlobals->firstSession = $row['MinDate'];
$userGlobals->lastSession = $row['MaxDate'];
// replace HitPrecision, ReactionTime and NbHit fields so that we don't have to create a new type for returning the results
$row['HitPrecision'] = (float)(($row['TotalPrecision'] ?? 0.0) / max(($row['TotalShots'] ?? 1.0), 1.0) );
$row['ReactionTime'] = (float)(($row['TotalReactionTime'] ?? 0.0) / max(($row['TotalHits'] ?? 1.0), 1.0) );
$row['NbHit'] = $row['TotalHits'] ?? 0;
$stats = SessionDebriefRow::withRow($row);
array_push( $userGlobals->sessionDebriefRows, $stats->toArray() );
//array_push( $this->elements, $stats->toArray() ); // old
}
if ($userGlobals->nbSessions == 1)
{
$userGlobals->firstSession = $sessionStats[0]->session->sessionDate;
$userGlobals->lastSession = $userGlobals->firstSession;
}
}
array_push( $this->elements, $userGlobals->toArray() ); // new
//return $userGlobals->toArray();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// debrief mission : get totals for given session and/or user
public function getReceivedHitsForUser ($sessionId, $userId, $fromUser=-1)
{
// create SQL constraints for query
$sessionConstraint = $sessionId > 0 ? " AND SDRE.srcEventSessionId = " . $sessionId : "";
$userConstraint = $userId > 0 ? " AND SDRE.hitUserId=" . $userId : "";
$query = "SELECT SDRE.srcEventSessionId AS SessionId, COALESCE(SDRE.hitUserId, -1) AS UserId,
SUM(CASE WHEN X.ShooterRoleId=3 THEN X.NbHits ELSE 0 END) AS NbEnemyShotsIA,
SUM(CASE WHEN X.ShooterRoleId=1 THEN X.NbHits ELSE 0 END) AS NbEnemyShotsUser,
SUM(CASE WHEN X.ShooterRoleId=0 THEN X.NbHits ELSE 0 END) AS NbPoliceHits
FROM " . REACTEVENTS_TABLE_NAME . " SDRE LEFT JOIN
(SELECT TE.SessionId AS SessionId, RE.hitUserId AS TargetUserId, TE.srcUserId AS ShooterRoleId, COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS NbHits, RE.id AS ReactId
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 GROUP BY SessionId, TargetUserId, ShooterRoleId) AS X
ON (X.SessionId=SDRE.srcEventSessionId AND X.TargetUserId=SDRE.hitUserId AND X.ReactId=SDRE.id) " .
" LEFT JOIN " . SESSIONS_TABLE_NAME . " SD ON (SD.id=SDRE.srcEventSessionId) " .
"WHERE 1 " . $sessionConstraint . $userConstraint . $this->getSessionDurationConstraint("SD") . " GROUP BY SDRE.srcEventSessionId, SDRE.hitUserId";
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// debrief mission : get totals for given session and/or user
public function getShotsFiredByUser ($sessionId, $userId, $fromUser=-1)
{
// create SQL constraints for query
$sessionConstraint = $sessionId > 0 ? " AND SDTE.SessionId = " . $sessionId : "";
$userConstraint = $userId > 0 ? " AND SDTE.srcUserId=" . $userId : "";
$query = "SELECT SDTE.SessionId AS SessionId, SDTE.srcUserId AS UserId,
COALESCE(X.NbShotsFired,0) AS NbShotsFired,
COALESCE(X.NbShotsFired,0) - COALESCE(SUM(CASE WHEN Y.ReactTypeId>=0 AND Y.ReactTypeId<6 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END),0) AS NbMissedShots,
SUM(CASE WHEN Y.ReactTypeId=0 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbEnemyHits,
SUM(CASE WHEN Y.ReactTypeId=1 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbCivilHits,
SUM(CASE WHEN Y.ReactTypeId=2 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbPoliceHits,
SUM(CASE WHEN (Y.ReactTypeId=4 OR Y.ReactTypeId=5) AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbObjectHits,
SUM(CASE WHEN Y.ReactTypeId=6 AND Y.ReactPrecision>0 THEN Y.NbHits ELSE 0 END) AS NbDeadBodyHits
FROM " . SESSIONS_TABLE_NAME . " SD LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " SDTE ON (SD.id = SDTE.sessionId)
LEFT JOIN " . REACTEVENTS_TABLE_NAME . " SDRE ON (SDTE.sessionId=SDRE.srcEventSessionId AND SDTE.indexCount=SDRE.srcEventIndex)
LEFT JOIN
(SELECT TE.sessionId AS SessionId, TE.srcUserId AS ShooterId, COUNT(DISTINCT TE.indexCount) AS NbShotsFired
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE GROUP BY TE.sessionId, TE.srcUserId) AS X
ON (X.SessionId=SDTE.SessionId AND X.ShooterId=SDTE.srcUserId)
LEFT JOIN
(SELECT TE.sessionId AS SessionId, TE.srcUserId AS ShooterId, RE.ReactType AS ReactTypeId, COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS NbHits, RE.id AS ReactId, RE.hitPrecision AS ReactPrecision
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE
WHERE TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 GROUP BY SessionId, ShooterId, ReactTypeId, ReactID) AS Y
ON (Y.SessionId=SDTE.SessionId AND Y.ShooterId=SDTE.srcUserId AND Y.ReactId=SDRE.id)
LEFT JOIN
(SELECT TE1.sessionId AS SessionId, TE1.srcUserId AS ShooterId, COALESCE(COUNT(DISTINCT TE1.sessionId, TE1.indexCount),0) AS NbMissedShots
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE1, " . REACTEVENTS_TABLE_NAME . " RE1
WHERE TE1.sessionId=RE1.srcEventSessionId AND TE1.indexCount NOT IN (SELECT RE2.srcEventIndex FROM " . REACTEVENTS_TABLE_NAME . " RE2 WHERE RE2.srcEventSessionId=RE1.srcEventSessionId)) AS Z
ON (Z.SessionId=SDTE.SessionId AND Z.ShooterId=SDTE.srcUserId)
WHERE 1 " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $sessionConstraint . $userConstraint . " GROUP BY SDTE.SessionId, SDTE.srcUserId";
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// debrief mission : get totals for given session and/or user
public function getTargetKilledInSession ($sessionId)
{
// create SQL constraints for query
$sessionConstraint = $sessionId > 0 ? " AND SDTE.SessionId = " . $sessionId : "";
$query = "SELECT SDTE.SessionId AS SessionId, COALESCE(SDTE.srcUserId,-1) AS UserId,
SUM(CASE WHEN X.ReactTypeId=0 THEN X.NbHits ELSE 0 END) AS NbEnemyKilled,
SUM(CASE WHEN X.ReactTypeId=1 THEN X.NbHits ELSE 0 END) AS NbCivilKilled,
SUM(CASE WHEN X.ReactTypeId=2 THEN X.NbHits ELSE 0 END) AS NbPoliceKilled
FROM " . SESSIONS_TABLE_NAME . " SD LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " SDTE ON (SD.id = SDTE.sessionId)
LEFT JOIN " . REACTEVENTS_TABLE_NAME . " SDRE ON (SDTE.sessionId=SDRE.srcEventSessionId AND SDTE.indexCount=SDRE.srcEventIndex)
LEFT JOIN
(SELECT TE.SessionId AS SessionId, RE.hitUserId AS TargetUserId, TE.srcUserId AS ShooterRoleId, COUNT(DISTINCT TE.indexCount, RE.hitTargetName) AS NbHits, RE.id AS ReactId, RE.ReactType AS ReactTypeId
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE, " . REACTEVENTS_TABLE_NAME . " RE WHERE TE.sessionId = RE.srcEventSessionId AND TE.indexCount = RE.srcEventIndex AND TE.indexCount!=-1 AND RE.ReactType!=-1 AND RE.TargetKilled=1
GROUP BY SessionId, TargetUserId, ShooterRoleId, ReactTypeId) AS X
ON (X.SessionId=SDTE.SessionId AND X.TargetUserId=SDRE.hitUserId AND X.ReactId=SDRE.id)
WHERE 1 " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $sessionConstraint . " GROUP BY SDTE.SessionId";
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// debrief mission : get averages for given session and/or user
public function getPrecisionAndReactionTimeForUser ($sessionId, $userId, $fromUser=-1)
{
// create SQL constraints for query
$sessionConstraint = $sessionId > 0 ? " AND SDTE.SessionId = " . $sessionId : "";
$query = "SELECT SDTE.SessionId AS SessionId, SDTE.srcUserId AS UserId, AVG(X.AvgHitPrec) AS AveragePrecision,
AVG(CASE WHEN X.HitPrecision > 0 AND X.ReactId > 0 THEN X.AvgReactTime END) AS AverageReactionTime
FROM " . SESSIONS_TABLE_NAME . " SD LEFT JOIN " . TRIGGEREVENTS_TABLE_NAME . " SDTE ON (SD.id = SDTE.sessionId)
LEFT JOIN " . REACTEVENTS_TABLE_NAME . " SDRE ON SDTE.sessionId=SDRE.srcEventSessionId AND SDTE.indexCount=SDRE.srcEventIndex
LEFT JOIN
(SELECT DISTINCT TE.SessionId AS SessionId, TE.srcUserId AS ShooterId, TE.indexCount, RE.hitTargetName, COALESCE(AVG(RE.hitPrecision),0) AS AvgHitPrec, COALESCE(AVG(RE.reactTime),0) AS AvgReactTime, COALESCE(RE.id,-1) AS ReactId, COALESCE(RE.hitPrecision,0) AS HitPrecision
FROM " . TRIGGEREVENTS_TABLE_NAME . " TE LEFT JOIN " . REACTEVENTS_TABLE_NAME . " RE ON TE.indexCount=RE.srcEventIndex AND TE.sessionId=RE.srcEventSessionId GROUP BY TE.SessionId, TE.srcUserId, TE.indexCount, RE.id, RE.hitTargetName) AS X
ON X.SessionId=SDTE.SessionId AND X.ShooterId=SDTE.srcUserId
WHERE 1 " . $this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $sessionConstraint . " GROUP BY SDTE.SessionId, SDTE.srcUserId";
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// debrief mission : get given users results in given session
public function getResultsForSession ($sessionId, $userId, $fromUserId=-1)
{
$userConstraint = $userId > 0 ? " AND SD.ShooterId=" . $userId : "";
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId=" . $sessionId : "";
$query = "SELECT SD.* FROM " . SESSIONDEBRIEFS_VIEW_NAME . " SD WHERE 1 " . $this->getCalibrationSessionsConstraint() . $userConstraint . $sessionConstraint . " GROUP BY SD.SessionId, SD.ShooterId, SD.ShotIndex";
//$query = "SELECT SD.* FROM (" . SESSIONDEBRIEFS_VIEW_QUERY . ") AS SD WHERE 1 " . $userConstraint . $sessionConstraint . " GROUP BY SD.SessionId, SD.ShooterId, SD.ShotIndex";
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
$shot = SessionDebriefRow::withRow($row);
array_push( $this->elements, $shot->toArray() );
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// debrief mission : get stats for given session and/or user
/*public function getSessionDebrief ($sessionId, $userId, $fromUser=-1)
{
// prepare and execute query
$stmt = $this->getDebriefRows($sessionId, $userId);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
// create a SessionDebriefRow with this data
$stats = SessionDebriefRow::withRow($row);
array_push( $this->elements, $stats->toArray() );
}
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function getDebriefRows ($sessionId, $userId)
{
// create SQL constraints for query
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId = " . $sessionId : "";
$userConstraint = $userId >= 0 ? " AND (SD.ShooterId = " . $userId . " OR SD.TargetUserId = " . $userId . ")" : "";
//$query = "SELECT SD.* FROM " . SESSIONDEBRIEFS_VIEW_NAME . " SD WHERE 1 " . $sessionConstraint . $userConstraint;
$query = "SELECT SD.* FROM (" . SESSIONDEBRIEFS_VIEW_QUERY . ") AS SD WHERE 1 " . $sessionConstraint . $userConstraint;
// prepare and execute query
$stmt = $this->conn->prepare($query);
$stmt->execute();
return $stmt;
}*/
}
?>

View File

@@ -0,0 +1,48 @@
<?php
include_once '../objects/stats_user_in_session_row.php';
class UserGlobalStats
{
public int $nbSessions = 0;
public float $totalDuration = 0.0;
public string $firstSession = "";
public string $lastSession = "";
public UserStatsInSessionRow $totals;
public $sessionDebriefRows = array();
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function __construct ()
{
$this->totals = new UserStatsInSessionRow();
$this->sessionDebriefRows = array();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function setSessionTotals (array $row)
{
$this->nbSessions = (int)$row['NbSessions'];
$this->totalDuration = (float)$row['TotalDuration'];
$this->firstSession = $row['MinDate'];
$this->lastSession = $row['MaxDate'];
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"nbSessions" => (int)$this->nbSessions,
"totalDuration" => (float)$this->totalDuration ?? 0.0,
"firstSessionAsString" => $this->firstSession ?? "",
"lastSessionAsString" => $this->lastSession ?? "",
"totals" => $this->totals->toArray(),
"sessionDebriefRows" => $this->sessionDebriefRows
);
}
}
?>

View File

@@ -0,0 +1,244 @@
<?php
include_once '../objects/db_user.php';
include_once '../objects/db_session.php';
class UserStatsInSessionRow
{
public GameSession $session;
public User $user;
public int $sessionId = -1;
public int $userId = -1;
public int $nbFiredShotsByUser = 0;
public int $nbEnemyHitsByUser = 0;
public int $nbCivilHitsByUser = 0;
public int $nbPoliceHitsByUser = 0;
public int $nbObjectHitsByUser = 0;
public int $nbMissedShotsByUser = 0;
public int $nbDeadBodyHitsByUser = 0;
public float $averagePrecision = 0.0;
public float $averageReactionTime = 0.0;
public int $nbReceivedHitsFromEnemyIA = 0;
public int $nbReceivedHitsFromEnemyUser = 0;
public int $nbReceivedHitsFromPoliceUser = 0;
public int $totalEnemyKilled = 0;
public int $totalCivilKilled = 0;
public int $totalPoliceKilled = 0;
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function __construct ()
{
$this->session = new GameSession(null);
$this->sessionId = -1;
$this->user = new User(null);
$this->userId = -1;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public static function withRow ($db, array $row)
{
$instance = new self();
$instance->createSession($db, (int)$row['SessionId']);
$instance->createUser($db, (int)$row['UserId']);
//$this->readRow($row);
return $instance;
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function createSessionAndUser ($db, int $sessionId=-1, int $userId=-1)
{
if ($sessionId > -1)
$this->createSession($db, $sessionId);
if ($userId > 0)
$this->createUser($db, $userId);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
private function createSession ($db, int $id)
{
$this->session = new GameSession($db);
$this->session->id = $id;
$this->sessionId = $id; // keeping this field for easier access in UE
$this->session->load();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
private function createUser ($db, int $id)
{
$this->user = new User($db);
$this->user->id = $id;
$this->userId = $id; // keeping this field for easier access in UE
$this->user->load();
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
function readRow (array $row)
{
$this->sessionId = $row['SessionId'];
//$this->sessionTypeId = $row['SessionTypeId'];
//$this->sessionType = $row['SessionType'];
//$this->sessionName = $row['SessionName'];
//$this->sessionDate = $row['SessionDate'];
//$this->mapName = $row['MapName'];
//$this->scenarioName = $row['ScenarioName'];
//$this->sessionSuccessful = $row['SessionSuccessful'] == 1;
//$this->sessionDuration = (float)$row['SessionDuration'];
//$this->triggerTypeId = (int)$row['TriggerTypeId'];
//$this->triggerType = $row['TriggerType'];
//$this->shooterId = (int)$row['ShooterId'];
//$this->shooterName = $row['ShooterName'];
//$this->shooterRoleId = (int)$row['ShooterRoleId'];
//$this->shooterRole = $row['ShooterRole'];
//$this->shotIndex = (int)$row['ShotIndex'];
//$this->reactId = (int)$row['ReactId'];
//$this->reactModeId = (int)$row['ReactModeId'];
//$this->reactMode = $row['ReactMode'];
//$this->reactTypeId = (int)$row['ReactTypeId'];
//$this->reactType = $row['ReactType'];
//$this->targetUserId = (int)$row['TargetUserId'];
//$this->targetUserName = $row['TargetUserName'];
//$this->targetRoleId = (int)$row['TargetRoleId'];
//$this->targetRole = $row['TargetRole'];
//$this->targetName = $row['TargetName'];
//$this->targetBoneName = $row['TargetBoneName'];
//$this->targetKilled = $row['TargetKilled'] == 1;
//$this->hitLocationX = (float)$row['HitLocationX'];
//$this->hitLocationY = (float)$row['HitLocationY'];
//$this->hitLocationTag = $row['HitLocationTag'];
//$this->hitPrecision = (float)$row['HitPrecision'];
//$this->hitTargetDistance = (float)$row['HitTargetDistance'];
//$this->reactTime = (float)$row['ReactionTime'];
//$this->timeStamp = (float)$row['TimeStamp'];
//$this->nbHit = (int)$row['NbHit'];
//$this->nbKilled = (int)$row['NbKilled'];
$this->userId = $row['UserId'];
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function setFiredShots (array $row)
{
$this->nbFiredShotsByUser = (int)$row['NbShotsFired'];
$this->nbMissedShotsByUser = (int)$row['NbMissedShots'];
$this->nbEnemyHitsByUser = (int)$row['NbEnemyHits'];
$this->nbCivilHitsByUser = (int)$row['NbCivilHits'];
$this->nbPoliceHitsByUser = (int)$row['NbPoliceHits'];
$this->nbObjectHitsByUser = (int)$row['NbObjectHits'];
$this->nbDeadBodyHitsByUser = (int)$row['NbDeadBodyHits'];
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function setPrecisionAndReactionTime (array $row)
{
$this->averagePrecision = (float)$row['AveragePrecision'];
$this->averageReactionTime = (float)$row['AverageReactionTime'];
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function setReceivedHits (array $row)
{
$this->nbReceivedHitsFromEnemyIA = (int)$row['NbEnemyShotsIA'];
$this->nbReceivedHitsFromEnemyUser = (int)$row['NbEnemyShotsUser'];
$this->nbReceivedHitsFromPoliceUser = (int)$row['NbPoliceHits'];
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function setTotalKills (array $row)
{
$this->totalEnemyKilled = (int)$row['NbEnemyKilled'];
$this->totalCivilKilled = (int)$row['NbCivilKilled'];
$this->totalPoliceKilled = (int)$row['NbPoliceKilled'];
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function toArray () : array
{
return array (
"session" => $this->session->toArray() ?? "",
"user" => $this->user->toArray() ?? "",
"userId" => (int)$this->userId ?? -1,
"sessionId" => (int)$this->sessionId ?? -1,
"userId" => (int)$this->userId ?? -1,
"nbFiredShotsByUser" => (int)$this->nbFiredShotsByUser ?? 0,
"nbEnemyHitsByUser" => (int)$this->nbEnemyHitsByUser ?? 0,
"nbCivilHitsByUser" => (int)$this->nbCivilHitsByUser ?? 0,
"nbPoliceHitsByUser" => (int)$this->nbPoliceHitsByUser ?? 0,
"nbObjectHitsByUser" => (int)$this->nbObjectHitsByUser ?? 0,
"nbDeadBodyHitsHitsByUser" => (int)$this->nbDeadBodyHitsByUser ?? 0,
"nbMissedShotsByUser" => (int)$this->nbMissedShotsByUser ?? 0,
"averagePrecision" => (float)$this->averagePrecision ?? 0.0,
"averageReactionTime" => (float)$this->averageReactionTime ?? 0.0,
"nbReceivedHitsFromEnemyIA" => (int)$this->nbReceivedHitsFromEnemyIA ?? 0,
"nbReceivedHitsFromEnemyUser" => (int)$this->nbReceivedHitsFromEnemyUser ?? 0,
"nbReceivedHitsFromPoliceUser" => (int)$this->nbReceivedHitsFromPoliceUser ?? 0,
"totalEnemyKilled" => (int)$this->totalEnemyKilled ?? 0,
"totalCivilKilled" => (int)$this->totalCivilKilled ?? 0,
"totalPoliceKilled" => (int)$this->totalPoliceKilled ?? 0
);
}
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
public function isForUserInSession ($userIdToCheck, $sessionIdToCheck)
{
return $this->userId == $userIdToCheck && $this->sessionId == $sessionIdToCheck;
}
}
class Stats_ShotsPerUserPerSession_Row
{
public int $sessionId = -1;
public int $userId = -1;
public $username = "";
public int $nbShotsFired = 0;
public int $nbEnemyHits = 0;
public int $nbCivilsHits = 0;
public int $nbObjectHits = 0;
public int $nbDeadHits = 0;
public function __construct($row)
{
$this->sessionId = $row['sessionId'];
$this->userId = $row['userId'];
$this->username = $row['username'];
$this->nbShotsFired = $row['nbShotsFired'];
$this->nbEnemyHits = $row['nbEnemyHits'];
$this->nbCivilsHits = $row['nbCivilsHits'];
$this->nbObjectHits = $row['nbObjectHits'];
$this->nbDeadHits = $row['nbDeadHits'];
}
public function toArray () : array
{
return array (
"sessionId" => (int)$this->sessionId ?? -1,
"userId" => (int)$this->userId ?? -1,
"username" => $this->username ?? "",
"nbShotsFired" => (int)$this->nbShotsFired ?? 0,
"nbEnemyHits" => (int)$this->nbEnemyHits ?? 0,
"nbCivilsHits" => (int)$this->nbCivilsHits ?? 0,
"nbObjectHits" => (int)$this->nbObjectHits ?? 0,
"nbDeadHits" => (int)$this->nbDeadHits ?? 0
);
}
}
?>

View File

@@ -0,0 +1,27 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_participates.php';
//include_once './score_algo1.php';
// prepare user object
$participation = new UserInGameSession($db);
// ensure sessionId and userId are passed in $_POST parameters
if (isset($_POST['sessionId']))
$participation->sessionId = $_POST['sessionId'];
else trigger_error( missing_parameter_error("sessionId") );
if (isset($_POST['userId']))
$participation->userId = $_POST['userId'];
else trigger_error( missing_parameter_error("userId") );
// calculate user score in session
if ($participation->updateUserScores())
{
$participation_arr= $participation->getResultArray(true,"Score_Updated_OK");
print_r(json_encode($participation_arr)); // OK
}
else trigger_error("User_Score_Not_Updated");
?>

View File

@@ -0,0 +1,23 @@
<?php
function calculate_v1 ($precisionOnEnemies, $nbCivils)
{
$pointsPerEnemy = 100;
$penaltyPerCivil = 200;
return $precisionOnEnemies*$pointsPerEnemy - $nbCivils*$penaltyPerCivil;
}
function calculate_firerange_v1 ($precision)
{
$pointsPerShot = 10;
return $precision*$pointsPerShot;
}
function is_success_v1 ($score)
{
$targetScore = 70;
return $score >= $targetScore;
}
?>

View File

@@ -0,0 +1,27 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$sessionId = -1;
// read mandatory $_POST properties : ensure sessionId is passed in $_POST parameters
if (isset($_POST['sessionId']))
$sessionId = $_POST['sessionId'];
else trigger_error( missing_parameter_error("sessionId") );
$userId = isset($_POST['userId']) ? $_POST['userId'] : -1;
// check sessionId is > 0
//if ($sessionId > 0)
//{
// prepare user object
$stats = new StatsObject($db);
$stats->getStatsForSession($sessionId, $userId);
$stats_arr = $stats->getResultArray(true, "Stats_Collected_OK");
print_r(json_encode($stats_arr)); // OK
//}
//else trigger_error("Invalid session id");
?>

View File

@@ -0,0 +1,16 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$stats = new StatsObject($db);
$nb = $stats->fixDurations();
/*if ($nb == 0)
echo "No session to be corrected.";
else if ($nb == 1)
echo $nb . " session has been corrected.";
else
echo $nb . " sessions have been corrected.";*/
?>

View File

@@ -0,0 +1,22 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_session.php';
// prepare user object
$gameSession = new GameSession($db);
// read mandatory $_POST properties : ensure sessionId is passed in $_POST parameters
if (isset($_POST['sessionId']))
$gameSession->id = $_POST['sessionId'];
else trigger_error( missing_parameter_error("sessionId") );
// create the game session
if ($gameSession->load())
{
$session_arr = $gameSession->getResultArray(true, "Session_Found");
print_r(json_encode($session_arr)); // OK
}
else trigger_error("Error_Occured");
?>

View File

@@ -0,0 +1,67 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_participates.php';
// prepare user object
$participation = new UserInGameSession($db);
$participation->userId = -1;
// read mandatory $_POST properties : ensure sessionId is passed in POST parameters
if (isset($_POST['sessionId']))
$participation->sessionId = $_POST['sessionId'];
else trigger_error( missing_parameter_error("sessionId") );
if (isset($_POST['userId']))
$participation->userId = $_POST['userId'];
$multiple = false;
// get overall objectives for all users in session
if ($participation->userId == -1)
{
$users = $participation->getAllUsersInSession();
$nbUsers = count($users);
if ($nbUsers > 1)
{
// multiple users for the session : get average of all users objectives
$multiple = true;
$firstElement = true;
foreach ($users as $oneUserId)
{
$part = new UserInGameSession($db);
$part->sessionId = $participation->sessionId;
$part->userId = (int)$oneUserId; // contains userId
if ($part->load())
{
// if this is the participation for the first user, simply update the participation variable
// otherwise, call add function to add this user's participation result to the overall one (participation variable)
if ($firstElement)
{
$participation->copy($part);
$firstElement = false;
}
else
$participation->add($part);
}
}
// once loop is completed, calculate average score, precision, reactionTime and objective completion
$participation->calculateAverages($nbUsers);
}
else if ($nbUsers == 1)
$participation->userId = (int)$users[0];
}
if (!$multiple && $participation->userId == -1)
trigger_error( missing_parameter_error("userId") );
// if we got participation for multiple users OR if we successfully load it for a single one, then everything is good
if ($multiple || (!$multiple && $participation->load()))
{
$participation_arr = $participation->getResultArray(true, "Results_Saved_OK");
print_r(json_encode($participation_arr)); // OK
}
else trigger_error("User_Results_Not_Found");
?>

View File

@@ -0,0 +1,31 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_participates.php';
// prepare user object
$participation = new UserInGameSession($db);
// read mandatory $_POST properties : ensure sessionId and userId are passed in POST parameters
if (isset($_POST['sessionId']))
$participation->sessionId = $_POST['sessionId'];
else trigger_error( missing_parameter_error("sessionId") );
if (isset($_POST['userId']))
$participation->userId = $_POST['userId'];
else trigger_error( missing_parameter_error("userId") );
// read other $_POST properties
$participation->avatar = $_POST['avatar'] ?? "";
$participation->weapon = $_POST['weapon'] ?? "";
$participation->role = $_POST['role'] ?? 0;
// register user to the game session
if ($participation->registerUser())
{
$participation_arr = $participation->getResultArray(true, "User_Registered_OK");
print_r(json_encode($participation_arr)); // OK
}
else trigger_error("User_Already_Registered");
?>

View File

@@ -0,0 +1,33 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_session.php';
// prepare user object
$gameSession = new GameSession($db);
// read mandatory $_POST properties : ensure sessionType and sessionName are passed in $_POST parameters
if (isset($_POST['sessionType']))
$gameSession->sessionType = $_POST['sessionType'];
else trigger_error( missing_parameter_error("sessionType") );
if (isset($_POST['sessionName']))
$gameSession->sessionName = $_POST['sessionName'];
else
$gameSession->sessionName = uniqid("session_");
// read other $_POST properties for game session
$gameSession->sessionDate = date('Y-m-d H:i:s');
$gameSession->mapName = $_POST['mapName'];
$gameSession->scenarioName = $_POST['scenarioName'];
$gameSession->replayFileName = $gameSession->sessionName . date('Y-m-d_H-i-s');
// create the game session
if ($gameSession->start())
{
$session_arr = $gameSession->getResultArray(true, "Session_In_Progress_OK");
print_r(json_encode($session_arr)); // OK
}
else trigger_error("Error_Occured");
?>

View File

@@ -0,0 +1,27 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_session.php';
// prepare user object
$gameSession = new GameSession($db);
// read mandatory $_POST properties : ensure sessionType and sessionName are passed in $_POST parameters
if (isset($_POST['sessionId']))
$gameSession->id = $_POST['sessionId'];
else trigger_error( missing_parameter_error("sessionId") );
// read other $_POST properties for game session
$gameSession->timeToFinish = $_POST['duration'];
$gameSession->success = (strcasecmp( ($_POST['success'] ?? false), "true" ) == 0) ? 1 : 0;
$gameSession->score = $_POST['score'];
// close the game session
if ($gameSession->stop())
{
$session_arr = $gameSession->getResultArray(true, "Session_Stop_OK");
print_r(json_encode($session_arr)); // OK
}
else trigger_error("Error_Occured");
?>

View File

@@ -0,0 +1,69 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_participates.php';
// prepare user object
$participation = new UserInGameSession($db);
// read mandatory $_POST properties : ensure sessionId and userId are passed in POST parameters
if (isset($_POST['sessionId']))
$participation->sessionId = $_POST['sessionId'];
else trigger_error( missing_parameter_error("sessionId") );
if (isset($_POST['userId']))
$participation->userId = $_POST['userId'];
else trigger_error( missing_parameter_error("userId") );
// get overall objectives for all users in session
if ($participation->userId == -1)
{
$users = $participation->getAllUsersInSession();
if (count($users) >= 1)
$participation->userId = $users[0];
//else
//{
// foreach ($users as $userId)
// {
// $participation->userId = $userId;
// $participation->load();
// $participation->results = $results;
// }
//}
}
// load from database
if ($participation->load())
{
// read other $_POST properties
$participation->results = $_POST['results'] ?? "";
// update objectives of user in the game session
if ($participation->updateObjectives())
{
$participation_arr = $participation->getResultArray(true, "Results_Saved_OK");
print_r(json_encode($participation_arr)); // OK
}
else trigger_error("User_Results_Not_Saved");
}
else trigger_error("Couldn't find user with id " . $participation->userId . " in session with id " . $participation->sessionId . ".");
//function loadObjectivesForParticipation ()
//{
// if ($participation->load())
// {
// // read other $_POST properties
// $participation->results = $results;
//
// // register user to the game session
// if ($participation->updateObjectives())
// {
// $participation_arr = $participation->getResultArray(true, "Results_Saved_OK");
// print_r(json_encode($participation_arr)); // OK
// }
// else trigger_error("User_Results_Not_Saved");
// }
// else trigger_error("Couldn't find user with id " . $participation->userId . " in session with id " . $participation->sessionId . ".");
//}
?>

View File

@@ -0,0 +1,34 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_participates.php';
// prepare user object
$participation = new UserInGameSession($db);
// ensure sessionId and userId are passed in $_POST parameters
if (isset($_POST['sessionId']))
$participation->sessionId = $_POST['sessionId'];
else trigger_error( missing_parameter_error("sessionId") );
if (isset($_POST['userId']))
$participation->userId = $_POST['userId'];
else trigger_error( missing_parameter_error("userId") );
if (isset($_POST['endStatus']))
$participation->endStatus = $_POST['endStatus'];
// user leaves session
if ($participation->userLeaves())
{
// update score and averages
if ($participation->updateUserScores())
{
$participation_arr = $participation->getResultArray(true, "User_Left_OK");
print_r(json_encode($participation_arr)); // OK
}
else trigger_error("Calculate_Score_Failed");
}
else trigger_error("Leave_Session_Failed");
?>

View File

@@ -0,0 +1,25 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$userId = isset($_POST['userId']) ? $_POST['userId'] : -1;
$sessionId = isset($_POST['sessionId']) ? $_POST['sessionId'] : -1;
$sessionType = isset($_POST['sessionType']) ? $_POST['sessionType'] : -1; // useless because it is "re-calculated"
$fromUserId = isset($_POST['fromUserId']) ? $_POST['fromUserId'] : -1;
if ($sessionId > 0)// && $sessionType < 0) // "calculate" sessionType based on session
{
$session = new GameSession($db);
$session->id = $sessionId;
$session->load();
$sessionType = $session->sessionType;
}
$stats = new StatsObject($db);
$stats->get($sessionId, $userId, $sessionType, $fromUserId);
$stats_arr = $stats->getResultArray(true, "Stats_Collected_OK");
print_r(json_encode($stats_arr)); // OK
?>

View File

@@ -0,0 +1,30 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/stats_object.php';
$userId = -1;
$sessionId = -1;
$quickMode = 0;
if (isset($_POST['userId']))
$userId = $_POST['userId'];
else trigger_error( missing_parameter_error("userId") );
if (isset($_POST['sessionId']))
$sessionId = $_POST['sessionId'];
else trigger_error( missing_parameter_error("sessionId") );
if (isset($_POST['quickMode']))
$quickMode = strtolower($_POST['quickMode']) == "true" ? 1 : 0;
//if ($userId > 0 && $sessionId > 0)
{
$stats = new StatsObject($db);
$stats->getUserHistory($userId, $sessionId, $quickMode);
$stats_arr = $stats->getResultArray(true, "Results_Collected_OK");
print_r(json_encode($stats_arr)); // OK
}
?>

View File

@@ -0,0 +1,22 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_user.php';
// prepare user object
$user = new User($db);
// read mandatory $_POST properties : ensure userId is passed in $_POST parameters
if (isset($_POST['userId']))
$user->id = $_POST['userId'];
else trigger_error( missing_parameter_error("userId") );
// create the game session
if ($user->load())
{
$user_arr = $user->getResultArray(true, "User_Found");
print_r(json_encode($user_arr)); // OK
}
else trigger_error("Error_Occured");
?>

View File

@@ -0,0 +1,40 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_user.php';
// prepare user object
$user = new User($db);
// ensure username and password are passed in $_POST parameters
if (isset($_POST['username']))
$user->username = $_POST['username'];
else trigger_error( missing_parameter_error("username") );
if (isset($_POST['password']))
$user->password = base64_encode($_POST['password']);
else trigger_error( missing_parameter_error("password") );
// read details of user to be edited
$stmt = $user->login();
if($stmt->rowCount() > 0)
{
// get retrieved row
$row = $stmt->fetch(PDO::FETCH_ASSOC);
// retrieve user values
$user->readRow($row);
// update last connection date
$user->refreshConnectionDate();
// create array
/*$user_arr=array (
"status" => true,
"message" => "Successfully Login!",
"user" => $user->toArray()
);*/
$user_arr = $user->getResultArray(true, "Login_OK");
print_r(json_encode($user_arr)); // OK
}
else trigger_error("Invalid_Username_Password");
?>

View File

@@ -0,0 +1,27 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../config/error.php';
include_once '../objects/db_user.php';
// prepare user object
$user = new User($db);
$user->id = isset($_POST['userId']) ? $_POST['userId'] : -1;
$user->username = isset($_POST['username']) ? $_POST['username'] : '';
// ensure userId is passed in $_POST parameters
if ($user->id <= 0 && $user->username == '')
trigger_error( missing_parameter_error("userId or username") );
if (isset($_POST['password']))
$user->password = base64_encode($_POST['password']);
else trigger_error( missing_parameter_error("password") );
// load all info for this user id
if ($user->resetPassword())
{
$user_arr = $user->getResultArray(true, "Password_Updated");
print_r(json_encode($user_arr)); // OK
}
else trigger_error("Error_Updating_Password");
?>

View File

@@ -0,0 +1,29 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../objects/db_user.php';
include_once '../config/error.php';
// set user property values
$user = new User($db);
// ensure username is passed in $_POST parameters
if (isset($_POST['username']))
$user->username = $_POST['username'];
else trigger_error( missing_parameter_error("username") );
// read other $_POST parameters
$user->password = base64_encode($_POST['password']);
$user->created = date('Y-m-d H:i:s');
$user->lastConnection = date('Y-m-d H:i:s');
// create the user
if ($user->signup())
{
// once signed up, read info from DB to retrieve default values for all fields
//$user->load(); // automatically called in $user->signup()
$user_arr = $user->getResultArray(true, "Signup_OK");
print_r(json_encode($user_arr)); // OK
}
else trigger_error("Username_Already_Exists");
?>

View File

@@ -0,0 +1,35 @@
<?php
// include database and object files
include_once '../config/init.php'; // contains $db
include_once '../objects/db_user.php';
include_once '../config/error.php';
// prepare user object
$user = new User($db);
// ensure userId is passed in $_POST parameters
if (isset($_POST['userId']))
$user->id = (int)$_POST['userId'];
else trigger_error( missing_parameter_error("userId") );
// load all info for this user id
if ($user->load())
{
// then update fields with the passed information (and update last connection time)
$user->firstName = $_POST['firstName'];
$user->lastName = $_POST['lastName'];
$user->leftHanded = (strcasecmp( $_POST['leftHanded'], "true" ) == 0) ? 1 : 0;
$user->maleGender = (strcasecmp( $_POST['maleGender'], "true" ) == 0) ? 1 : 0;
$user->charSkinAssetName = $_POST['charSkinAssetName'];
$user->weaponAssetName = $_POST['weaponAssetName'];
$user->size = (int)$_POST['size'];
if ($user->update())
{
$user_arr = $user->getResultArray(true, "User_Info_Updated");
print_r(json_encode($user_arr)); // OK
}
else trigger_error("Error_Retrieving_User_Info");
}
else trigger_error("Unknown_User_ID");
?>

View File

@@ -0,0 +1,2 @@
[/Game/PROSERVE/Blueprints/Common/PS_GameInstance.PS_GameInstance_C]
isSDMIS=true

View File

@@ -0,0 +1,897 @@
<!doctype html>
<html lang="en" class="no-js">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="icon" href="/assets/images/favicon.png">
<meta name="generator" content="mkdocs-1.6.1, mkdocs-material-9.6.5">
<title>PROSERVE Documentation</title>
<link rel="stylesheet" href="/assets/stylesheets/main.8608ea7d.min.css">
<link rel="stylesheet" href="/assets/stylesheets/palette.06af60db.min.css">
<script src="https://unpkg.com/iframe-worker/shim"></script>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i%7CRoboto+Mono:400,400i,700,700i&display=fallback">
<style>:root{--md-text-font:"Roboto";--md-code-font:"Roboto Mono"}</style>
<script>__md_scope=new URL("/",location),__md_hash=e=>[...e].reduce(((e,_)=>(e<<5)-e+_.charCodeAt(0)),0),__md_get=(e,_=localStorage,t=__md_scope)=>JSON.parse(_.getItem(t.pathname+"."+e)),__md_set=(e,_,t=localStorage,a=__md_scope)=>{try{t.setItem(a.pathname+"."+e,JSON.stringify(_))}catch(e){}}</script>
</head>
<body dir="ltr" data-md-color-scheme="default" data-md-color-primary="indigo" data-md-color-accent="indigo">
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
<label class="md-overlay" for="__drawer"></label>
<div data-md-component="skip">
</div>
<div data-md-component="announce">
</div>
<header class="md-header md-header--shadow" data-md-component="header">
<nav class="md-header__inner md-grid" aria-label="Header">
<a href="/index.html" title="PROSERVE Documentation" class="md-header__button md-logo" aria-label="PROSERVE Documentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54"/></svg>
</a>
<label class="md-header__button md-icon" for="__drawer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg>
</label>
<div class="md-header__title" data-md-component="header-title">
<div class="md-header__ellipsis">
<div class="md-header__topic">
<span class="md-ellipsis">
PROSERVE Documentation
</span>
</div>
<div class="md-header__topic" data-md-component="header-topic">
<span class="md-ellipsis">
</span>
</div>
</div>
</div>
<form class="md-header__option" data-md-component="palette">
<input class="md-option" data-md-color-media="(prefers-color-scheme: light)" data-md-color-scheme="default" data-md-color-primary="indigo" data-md-color-accent="indigo" aria-label="Switch to dark mode" type="radio" name="__palette" id="__palette_0">
<label class="md-header__button md-icon" title="Switch to dark mode" for="__palette_1" hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a4 4 0 0 0-4 4 4 4 0 0 0 4 4 4 4 0 0 0 4-4 4 4 0 0 0-4-4m0 10a6 6 0 0 1-6-6 6 6 0 0 1 6-6 6 6 0 0 1 6 6 6 6 0 0 1-6 6m8-9.31V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12z"/></svg>
</label>
<input class="md-option" data-md-color-media="(prefers-color-scheme: dark)" data-md-color-scheme="slate" data-md-color-primary="indigo" data-md-color-accent="indigo" aria-label="Switch to light mode" type="radio" name="__palette" id="__palette_1">
<label class="md-header__button md-icon" title="Switch to light mode" for="__palette_0" hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 18c-.89 0-1.74-.2-2.5-.55C11.56 16.5 13 14.42 13 12s-1.44-4.5-3.5-5.45C10.26 6.2 11.11 6 12 6a6 6 0 0 1 6 6 6 6 0 0 1-6 6m8-9.31V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12z"/></svg>
</label>
</form>
<script>var palette=__md_get("__palette");if(palette&&palette.color){if("(prefers-color-scheme)"===palette.color.media){var media=matchMedia("(prefers-color-scheme: light)"),input=document.querySelector(media.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");palette.color.media=input.getAttribute("data-md-color-media"),palette.color.scheme=input.getAttribute("data-md-color-scheme"),palette.color.primary=input.getAttribute("data-md-color-primary"),palette.color.accent=input.getAttribute("data-md-color-accent")}for(var[key,value]of Object.entries(palette.color))document.body.setAttribute("data-md-color-"+key,value)}</script>
<label class="md-header__button md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
</label>
<div class="md-search" data-md-component="search" role="dialog">
<label class="md-search__overlay" for="__search"></label>
<div class="md-search__inner" role="search">
<form class="md-search__form" name="search">
<input type="text" class="md-search__input" name="query" aria-label="Search" placeholder="Search" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" required>
<label class="md-search__icon md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg>
</label>
<nav class="md-search__options" aria-label="Search">
<a href="javascript:void(0)" class="md-search__icon md-icon" title="Share" aria-label="Share" data-clipboard data-clipboard-text="" data-md-component="search-share" tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3 3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66 0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08"/></svg>
</a>
<button type="reset" class="md-search__icon md-icon" title="Clear" aria-label="Clear" tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</nav>
<div class="md-search__suggest" data-md-component="search-suggest"></div>
</form>
<div class="md-search__output">
<div class="md-search__scrollwrap" tabindex="0" data-md-scrollfix>
<div class="md-search-result" data-md-component="search-result">
<div class="md-search-result__meta">
Initializing search
</div>
<ol class="md-search-result__list" role="presentation"></ol>
</div>
</div>
</div>
</div>
</div>
</nav>
</header>
<div class="md-container" data-md-component="container">
<main class="md-main" data-md-component="main">
<div class="md-main__inner md-grid">
<div class="md-sidebar md-sidebar--primary" data-md-component="sidebar" data-md-type="navigation" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--primary md-nav--integrated" aria-label="Navigation" data-md-level="0">
<label class="md-nav__title" for="__drawer">
<a href="/index.html" title="PROSERVE Documentation" class="md-nav__button md-logo" aria-label="PROSERVE Documentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54"/></svg>
</a>
PROSERVE Documentation
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="/index.html" class="md-nav__link">
<span class="md-ellipsis">
Welcome to PROSERVE Documentation
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Content.html" class="md-nav__link">
<span class="md-ellipsis">
Content
</span>
</a>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle md-toggle--indeterminate" type="checkbox" id="__nav_3" >
<label class="md-nav__link" for="__nav_3" id="__nav_3_label" tabindex="0">
<span class="md-ellipsis">
Commissioning
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_3_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_3">
<span class="md-nav__icon md-icon"></span>
Commissioning
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="/Commissioning/Codes.html" class="md-nav__link">
<span class="md-ellipsis">
Technical Information
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Commissioning/Charging.html" class="md-nav__link">
<span class="md-ellipsis">
Charging VR Equipment
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Commissioning/ConnectionIgnition.html" class="md-nav__link">
<span class="md-ellipsis">
Connection and Ignition
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Commissioning/Preparation.html" class="md-nav__link">
<span class="md-ellipsis">
Connection and Preparation
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Commissioning/Adjusting.html" class="md-nav__link">
<span class="md-ellipsis">
Adjusting the Headset
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Commissioning/Wrist.html" class="md-nav__link">
<span class="md-ellipsis">
Wrist Tracker
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Commissioning/Checks.html" class="md-nav__link">
<span class="md-ellipsis">
Global Checks
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Commissioning/Refill.html" class="md-nav__link">
<span class="md-ellipsis">
Gas Refills
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle md-toggle--indeterminate" type="checkbox" id="__nav_4" >
<label class="md-nav__link" for="__nav_4" id="__nav_4_label" tabindex="0">
<span class="md-ellipsis">
Using PROSERVE
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_4_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_4">
<span class="md-nav__icon md-icon"></span>
Using PROSERVE
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="/Use/Launch.html" class="md-nav__link">
<span class="md-ellipsis">
Launch PROSERVE
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Use/Login.html" class="md-nav__link">
<span class="md-ellipsis">
User Identification
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Use/Setup.html" class="md-nav__link">
<span class="md-ellipsis">
User Setup
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Use/Scenario.html" class="md-nav__link">
<span class="md-ellipsis">
Scenario Selection
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Use/End.html" class="md-nav__link">
<span class="md-ellipsis">
End of Scenario
</span>
</a>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle md-toggle--indeterminate" type="checkbox" id="__nav_4_6" >
<label class="md-nav__link" for="__nav_4_6" id="__nav_4_6_label" tabindex="0">
<span class="md-ellipsis">
Calibration
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="2" aria-labelledby="__nav_4_6_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_4_6">
<span class="md-nav__icon md-icon"></span>
Calibration
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="/Calibration/Calibration.html" class="md-nav__link">
<span class="md-ellipsis">
Concept
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Calibration/Procedure.html" class="md-nav__link">
<span class="md-ellipsis">
Procedure
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="/Use/Multiplayer.html" class="md-nav__link">
<span class="md-ellipsis">
Multi Trainee Pack
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle md-toggle--indeterminate" type="checkbox" id="__nav_5" >
<label class="md-nav__link" for="__nav_5" id="__nav_5_label" tabindex="0">
<span class="md-ellipsis">
Hardware
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_5_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_5">
<span class="md-nav__icon md-icon"></span>
Hardware
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="/Hardware/ViveFocus3.html" class="md-nav__link">
<span class="md-ellipsis">
Vive Focus 3
</span>
</a>
</li>
<li class="md-nav__item">
<a href="/Hardware/Trackers.html" class="md-nav__link">
<span class="md-ellipsis">
Trackers
</span>
</a>
</li>
</ul>
</nav>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="md-content" data-md-component="content">
<article class="md-content__inner md-typeset">
<h1>404 - Not found</h1>
</article>
</div>
<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script>
</div>
</main>
<footer class="md-footer">
<div class="md-footer-meta md-typeset">
<div class="md-footer-meta__inner md-grid">
<div class="md-copyright">
<div class="md-copyright__highlight">
&copy; Copyright 2016 - 2024 | ASTERION VR - All Rights Reserved
</div>
</div>
</div>
</div>
</footer>
</div>
<div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div>
</div>
<script id="__config" type="application/json">{"base": "/", "features": ["navigation.instant", "search.suggest", "search.highlight", "search.share", "navigation.expand", "toc.integrate"], "search": "/assets/javascripts/workers/search.f8cc74c7.min.js", "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}}</script>
<script src="/assets/javascripts/bundle.f1b6f286.min.js"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 348 KiB

View File

@@ -0,0 +1,998 @@
<!doctype html>
<html lang="en" class="no-js">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<link rel="canonical" href="http://localhost:8000/Commissioning/Adjusting.html">
<link rel="prev" href="Preparation.html">
<link rel="next" href="Wrist.html">
<link rel="icon" href="../assets/images/favicon.png">
<meta name="generator" content="mkdocs-1.6.1, mkdocs-material-9.6.5">
<title>Adjusting the Headset - PROSERVE Documentation</title>
<link rel="stylesheet" href="../assets/stylesheets/main.8608ea7d.min.css">
<link rel="stylesheet" href="../assets/stylesheets/palette.06af60db.min.css">
<script src="https://unpkg.com/iframe-worker/shim"></script>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,300i,400,400i,700,700i%7CRoboto+Mono:400,400i,700,700i&display=fallback">
<style>:root{--md-text-font:"Roboto";--md-code-font:"Roboto Mono"}</style>
<script>__md_scope=new URL("..",location),__md_hash=e=>[...e].reduce(((e,_)=>(e<<5)-e+_.charCodeAt(0)),0),__md_get=(e,_=localStorage,t=__md_scope)=>JSON.parse(_.getItem(t.pathname+"."+e)),__md_set=(e,_,t=localStorage,a=__md_scope)=>{try{t.setItem(a.pathname+"."+e,JSON.stringify(_))}catch(e){}}</script>
</head>
<body dir="ltr" data-md-color-scheme="default" data-md-color-primary="indigo" data-md-color-accent="indigo">
<input class="md-toggle" data-md-toggle="drawer" type="checkbox" id="__drawer" autocomplete="off">
<input class="md-toggle" data-md-toggle="search" type="checkbox" id="__search" autocomplete="off">
<label class="md-overlay" for="__drawer"></label>
<div data-md-component="skip">
<a href="#adjusting-the-headset" class="md-skip">
Skip to content
</a>
</div>
<div data-md-component="announce">
</div>
<header class="md-header md-header--shadow" data-md-component="header">
<nav class="md-header__inner md-grid" aria-label="Header">
<a href="../index.html" title="PROSERVE Documentation" class="md-header__button md-logo" aria-label="PROSERVE Documentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54"/></svg>
</a>
<label class="md-header__button md-icon" for="__drawer">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M3 6h18v2H3zm0 5h18v2H3zm0 5h18v2H3z"/></svg>
</label>
<div class="md-header__title" data-md-component="header-title">
<div class="md-header__ellipsis">
<div class="md-header__topic">
<span class="md-ellipsis">
PROSERVE Documentation
</span>
</div>
<div class="md-header__topic" data-md-component="header-topic">
<span class="md-ellipsis">
Adjusting the Headset
</span>
</div>
</div>
</div>
<form class="md-header__option" data-md-component="palette">
<input class="md-option" data-md-color-media="(prefers-color-scheme: light)" data-md-color-scheme="default" data-md-color-primary="indigo" data-md-color-accent="indigo" aria-label="Switch to dark mode" type="radio" name="__palette" id="__palette_0">
<label class="md-header__button md-icon" title="Switch to dark mode" for="__palette_1" hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a4 4 0 0 0-4 4 4 4 0 0 0 4 4 4 4 0 0 0 4-4 4 4 0 0 0-4-4m0 10a6 6 0 0 1-6-6 6 6 0 0 1 6-6 6 6 0 0 1 6 6 6 6 0 0 1-6 6m8-9.31V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12z"/></svg>
</label>
<input class="md-option" data-md-color-media="(prefers-color-scheme: dark)" data-md-color-scheme="slate" data-md-color-primary="indigo" data-md-color-accent="indigo" aria-label="Switch to light mode" type="radio" name="__palette" id="__palette_1">
<label class="md-header__button md-icon" title="Switch to light mode" for="__palette_0" hidden>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 18c-.89 0-1.74-.2-2.5-.55C11.56 16.5 13 14.42 13 12s-1.44-4.5-3.5-5.45C10.26 6.2 11.11 6 12 6a6 6 0 0 1 6 6 6 6 0 0 1-6 6m8-9.31V4h-4.69L12 .69 8.69 4H4v4.69L.69 12 4 15.31V20h4.69L12 23.31 15.31 20H20v-4.69L23.31 12z"/></svg>
</label>
</form>
<script>var palette=__md_get("__palette");if(palette&&palette.color){if("(prefers-color-scheme)"===palette.color.media){var media=matchMedia("(prefers-color-scheme: light)"),input=document.querySelector(media.matches?"[data-md-color-media='(prefers-color-scheme: light)']":"[data-md-color-media='(prefers-color-scheme: dark)']");palette.color.media=input.getAttribute("data-md-color-media"),palette.color.scheme=input.getAttribute("data-md-color-scheme"),palette.color.primary=input.getAttribute("data-md-color-primary"),palette.color.accent=input.getAttribute("data-md-color-accent")}for(var[key,value]of Object.entries(palette.color))document.body.setAttribute("data-md-color-"+key,value)}</script>
<label class="md-header__button md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
</label>
<div class="md-search" data-md-component="search" role="dialog">
<label class="md-search__overlay" for="__search"></label>
<div class="md-search__inner" role="search">
<form class="md-search__form" name="search">
<input type="text" class="md-search__input" name="query" aria-label="Search" placeholder="Search" autocapitalize="off" autocorrect="off" autocomplete="off" spellcheck="false" data-md-component="search-query" required>
<label class="md-search__icon md-icon" for="__search">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M9.5 3A6.5 6.5 0 0 1 16 9.5c0 1.61-.59 3.09-1.56 4.23l.27.27h.79l5 5-1.5 1.5-5-5v-.79l-.27-.27A6.52 6.52 0 0 1 9.5 16 6.5 6.5 0 0 1 3 9.5 6.5 6.5 0 0 1 9.5 3m0 2C7 5 5 7 5 9.5S7 14 9.5 14 14 12 14 9.5 12 5 9.5 5"/></svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M20 11v2H8l5.5 5.5-1.42 1.42L4.16 12l7.92-7.92L13.5 5.5 8 11z"/></svg>
</label>
<nav class="md-search__options" aria-label="Search">
<a href="javascript:void(0)" class="md-search__icon md-icon" title="Share" aria-label="Share" data-clipboard data-clipboard-text="" data-md-component="search-share" tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M18 16.08c-.76 0-1.44.3-1.96.77L8.91 12.7c.05-.23.09-.46.09-.7s-.04-.47-.09-.7l7.05-4.11c.54.5 1.25.81 2.04.81a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3c0 .24.04.47.09.7L8.04 9.81C7.5 9.31 6.79 9 6 9a3 3 0 0 0-3 3 3 3 0 0 0 3 3c.79 0 1.5-.31 2.04-.81l7.12 4.15c-.05.21-.08.43-.08.66 0 1.61 1.31 2.91 2.92 2.91s2.92-1.3 2.92-2.91A2.92 2.92 0 0 0 18 16.08"/></svg>
</a>
<button type="reset" class="md-search__icon md-icon" title="Clear" aria-label="Clear" tabindex="-1">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg>
</button>
</nav>
<div class="md-search__suggest" data-md-component="search-suggest"></div>
</form>
<div class="md-search__output">
<div class="md-search__scrollwrap" tabindex="0" data-md-scrollfix>
<div class="md-search-result" data-md-component="search-result">
<div class="md-search-result__meta">
Initializing search
</div>
<ol class="md-search-result__list" role="presentation"></ol>
</div>
</div>
</div>
</div>
</div>
</nav>
</header>
<div class="md-container" data-md-component="container">
<main class="md-main" data-md-component="main">
<div class="md-main__inner md-grid">
<div class="md-sidebar md-sidebar--primary" data-md-component="sidebar" data-md-type="navigation" >
<div class="md-sidebar__scrollwrap">
<div class="md-sidebar__inner">
<nav class="md-nav md-nav--primary md-nav--integrated" aria-label="Navigation" data-md-level="0">
<label class="md-nav__title" for="__drawer">
<a href="../index.html" title="PROSERVE Documentation" class="md-nav__button md-logo" aria-label="PROSERVE Documentation" data-md-component="logo">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"><path d="M12 8a3 3 0 0 0 3-3 3 3 0 0 0-3-3 3 3 0 0 0-3 3 3 3 0 0 0 3 3m0 3.54C9.64 9.35 6.5 8 3 8v11c3.5 0 6.64 1.35 9 3.54 2.36-2.19 5.5-3.54 9-3.54V8c-3.5 0-6.64 1.35-9 3.54"/></svg>
</a>
PROSERVE Documentation
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="../index.html" class="md-nav__link">
<span class="md-ellipsis">
Welcome to PROSERVE Documentation
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../Content.html" class="md-nav__link">
<span class="md-ellipsis">
Content
</span>
</a>
</li>
<li class="md-nav__item md-nav__item--active md-nav__item--nested">
<input class="md-nav__toggle md-toggle " type="checkbox" id="__nav_3" checked>
<label class="md-nav__link" for="__nav_3" id="__nav_3_label" tabindex="0">
<span class="md-ellipsis">
Commissioning
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_3_label" aria-expanded="true">
<label class="md-nav__title" for="__nav_3">
<span class="md-nav__icon md-icon"></span>
Commissioning
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="Codes.html" class="md-nav__link">
<span class="md-ellipsis">
Technical Information
</span>
</a>
</li>
<li class="md-nav__item">
<a href="Charging.html" class="md-nav__link">
<span class="md-ellipsis">
Charging VR Equipment
</span>
</a>
</li>
<li class="md-nav__item">
<a href="ConnectionIgnition.html" class="md-nav__link">
<span class="md-ellipsis">
Connection and Ignition
</span>
</a>
</li>
<li class="md-nav__item">
<a href="Preparation.html" class="md-nav__link">
<span class="md-ellipsis">
Connection and Preparation
</span>
</a>
</li>
<li class="md-nav__item md-nav__item--active">
<input class="md-nav__toggle md-toggle" type="checkbox" id="__toc">
<label class="md-nav__link md-nav__link--active" for="__toc">
<span class="md-ellipsis">
Adjusting the Headset
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<a href="Adjusting.html" class="md-nav__link md-nav__link--active">
<span class="md-ellipsis">
Adjusting the Headset
</span>
</a>
<nav class="md-nav md-nav--secondary" aria-label="Table of contents">
<label class="md-nav__title" for="__toc">
<span class="md-nav__icon md-icon"></span>
Table of contents
</label>
<ul class="md-nav__list" data-md-component="toc" data-md-scrollfix>
<li class="md-nav__item">
<a href="#vertical-positioning" class="md-nav__link">
<span class="md-ellipsis">
Vertical Positioning
</span>
</a>
</li>
<li class="md-nav__item">
<a href="#adjusting-the-interpupillary-distance-ipd" class="md-nav__link">
<span class="md-ellipsis">
Adjusting the Interpupillary Distance (IPD)
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="Wrist.html" class="md-nav__link">
<span class="md-ellipsis">
Wrist Tracker
</span>
</a>
</li>
<li class="md-nav__item">
<a href="Checks.html" class="md-nav__link">
<span class="md-ellipsis">
Global Checks
</span>
</a>
</li>
<li class="md-nav__item">
<a href="Refill.html" class="md-nav__link">
<span class="md-ellipsis">
Gas Refills
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle md-toggle--indeterminate" type="checkbox" id="__nav_4" >
<label class="md-nav__link" for="__nav_4" id="__nav_4_label" tabindex="0">
<span class="md-ellipsis">
Using PROSERVE
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_4_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_4">
<span class="md-nav__icon md-icon"></span>
Using PROSERVE
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="../Use/Launch.html" class="md-nav__link">
<span class="md-ellipsis">
Launch PROSERVE
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../Use/Login.html" class="md-nav__link">
<span class="md-ellipsis">
User Identification
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../Use/Setup.html" class="md-nav__link">
<span class="md-ellipsis">
User Setup
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../Use/Scenario.html" class="md-nav__link">
<span class="md-ellipsis">
Scenario Selection
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../Use/End.html" class="md-nav__link">
<span class="md-ellipsis">
End of Scenario
</span>
</a>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle md-toggle--indeterminate" type="checkbox" id="__nav_4_6" >
<label class="md-nav__link" for="__nav_4_6" id="__nav_4_6_label" tabindex="0">
<span class="md-ellipsis">
Calibration
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="2" aria-labelledby="__nav_4_6_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_4_6">
<span class="md-nav__icon md-icon"></span>
Calibration
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="../Calibration/Calibration.html" class="md-nav__link">
<span class="md-ellipsis">
Concept
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../Calibration/Procedure.html" class="md-nav__link">
<span class="md-ellipsis">
Procedure
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item">
<a href="../Use/Multiplayer.html" class="md-nav__link">
<span class="md-ellipsis">
Multi Trainee Pack
</span>
</a>
</li>
</ul>
</nav>
</li>
<li class="md-nav__item md-nav__item--nested">
<input class="md-nav__toggle md-toggle md-toggle--indeterminate" type="checkbox" id="__nav_5" >
<label class="md-nav__link" for="__nav_5" id="__nav_5_label" tabindex="0">
<span class="md-ellipsis">
Hardware
</span>
<span class="md-nav__icon md-icon"></span>
</label>
<nav class="md-nav" data-md-level="1" aria-labelledby="__nav_5_label" aria-expanded="false">
<label class="md-nav__title" for="__nav_5">
<span class="md-nav__icon md-icon"></span>
Hardware
</label>
<ul class="md-nav__list" data-md-scrollfix>
<li class="md-nav__item">
<a href="../Hardware/ViveFocus3.html" class="md-nav__link">
<span class="md-ellipsis">
Vive Focus 3
</span>
</a>
</li>
<li class="md-nav__item">
<a href="../Hardware/Trackers.html" class="md-nav__link">
<span class="md-ellipsis">
Trackers
</span>
</a>
</li>
</ul>
</nav>
</li>
</ul>
</nav>
</div>
</div>
</div>
<div class="md-content" data-md-component="content">
<article class="md-content__inner md-typeset">
<h1 id="adjusting-the-headset">Adjusting the Headset</h1>
<h2 id="vertical-positioning">Vertical Positioning</h2>
<p><img src="Adjusting/image25.png" alt="Make sure the lenses rest over your eyes" width="1000px"></p>
<p>To ensure optimal comfort and clarity:</p>
<ul>
<li>Adjust the <strong>vertical position</strong> of the headset by gently moving it up and down on your face</li>
<li>Make sure the lenses rest directly over your eyes to align with your line of sight.</li>
</ul>
<hr />
<h2 id="adjusting-the-interpupillary-distance-ipd">Adjusting the Interpupillary Distance (IPD)</h2>
<p><img src="Adjusting/image24.png" alt="Adjust the IPD by turning the dial until text and lines are clear" width="1000px"></p>
<p>For a sharp and comfortable viewing experience:</p>
<ul>
<li>Adjust the <strong>horizontal position</strong> of the lenses using the <strong>IPD dial</strong> located under the headset.</li>
<li>Horizontal and vertical alignment lines will appear in your view to help you fine-tune the lens spacing to match your vision. Turn the dial until text and lines appear sharp and clear.</li>
</ul>
</article>
</div>
<script>var target=document.getElementById(location.hash.slice(1));target&&target.name&&(target.checked=target.name.startsWith("__tabbed_"))</script>
</div>
</main>
<footer class="md-footer">
<div class="md-footer-meta md-typeset">
<div class="md-footer-meta__inner md-grid">
<div class="md-copyright">
<div class="md-copyright__highlight">
&copy; Copyright 2016 - 2024 | ASTERION VR - All Rights Reserved
</div>
</div>
</div>
</div>
</footer>
</div>
<div class="md-dialog" data-md-component="dialog">
<div class="md-dialog__inner md-typeset"></div>
</div>
<script id="__config" type="application/json">{"base": "..", "features": ["navigation.instant", "search.suggest", "search.highlight", "search.share", "navigation.expand", "toc.integrate"], "search": "../assets/javascripts/workers/search.f8cc74c7.min.js", "translations": {"clipboard.copied": "Copied to clipboard", "clipboard.copy": "Copy to clipboard", "search.result.more.one": "1 more on this page", "search.result.more.other": "# more on this page", "search.result.none": "No matching documents", "search.result.one": "1 matching document", "search.result.other": "# matching documents", "search.result.placeholder": "Type to start searching", "search.result.term.missing": "Missing", "select.version": "Select version"}}</script>
<script src="../assets/javascripts/bundle.f1b6f286.min.js"></script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1000 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 982 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Some files were not shown because too many files have changed in this diff Show More