Compare commits

..

14 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
261 changed files with 40587 additions and 94 deletions

View File

@@ -11,7 +11,7 @@
#define MyAppName "PROSERVE Launcher" #define MyAppName "PROSERVE Launcher"
#define MyAppShortName "PS_Launcher" #define MyAppShortName "PS_Launcher"
#define MyAppVersion "1.0.3" #define MyAppVersion "1.0.13"
#define MyAppPublisher "ASTERION VR" #define MyAppPublisher "ASTERION VR"
#define MyAppURL "https://asterionvr.com" #define MyAppURL "https://asterionvr.com"
#define MyAppExeName "PS_Launcher.exe" #define MyAppExeName "PS_Launcher.exe"

View File

@@ -450,6 +450,26 @@ HTML;
* - assure le suffixe .zip * - assure le suffixe .zip
* - retombe sur la valeur par défaut si l'input est vide ou inexploitable * - 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 function ps_normalize_zip_filename(string $raw, string $version): string
{ {
$default = "proserve-{$version}.zip"; $default = "proserve-{$version}.zip";
@@ -521,12 +541,43 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
if (!preg_match('/^[A-Za-z0-9_.\-]+\.exe$/', $exeName)) { if (!preg_match('/^[A-Za-z0-9_.\-]+\.exe$/', $exeName)) {
$exeName = 'PROSERVE_UE_5_7.exe'; $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 = [ $entry = [
'id' => $entryId, 'id' => $entryId,
'version' => $version, 'version' => $version,
'releasedAt' => $releasedAtIso, 'releasedAt' => $releasedAtIso,
'executable' => $exeName, 'executable' => $exeName,
'installFolderTemplate' => 'PROSERVE v{version}', 'installFolderTemplate' => $installFolderTemplateInput,
'channels' => $channels, 'channels' => $channels,
'download' => [ 'download' => [
'url' => "{$base}/builds/{$zipFilename}", 'url' => "{$base}/builds/{$zipFilename}",
@@ -594,6 +645,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD'; $manifest['versions'][$idx]['download']['sha256'] = 'REPLACE_AFTER_BUILD';
$manifest['versions'][$idx]['download']['sizeBytes'] = 0; $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); saveManifest($manifestPath, $manifest);
$message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.'); $message = "Méta de v{$version} mises à jour" . ($newZipName !== '' ? ". ⚠️ Le ZIP a été renommé : upload le nouveau fichier puis re-Sync." : '.');
} }
@@ -671,11 +745,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
require_once "$root/tools/SignManifest.php"; require_once "$root/tools/SignManifest.php";
$signer = new \PSLauncher\Tools\SignManifest($root); $signer = new \PSLauncher\Tools\SignManifest($root);
$force = !empty($_POST['force']); $force = !empty($_POST['force']);
// SignManifest::run filtre par numéro de version ; en cas d'entries // Filtre par entryId (pas par numéro de version). Sinon, quand deux
// de même version sur des channels différents, le hash sera recalculé // entrées partagent le même numéro (channels firefighter vs full sur
// pour toutes celles qui matchent (chacune pointe sur son propre ZIP, // 1.5.4.32 p.ex.), les DEUX ZIPs sont hashés dans la même requête HTTP
// donc le résultat est correct, juste un peu plus de boulot). // → 2 × 14 Go = risque de dépasser le timeout front OVH sur mutualisé.
$result = $signer->run('versions', $force, $version); $result = $signer->run('versions', $force, null, $entryId);
$forceLabel = $force ? ' [FORCE]' : ''; $forceLabel = $force ? ' [FORCE]' : '';
$message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']); $message = "Hash de v{$version}{$forceLabel} :\n" . implode("\n", $result['log']);
if (!$result['ok']) $messageType = 'error'; if (!$result['ok']) $messageType = 'error';
@@ -931,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> <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"> <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>
<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"> <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> <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;"> <div style="display: flex; flex-wrap: wrap; gap: 12px; padding: 8px 0;">
@@ -1161,6 +1240,15 @@ Layout::header('Versions', 'versions');
<input type="text" name="zip_filename" placeholder="laisse vide pour ne rien changer"> <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> <p class="warn">⚠ Renommer invalide le sha256 — re-upload le nouveau ZIP en SFTP puis « 🔁 Sync ».</p>
</div> </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;"> <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="button" class="btn btn-secondary" onclick="this.closest('dialog').close()">Annuler</button>
<button type="submit" class="btn btn-primary">Enregistrer Méta</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); Response::error('manifest_missing', 'Manifest absent côté serveur', 500);
} }
$manifest = json_decode(file_get_contents($manifestPath), true); $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; $entry = null;
foreach ($manifest['versions'] ?? [] as $v) { 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) { 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 // Vérif droits téléchargement

View File

@@ -40,9 +40,17 @@ final class Manifest
$clientChannel = null; $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'] = self::filterVersions(
$manifest['versions'] ?? [], $manifest['versions'] ?? [],
$clientChannel $clientChannel,
$multiChannel
); );
// Re-signature à la volée. La clé privée Ed25519 est dans config.php // 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 * @param array<int, array<string,mixed>> $versions
* @return list<array<string,mixed>> * @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 // Étape 1 : filter visible par ce client
$visible = []; $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 = []; $byVersion = [];
foreach ($visible as $item) { foreach ($visible as $item) {
$key = (string)($item['entry']['version'] ?? '?'); $key = (string)($item['entry']['version'] ?? '?');

View File

@@ -83,7 +83,37 @@ final class SignManifest
} }
$start = microtime(true); $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); $duration = (int)((microtime(true) - $start) * 1000);
$cache[$key] = ['size' => $size, 'mtime' => $mtime, 'sha256' => $sha]; $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 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 * @param ?string $onlyVersion Si non null, ne touche QUE cette version dans la section
* versions[] (les autres restent inchangées). Le scope 'launcher' * 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[]} * @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)) { if (!is_file($this->manifestPath)) {
$this->out("Manifest introuvable : {$this->manifestPath}"); $this->out("Manifest introuvable : {$this->manifestPath}");
return ['ok' => false, 'log' => $this->log]; return ['ok' => false, 'log' => $this->log];
@@ -127,9 +203,9 @@ final class SignManifest
$doVersions = ($scope === 'all' || $scope === 'versions'); $doVersions = ($scope === 'all' || $scope === 'versions');
$doLauncher = ($scope === 'all' || $scope === 'launcher'); $doLauncher = ($scope === 'all' || $scope === 'launcher');
if ($onlyVersion !== null) { if ($onlyVersion !== null || $onlyEntryId !== null) {
// Mode "hash une seule version" : on ne touche pas au launcher, // Mode "hash une seule entrée / version" : on ne touche pas au launcher,
// et on ne hash que la version demandée dans la section versions[]. // et on ne hash que ce qui correspond au filtre demandé dans versions[].
$doLauncher = false; $doLauncher = false;
$doVersions = true; $doVersions = true;
} }
@@ -141,7 +217,15 @@ final class SignManifest
$hashedVersions = []; $hashedVersions = [];
if ($doVersions) foreach ($manifest['versions'] as &$v) { if ($doVersions) foreach ($manifest['versions'] as &$v) {
$version = $v['version'] ?? '?'; $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 continue; // skip silently les autres versions
} }
$url = $v['download']['url'] ?? ''; $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; $cacheChanged = true;
$note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)"; $note = $r['fromCache'] ? '(cache)' : "(calculé en {$r['durationMs']} ms)";
$this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note"); $this->out(" [hash] $version : " . basename($zip) . " ($size octets) sha256={$r['sha256']} $note");

View File

@@ -18,9 +18,9 @@
<Product>PROSERVE Launcher</Product> <Product>PROSERVE Launcher</Product>
<Copyright>© 2026 ASTERION VR — All rights reserved</Copyright> <Copyright>© 2026 ASTERION VR — All rights reserved</Copyright>
<RootNamespace>PSLauncher.App</RootNamespace> <RootNamespace>PSLauncher.App</RootNamespace>
<Version>1.0.3</Version> <Version>1.0.13</Version>
<AssemblyVersion>1.0.3.0</AssemblyVersion> <AssemblyVersion>1.0.13.0</AssemblyVersion>
<FileVersion>1.0.3.0</FileVersion> <FileVersion>1.0.13.0</FileVersion>
<!-- Single-file self-contained publish profile (used by `dotnet publish`) --> <!-- Single-file self-contained publish profile (used by `dotnet publish`) -->
<PublishSingleFile>true</PublishSingleFile> <PublishSingleFile>true</PublishSingleFile>

View File

@@ -543,7 +543,14 @@ public sealed partial class MainViewModel : ObservableObject
var preserveActive = oldActive is not null var preserveActive = oldActive is not null
&& oldActive.State is VersionRowState.Downloading or VersionRowState.Verifying; && 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 // 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à // les versions taggées isBeta=true. Les installations locales déjà
@@ -555,23 +562,95 @@ public sealed partial class MainViewModel : ObservableObject
var remote = canSeeBetas var remote = canSeeBetas
? rawRemote ? rawRemote
: rawRemote.Where(v => !v.IsBeta).ToList(); : rawRemote.Where(v => !v.IsBeta).ToList();
var remoteByVer = remote.ToDictionary(v => v.Version);
var allVersions = installed.Keys.Union(remoteByVer.Keys) // Row identity : Id de l'entrée manifest quand dispo (unique par entry,
.OrderByDescending(v => SemVer.Parse(v)) // survit à un rename de folder), sinon fallback sur le folder name
.ToList(); // 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>(); var remoteByRowKey = new Dictionary<string, VersionManifest>(StringComparer.OrdinalIgnoreCase);
foreach (var ver in allVersions) foreach (var v in remote)
{ {
VersionRowViewModel row; var key = RemoteRowKey(v);
if (installed.TryGetValue(ver, out var inst)) 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 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); WireRowHandlers(row);
rows.Add(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. // bon objet et que la barre de progress de la row avance live.
if (preserveActive && oldActive is not null) 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) if (matching is not null)
{ {
matching.State = oldActive.State; 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 // Featured : plus haute installée, sinon plus haute distante
var featured = rows.FirstOrDefault(r => r.IsInstalled) ?? rows.FirstOrDefault(); var featured = rows.FirstOrDefault(r => r.IsInstalled) ?? rows.FirstOrDefault();
FeaturedVersion = featured; FeaturedVersion = featured;
@@ -747,7 +837,13 @@ public sealed partial class MainViewModel : ObservableObject
// on garde le cache silencieusement (pas de crash). // on garde le cache silencieusement (pas de crash).
await RefreshLicenseFromServerAsync(CancellationToken.None); 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) if (result.Error is not null)
{ {
StatusMessage = Strings.StatusError(result.Error); StatusMessage = Strings.StatusError(result.Error);
@@ -1090,13 +1186,22 @@ public sealed partial class MainViewModel : ObservableObject
try try
{ {
// Si on lance la version désignée comme "auto", on passe les CLI args // Composition des CLI args passés à PROSERVE_UE_*.exe :
// configurés (ex: -autoconnect -login=Jerome2 -password=… -ipserver=10.0.4.100). // 1. _config.DefaultLaunchArgs — appliqués à CHAQUE lancement (auto ou manuel).
// Pour les lancements manuels d'autres versions, args = null (comportement standard). // 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 var isAutoVersion = _config.AutoMode.FeatureEnabled
&& !string.IsNullOrEmpty(_config.AutoMode.SelectedVersion) && !string.IsNullOrEmpty(_config.AutoMode.SelectedVersion)
&& string.Equals(_config.AutoMode.SelectedVersion, row.Version, StringComparison.Ordinal); && 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); var proc = _processLauncher.Launch(row.Installed, cliArgs);
_runningProserve = proc; _runningProserve = proc;
@@ -1441,8 +1546,14 @@ public sealed partial class MainViewModel : ObservableObject
var target = _config.AutoMode.SelectedVersion; var target = _config.AutoMode.SelectedVersion;
if (string.IsNullOrEmpty(target)) return; if (string.IsNullOrEmpty(target)) return;
var row = (FeaturedVersion?.Version == target ? FeaturedVersion : null) // AutoMode config stocke le numéro de version (pas le RowKey). Sur un
?? OtherVersions.FirstOrDefault(r => r.Version == target); // 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) if (row is null || !row.IsInstalled)
{ {
_logger.LogInformation("Auto-launch skipped: v{Version} not installed locally", target); _logger.LogInformation("Auto-launch skipped: v{Version} not installed locally", target);
@@ -1473,6 +1584,39 @@ public sealed partial class MainViewModel : ObservableObject
try 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 : // 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. // l'utilisateur a déjà confirmé sa décision la première fois.
var isResume = row.HasResumableDownload; var isResume = row.HasResumableDownload;
@@ -1531,7 +1675,15 @@ public sealed partial class MainViewModel : ObservableObject
else else
{ {
_currentPeerHost = null; _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; var urlString = signed ?? row.Remote.Download.Url;
url = new Uri(urlString); url = new Uri(urlString);
@@ -1573,7 +1725,10 @@ public sealed partial class MainViewModel : ObservableObject
async Task<Uri?> RefreshUrlAsync(CancellationToken c) async Task<Uri?> RefreshUrlAsync(CancellationToken c)
{ {
if (peerSrc is not null) return peerSrc.ZipUrl; 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); return fresh is null ? null : new Uri(fresh);
} }
var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256) var job = new DownloadJob(row.Version, url, row.Remote.Download.SizeBytes, row.Remote.Download.Sha256)
@@ -1661,7 +1816,7 @@ public sealed partial class MainViewModel : ObservableObject
try try
{ {
if (!string.IsNullOrWhiteSpace(row.Remote.Executable)) 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) catch (Exception ex)
{ {
@@ -2150,8 +2305,10 @@ public sealed partial class MainViewModel : ObservableObject
// Récupère la row fraîche depuis le rebuild (l'instance peut // Récupère la row fraîche depuis le rebuild (l'instance peut
// avoir changé) pour relire la nouvelle URL du manifest. // avoir changé) pour relire la nouvelle URL du manifest.
var freshRow = (FeaturedVersion?.Version == row.Version ? FeaturedVersion : null) // Match par RowKey (folder name) plutôt que Version pour supporter
?? OtherVersions.FirstOrDefault(r => r.Version == row.Version); // 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; var urlAfter = freshRow?.Remote?.Download.Url;
_logger.LogInformation( _logger.LogInformation(
"Manifest URL for v{Version} — before: {Before}, after refresh: {After}", "Manifest URL for v{Version} — before: {Before}, after refresh: {After}",
@@ -2381,9 +2538,12 @@ public sealed partial class MainViewModel : ObservableObject
/// (<c>PROSERVE_UE_5_7.exe</c> → <c>PROSERVE_UE_5_7/</c>). Compatible avec /// (<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 /// 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> /// du dossier UE source vers le dossier UE cible (renommage transparent).</item>
/// <item>Mode COPIE NON DESTRUCTIVE : si le nouveau ZIP livre déjà un /// <item>Mode USER-DATA WINS : les <c>.sav</c>/<c>.replay</c> sont des
/// fichier au même chemin (ex : profil par défaut bundlé), on NE /// données utilisateur (progression, réglages, replays de session). Si
/// l'écrase PAS. Seuls les fichiers absents côté cible sont copiés.</item> /// 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 — /// <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> /// l'install ne doit pas échouer pour une copie de saves.</item>
/// </list> /// </list>
@@ -2391,12 +2551,18 @@ public sealed partial class MainViewModel : ObservableObject
private async Task CopyPreviousSaveGamesAsync(string newInstallDir, string newVersion, CancellationToken ct) private async Task CopyPreviousSaveGamesAsync(string newInstallDir, string newVersion, CancellationToken ct)
{ {
var installed = _registry.Scan(); 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 var previous = installed
.Where(v => !string.Equals(v.Version, newVersion, StringComparison.OrdinalIgnoreCase)) .Where(v => !string.Equals(v.Version, newVersion, StringComparison.OrdinalIgnoreCase))
.FirstOrDefault(); .FirstOrDefault();
if (previous is null) if (previous is null)
{ {
_logger.LogDebug("No previous version installed, nothing to copy for v{Version}", newVersion); _logger.LogInformation(
"SaveGames copy skipped : no previous version found other than v{New} (aucune version antérieure à copier)",
newVersion);
return; return;
} }
@@ -2408,10 +2574,15 @@ public sealed partial class MainViewModel : ObservableObject
var srcProjectName = Path.GetFileNameWithoutExtension(previous.ExecutablePath); var srcProjectName = Path.GetFileNameWithoutExtension(previous.ExecutablePath);
if (string.IsNullOrWhiteSpace(srcProjectName)) if (string.IsNullOrWhiteSpace(srcProjectName))
{ {
_logger.LogDebug("Cannot resolve project name from previous exe {Exe}", previous.ExecutablePath); _logger.LogWarning(
"SaveGames copy aborted : cannot resolve project name from previous exe {Exe}",
previous.ExecutablePath);
return; return;
} }
var srcSavedRoot = Path.Combine(previous.FolderPath, srcProjectName, "Saved"); 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 // Pre-scan : on liste TOUS les fichiers à copier (savegames + demos) AVANT
// de toucher quoi que ce soit, pour pouvoir afficher un count total dans // de toucher quoi que ce soit, pour pouvoir afficher un count total dans
@@ -2420,13 +2591,23 @@ public sealed partial class MainViewModel : ObservableObject
foreach (var (subDir, glob) in PreservedSavedSubdirs) foreach (var (subDir, glob) in PreservedSavedSubdirs)
{ {
var srcSubDir = Path.Combine(srcSavedRoot, subDir); var srcSubDir = Path.Combine(srcSavedRoot, subDir);
if (!Directory.Exists(srcSubDir)) continue; if (!Directory.Exists(srcSubDir))
foreach (var file in Directory.GetFiles(srcSubDir, glob, SearchOption.AllDirectories)) {
_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)); plan.Add((subDir, file));
} }
if (plan.Count == 0) if (plan.Count == 0)
{ {
_logger.LogDebug("Previous v{Version} has no save files / demos to copy", previous.Version); _logger.LogInformation(
"SaveGames copy skipped : previous v{Version} has no matching files in {Root}",
previous.Version, srcSavedRoot);
return; return;
} }
@@ -2451,7 +2632,7 @@ public sealed partial class MainViewModel : ObservableObject
"Copying {Count} user file(s) from v{From} ({SrcProj}) to v{To} ({DstProj}) — savegames+demos", "Copying {Count} user file(s) from v{From} ({SrcProj}) to v{To} ({DstProj}) — savegames+demos",
plan.Count, previous.Version, srcProjectName, newVersion, dstProjectName); plan.Count, previous.Version, srcProjectName, newVersion, dstProjectName);
int copied = 0, skippedExisting = 0; int copied = 0, overwritten = 0;
foreach (var (subDir, srcFile) in plan) foreach (var (subDir, srcFile) in plan)
{ {
ct.ThrowIfCancellationRequested(); ct.ThrowIfCancellationRequested();
@@ -2460,20 +2641,20 @@ public sealed partial class MainViewModel : ObservableObject
var dstFile = Path.Combine(dstSavedRoot, subDir, relPath); var dstFile = Path.Combine(dstSavedRoot, subDir, relPath);
try try
{ {
if (File.Exists(dstFile)) // Mode USER-DATA WINS : on ÉCRASE toujours le fichier cible s'il
{ // existe déjà. Le ZIP peut livrer des defaults (ex :
// Le ZIP livre déjà ce fichier (profil/replay bundlé) — on ne // GeneralSettings.sav avec les réglages usine) mais la version
// l'écrase pas. La version la plus à jour côté installer prime // customisée par l'utilisateur dans l'install précédente prime
// pour ce slot précis. Les autres fichiers (créés par l'opérateur // sinon les réglages custom seraient perdus à chaque upgrade au
// en cours d'utilisation) sont copiés normalement. // profit des defaults bundlés dans le ZIP.
skippedExisting++; bool wasOverwrite = File.Exists(dstFile);
continue;
}
Directory.CreateDirectory(Path.GetDirectoryName(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 src = new FileStream(srcFile, FileMode.Open, FileAccess.Read, FileShare.Read, 81920, useAsync: true))
await using var dst = new FileStream(dstFile, FileMode.CreateNew, FileAccess.Write, FileShare.None, 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); {
copied++; await src.CopyToAsync(dst, ct).ConfigureAwait(false);
}
if (wasOverwrite) overwritten++; else copied++;
} }
catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; }
catch (Exception ex) catch (Exception ex)
@@ -2483,8 +2664,8 @@ public sealed partial class MainViewModel : ObservableObject
} }
_logger.LogInformation( _logger.LogInformation(
"User data copy complete : {Copied} copied, {Skipped} skipped (already in new install)", "User data copy complete : {Copied} new + {Overwritten} overwritten (ZIP defaults replaced by user data)",
copied, skippedExisting); copied, overwritten);
} }
private async Task UninstallVersionAsync(VersionRowViewModel row) private async Task UninstallVersionAsync(VersionRowViewModel row)

View File

@@ -129,6 +129,10 @@ public sealed partial class SettingsViewModel : ObservableObject
public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new(); public System.Collections.ObjectModel.ObservableCollection<string> LanDiscoveredPeers { get; } = new();
public bool HasLanDiscoveredPeers => LanDiscoveredPeers.Count > 0; 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 ----- // ----- Mode auto -----
[ObservableProperty] private bool _autoModeFeatureEnabled; [ObservableProperty] private bool _autoModeFeatureEnabled;
[ObservableProperty] private int _autoModeGraceSeconds; [ObservableProperty] private int _autoModeGraceSeconds;
@@ -276,6 +280,9 @@ public sealed partial class SettingsViewModel : ObservableObject
Environment.NewLine, Environment.NewLine,
config.SteamVr.ProcessesToKill ?? new List<string>()); 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; _autoModeFeatureEnabled = config.AutoMode.FeatureEnabled;
_autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds; _autoModeGraceSeconds = config.AutoMode.GracePeriodSeconds;
_autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty; _autoModeSelectedVersion = config.AutoMode.SelectedVersion ?? string.Empty;
@@ -465,6 +472,15 @@ public sealed partial class SettingsViewModel : ObservableObject
.Distinct(StringComparer.OrdinalIgnoreCase) .Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(); .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.FeatureEnabled = AutoModeFeatureEnabled;
_config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60); _config.AutoMode.GracePeriodSeconds = Math.Clamp(AutoModeGraceSeconds, 1, 60);
_config.AutoMode.SelectedVersion = string.IsNullOrWhiteSpace(AutoModeSelectedVersion) _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 ===================== // ===================== AUTO MODE ARGS =====================
[RelayCommand] [RelayCommand]

View File

@@ -26,6 +26,55 @@ public sealed partial class VersionRowViewModel : ObservableObject
public VersionManifest? Remote { get; } public VersionManifest? Remote { get; }
public bool HasReleaseNotes => Remote?.ReleaseNotesUrl is not null; 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> /// <summary>
/// True si la version est taggée BÊTA dans le manifest (champ /// True si la version est taggée BÊTA dans le manifest (champ
/// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge /// <see cref="VersionManifest.IsBeta"/>). Pilote l'affichage du badge
@@ -197,6 +246,19 @@ public sealed partial class VersionRowViewModel : ObservableObject
Installed = installed; Installed = installed;
Remote = remote; Remote = remote;
_state = initialState; _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 // Les commandes sont câblées par le MainViewModel après instanciation

View File

@@ -96,6 +96,20 @@
FontSize="10" FontWeight="Bold" FontSize="10" FontWeight="Bold"
Foreground="#1A0A00" /> Foreground="#1A0A00" />
</Border> </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" <Border CornerRadius="10" Padding="8,3" Margin="12,0,0,0"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Border.Style> <Border.Style>
@@ -518,6 +532,20 @@
FontSize="11" FontWeight="Bold" FontSize="11" FontWeight="Bold"
Foreground="#1A0A00" /> Foreground="#1A0A00" />
</Border> </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" <Border CornerRadius="12" Padding="10,4" Margin="16,8,0,0"
VerticalAlignment="Center"> VerticalAlignment="Center">
<Border.Style> <Border.Style>

View File

@@ -696,6 +696,68 @@
</StackPanel> </StackPanel>
</Border> </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 --> <!-- Mode auto : auto-launch + auto-relaunch d'une version désignée -->
<Border Background="{StaticResource Brush.Bg.Card}" <Border Background="{StaticResource Brush.Bg.Card}"
BorderBrush="{StaticResource Brush.Border}" BorderThickness="1" BorderBrush="{StaticResource Brush.Border}" BorderThickness="1"

View File

@@ -11,15 +11,26 @@ public interface IInstallationRegistry
/// <summary> /// <summary>
/// Écrit le metadata <c>.proserve-meta.json</c> dans le dossier d'install avec /// É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 /// le nom du .exe à lancer (récupéré du manifest serveur) + l'identifiant
/// <c>MainViewModel</c> après l'extraction du ZIP, pour que le futur <c>Scan</c> /// d'entrée manifest (<paramref name="entryId"/>) pour détecter les
/// trouve le bon exe sans avoir besoin du manifest en mémoire. /// 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> /// </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> /// <summary>Marque les redists comme installés (timestamp UTC) dans la metadata.</summary>
Task MarkRedistInstalledAsync(string installDir, CancellationToken ct); Task MarkRedistInstalledAsync(string installDir, CancellationToken ct);
/// <summary>Vrai si la metadata indique que les redists ont déjà été installés.</summary> /// <summary>Vrai si la metadata indique que les redists ont déjà été installés.</summary>
bool IsRedistInstalled(string installDir); 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 // 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 // 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. // 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 static partial Regex VersionFolderRegex();
private readonly ILogger<InstallationRegistry> _logger; private readonly ILogger<InstallationRegistry> _logger;
@@ -67,7 +76,8 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
FolderPath: dir, FolderPath: dir,
ExecutablePath: exe, ExecutablePath: exe,
InstalledAt: info.CreationTimeUtc, InstalledAt: info.CreationTimeUtc,
SizeBytes: SafeDirectorySize(dir))); SizeBytes: SafeDirectorySize(dir),
EntryId: TryReadEntryId(dir)));
} }
return results return results
@@ -123,14 +133,44 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
/// <summary> /// <summary>
/// Écrit le fichier metadata <c>.proserve-meta.json</c> dans le dossier d'install /// É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, /// 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> /// </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 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 }); 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> /// <summary>
@@ -174,6 +214,14 @@ public sealed partial class InstallationRegistry : IInstallationRegistry
{ {
public string Executable { get; set; } = string.Empty; public string Executable { get; set; } = string.Empty;
public DateTime? RedistInstalledAt { get; set; } 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) => 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. /// 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), /// Retourne null si l'endpoint n'est pas dispo / pas configuré (ancien serveur),
/// auquel cas l'appelant retombe sur l'URL publique du manifest. /// 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> /// </summary>
Task<string?> GetSignedDownloadUrlAsync(string version, CancellationToken ct); Task<string?> GetSignedDownloadUrlAsync(string version, string? expectedFilename, CancellationToken ct);
/// <summary> /// <summary>
/// Décrypte la clé de license stockée en cache (DPAPI CurrentUser). Retourne null /// 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); 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(); var key = GetDecryptedKey();
if (string.IsNullOrEmpty(key)) return null; 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. // 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 encodedKey = Uri.EscapeDataString(key);
var url = TrimSlash(_serverBaseUrlProvider()) + "/download-url/" + version + "?key=" + encodedKey; 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 try
{ {
// Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas // Timeout court : on est dans le hot path d'Install. Si OVH ne répond pas

View File

@@ -317,6 +317,16 @@ public static class Strings
public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار", "Notas de versión", "Versionshinweise"); public static string MsgBoxReleaseNotes => T("Release notes", "Release notes", "版本说明", "บันทึกการเปลี่ยนแปลง", "ملاحظات الإصدار", "Notas de versión", "Versionshinweise");
// ==================== MESSAGEBOX MESSAGES ==================== // ==================== 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( public static string MsgBusy => T(
"Une autre opération est déjà en cours.", "Une autre opération est déjà en cours.",
"Another operation is already in progress.", "Another operation is already in progress.",
@@ -980,6 +990,26 @@ public static class Strings
"هناك نسخة من PROSERVE قيد التشغيل بالفعل. يرفض المشغل بدء نسخة ثانية لتجنب التعارضات (الجلسات، المنافذ، الملفات المقفلة).\n\nأغلق النسخة الحالية قبل بدء أخرى." "هناك نسخة من 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 ---- // ---- Section Settings → Avancés → Mode auto ----
public static string SettingsAutoMode => T("MODE AUTO", "AUTO MODE", "自动模式", "โหมดอัตโนมัติ", "الوضع التلقائي"); public static string SettingsAutoMode => T("MODE AUTO", "AUTO MODE", "自动模式", "โหมดอัตโนมัติ", "الوضع التلقائي");
public static string SettingsAutoModeFeatureEnabled => T( 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. // Whitelist côté client aussi pour éviter des chars exotiques qui casseraient l'URL.
var channel = _channelProvider(); var channel = _channelProvider();
var baseUrl = TrimSlash(_serverBaseUrlProvider()) + "/manifest"; var baseUrl = TrimSlash(_serverBaseUrlProvider()) + "/manifest";
var url = !string.IsNullOrWhiteSpace(channel) && System.Text.RegularExpressions.Regex.IsMatch(channel!, "^[a-z0-9_-]{1,64}$") // Query params : ?channel=X (filtrage par channel license) + &multiChannel=1
? $"{baseUrl}?channel={Uri.EscapeDataString(channel!)}" // (opt-in au comportement v1.0.10+ : le serveur envoie plusieurs entries au
: baseUrl; // 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); using var ovhCts = CancellationTokenSource.CreateLinkedTokenSource(ct);
ovhCts.CancelAfter(OvhFetchTimeoutMs); ovhCts.CancelAfter(OvhFetchTimeoutMs);
using var req = new HttpRequestMessage(HttpMethod.Get, url); using var req = new HttpRequestMessage(HttpMethod.Get, url);

View File

@@ -4,7 +4,14 @@ namespace PSLauncher.Core.Updates;
public interface IUpdateChecker 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( public sealed record UpdateCheckResult(

View File

@@ -21,29 +21,56 @@ public sealed class UpdateChecker : IUpdateChecker
_logger = logger; _logger = logger;
} }
public async Task<UpdateCheckResult> CheckAsync(CancellationToken ct) public async Task<UpdateCheckResult> CheckAsync(bool canSeeBetas, CancellationToken ct)
{ {
try try
{ {
var manifest = await _manifestService.FetchAsync(ct).ConfigureAwait(false); var manifest = await _manifestService.FetchAsync(ct).ConfigureAwait(false);
// On ignore le champ `manifest.Latest` (trop facile à oublier au serveur). // On ignore le champ `manifest.Latest` (trop facile à oublier au serveur).
// On prend toujours la plus haute version SemVer disponible et téléchargeable. // On prend toujours la plus haute version disponible et téléchargeable, en
var latest = manifest.Versions // ordonnant via VersionOrder — ça respecte la règle « non-beta > beta au
.Where(v => v.AvailableForDownload) // même préfixe 3-digit » (ex : 1.5.4 non-beta > 1.5.4.32 beta), sinon
.OrderByDescending(v => SemVer.Parse(v.Version)) // 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(); .FirstOrDefault();
var installed = _registry.Scan(); 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 var latestInstalled = installed
.OrderByDescending(v => SemVer.Parse(v.Version)) .OrderByDescending(v => SemVer.Parse(v.Version))
.FirstOrDefault(); .FirstOrDefault();
var isLatestInstalled = latest is not null && latestInstalled is not null var isLatestInstalled = latest is not null && latestInstalled is not null
&& latestInstalled.Version == latest.Version; && 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 var isNewer = latest is not null
&& (latestInstalled is 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); return new UpdateCheckResult(manifest, latest, latestInstalled, isLatestInstalled, isNewer, null);
} }

View File

@@ -1,8 +1,14 @@
namespace PSLauncher.Models; 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( public sealed record InstalledVersion(
string Version, string Version,
string FolderPath, string FolderPath,
string ExecutablePath, string ExecutablePath,
DateTime InstalledAt, 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 InstallRoot { get; set; } = string.Empty;
public string? LastLaunchedVersion { get; set; } 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> /// <summary>
/// Code de langue de l'UI : "auto" (suit la langue Windows), "fr", "en", /// 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. /// "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 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")] [JsonPropertyName("version")]
public string Version { get; set; } = string.Empty; 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

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 65 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 100 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 173 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

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