Compare commits

...

3 Commits

Author SHA1 Message Date
68cea1eb4d V2 : optimisation API (cache, index, vue materialisee)
- BDD (migrations 1.6.0 + 1.7.0, idempotentes) :
  * 4 nouveaux index composites sur reactevents, triggerevents, sessions
  * vue sessiondebriefs reecrite (sous-requetes correlees -> JOINs)
    puis materialisee dans table session_debriefs_cache via procedure
    stockee rebuild_debrief_cache, recalculee uniquement aux changements
  * fix bug double JOIN participates : TargetRole = "IA" pour les hits
    NPC/objets (au lieu d'un role aleatoire de participant)
- API PHP :
  * connexion PDO en singleton + ATTR_PERSISTENT
  * cache disque par session (cache/sess_<id>/) pour debrief, get, userhistory
    avec invalidation ciblee sur events et lifecycle
  * pagination opt-in (limit/offset) sur lists/sessions_for_user
  * helpers ensure_/rebuild_/invalidate_session_debrief_cache_db
  * debug/benchmark.php pour mesurer la latence des 6 endpoints cles
- Endpoints HTTP : signatures inchangees, compat Unreal preservee
- Gain mesure : -58% a -83% sur les 5 endpoints cles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 16:41:52 +02:00
97b980a05a no message 2026-03-14 10:53:16 +01:00
2b9d532627 V1 : session detail redesign, computed success logic, UI improvements
- Redesign session header: title on top, badge + date/map/scenario below, participants aligned
- Add 3-column layout for standard sessions: KPIs | Global+Personal Stats | BarChart
- FireRange/LongRange: per-variant target sizing (human=320px, longRange=480px)
- Challenge: hide target and objectives, show reaction time chart
- Add TargetVisualization component with SVG hit markers
- Compute success/failed from debrief data (civilKilled, policeKilled, hitsReceived)
- Apply computed success across Dashboard, Sessions list, UserDetail, SessionDetail
- Add useComputedSuccess hook for batch debrief loading
- Unified participant combo box in session header with auto-select for single player
- Dark theme: lighter dropdown arrows, brighter muted text, larger ScoreBadge
- Add i18n keys for new stats labels (FR/EN)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-14 10:52:43 +01:00
41 changed files with 1417 additions and 414 deletions

View File

@@ -18,7 +18,25 @@
"Bash(curl -s -X POST \"http://localhost/proserve/lists/all_sessions.php\" -d \"\")",
"Bash(curl -s -X POST \"http://localhost/proserve/user/get.php\" -d \"userId=3\")",
"Bash(curl -s -X POST \"http://localhost/proserve/stats/userhistory.php\" -d \"userId=3&quickMode=true\")",
"Bash(npx vite build)"
"Bash(npx vite build)",
"Bash(find C:ASTERIONGITPS_ProserveReport -type f \\\\\\(-name *.js -o -name *.jsx -o -name *.ts -o -name *.tsx \\\\\\) ! -path */node_modules/*)",
"Bash(find C:ASTERIONGITPS_ProserveReportPS_Report -type f -name *.jsx -o -name *.js -o -name *.tsx)",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(''''Status:'''', d.get\\(''''status''''\\)\\); stats=d.get\\(''''stats'''',[]\\); print\\(''''Nombre de sessions retournées:'''', len\\(stats\\)\\); [print\\(f'''' ID={s[\"\"id\"\"]} | {s[\"\"scenarioName\"\"]} | type={s[\"\"sessionType\"\"]} | durée={s[\"\"timeToFinish\"\"]}s''''\\) for s in stats]\")",
"Bash(mysql -u root ProserveAPI -e \"SELECT COUNT\\(*\\) as total FROM sessions; SELECT COUNT\\(*\\) as calibration FROM sessions WHERE ScenarioName LIKE ''%Calibration%''; SELECT COUNT\\(*\\) as courtes FROM sessions WHERE timeToFinish <= 10 AND timeToFinish != 0 AND ScenarioName NOT LIKE ''%Calibration%''; SELECT id, ScenarioName, timeToFinish FROM sessions ORDER BY id DESC;\")",
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT COUNT\\(*\\) as total FROM sessions; SELECT COUNT\\(*\\) as calibration FROM sessions WHERE ScenarioName LIKE ''%Calibration%''; SELECT COUNT\\(*\\) as courtes FROM sessions WHERE timeToFinish <= 10 AND timeToFinish != 0 AND ScenarioName NOT LIKE ''%Calibration%''; SELECT id, ScenarioName, timeToFinish FROM sessions ORDER BY id DESC;\")",
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT COUNT\\(*\\) as courtes FROM sessions WHERE timeToFinish <= 10 AND timeToFinish != 0 AND ScenarioName NOT LIKE ''%Calibration%''\")",
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT id, ScenarioName, timeToFinish FROM sessions ORDER BY id DESC\")",
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT SD.id, SD.ScenarioName, SD.timeToFinish FROM sessions SD WHERE SD.ScenarioName NOT LIKE ''%Calibration%'' AND \\(SD.timeToFinish > 10 OR SD.timeToFinish = 0\\) AND SD.id NOT IN \\(SELECT sessionId FROM participates\\) ORDER BY SD.id DESC\")",
"Bash(curl -s -X POST http://localhost/proserve/lists/all_sessions.php -H \"Content-Type: application/x-www-form-urlencoded\" -d \"typeId=-1\")",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); sessions=d.get\\(''''stats'''',[]\\); s=sessions[0] if sessions else {}; print\\(json.dumps\\(s, indent=2\\)\\)\")",
"Bash(curl -s -X POST http://localhost/proserve/stats/get.php -H \"Content-Type: application/x-www-form-urlencoded\" -d \"sessionId=70&sessionType=0\")",
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d, indent=2\\)\\)\")",
"Bash(curl -s -X POST \"http://localhost/proserve/lists/all_sessions.php\" -d \"typeId=-1\")",
"Bash(python -c \"import sys,json; data=json.load\\(sys.stdin\\); print\\(json.dumps\\(data[''''stats''''][0], indent=2\\)\\)\")",
"Bash(curl -s -X POST http://localhost/proserve/lists/all_sessions.php -d typeId=-1)",
"Bash(curl -s -X POST \"http://localhost/proserve/stats/get.php\" -d \"sessionId=73&userId=-1\")",
"Bash(git add:*)",
"Bash(git commit:*)"
]
}
}

BIN
FireRange.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
Nouveau dossier/other.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

BIN
PS_Report/dist/HumanTarget.PNG vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

143
PS_Report/dist/assets/index-Hi0hQkPy.js vendored Normal file

File diff suppressed because one or more lines are too long

View File

@@ -4,8 +4,8 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Proserve Report</title>
<script type="module" crossorigin src="/ProserveReport/assets/index-CSeKVt42.js"></script>
<link rel="stylesheet" crossorigin href="/ProserveReport/assets/index-DLRhV40F.css">
<script type="module" crossorigin src="/ProserveReport/assets/index-Hi0hQkPy.js"></script>
<link rel="stylesheet" crossorigin href="/ProserveReport/assets/index-BJVnQUvh.css">
</head>
<body>
<div id="root"></div>

