You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

199 lines
6.0 KiB
TypeScript

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import { useNavigate } from '@solidjs/router'
import { createEffect, createMemo, createSignal, onMount } from 'solid-js'
import {
AnswerInput,
AttemptIndicator,
GameCard,
HintButton,
ScoreDisplay,
Timer,
} from '@knowfoolery/ui-components'
import Button from '@suid/material/Button'
import Stack from '@suid/material/Stack'
import Typography from '@suid/material/Typography'
import { useTimer } from '../hooks/useTimer'
import { leaderboardClient } from '../services/api'
import { appendGameHistory, saveLastResult } from '../services/session'
type QuizQuestion = {
theme: string
text: string
answer: string
hint: string
}
const QUESTION: QuizQuestion = {
theme: 'Général',
text: 'Quel est le plus petit nombre premier ?',
answer: '2',
hint: 'Cest le seul nombre premier pair.',
}
function normalizeAnswer(answer: string): string {
return answer.trim().toLowerCase()
}
async function estimateLeaderboardPosition(score: number, durationSec: number): Promise<number | null> {
try {
const rows = await leaderboardClient.top10()
const sorted = [...rows].sort((a, b) => {
if (b.score !== a.score) return b.score - a.score
const durationA = typeof a.durationSec === 'number' ? a.durationSec : Number.MAX_SAFE_INTEGER
const durationB = typeof b.durationSec === 'number' ? b.durationSec : Number.MAX_SAFE_INTEGER
return durationA - durationB
})
let rank = 1
for (const row of sorted) {
const rowDuration = typeof row.durationSec === 'number' ? row.durationSec : Number.MAX_SAFE_INTEGER
if (score > row.score) break
if (score === row.score && durationSec < rowDuration) break
rank += 1
}
return rank <= 10 ? rank : null
} catch {
return null
}
}
export default function GameRoute() {
const navigate = useNavigate()
const [question] = createSignal(QUESTION)
const [answer, setAnswer] = createSignal('')
const [attempts, setAttempts] = createSignal(0)
const [hintUsed, setHintUsed] = createSignal(false)
const [score, setScore] = createSignal(0)
const [message, setMessage] = createSignal<string | null>(null)
const [finished, setFinished] = createSignal(false)
const durationMs = 30 * 60 * 1000
const startedAt = Date.now()
const { remainingMs, isExpired, start, stop } = useTimer(durationMs)
onMount(() => start())
const attemptsLeft = createMemo(() => Math.max(0, 3 - attempts()))
const answerLocked = createMemo(() => finished() || attemptsLeft() === 0 || isExpired())
const finalize = async (finalScore: number, answered: number, correct: number) => {
if (finished()) return
setFinished(true)
stop()
const elapsedSec = Math.max(1, Math.floor((Date.now() - startedAt) / 1000))
const durationSec = Math.min(Math.floor(durationMs / 1000), elapsedSec)
const successRate = answered > 0 ? Math.round((correct / answered) * 100) : 0
const playerName = window.localStorage.getItem('kf.playerName') ?? 'Anonymous'
const leaderboardPosition = await estimateLeaderboardPosition(finalScore, durationSec)
const result = {
playerName,
finalScore,
answered,
correct,
successRate,
durationSec,
leaderboardPosition,
finishedAt: new Date().toISOString(),
}
saveLastResult(result)
appendGameHistory(result)
navigate('/results')
}
createEffect(() => {
if (isExpired() && !finished()) {
setMessage('Temps écoulé.')
const answered = attempts() > 0 ? 1 : 0
void finalize(score(), answered, 0)
}
})
const submit = () => {
if (answerLocked()) return
const normalized = normalizeAnswer(answer())
if (!normalized) {
setMessage('Saisis une réponse avant denvoyer.')
return
}
const isCorrect = normalized === normalizeAnswer(question().answer) || normalized === 'deux'
const nextAttempts = attempts() + 1
setAttempts(nextAttempts)
if (isCorrect) {
const delta = hintUsed() ? 1 : 2
const nextScore = score() + delta
setScore(nextScore)
setMessage(`Bonne réponse (+${delta}).`)
void finalize(nextScore, 1, 1)
return
}
if (nextAttempts >= 3) {
setMessage("Mauvaise réponse. Plus d'essais.")
void finalize(score(), 1, 0)
} else {
setMessage('Mauvaise réponse.')
}
setAnswer('')
}
const confirmHint = () => {
if (hintUsed() || attempts() > 0 || answerLocked()) return
setHintUsed(true)
setMessage(`Indice: ${question().hint}`)
}
return (
<GameCard
title="Partie"
theme={question().theme}
themeLabelPrefix="Thème : "
questionText={question().text}
timerSlot={<Timer remainingMs={remainingMs()} />}
scoreSlot={<ScoreDisplay score={score()} />}
answerSlot={
<AnswerInput
label="Ta réponse"
value={answer()}
disabled={answerLocked()}
onInputValue={setAnswer}
onSubmit={submit}
/>
}
attemptSlot={<AttemptIndicator attemptsUsed={attempts()} attemptsMax={3} />}
actionsSlot={
<Stack direction={{ xs: 'column', sm: 'row' }} spacing={2}>
<Button variant="contained" disabled={answerLocked()} onClick={submit}>
Envoyer
</Button>
<HintButton
disabled={hintUsed() || attempts() > 0 || answerLocked()}
buttonLabel="Indice (score réduit)"
confirmTitle="Confirmer lindice"
confirmMessage="Demander un indice réduit le score maximal de la question de 2 à 1 point. Continuer ?"
confirmLabel="Oui, utiliser un indice"
cancelLabel="Annuler"
onConfirm={confirmHint}
/>
</Stack>
}
feedbackSlot={
<>
{message() && <Typography sx={{ opacity: 0.9 }}>{message()}</Typography>}
{attemptsLeft() === 0 && <Typography color="warning.main">Plus d'essais.</Typography>}
{isExpired() && <Typography color="error.main">Temps écoulé.</Typography>}
</>
}
/>
)
}