BIN
PS_Report/dist/longRangeTarget.PNG vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 460 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@@ -9,7 +9,7 @@ interface ScoreBadgeProps {
function ScoreBadge({ success, score }: ScoreBadgeProps) {
const { t } = useI18n();
return (
<Badge bg={success ? 'success' : 'danger'}>
<Badge bg={success ? 'success' : 'danger'} style={{ fontSize: '1rem', padding: '0.5rem 1rem' }}>
{score !== undefined && <span className="me-1">{score}</span>}
{success ? t('badge.success') : t('badge.failed')}
</Badge>

View File

@@ -0,0 +1,80 @@
interface Shot {
index: number;
x: number; // 0-1 normalized
y: number; // 0-1 normalized
precision: number;
}
interface TargetVisualizationProps {
shots: Shot[];
variant?: 'human' | 'longRange';
}
const TARGETS = {
human: { image: 'HumanTarget.png', ratio: 0.7183098591549296, maxW: 320 },
longRange: { image: 'longRangeTarget.PNG', ratio: 1, maxW: 480 },
};
function TargetVisualization({ shots, variant = 'human' }: TargetVisualizationProps) {
const { image, ratio, maxW } = TARGETS[variant];
const VB_H = 100;
const VB_W = VB_H * ratio;
return (
<div style={{
width: '100%',
maxWidth: maxW,
margin: '0 auto',
aspectRatio: `${ratio}`,
}}>
<svg
viewBox={`0 0 ${VB_W} ${VB_H}`}
width="100%"
height="100%"
style={{ display: 'block' }}
>
{/* Background target image — fills the entire viewBox */}
<image
href={`${import.meta.env.BASE_URL}${image}`}
x="0"
y="0"
width={VB_W}
height={VB_H}
preserveAspectRatio="none"
/>
{/* Shot markers */}
{shots.map((shot) => {
const cx = shot.x * VB_W;
const cy = shot.y * VB_H;
return (
<g key={shot.index}>
<circle
cx={cx}
cy={cy}
r={1.2}
fill="#00e5ff"
stroke="#000"
strokeWidth={0.3}
opacity={0.9}
/>
<text
x={cx + 2}
y={cy + 1}
fill="#00e5ff"
fontSize={2.8}
fontWeight="bold"
stroke="#000"
strokeWidth={0.15}
paintOrder="stroke"
>
{shot.index}
</text>
</g>
);
})}
</svg>
</div>
);
}
export default TargetVisualization;

View File

@@ -0,0 +1,60 @@
import { useState, useEffect, useRef } from 'react';
import { getDebrief } from '../api/client';
import type { Session, DebriefRow } from '../types';
/**
* Computes success/failure for a list of sessions based on debrief data.
* A session is failed if any: civilKilled > 0, policeKilled > 0, or hitsReceived > 0.
* Only fetches debriefs for the given sessions (use with paginated/visible sessions).
* Returns a Map<sessionId, computedSuccess>.
*/
export function useComputedSuccess(sessions: Session[]): Map<number, boolean> {
const [successMap, setSuccessMap] = useState<Map<number, boolean>>(new Map());
const fetchedRef = useRef<Set<number>>(new Set());
useEffect(() => {
if (sessions.length === 0) return;
// Only fetch sessions we haven't already fetched
const toFetch = sessions.filter(s => !fetchedRef.current.has(s.id));
if (toFetch.length === 0) return;
Promise.all(
toFetch.map(async (session) => {
try {
const debrief = await getDebrief(session.id);
return { id: session.id, success: computeSuccess(session.success, debrief) };
} catch {
return { id: session.id, success: session.success };
}
}),
).then((results) => {
setSuccessMap(prev => {
const next = new Map(prev);
for (const r of results) {
next.set(r.id, r.success);
fetchedRef.current.add(r.id);
}
return next;
});
});
}, [sessions]);
return successMap;
}
/** Compute success from debrief rows */
export function computeSuccess(originalSuccess: boolean, debrief: DebriefRow[]): boolean {
const totalCivilKilled = debrief.reduce((s, r) => s + (Number(r.totalCivilKilled) || 0), 0);
const totalPoliceKilled = debrief.reduce((s, r) => s + (Number(r.totalPoliceKilled) || 0), 0);
const totalHitsReceived = debrief.reduce(
(s, r) => s + (Number(r.nbReceivedHitsFromEnemyIA) || 0) + (Number(r.nbReceivedHitsFromEnemyUser) || 0) + (Number(r.nbReceivedHitsFromPoliceUser) || 0),
0,
);
return originalSuccess && totalCivilKilled === 0 && totalPoliceKilled === 0 && totalHitsReceived === 0;
}
/** Helper: get computed success for a single session from the map, fallback to session.success */
export function getComputedSuccess(successMap: Map<number, boolean>, session: Session): boolean {
return successMap.has(session.id) ? successMap.get(session.id)! : session.success;
}

View File

@@ -40,6 +40,13 @@ export const translations = {
'session.objectives': { fr: 'Objectifs', en: 'Objectives' },
'session.hitDistribution': { fr: 'Répartition des impacts', en: 'Hit Distribution' },
'session.shotDetails': { fr: 'Détail des tirs', en: 'Shot Details' },
'session.global': { fr: 'Global', en: 'Global' },
'session.personalStats': { fr: 'Statistiques personnelles', en: 'Personal Statistics' },
'session.globalStats': { fr: 'Statistiques globales', en: 'Global Statistics' },
'session.friendlyFire': { fr: 'Tirs amis', en: 'Friendly Fire' },
'session.hitsReceived': { fr: 'Tirs reçus', en: 'Hits Received' },
'session.shotsInSession': { fr: 'Tirs durant la session', en: 'Shots In Session' },
'session.missed': { fr: 'Manqués', en: 'Missed' },
// Table headers
'table.date': { fr: 'Date', en: 'Date' },
@@ -156,6 +163,17 @@ export const translations = {
'badge.failed': { fr: 'Échoué', en: 'Failed' },
'badge.killed': { fr: 'Tué', en: 'Killed' },
// FireRange detail
'firerange.personalStats': { fr: 'Statistiques personnelles', en: 'Personal Statistics' },
'firerange.shotsFired': { fr: 'Tirs effectués', en: 'Shots Fired' },
'firerange.shotsMissed': { fr: 'Tirs manqués', en: 'Missed Shots' },
'firerange.avgPrecision': { fr: 'Précision moyenne', en: 'Average Precision' },
'firerange.precisionChart': { fr: 'Précision des tirs durant la session', en: 'Shots Precision During Session' },
'firerange.targetView': { fr: 'Impacts sur la cible', en: 'Target Hits' },
'firerange.targetsHit': { fr: 'Cibles touchées', en: 'Targets Hit' },
'firerange.avgReaction': { fr: 'Réaction moyenne', en: 'Avg. Reaction Time' },
'firerange.reactionChart': { fr: 'Temps de réaction durant la session', en: 'Reaction Time During Session' },
// Charts
'chart.sessions': { fr: 'Sessions', en: 'Sessions' },
'chart.precision': { fr: 'Précision', en: 'Precision' },

View File

@@ -6,6 +6,7 @@ import Card from 'react-bootstrap/Card';
import Table from 'react-bootstrap/Table';
import { getAllSessions, getAllUsers } from '../api/client';
import type { Session, User } from '../types';
import { useComputedSuccess, getComputedSuccess } from '../hooks/useComputedSuccess';
import StatCard from '../components/StatCard';
import ScoreBadge from '../components/ScoreBadge';
import SessionTypeBadge from '../components/SessionTypeBadge';
@@ -42,12 +43,14 @@ function Dashboard() {
.finally(() => setLoading(false));
}, []);
const successMap = useComputedSuccess(sessions);
if (loading) return <LoadingSpinner />;
const totalSessions = sessions.length;
const totalUsers = users.length;
const successRate = totalSessions > 0
? Math.round((sessions.filter((s) => s.success).length / totalSessions) * 100)
? Math.round((sessions.filter((s) => getComputedSuccess(successMap, s)).length / totalSessions) * 100)
: 0;
const avgPrecision = users.length > 0
? Math.round(users.reduce((acc, u) => acc + u.avgPrecision, 0) / users.length * 100) / 100
@@ -118,7 +121,7 @@ function Dashboard() {
<td>{session.scenarioName || '-'}</td>
<td>{session.score}</td>
<td>{formatDuration(session.timeToFinish)}</td>
<td><ScoreBadge success={session.success} /></td>
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td>
</tr>
))}
</tbody>

View File

@@ -3,20 +3,22 @@ import { useParams, Link } from 'react-router-dom';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Card from 'react-bootstrap/Card';
import Table from 'react-bootstrap/Table';
import Badge from 'react-bootstrap/Badge';
import Form from 'react-bootstrap/Form';
import ProgressBar from 'react-bootstrap/ProgressBar';
import { getSession, getUsersInSession, getDebrief, getObjectives, getSessionStats } from '../api/client';
import type { Session, User, DebriefRow, Participation, ObjectiveResults, SessionDebriefRow } from '../types';
import { REACT_EVENT_TYPE_LABELS, SessionType } from '../types';
import { SessionType } from '../types';
import SessionTypeBadge from '../components/SessionTypeBadge';
import ScoreBadge from '../components/ScoreBadge';
import StatCard from '../components/StatCard';
import LoadingSpinner from '../components/LoadingSpinner';
import PrintHeader from '../components/PrintHeader';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { Tooltip, ResponsiveContainer, Legend, LineChart, Line, XAxis, YAxis, CartesianGrid, BarChart, Bar } from 'recharts';
import { useI18n } from '../i18n/context';
import type { TranslationKey } from '../i18n/translations';
import TargetVisualization from '../components/TargetVisualization';
import { computeSuccess } from '../hooks/useComputedSuccess';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
@@ -30,37 +32,6 @@ function formatDate(dateStr: string, lang: string): string {
return d.toLocaleDateString(lang === 'fr' ? 'fr-FR' : 'en-US', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
const HIT_TYPE_COLORS: Record<string, string> = {
'enemy': '#27ae60',
'civilian': '#e74c3c',
'police': '#f39c12',
'object': '#6c757d',
'target': '#4a90d9',
'paperTarget': '#2980b9',
'deadBody': '#95a5a6',
};
/** Maps French REACT_EVENT_TYPE_LABELS values to internal hit-type keys */
const FRENCH_LABEL_TO_HIT_KEY: Record<string, string> = {
'Ennemi': 'enemy',
'Civil': 'civilian',
'Police': 'police',
'Objet': 'object',
'Cible': 'target',
'Cible Papier': 'paperTarget',
'Corps': 'deadBody',
};
/** Maps internal hit-type keys to TranslationKey */
const HIT_KEY_TO_TRANSLATION: Record<string, TranslationKey> = {
'enemy': 'hitType.enemy',
'civilian': 'hitType.civilian',
'police': 'hitType.police',
'object': 'hitType.object',
'target': 'hitType.target',
'paperTarget': 'hitType.paperTarget',
'deadBody': 'hitType.deadBody',
};
function SessionDetail() {
const { id } = useParams<{ id: string }>();
@@ -73,6 +44,7 @@ function SessionDetail() {
const [objectives, setObjectives] = useState<Participation | null>(null);
const [shotDetails, setShotDetails] = useState<SessionDebriefRow[]>([]);
const [loading, setLoading] = useState(true);
const [selectedUserId, setSelectedUserId] = useState<number>(-1);
useEffect(() => {
if (!sessionId) return;
@@ -85,16 +57,15 @@ function SessionDetail() {
])
.then(([sessionData, usersData, debriefData, objectivesData]) => {
setSession(sessionData as Session | null);
setUsers(usersData as User[]);
const uList = usersData as User[];
setUsers(uList);
if (uList.length === 1) setSelectedUserId(uList[0].id);
setDebrief(debriefData as DebriefRow[]);
setObjectives(objectivesData as Participation | null);
// Load shot details for firerange/challenge types
// Load shot details for all session types
if (sessionData) {
const st = sessionData.sessionTypeAsInt;
if (st === SessionType.FireRange || st === SessionType.Challenge || st === SessionType.LongRange) {
getSessionStats(sessionId, -1, st).then(setShotDetails);
}
getSessionStats(sessionId, -1, sessionData.sessionTypeAsInt).then(setShotDetails);
}
})
.finally(() => setLoading(false));
@@ -113,23 +84,16 @@ function SessionDetail() {
}
}
// Build hit distribution data from debrief
const hitDistribution: Record<string, number> = {};
debrief.forEach((row) => {
if (row.nbEnemyHitsByUser > 0) hitDistribution['enemy'] = (hitDistribution['enemy'] || 0) + row.nbEnemyHitsByUser;
if (row.nbCivilHitsByUser > 0) hitDistribution['civilian'] = (hitDistribution['civilian'] || 0) + row.nbCivilHitsByUser;
if (row.nbPoliceHitsByUser > 0) hitDistribution['police'] = (hitDistribution['police'] || 0) + row.nbPoliceHitsByUser;
if (row.nbObjectHitsByUser > 0) hitDistribution['object'] = (hitDistribution['object'] || 0) + row.nbObjectHitsByUser;
});
const hitChartData = Object.entries(hitDistribution).map(([key, value]) => ({
name: t(HIT_KEY_TO_TRANSLATION[key]),
value,
color: HIT_TYPE_COLORS[key] || '#6c757d',
}));
// Map userId to username
const userMap = new Map(users.map((u) => [u.id, u]));
const isFireRange = session.sessionTypeAsInt === SessionType.FireRange
|| session.sessionTypeAsInt === SessionType.Challenge
|| session.sessionTypeAsInt === SessionType.LongRange;
const isChallenge = session.sessionTypeAsInt === SessionType.Challenge;
const computedSuccess = computeSuccess(session.success, debrief);
return (
<>
<PrintHeader subtitle={session.sessionName || session.scenarioName || `Session #${session.id}`} />
@@ -144,101 +108,315 @@ function SessionDetail() {
{/* Session Header */}
<Card className="data-card mb-4">
<Card.Body>
<Row className="align-items-center">
<Row className="align-items-start">
<Col>
<h3 className="mb-1">
<h3 className="mb-2">
{session.sessionName || session.scenarioName || `Session #${session.id}`}
</h3>
<div className="d-flex gap-3 align-items-center flex-wrap">
<div className="d-flex align-items-center gap-3">
<SessionTypeBadge typeId={session.sessionTypeAsInt} />
<span className="text-muted-custom">{formatDate(session.sessionDateAsString, lang)}</span>
<span className="text-muted-custom">Map: {session.mapName || '-'}</span>
<span className="text-muted-custom">Scenario: {session.scenarioName || '-'}</span>
<div>
<div className="text-muted-custom">{formatDate(session.sessionDateAsString, lang)} Map: {session.mapName || '-'}</div>
<div className="text-muted-custom">Scenario: {session.scenarioName || '-'}</div>
</div>
</div>
</Col>
<Col xs="auto">
<ScoreBadge success={session.success} score={session.score} />
{users.length > 0 && (
<Col xs="auto" style={{ minWidth: 200 }}>
<h3 className="mb-2">{t('session.participants')}</h3>
<Form.Select
size="sm"
value={selectedUserId}
onChange={e => setSelectedUserId(Number(e.target.value))}
style={{ backgroundColor: '#1a1a2e', color: '#e0e0e0', borderColor: '#333' }}
>
<option value={-1}>{t('session.global')}</option>
{users.map(u => (
<option key={u.id} value={u.id}>
{u.firstName && u.lastName ? `${u.firstName} ${u.lastName}` : u.username}
</option>
))}
</Form.Select>
</Col>
)}
<Col className="flex-grow-1" />
<Col xs="auto" className="align-self-center">
<ScoreBadge success={computedSuccess} />
</Col>
</Row>
</Card.Body>
</Card>
{/* KPI Cards */}
<Row className="mb-4 g-3">
<Col xs={6} md={2}>
<StatCard title={t('session.score')} value={session.score} color="#4a90d9" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.duration')} value={formatDuration(session.timeToFinish)} color="#9b59b6" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.enemiesHit')} value={session.nbEnemyHit} color="#27ae60" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.civiliansHit')} value={session.nbCivilsHit} color={session.nbCivilsHit > 0 ? '#e74c3c' : '#27ae60'} />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.damageTaken')} value={Math.round(session.damageTaken)} color="#f39c12" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.participants')} value={users.length} color="#1abc9c" />
</Col>
</Row>
{/* Standard sessions: stats left + bar chart right */}
{!isFireRange && (() => {
// Compute filtered debrief stats
const filteredDebrief = selectedUserId === -1
? debrief
: debrief.filter(r => r.userId === selectedUserId);
const sumField = (field: keyof DebriefRow) =>
filteredDebrief.reduce((sum, r) => sum + (Number(r[field]) || 0), 0);
const avgPrecision = filteredDebrief.length > 0
? filteredDebrief.reduce((sum, r) => sum + (r.averagePrecision || 0), 0) / filteredDebrief.length
: 0;
{/* Participants */}
<Card className="data-card mb-4">
<Card.Body>
<Card.Title>{t('session.participants')}</Card.Title>
<Table hover responsive className="data-table mb-0">
<thead>
<tr>
<th>{t('table.user')}</th>
<th>{t('table.shotsFired')}</th>
<th>{t('table.shotsMissed')}</th>
<th>{t('table.enemiesHit')}</th>
<th>{t('table.civiliansHit')}</th>
<th>{t('table.avgPrecision')}</th>
<th>{t('table.reactionTime')}</th>
<th>{t('table.hitsReceivedIA')}</th>
<th>{t('table.enemiesKilled')}</th>
<th>{t('table.civiliansKilled')}</th>
</tr>
</thead>
<tbody>
{debrief.map((row, i) => {
const user = userMap.get(row.userId);
return (
<tr key={i}>
<td>
<Link to={`/users/${row.userId}`} className="table-link">
{user ? (user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : user.username) : `User #${row.userId}`}
</Link>
</td>
<td>{row.nbFiredShotsByUser}</td>
<td>{row.nbMissedShotsByUser}</td>
<td>{row.nbEnemyHitsByUser}</td>
<td className={row.nbCivilHitsByUser > 0 ? 'text-danger' : ''}>{row.nbCivilHitsByUser}</td>
<td>{row.averagePrecision != null ? `${(row.averagePrecision * 100).toFixed(1)}%` : '-'}</td>
<td>{row.averageReactionTime != null && row.averageReactionTime > 0 ? `${row.averageReactionTime.toFixed(0)} ms` : '-'}</td>
<td>{row.nbReceivedHitsFromEnemyIA ?? 0}</td>
<td>{row.totalEnemyKilled ?? 0}</td>
<td className={(row.totalCivilKilled ?? 0) > 0 ? 'text-danger' : ''}>{row.totalCivilKilled ?? 0}</td>
</tr>
);
})}
{debrief.length === 0 && (
<tr><td colSpan={10} className="text-center text-muted-custom">{t('noData')}</td></tr>
)}
</tbody>
</Table>
</Card.Body>
</Card>
// Bar chart data: per participant
const barData = debrief.map(row => {
const user = userMap.get(row.userId);
const name = user ? (user.firstName || user.username) : `#${row.userId}`;
return {
name,
[t('hitType.enemy')]: row.nbEnemyHitsByUser,
[t('hitType.civilian')]: row.nbCivilHitsByUser,
[t('hitType.police')]: row.nbPoliceHitsByUser,
[t('session.missed')]: row.nbMissedShotsByUser,
};
});
<Row className="mb-4 g-3">
{/* Objectives */}
{parsedObjectives && (
<Col md={6}>
<Card className="data-card h-100">
return (
<Row className="mb-4 g-3 align-items-stretch">
{/* Col 1: KPIs */}
<Col lg={2} className="d-flex">
<div className="d-flex flex-column justify-content-between flex-grow-1" style={{ gap: '0.5rem' }}>
<Card className="data-card flex-grow-1">
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('session.duration')}</div>
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{formatDuration(session.timeToFinish)}</div>
</Card.Body>
</Card>
<Card className="data-card flex-grow-1">
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('firerange.shotsFired')}</div>
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{sumField('nbFiredShotsByUser')}</div>
</Card.Body>
</Card>
<Card className="data-card flex-grow-1">
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('firerange.shotsMissed')}</div>
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{sumField('nbMissedShotsByUser')}</div>
</Card.Body>
</Card>
<Card className="data-card flex-grow-1">
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('firerange.avgPrecision')}</div>
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{avgPrecision > 0 ? `${(avgPrecision * 100).toFixed(2)}%` : '-'}</div>
</Card.Body>
</Card>
</div>
</Col>
{/* Col 2: Global Stats + Personal Stats */}
<Col lg={3} className="d-flex">
<div className="d-flex flex-column justify-content-between flex-grow-1">
<Card className="data-card mb-2 flex-grow-1">
<Card.Body className="py-2 px-3">
<h6 className="mb-2" style={{ fontSize: '0.95rem' }}>{t('session.globalStats')}</h6>
<table className="w-100" style={{ fontSize: '0.9rem' }}>
<tbody>
<tr><td className="py-1" style={{ color: '#27ae60' }}>{t('stats.enemiesKilled')}</td><td className="text-end fw-bold">{sumField('totalEnemyKilled')}</td></tr>
<tr><td className="py-1" style={{ color: '#e74c3c' }}>{t('stats.civiliansKilled')}</td><td className="text-end fw-bold">{sumField('totalCivilKilled')}</td></tr>
<tr><td className="py-1" style={{ color: '#1abc9c' }}>{t('stats.policeKilled')}</td><td className="text-end fw-bold">{sumField('totalPoliceKilled')}</td></tr>
<tr><td className="py-1" style={{ color: '#f39c12' }}>{t('session.hitsReceived')}</td><td className="text-end fw-bold">{sumField('nbReceivedHitsFromEnemyIA') + sumField('nbReceivedHitsFromEnemyUser') + sumField('nbReceivedHitsFromPoliceUser')}</td></tr>
</tbody>
</table>
</Card.Body>
</Card>
<Card className="data-card flex-grow-1">
<Card.Body className="py-2 px-3">
<h6 className="mb-2" style={{ fontSize: '0.95rem' }}>{t('session.personalStats')}</h6>
<table className="w-100" style={{ fontSize: '0.9rem' }}>
<tbody>
<tr><td className="py-1" style={{ color: '#27ae60' }}>{t('session.enemiesHit')}</td><td className="text-end fw-bold">{sumField('nbEnemyHitsByUser')}</td></tr>
<tr><td className="py-1" style={{ color: '#e74c3c' }}>{t('session.civiliansHit')}</td><td className="text-end fw-bold">{sumField('nbCivilHitsByUser')}</td></tr>
<tr><td className="py-1" style={{ color: '#1abc9c' }}>{t('session.friendlyFire')}</td><td className="text-end fw-bold">{sumField('nbPoliceHitsByUser')}</td></tr>
<tr><td className="py-1" style={{ color: '#f39c12' }}>{t('session.hitsReceived')}</td><td className="text-end fw-bold">{sumField('nbReceivedHitsFromEnemyIA') + sumField('nbReceivedHitsFromEnemyUser') + sumField('nbReceivedHitsFromPoliceUser')}</td></tr>
</tbody>
</table>
</Card.Body>
</Card>
</div>
</Col>
{/* Col 3: Bar chart */}
<Col lg={7} className="d-flex">
<Card className="data-card flex-grow-1">
<Card.Body className="d-flex flex-column">
<Card.Title className="text-center">{t('session.shotsInSession')}</Card.Title>
<div className="d-flex flex-grow-1 align-items-center">
<ResponsiveContainer width="100%" height={350}>
<BarChart data={barData} margin={{ top: 10, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="name" stroke="#999" tick={false} />
<YAxis stroke="#999" />
<Tooltip contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }} />
<Legend />
<Bar dataKey={t('hitType.enemy')} fill="#ff6b8a" />
<Bar dataKey={t('hitType.civilian')} fill="#f39c12" />
<Bar dataKey={t('hitType.police')} fill="#1abc9c" />
<Bar dataKey={t('session.missed')} fill="#ff9ec4" />
</BarChart>
</ResponsiveContainer>
</div>
</Card.Body>
</Card>
</Col>
</Row>
);
})()}
{/* FireRange / Challenge / LongRange - KPIs left + Chart/Target right */}
{isFireRange && shotDetails.length > 0 && (() => {
// Handle both PascalCase (API) and camelCase (TS interface) property names
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const getVal = (shot: any, ...keys: string[]): number => {
for (const k of keys) { if (shot[k] != null) return shot[k] as number; }
return 0;
};
const hitsOnly = shotDetails.filter(s => getVal(s, 'ReactId', 'reactId') >= 0);
const missedCount = shotDetails.filter(s => getVal(s, 'ReactId', 'reactId') < 0).length;
const avgPrecisionStr = (() => {
if (hitsOnly.length === 0) return '-';
const avg = hitsOnly.reduce((sum, s) => sum + getVal(s, 'HitPrecision', 'hitPrecision'), 0) / hitsOnly.length;
return `${(avg * 100).toFixed(1)}%`;
})();
const avgReactionStr = (() => {
const withReaction = shotDetails.filter(s => getVal(s, 'ReactionTime', 'reactionTime') > 0);
if (withReaction.length === 0) return '-';
const avg = withReaction.reduce((sum, s) => sum + getVal(s, 'ReactionTime', 'reactionTime'), 0) / withReaction.length;
return `${(avg / 1000).toFixed(3)} s`;
})();
// Precision chart data
const precisionData = shotDetails.map(s => ({
shot: getVal(s, 'ShotIndex', 'shotIndex'),
precision: getVal(s, 'ReactId', 'reactId') >= 0
? Math.round(getVal(s, 'HitPrecision', 'hitPrecision') * 100)
: 0,
}));
// Reaction time chart data (for Challenge)
const reactionData = hitsOnly
.filter(s => getVal(s, 'ReactionTime', 'reactionTime') > 0)
.map(s => ({
shot: getVal(s, 'ShotIndex', 'shotIndex'),
reaction: Math.round(getVal(s, 'ReactionTime', 'reactionTime')),
}));
// Target hit positions (only shots that hit)
const targetShots = hitsOnly.map(s => ({
index: getVal(s, 'ShotIndex', 'shotIndex'),
x: getVal(s, 'HitLocationX', 'hitLocationX'),
y: getVal(s, 'HitLocationY', 'hitLocationY'),
precision: getVal(s, 'HitPrecision', 'hitPrecision'),
}));
return (
<Row className="mb-4 g-3">
{/* Left: KPI cards vertical */}
<Col lg={3}>
<Row className="g-3">
<Col xs={6} lg={isChallenge ? 6 : 12}>
<StatCard title={t('session.duration')} value={formatDuration(session.timeToFinish)} color="#9b59b6" />
</Col>
<Col xs={6} lg={isChallenge ? 6 : 12}>
<StatCard title={t('firerange.shotsFired')} value={shotDetails.length} color="#1abc9c" />
</Col>
{isChallenge && (
<Col xs={6} lg={6}>
<StatCard title={t('firerange.targetsHit')} value={hitsOnly.length} color="#4a90d9" />
</Col>
)}
<Col xs={6} lg={isChallenge ? 6 : 12}>
<StatCard title={t('firerange.shotsMissed')} value={missedCount} color="#e74c3c" />
</Col>
<Col xs={6} lg={isChallenge ? 6 : 12}>
<StatCard title={t('firerange.avgPrecision')} value={avgPrecisionStr} color="#27ae60" />
</Col>
{isChallenge && (
<Col xs={6} lg={6}>
<StatCard title={t('firerange.avgReaction')} value={avgReactionStr} color="#f39c12" />
</Col>
)}
</Row>
</Col>
{/* Middle: Target (not for Challenge) */}
{!isChallenge && (
<Col lg={4}>
<Card className="data-card h-100">
<Card.Body className="d-flex flex-column">
<Card.Title className="text-center">{t('firerange.targetView')}</Card.Title>
<div className="d-flex flex-grow-1 align-items-center justify-content-center">
<TargetVisualization shots={targetShots} variant={session.sessionTypeAsInt === SessionType.LongRange ? 'longRange' : 'human'} />
</div>
</Card.Body>
</Card>
</Col>
)}
{/* Right: Chart */}
<Col lg={isChallenge ? 9 : 5}>
<Card className="data-card h-100">
<Card.Body className="d-flex flex-column">
<Card.Title className="text-center">
{isChallenge ? t('firerange.reactionChart') : t('firerange.precisionChart')}
</Card.Title>
<div className="d-flex flex-grow-1 align-items-center">
{isChallenge ? (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={reactionData} margin={{ top: 10, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="shot" stroke="#999" />
<YAxis stroke="#999" width={50} tickFormatter={v => `${v}`} />
<Tooltip
contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }}
formatter={(value: number) => [`${value} ms`, t('chart.reactionTime')]}
/>
<Line
type="linear"
dataKey="reaction"
stroke="#f39c12"
strokeWidth={2}
dot={{ fill: '#f39c12', r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
) : (
<ResponsiveContainer width="100%" height={250}>
<LineChart data={precisionData} margin={{ top: 10, right: 20, left: 10, bottom: 5 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
<XAxis dataKey="shot" stroke="#999" />
<YAxis domain={[0, 100]} stroke="#999" width={50} tickFormatter={v => `${v}`} />
<Tooltip
contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }}
formatter={(value: number) => [`${value}%`, t('chart.precision')]}
/>
<Line
type="linear"
dataKey="precision"
stroke="#ff6b8a"
strokeWidth={2}
dot={{ fill: '#ff6b8a', r: 4 }}
activeDot={{ r: 6 }}
/>
</LineChart>
</ResponsiveContainer>
)}
</div>
</Card.Body>
</Card>
</Col>
</Row>
);
})()}
{/* Objectives (hidden for FireRange/Challenge) */}
{parsedObjectives && !isFireRange && (
<Row className="mb-4 g-3">
<Col md={12}>
<Card className="data-card">
<Card.Body>
<Card.Title>{t('session.objectives')}</Card.Title>
{Object.entries(parsedObjectives).map(([key, obj]) => {
@@ -268,82 +446,7 @@ function SessionDetail() {
</Card.Body>
</Card>
</Col>
)}
{/* Hit Distribution Chart */}
{hitChartData.length > 0 && (
<Col md={parsedObjectives ? 6 : 12}>
<Card className="chart-card h-100">
<Card.Body>
<Card.Title>{t('session.hitDistribution')}</Card.Title>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={hitChartData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label>
{hitChartData.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Pie>
<Tooltip contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }} />
<Legend />
</PieChart>
</ResponsiveContainer>
</Card.Body>
</Card>
</Col>
)}
</Row>
{/* Shot Details for FireRange/Challenge */}
{shotDetails.length > 0 && (
<Card className="data-card mb-4">
<Card.Body>
<Card.Title>{t('session.shotDetails')}</Card.Title>
<Table hover responsive className="data-table mb-0" size="sm">
<thead>
<tr>
<th>{t('shot.index')}</th>
<th>{t('shot.shooter')}</th>
<th>{t('shot.impactType')}</th>
<th>{t('shot.target')}</th>
<th>{t('shot.boneZone')}</th>
<th>{t('shot.precision')}</th>
<th>{t('shot.distance')}</th>
<th>{t('shot.reaction')}</th>
<th>{t('shot.killed')}</th>
<th>{t('shot.time')}</th>
</tr>
</thead>
<tbody>
{shotDetails.map((shot, i) => {
const frenchLabel = REACT_EVENT_TYPE_LABELS[shot.reactTypeId] || '';
const hitKey = FRENCH_LABEL_TO_HIT_KEY[frenchLabel] || '';
const translatedLabel = hitKey && HIT_KEY_TO_TRANSLATION[hitKey]
? t(HIT_KEY_TO_TRANSLATION[hitKey])
: shot.reactType || '-';
const badgeColor = HIT_TYPE_COLORS[hitKey] || '#6c757d';
return (
<tr key={i}>
<td>{shot.shotIndex}</td>
<td>{shot.shooterName || `#${shot.shooterId}`}</td>
<td>
<Badge bg="secondary" style={{ backgroundColor: badgeColor }}>
{translatedLabel}
</Badge>
</td>
<td>{shot.targetName || shot.targetUserName || '-'}</td>
<td>{shot.targetBoneName || shot.hitLocationTag || '-'}</td>
<td>{shot.hitPrecision != null ? `${(shot.hitPrecision * 100).toFixed(1)}%` : '-'}</td>
<td>{shot.hitTargetDistance != null && shot.hitTargetDistance > 0 ? `${shot.hitTargetDistance.toFixed(1)}m` : '-'}</td>
<td>{shot.reactionTime != null && shot.reactionTime > 0 ? `${shot.reactionTime.toFixed(0)}ms` : '-'}</td>
<td>{shot.targetKilled ? <Badge bg="danger">{t('badge.killed')}</Badge> : '-'}</td>
<td>{shot.timeStamp != null ? `${shot.timeStamp.toFixed(1)}s` : '-'}</td>
</tr>
);
})}
</tbody>
</Table>
</Card.Body>
</Card>
</Row>
)}
</>
);

View File

@@ -8,6 +8,7 @@ import Form from 'react-bootstrap/Form';
import Pagination from 'react-bootstrap/Pagination';
import { getAllSessions } from '../api/client';
import type { Session } from '../types';
import { useComputedSuccess, getComputedSuccess } from '../hooks/useComputedSuccess';
import SessionTypeBadge from '../components/SessionTypeBadge';
import ScoreBadge from '../components/ScoreBadge';
import LoadingSpinner from '../components/LoadingSpinner';
@@ -50,11 +51,13 @@ function Sessions() {
getAllSessions().then(setSessions).finally(() => setLoading(false));
}, []);
const successMap = useComputedSuccess(sessions);
const filtered = useMemo(() => {
let result = sessions;
if (typeFilter >= 0) result = result.filter((s) => s.sessionTypeAsInt === typeFilter);
if (successFilter === 'success') result = result.filter((s) => s.success);
else if (successFilter === 'failed') result = result.filter((s) => !s.success);
if (successFilter === 'success') result = result.filter((s) => getComputedSuccess(successMap, s));
else if (successFilter === 'failed') result = result.filter((s) => !getComputedSuccess(successMap, s));
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter((s) => s.scenarioName.toLowerCase().includes(q) || s.mapName.toLowerCase().includes(q) || s.sessionName.toLowerCase().includes(q));
@@ -64,7 +67,7 @@ function Sessions() {
if (typeof aVal === 'string' && typeof bVal === 'string') return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
return sortDir === 'asc' ? Number(aVal) - Number(bVal) : Number(bVal) - Number(aVal);
});
}, [sessions, typeFilter, successFilter, searchQuery, sortKey, sortDir]);
}, [sessions, successMap, typeFilter, successFilter, searchQuery, sortKey, sortDir]);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
@@ -155,7 +158,7 @@ function Sessions() {
<td>{session.nbEnemyHit}</td>
<td className={session.nbCivilsHit > 0 ? 'text-danger' : ''}>{session.nbCivilsHit}</td>
<td>{formatDuration(session.timeToFinish)}</td>
<td><ScoreBadge success={session.success} /></td>
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td>
</tr>
))}
{paginated.length === 0 && (

View File

@@ -6,6 +6,7 @@ import Card from 'react-bootstrap/Card';
import Table from 'react-bootstrap/Table';
import { getUser, getSessionsForUser, getUserHistory } from '../api/client';
import type { User, Session, UserGlobalStats } from '../types';
import { useComputedSuccess, getComputedSuccess } from '../hooks/useComputedSuccess';
import StatCard from '../components/StatCard';
import SessionTypeBadge from '../components/SessionTypeBadge';
import ScoreBadge from '../components/ScoreBadge';
@@ -38,6 +39,7 @@ function UserDetail() {
const [sessions, setSessions] = useState<Session[]>([]);
const [history, setHistory] = useState<UserGlobalStats | null>(null);
const [loading, setLoading] = useState(true);
const successMap = useComputedSuccess(sessions);
useEffect(() => {
if (!userId) return;
@@ -229,7 +231,7 @@ function UserDetail() {
<td>{session.scenarioName || '-'}</td>
<td className="fw-bold">{session.score}</td>
<td>{formatDuration(session.timeToFinish)}</td>
<td><ScoreBadge success={session.success} /></td>
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td>
</tr>
))}
{sessions.length === 0 && (

View File

@@ -18,7 +18,7 @@
--bg-hover: #252545;
--text-primary: #e0e0e0;
--text-secondary: #a0a0b0;
--text-muted: #6c6c80;
--text-muted: #9090a8;
--accent-blue: #4a90d9;
--accent-orange: #f39c12;
--accent-green: #27ae60;
@@ -233,6 +233,10 @@ h2, h3 {
color: var(--text-primary) !important;
}
.form-select {
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23a0a0b0' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
}
.form-label {
color: var(--text-secondary) !important;
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout.tsx","./src/components/loadingspinner.tsx","./src/components/navbar.tsx","./src/components/printheader.tsx","./src/components/scorebadge.tsx","./src/components/sessiontypebadge.tsx","./src/components/statcard.tsx","./src/components/charts/activitychart.tsx","./src/components/charts/precisionchart.tsx","./src/components/charts/sessionsbytypechart.tsx","./src/i18n/context.tsx","./src/i18n/translations.ts","./src/pages/dashboard.tsx","./src/pages/sessiondetail.tsx","./src/pages/sessions.tsx","./src/pages/userdetail.tsx","./src/pages/users.tsx","./src/types/index.ts"],"version":"5.6.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout.tsx","./src/components/loadingspinner.tsx","./src/components/navbar.tsx","./src/components/printheader.tsx","./src/components/scorebadge.tsx","./src/components/sessiontypebadge.tsx","./src/components/statcard.tsx","./src/components/targetvisualization.tsx","./src/components/charts/activitychart.tsx","./src/components/charts/precisionchart.tsx","./src/components/charts/sessionsbytypechart.tsx","./src/hooks/usecomputedsuccess.ts","./src/i18n/context.tsx","./src/i18n/translations.ts","./src/pages/dashboard.tsx","./src/pages/sessiondetail.tsx","./src/pages/sessions.tsx","./src/pages/userdetail.tsx","./src/pages/users.tsx","./src/types/index.ts"],"version":"5.6.3"}

View File

@@ -0,0 +1,3 @@
*
!.gitignore
!.htaccess

View File

@@ -0,0 +1,4 @@
Options -Indexes
<FilesMatch "\.json$">
Require all denied
</FilesMatch>

View File

@@ -0,0 +1,109 @@
<?php
// Cache disque pour resultats lourds et immuables (debrief, userhistory, get session).
// Chaque session a son propre sous-dossier cache/sess_<id>/ : invalider une session
// se reduit a supprimer le dossier, sans risque de match partiel sur d'autres ids.
// Toutes les fonctions sont silencieuses en cas d'echec (best-effort, jamais bloquant).
if (!defined('PROSERVE_CACHE_DIR'))
define('PROSERVE_CACHE_DIR', __DIR__ . '/../cache');
function cache_session_dir($sessionId)
{
return PROSERVE_CACHE_DIR . '/sess_' . intval($sessionId);
}
function cache_path($sessionId, $key)
{
$safe = preg_replace('/[^a-zA-Z0-9_-]/', '_', $key);
return cache_session_dir($sessionId) . '/' . $safe . '.json';
}
function cache_get($sessionId, $key)
{
if (intval($sessionId) <= 0) return null;
$path = cache_path($sessionId, $key);
if (!file_exists($path)) return null;
$data = @file_get_contents($path);
if ($data === false || $data === '') return null;
return $data;
}
function cache_put($sessionId, $key, $jsonString)
{
if (intval($sessionId) <= 0) return;
$dir = cache_session_dir($sessionId);
if (!is_dir($dir)) @mkdir($dir, 0755, true);
@file_put_contents(cache_path($sessionId, $key), $jsonString, LOCK_EX);
}
function cache_invalidate_session($sessionId)
{
$sid = intval($sessionId);
if ($sid <= 0) return;
$dir = cache_session_dir($sid);
if (!is_dir($dir)) return;
$files = glob($dir . '/*');
if (is_array($files)) {
foreach ($files as $f) @unlink($f);
}
@rmdir($dir);
}
// =============================================================================
// DB-level cache for the materialized sessiondebriefs view (table session_debriefs_cache)
// Populated by stored procedure rebuild_debrief_cache(p_session_id) created in migration 1.7.0.
// =============================================================================
// Marque le cache DB d'une session comme stale (delete des lignes).
// Appele depuis les endpoints d'evenements (storetriggerevent, storereactevent)
// pour amortir le cout : la reconstruction se fait paresseusement au prochain
// read via ensure_session_debriefs_cached_db, ou explicitement via rebuild...
function invalidate_session_debrief_cache_db($db, $sessionId)
{
$sid = intval($sessionId);
if ($sid <= 0 || $db === null) return;
try {
$stmt = $db->prepare("DELETE FROM session_debriefs_cache WHERE SessionId = ?");
$stmt->execute([$sid]);
} catch (Exception $e) {
// best-effort, ne jamais bloquer la requete metier
}
}
// Reconstruit immediatement le cache DB d'une session (DELETE + INSERT via la proc).
// Appele depuis session/stop, session/userleave, session/updateobjectives.
function rebuild_session_debrief_cache_db($db, $sessionId)
{
$sid = intval($sessionId);
if ($sid <= 0 || $db === null) return;
try {
$stmt = $db->prepare("CALL rebuild_debrief_cache(?)");
$stmt->execute([$sid]);
$stmt->closeCursor();
} catch (Exception $e) {
// best-effort
}
}
// Garantit que le cache DB contient au moins une ligne pour la session.
// Appele avant tout SELECT sur sessiondebriefs cote stats_object.php.
// Si le cache a ete invalide par un evenement et n'a pas encore ete reconstruit,
// cette fonction le reconstruit a la demande.
function ensure_session_debriefs_cached_db($db, $sessionId)
{
$sid = intval($sessionId);
if ($sid <= 0 || $db === null) return;
try {
$stmt = $db->prepare("SELECT 1 FROM session_debriefs_cache WHERE SessionId = ? LIMIT 1");
$stmt->execute([$sid]);
$exists = $stmt->fetchColumn();
$stmt->closeCursor();
if ($exists === false) {
rebuild_session_debrief_cache_db($db, $sid);
}
} catch (Exception $e) {
// si la table n'existe pas (migration 1.7.0 pas encore appliquee),
// on ne fait rien : le code legacy continue de marcher via la vue
}
}
?>

View File

@@ -24,11 +24,11 @@ define("SESSIONDEBRIEFS_VIEW_NAME", "sessiondebriefs");
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.type, -1) AS TriggerTypeId, COALESCE(TT.displayName, '') AS TriggerType, IFNULL(T.srcUserId, PS.userId) AS ShooterId, US.username AS ShooterName, " .
"IFNULL(PS.role, 3) AS ShooterRoleId, URS.displayName 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(UH.username, '') AS TargetUserName, IFNULL(PH.role, 3) AS TargetRoleId, URT.displayName 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, " .
@@ -42,7 +42,9 @@ define ("SESSIONDEBRIEFS_VIEW_QUERY_FROM",
"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)");
"LEFT JOIN " . REACTEVENTS_TABLE_NAME . " RK ON (R.id = RK.id AND RK.targetKilled = 1) " .
"LEFT JOIN " . USERROLES_TABLE_NAME . " URS ON (URS.id = IFNULL(PS.role, 3)) " .
"LEFT JOIN " . USERROLES_TABLE_NAME . " URT ON (URT.id = IFNULL(PH.role, 3))");
// GROUP BY clause
define("SESSIONDEBRIEFS_VIEW_QUERY_GROUPBY", " GROUP BY SessionId,ShooterId,ShotIndex,TargetName");

View File

@@ -8,21 +8,33 @@ class Database
private $password = ""; //Password associated with username
public $conn;
// shared connection re-used across endpoint includes within a single request,
// and (with PDO::ATTR_PERSISTENT) across requests within an Apache child process.
private static $sharedConn = null;
// 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");
{
if (self::$sharedConn === null)
{
try
{
self::$sharedConn = new PDO(
"mysql:host=" . $this->host . ";dbname=" . $this->db_name,
$this->username,
$this->password,
[PDO::ATTR_PERSISTENT => true]
);
self::$sharedConn->exec("set names utf8");
}
catch(PDOException $exception)
{
echo "Connection error: " . $exception->getMessage();
return null;
}
}
catch(PDOException $exception)
{
echo "Connection error: " . $exception->getMessage();
}
return $this->conn;
$this->conn = self::$sharedConn;
return self::$sharedConn;
}
}
?>

View File

@@ -13,6 +13,7 @@ if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
// include database and object files
include_once '../config/database.php';
include_once '../config/cache.php';
// get database connection
$database = new Database();

View File

@@ -0,0 +1,106 @@
<?php
// Mesure de latence des endpoints lourds de l'API ProserveAPI.
// A lancer AVANT et APRES chaque palier d'optimisation pour avoir des chiffres comparatifs.
// Usage : POST sur ce fichier avec optionnellement userId et sessionId.
// curl -X POST -d "userId=1&sessionId=42" http://localhost/proserve/debug/benchmark.php
// ou ouvrir directement dans le navigateur (lance avec userId=-1 et sessionId=-1, certains endpoints retourneront vide mais la mesure du round-trip reste valide).
include_once '../config/init.php';
$userId = isset($_POST['userId']) ? intval($_POST['userId']) : (isset($_GET['userId']) ? intval($_GET['userId']) : -1);
$sessionId = isset($_POST['sessionId']) ? intval($_POST['sessionId']) : (isset($_GET['sessionId']) ? intval($_GET['sessionId']) : -1);
$iterations = isset($_POST['iterations']) ? max(1, intval($_POST['iterations'])) : (isset($_GET['iterations']) ? max(1, intval($_GET['iterations'])) : 5);
// si pas de sessionId fourni, prendre la plus recente pour avoir des donnees realistes
if ($sessionId <= 0) {
$stmt = $db->query("SELECT id FROM sessions ORDER BY id DESC LIMIT 1");
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
if ($row) $sessionId = intval($row['id']);
}
if ($userId <= 0) {
$stmt = $db->query("SELECT userId FROM participates WHERE sessionId=" . intval($sessionId) . " LIMIT 1");
$row = $stmt ? $stmt->fetch(PDO::FETCH_ASSOC) : null;
if ($row) $userId = intval($row['userId']);
}
// resolution de l'URL de base (meme host et meme path)
$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http';
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
$base = $scheme . '://' . $host . rtrim(dirname(dirname($_SERVER['SCRIPT_NAME'])), '/\\');
$endpoints = [
'session/debrief' => ['sessionId' => $sessionId, 'userId' => $userId],
'session/get' => ['sessionId' => $sessionId],
'stats/userhistory' => ['userId' => $userId, 'sessionId' => $sessionId, 'quickMode' => 'false'],
'stats/get' => ['userId' => $userId, 'sessionId' => $sessionId],
'lists/sessions_for_user' => ['userId' => $userId],
'lists/users_in_session' => ['sessionId' => $sessionId],
];
function call_endpoint($url, $params)
{
// ext-curl est dispo dans XAMPP par defaut et bcp plus rapide que
// file_get_contents+stream_context_create sur Windows (qui ajoute ~10ms par appel).
static $ch = null;
if ($ch === null) {
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_TIMEOUT => 60,
CURLOPT_FORBID_REUSE => false,
CURLOPT_FRESH_CONNECT => false,
]);
}
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($params));
$start = microtime(true);
$resp = curl_exec($ch);
$elapsed_ms = (microtime(true) - $start) * 1000;
$err = curl_errno($ch);
return [
'ms' => round($elapsed_ms, 2),
'bytes' => $resp === false ? 0 : strlen($resp),
'ok' => $err === 0 && $resp !== false,
];
}
function summarize($samples)
{
$times = array_column($samples, 'ms');
sort($times);
$n = count($times);
return [
'min' => $times[0],
'max' => $times[$n - 1],
'median' => $times[intval($n / 2)],
'avg' => round(array_sum($times) / $n, 2),
'bytes' => $samples[0]['bytes'],
'ok' => count(array_filter(array_column($samples, 'ok'))) === $n,
];
}
$results = [
'context' => [
'userId' => $userId,
'sessionId' => $sessionId,
'iterations' => $iterations,
'base_url' => $base,
'timestamp' => date('c'),
],
'endpoints' => [],
];
foreach ($endpoints as $path => $params) {
$url = $base . '/' . $path . '.php';
$samples = [];
for ($i = 0; $i < $iterations; $i++) {
$samples[] = call_endpoint($url, $params);
}
$results['endpoints'][$path] = summarize($samples);
}
print_r(json_encode($results, JSON_PRETTY_PRINT));
?>

View File

@@ -40,6 +40,8 @@ $event->timeStamp = $_POST['timestamp'];
// create reactevent
if ($event->record())
{
cache_invalidate_session($event->srcEventSessionId);
invalidate_session_debrief_cache_db($db, $event->srcEventSessionId);
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
print_r(json_encode($evt_arr)); // OK
}

View File

@@ -29,6 +29,8 @@ $event->successful = $_POST['success'];
// create trigger event
if ($event->record())
{
cache_invalidate_session($event->sessionId);
invalidate_session_debrief_cache_db($db, $event->sessionId);
$evt_arr = $event->getResultArray(true, "Event_Stored_OK");
print_r(json_encode($evt_arr)); // OK
}

View File

@@ -6,9 +6,13 @@ include_once '../objects/stats_object.php';
$userId = isset($_POST['userId']) ? $_POST['userId'] : -1;
$typeId = isset($_POST['typeId']) ? $_POST['typeId'] : -1;
// Optional pagination : Unreal n'envoie pas ces champs (comportement legacy preserve),
// le futur web UI peut les passer pour ne pas charger toutes les sessions d'un coup.
$limit = isset($_POST['limit']) ? intval($_POST['limit']) : -1;
$offset = isset($_POST['offset']) ? intval($_POST['offset']) : 0;
$stats = new StatsObject($db);
$stats->getSessionsForUser($userId, $typeId);
$stats->getSessionsForUser($userId, $typeId, $limit, $offset);
$stats_arr = $stats->getResultArray(true, "User_Sessions_List_OK");
print_r(json_encode($stats_arr)); // OK

View File

@@ -118,7 +118,8 @@ class StatsObject extends DBTableObject
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// get sessions of given type that given user has taken part in
public function getRawSessions ($userId=-1, $typeId=-1)
// $limit and $offset are optional : when $limit <= 0 the full list is returned (legacy behavior)
public function getRawSessions ($userId=-1, $typeId=-1, $limit=-1, $offset=0)
{
// Check if we are looking for a specific user and/or type of session
$userConstraint = $userId > 0 ? " AND P.userId=" . $userId : "";
@@ -127,9 +128,15 @@ class StatsObject extends DBTableObject
// Order results from newest to oldest session
$orderConstraint = " ORDER BY SD.id DESC";
// Optional pagination : SQL-level LIMIT/OFFSET when caller provided a positive limit
$limitConstraint = "";
if (intval($limit) > 0) {
$limitConstraint = " LIMIT " . intval($limit) . " OFFSET " . max(0, intval($offset));
}
//$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;
$this->getCalibrationSessionsConstraint("SD") . $this->getSessionDurationConstraint("SD") . $userConstraint . $typeConstraint . $orderConstraint . $limitConstraint;
// prepare and execute query
$stmt = $this->conn->prepare($query);
@@ -141,10 +148,10 @@ class StatsObject extends DBTableObject
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// get sessions that given user has taken part in
public function getSessionsForUser ($userId, $typeId=-1)
public function getSessionsForUser ($userId, $typeId=-1, $limit=-1, $offset=0)
{
// Check if we are looking for a specific user and/or session
$stmt = $this->getRawSessions($userId, $typeId);
$stmt = $this->getRawSessions($userId, $typeId, $limit, $offset);
while ($row = $stmt->fetch(PDO::FETCH_ASSOC))
{
@@ -469,6 +476,11 @@ class StatsObject extends DBTableObject
}
else
{
// Lazy rebuild du cache materialise pour cette session avant le SELECT FROM sessiondebriefs
if ($sessionId > 0 && function_exists('ensure_session_debriefs_cached_db')) {
ensure_session_debriefs_cached_db($this->conn, $sessionId);
}
// create SQL constraints for query
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId = " . $sessionId : "";
$userConstraint = $userId >= 0 ? " AND SD.ShooterId = " . $userId : "";
@@ -692,6 +704,12 @@ class StatsObject extends DBTableObject
// debrief mission : get given users results in given session
public function getResultsForSession ($sessionId, $userId, $fromUserId=-1)
{
// Lazy rebuild du cache materialise (table session_debriefs_cache)
// si la session a ete invalidee par un evenement et pas encore reconstruite
if ($sessionId > 0 && function_exists('ensure_session_debriefs_cached_db')) {
ensure_session_debriefs_cached_db($this->conn, $sessionId);
}
$userConstraint = $userId > 0 ? " AND SD.ShooterId=" . $userId : "";
$sessionConstraint = $sessionId > 0 ? " AND SD.SessionId=" . $sessionId : "";

View File

@@ -325,7 +325,61 @@ INSERT INTO `userstatus` (`id`, `displayName`, `description`) VALUES
--
DROP TABLE IF EXISTS `sessiondebriefs`;
CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`localhost` SQL SECURITY DEFINER VIEW `sessiondebriefs` AS 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 (((((((((((`sessions` `s` left join `participates` `ps` on(`s`.`id` = `ps`.`sessionId`)) left join `participates` `ph` on(`s`.`id` = `ph`.`sessionId`)) left join `triggerevents` `t` on(`s`.`id` = `t`.`sessionId`)) left join `triggereventtypes` `tt` on(`tt`.`id` = `t`.`type`)) left join `sessiontypes` `st` on(`st`.`id` = `s`.`sessionType`)) left join `reactevents` `r` on(`t`.`indexCount` = `r`.`srcEventIndex` and `t`.`sessionId` = `r`.`srcEventSessionId`)) left join `users` `uh` on(`uh`.`id` = `r`.`hitUserId`)) left join `users` `us` on(`us`.`id` = `t`.`srcUserId` or `us`.`id` = `ps`.`userId`)) left join `reacteventtypes` `rt` on(`rt`.`id` = `r`.`reactType`)) left join `reacteventmodes` `rm` on(`rm`.`id` = coalesce(`r`.`reactMode`,2))) left join `reactevents` `rk` on(`r`.`id` = `rk`.`id` and `rk`.`targetKilled` = 1)) GROUP BY `s`.`id`, (select ifnull(`t`.`srcUserId`,`ps`.`userId`)), `t`.`indexCount`, `r`.`hitTargetName` ORDER BY `s`.`id` ASC, (select ifnull(`t`.`srcUserId`,`ps`.`userId`)) ASC, `t`.`indexCount` ASC, `r`.`id` ASC ;
CREATE ALGORITHM=UNDEFINED DEFINER=`root`@`localhost` SQL SECURITY DEFINER VIEW `sessiondebriefs` AS
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`,
IFNULL(`t`.`srcUserId`, `ps`.`userId`) AS `ShooterId`,
`us`.`username` AS `ShooterName`,
IFNULL(`ps`.`role`, 3) AS `ShooterRoleId`,
`urs`.`displayName` 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`,
IFNULL(`ph`.`role`, 3) AS `TargetRoleId`,
`urt`.`displayName` 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 `sessions` `s`
LEFT JOIN `participates` `ps` ON (`s`.`id` = `ps`.`sessionId`)
LEFT JOIN `participates` `ph` ON (`s`.`id` = `ph`.`sessionId`)
LEFT JOIN `triggerevents` `t` ON (`s`.`id` = `t`.`sessionId`)
LEFT JOIN `triggereventtypes` `tt` ON (`tt`.`id` = `t`.`type`)
LEFT JOIN `sessiontypes` `st` ON (`st`.`id` = `s`.`sessionType`)
LEFT JOIN `reactevents` `r` ON (`t`.`indexCount` = `r`.`srcEventIndex` AND `t`.`sessionId` = `r`.`srcEventSessionId`)
LEFT JOIN `users` `uh` ON (`uh`.`id` = `r`.`hitUserId`)
LEFT JOIN `users` `us` ON (`us`.`id` = `t`.`srcUserId` OR `us`.`id` = `ps`.`userId`)
LEFT JOIN `reacteventtypes` `rt` ON (`rt`.`id` = `r`.`reactType`)
LEFT JOIN `reacteventmodes` `rm` ON (`rm`.`id` = COALESCE(`r`.`reactMode`, 2))
LEFT JOIN `reactevents` `rk` ON (`r`.`id` = `rk`.`id` AND `rk`.`targetKilled` = 1)
LEFT JOIN `userroles` `urs` ON (`urs`.`id` = IFNULL(`ps`.`role`, 3))
LEFT JOIN `userroles` `urt` ON (`urt`.`id` = IFNULL(`ph`.`role`, 3))
GROUP BY `SessionId`, `ShooterId`, `ShotIndex`, `TargetName`
ORDER BY `SessionId`, `ShooterId`, `ShotIndex`, `ReactId`;
--
-- Index pour les tables déchargées

View File

@@ -16,12 +16,22 @@ $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);
$cacheKey = "debrief_" . intval($userId);
$cached = cache_get($sessionId, $cacheKey);
if ($cached !== null)
{
print_r($cached);
}
else
{
$stats = new StatsObject($db);
$stats->getStatsForSession($sessionId, $userId);
$stats_arr = $stats->getResultArray(true, "Stats_Collected_OK");
print_r(json_encode($stats_arr)); // OK
$stats_arr = $stats->getResultArray(true, "Stats_Collected_OK");
$json = json_encode($stats_arr);
cache_put($sessionId, $cacheKey, $json);
print_r($json); // OK
}
//}
//else trigger_error("Invalid session id");
?>

View File

@@ -12,11 +12,17 @@ if (isset($_POST['sessionId']))
$gameSession->id = $_POST['sessionId'];
else trigger_error( missing_parameter_error("sessionId") );
// create the game session
if ($gameSession->load())
$cached = cache_get($gameSession->id, "session");
if ($cached !== null)
{
print_r($cached);
}
else if ($gameSession->load())
{
$session_arr = $gameSession->getResultArray(true, "Session_Found");
print_r(json_encode($session_arr)); // OK
$json = json_encode($session_arr);
cache_put($gameSession->id, "session", $json);
print_r($json); // OK
}
else trigger_error("Error_Occured");
?>

View File

@@ -20,6 +20,8 @@ $gameSession->score = $_POST['score'];
// close the game session
if ($gameSession->stop())
{
cache_invalidate_session($gameSession->id);
rebuild_session_debrief_cache_db($db, $gameSession->id);
$session_arr = $gameSession->getResultArray(true, "Session_Stop_OK");
print_r(json_encode($session_arr)); // OK
}

View File

@@ -42,6 +42,8 @@ if ($participation->load())
// update objectives of user in the game session
if ($participation->updateObjectives())
{
cache_invalidate_session($participation->sessionId);
rebuild_session_debrief_cache_db($db, $participation->sessionId);
$participation_arr = $participation->getResultArray(true, "Results_Saved_OK");
print_r(json_encode($participation_arr)); // OK
}

View File

@@ -25,6 +25,8 @@ if ($participation->userLeaves())
// update score and averages
if ($participation->updateUserScores())
{
cache_invalidate_session($participation->sessionId);
rebuild_session_debrief_cache_db($db, $participation->sessionId);
$participation_arr = $participation->getResultArray(true, "User_Left_OK");
print_r(json_encode($participation_arr)); // OK
}

View File

@@ -21,10 +21,21 @@ if (isset($_POST['quickMode']))
//if ($userId > 0 && $sessionId > 0)
{
$stats = new StatsObject($db);
$stats->getUserHistory($userId, $sessionId, $quickMode);
$cacheKey = "userhistory_" . intval($userId) . "_" . intval($quickMode);
$cached = cache_get($sessionId, $cacheKey);
if ($cached !== null)
{
print_r($cached);
}
else
{
$stats = new StatsObject($db);
$stats->getUserHistory($userId, $sessionId, $quickMode);
$stats_arr = $stats->getResultArray(true, "Results_Collected_OK");
print_r(json_encode($stats_arr)); // OK
$stats_arr = $stats->getResultArray(true, "Results_Collected_OK");
$json = json_encode($stats_arr);
cache_put($sessionId, $cacheKey, $json);
print_r($json); // OK
}
}
?>

View File

@@ -0,0 +1,126 @@
-- ============================================================
-- Migration 1.6.0 - Optimisation des temps de reponse
-- ============================================================
-- Cible : MariaDB 10.4+ (XAMPP local).
-- Idempotent : ce fichier peut etre rejoue sans erreur sur n'importe quel poste.
-- Aucun changement de structure : seuls des index sont ajoutes
-- (via une procedure stockee qui teste leur presence) et la vue
-- sessiondebriefs est redefinie via CREATE OR REPLACE VIEW.
-- ============================================================
USE proserveapi;
-- ------------------------------------------------------------
-- Procedure helper : ajoute un index seulement s'il n'existe pas
-- (CREATE INDEX IF NOT EXISTS n'est dispo qu'a partir de MariaDB 10.5.4)
-- ------------------------------------------------------------
DROP PROCEDURE IF EXISTS create_index_if_missing;
DELIMITER $$
CREATE PROCEDURE create_index_if_missing(
IN p_table VARCHAR(64),
IN p_index VARCHAR(64),
IN p_cols VARCHAR(255)
)
BEGIN
IF NOT EXISTS (
SELECT 1 FROM information_schema.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = p_table
AND INDEX_NAME = p_index
) THEN
SET @sql = CONCAT('ALTER TABLE `', p_table, '` ADD INDEX `', p_index, '` (', p_cols, ')');
PREPARE stmt FROM @sql;
EXECUTE stmt;
DEALLOCATE PREPARE stmt;
END IF;
END$$
DELIMITER ;
-- ------------------------------------------------------------
-- Index manquants identifies a l'analyse des requetes lourdes
-- ------------------------------------------------------------
-- JOIN reactevents <-> triggerevents : la cle composite la plus utilisee
CALL create_index_if_missing('reactevents', 'idx_react_session_event', '`srcEventSessionId`, `srcEventIndex`');
-- Filtres "tirs d'un user dans une session" (stats_object getShotsFiredByUser, getTargetKilledInSession)
CALL create_index_if_missing('triggerevents', 'idx_trigger_session_user', '`sessionId`, `srcUserId`');
-- Agregation NbKilled (auto-jointure RK dans la vue sessiondebriefs)
CALL create_index_if_missing('reactevents', 'idx_react_killed', '`targetKilled`, `srcEventSessionId`');
-- Tri/filtrage des sessions par date (listes recentes)
CALL create_index_if_missing('sessions', 'idx_sessions_date', '`sessionDate` DESC');
-- Nettoyage de la procedure helper
DROP PROCEDURE create_index_if_missing;
-- ------------------------------------------------------------
-- Redefinition de la vue sessiondebriefs (idempotent via CREATE OR REPLACE)
-- ------------------------------------------------------------
-- Modifications par rapport a la definition d'origine :
-- - Suppression des wraps "(SELECT IFNULL(...))" qui forcaient une evaluation par row
-- -> remplaces par IFNULL(...) directement (semantique identique).
-- - Suppression des sous-requetes correlees (SELECT R.displayName FROM userroles R WHERE R.id = ...)
-- -> remplacees par deux LEFT JOIN explicites sur userroles (URS et URT).
-- - Tous les autres JOIN, le GROUP BY et l'ORDER BY restent strictement identiques
-- pour preserver le contrat de sortie (memes colonnes, memes lignes, memes valeurs).
-- ------------------------------------------------------------
CREATE OR REPLACE VIEW sessiondebriefs AS
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,
IFNULL(T.srcUserId, PS.userId) AS ShooterId,
US.username AS ShooterName,
IFNULL(PS.role, 3) AS ShooterRoleId,
URS.displayName 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,
IFNULL(PH.role, 3) AS TargetRoleId,
URT.displayName 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 sessions S
LEFT JOIN participates PS ON (S.id = PS.sessionId)
LEFT JOIN participates PH ON (S.id = PH.sessionId)
LEFT JOIN triggerevents T ON (S.id = T.sessionId)
LEFT JOIN triggereventtypes TT ON (TT.id = T.type)
LEFT JOIN sessiontypes ST ON (ST.id = S.sessionType)
LEFT JOIN reactevents R ON (T.indexCount = R.srcEventIndex AND T.sessionId = R.srcEventSessionId)
LEFT JOIN users UH ON (UH.id = R.hitUserId)
LEFT JOIN users US ON (US.id = T.srcUserId OR US.id = PS.userId)
LEFT JOIN reacteventtypes RT ON (RT.id = R.reactType)
LEFT JOIN reacteventmodes RM ON (RM.id = COALESCE(R.reactMode, 2))
LEFT JOIN reactevents RK ON (R.id = RK.id AND RK.targetKilled = 1)
LEFT JOIN userroles URS ON (URS.id = IFNULL(PS.role, 3))
LEFT JOIN userroles URT ON (URT.id = IFNULL(PH.role, 3))
GROUP BY SessionId, ShooterId, ShotIndex, TargetName
ORDER BY SessionId, ShooterId, ShotIndex, ReactId;

View File

@@ -0,0 +1,236 @@
-- ============================================================
-- Migration 1.7.0 - Materialisation de la vue sessiondebriefs
-- ============================================================
-- Cible : MariaDB 10.4+.
-- Idempotent : peut etre rejoue sans erreur. Le cache est entierement
-- reconstruit a chaque execution (TRUNCATE + backfill).
--
-- Changement principal : la vue sessiondebriefs etait recalculee a chaque
-- requete (12 LEFT JOIN + GROUP BY + sous-requetes correlees). Elle est
-- desormais materialisee dans une vraie table session_debriefs_cache,
-- peuplee :
-- - en bulk au moment de cette migration (backfill toutes sessions),
-- - puis par une procedure stockee rebuild_debrief_cache(p_session_id)
-- appelee depuis le code PHP a chaque modification de session
-- (session/stop, session/userleave, session/updateobjectives,
-- events/storetriggerevent, events/storereactevent).
--
-- La vue sessiondebriefs garde son nom et ses colonnes : le code consommateur
-- (stats_object.php : getResultsForSession, getUserHistory) ne change pas.
-- Elle devient un simple passe-plat sur la table cache.
--
-- Action 2 (fix partiel du double JOIN participates) : la jointure PH
-- (qui sert a determiner TargetRoleId/TargetRole) est desormais contrainte
-- sur PH.userId = R.hitUserId au lieu de matcher tous les participants.
-- Effet :
-- * Pour les hits user-on-user : meme resultat (correct par construction).
-- * Pour les hits NPC/objets : TargetRoleId vaut 3 (IA) au lieu d'un
-- role aleatoire d'un autre participant. C'est un fix de bug.
-- La jointure PS reste inchangee (preserve le fallback ShooterId quand T est NULL).
-- ============================================================
USE proserveapi;
-- ------------------------------------------------------------
-- 1) Table de cache (creation idempotente)
-- ------------------------------------------------------------
CREATE TABLE IF NOT EXISTS `session_debriefs_cache` (
`SessionId` int(11) NOT NULL,
`SessionTypeId` int(11) DEFAULT NULL,
`SessionType` varchar(255) DEFAULT NULL,
`SessionName` varchar(255) DEFAULT NULL,
`SessionDate` datetime DEFAULT NULL,
`MapName` varchar(255) DEFAULT NULL,
`ScenarioName` varchar(255) DEFAULT NULL,
`SessionSuccessful` tinyint(1) DEFAULT NULL,
`SessionDuration` float DEFAULT NULL,
`TriggerTypeId` int(11) DEFAULT NULL,
`TriggerType` varchar(255) DEFAULT NULL,
`ShooterId` int(11) DEFAULT NULL,
`ShooterName` varchar(255) DEFAULT NULL,
`ShooterRoleId` int(11) DEFAULT NULL,
`ShooterRole` varchar(255) DEFAULT NULL,
`ShotIndex` int(11) DEFAULT NULL,
`ReactId` int(11) DEFAULT NULL,
`ReactModeId` int(11) DEFAULT NULL,
`ReactMode` varchar(255) DEFAULT NULL,
`ReactTypeId` int(11) DEFAULT NULL,
`ReactType` varchar(255) DEFAULT NULL,
`TargetUserId` int(11) DEFAULT NULL,
`TargetUserName` varchar(255) DEFAULT NULL,
`TargetRoleId` int(11) DEFAULT NULL,
`TargetRole` varchar(255) DEFAULT NULL,
`TargetName` varchar(255) DEFAULT NULL,
`TargetBoneName` varchar(255) DEFAULT NULL,
`TargetKilled` int(4) DEFAULT NULL,
`HitLocationX` double DEFAULT NULL,
`HitLocationY` double DEFAULT NULL,
`HitLocationTag` varchar(255) DEFAULT NULL,
`HitPrecision` double DEFAULT NULL,
`HitTargetDistance` double DEFAULT NULL,
`ReactionTime` double DEFAULT NULL,
`TimeStamp` double DEFAULT NULL,
`NbHit` bigint(21) DEFAULT NULL,
`NbKilled` bigint(21) DEFAULT NULL,
KEY `idx_sdc_session` (`SessionId`),
KEY `idx_sdc_session_shooter` (`SessionId`, `ShooterId`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
-- ------------------------------------------------------------
-- 2) Procedure stockee pour reconstruire le cache d'une session
-- ------------------------------------------------------------
DROP PROCEDURE IF EXISTS rebuild_debrief_cache;
DELIMITER $$
CREATE PROCEDURE rebuild_debrief_cache(IN p_session_id INT)
BEGIN
DELETE FROM session_debriefs_cache WHERE SessionId = p_session_id;
INSERT INTO session_debriefs_cache (
SessionId, SessionTypeId, SessionType, SessionName, SessionDate,
MapName, ScenarioName, SessionSuccessful, SessionDuration,
TriggerTypeId, TriggerType, ShooterId, ShooterName,
ShooterRoleId, ShooterRole, ShotIndex, ReactId, ReactModeId,
ReactMode, ReactTypeId, ReactType, TargetUserId, TargetUserName,
TargetRoleId, TargetRole, TargetName, TargetBoneName, TargetKilled,
HitLocationX, HitLocationY, HitLocationTag, HitPrecision,
HitTargetDistance, ReactionTime, TimeStamp, NbHit, NbKilled
)
SELECT
S.id,
S.sessionType,
ST.displayName,
S.sessionName,
S.sessionDate,
S.mapName,
S.scenarioName,
S.success,
S.timeToFinish,
COALESCE(T.type, -1),
COALESCE(TT.displayName, ''),
IFNULL(T.srcUserId, PS.userId),
US.username,
IFNULL(PS.role, 3),
URS.displayName,
COALESCE(T.indexCount, -1),
COALESCE(R.id, -1),
COALESCE(R.reactMode, 2),
COALESCE(RM.displayName, ''),
COALESCE(R.reactType, -1),
COALESCE(RT.displayName, ''),
COALESCE(R.hitUserId, -1),
COALESCE(UH.username, ''),
IFNULL(PH.role, 3),
URT.displayName,
COALESCE(R.hitTargetName, ''),
COALESCE(R.hitBoneName, ''),
COALESCE(R.targetKilled, 0),
COALESCE(R.objectHitLocationX, 0),
COALESCE(R.objectHitLocationY, 0),
COALESCE(R.objectHitTagLocation, ''),
COALESCE(R.hitPrecision, 0),
COALESCE(R.distance, 0),
COALESCE(R.reactTime, 0),
COALESCE(R.timeStamp, 0),
COUNT(DISTINCT R.id),
COUNT(DISTINCT RK.srcEventIndex, RK.hitTargetName)
FROM sessions S
LEFT JOIN participates PS ON (S.id = PS.sessionId)
LEFT JOIN triggerevents T ON (S.id = T.sessionId)
LEFT JOIN triggereventtypes TT ON (TT.id = T.type)
LEFT JOIN sessiontypes ST ON (ST.id = S.sessionType)
LEFT JOIN reactevents R ON (T.indexCount = R.srcEventIndex AND T.sessionId = R.srcEventSessionId)
LEFT JOIN users UH ON (UH.id = R.hitUserId)
LEFT JOIN users US ON (US.id = T.srcUserId OR US.id = PS.userId)
LEFT JOIN reacteventtypes RT ON (RT.id = R.reactType)
LEFT JOIN reacteventmodes RM ON (RM.id = COALESCE(R.reactMode, 2))
LEFT JOIN reactevents RK ON (R.id = RK.id AND RK.targetKilled = 1)
-- PH : Action 2 du fix - PH represente la participation de l'utilisateur cible
-- (au lieu de "n'importe quel participant") pour que TargetRole soit coherent.
LEFT JOIN participates PH ON (S.id = PH.sessionId AND PH.userId = R.hitUserId)
LEFT JOIN userroles URS ON (URS.id = IFNULL(PS.role, 3))
LEFT JOIN userroles URT ON (URT.id = IFNULL(PH.role, 3))
WHERE S.id = p_session_id
GROUP BY S.id, IFNULL(T.srcUserId, PS.userId), COALESCE(T.indexCount, -1), COALESCE(R.hitTargetName, '')
ORDER BY S.id, IFNULL(T.srcUserId, PS.userId), COALESCE(T.indexCount, -1), COALESCE(R.id, -1);
END$$
DELIMITER ;
-- ------------------------------------------------------------
-- 3) Backfill complet : recalcule le cache pour toutes les sessions existantes
-- ------------------------------------------------------------
TRUNCATE TABLE session_debriefs_cache;
INSERT INTO session_debriefs_cache (
SessionId, SessionTypeId, SessionType, SessionName, SessionDate,
MapName, ScenarioName, SessionSuccessful, SessionDuration,
TriggerTypeId, TriggerType, ShooterId, ShooterName,
ShooterRoleId, ShooterRole, ShotIndex, ReactId, ReactModeId,
ReactMode, ReactTypeId, ReactType, TargetUserId, TargetUserName,
TargetRoleId, TargetRole, TargetName, TargetBoneName, TargetKilled,
HitLocationX, HitLocationY, HitLocationTag, HitPrecision,
HitTargetDistance, ReactionTime, TimeStamp, NbHit, NbKilled
)
SELECT
S.id,
S.sessionType,
ST.displayName,
S.sessionName,
S.sessionDate,
S.mapName,
S.scenarioName,
S.success,
S.timeToFinish,
COALESCE(T.type, -1),
COALESCE(TT.displayName, ''),
IFNULL(T.srcUserId, PS.userId),
US.username,
IFNULL(PS.role, 3),
URS.displayName,
COALESCE(T.indexCount, -1),
COALESCE(R.id, -1),
COALESCE(R.reactMode, 2),
COALESCE(RM.displayName, ''),
COALESCE(R.reactType, -1),
COALESCE(RT.displayName, ''),
COALESCE(R.hitUserId, -1),
COALESCE(UH.username, ''),
IFNULL(PH.role, 3),
URT.displayName,
COALESCE(R.hitTargetName, ''),
COALESCE(R.hitBoneName, ''),
COALESCE(R.targetKilled, 0),
COALESCE(R.objectHitLocationX, 0),
COALESCE(R.objectHitLocationY, 0),
COALESCE(R.objectHitTagLocation, ''),
COALESCE(R.hitPrecision, 0),
COALESCE(R.distance, 0),
COALESCE(R.reactTime, 0),
COALESCE(R.timeStamp, 0),
COUNT(DISTINCT R.id),
COUNT(DISTINCT RK.srcEventIndex, RK.hitTargetName)
FROM sessions S
LEFT JOIN participates PS ON (S.id = PS.sessionId)
LEFT JOIN triggerevents T ON (S.id = T.sessionId)
LEFT JOIN triggereventtypes TT ON (TT.id = T.type)
LEFT JOIN sessiontypes ST ON (ST.id = S.sessionType)
LEFT JOIN reactevents R ON (T.indexCount = R.srcEventIndex AND T.sessionId = R.srcEventSessionId)
LEFT JOIN users UH ON (UH.id = R.hitUserId)
LEFT JOIN users US ON (US.id = T.srcUserId OR US.id = PS.userId)
LEFT JOIN reacteventtypes RT ON (RT.id = R.reactType)
LEFT JOIN reacteventmodes RM ON (RM.id = COALESCE(R.reactMode, 2))
LEFT JOIN reactevents RK ON (R.id = RK.id AND RK.targetKilled = 1)
LEFT JOIN participates PH ON (S.id = PH.sessionId AND PH.userId = R.hitUserId)
LEFT JOIN userroles URS ON (URS.id = IFNULL(PS.role, 3))
LEFT JOIN userroles URT ON (URT.id = IFNULL(PH.role, 3))
GROUP BY S.id, IFNULL(T.srcUserId, PS.userId), COALESCE(T.indexCount, -1), COALESCE(R.hitTargetName, '')
ORDER BY S.id, IFNULL(T.srcUserId, PS.userId), COALESCE(T.indexCount, -1), COALESCE(R.id, -1);
-- ------------------------------------------------------------
-- 4) La vue devient un passe-plat sur la table cache
-- ------------------------------------------------------------
CREATE OR REPLACE VIEW sessiondebriefs AS
SELECT * FROM session_debriefs_cache;