# 🎬 Petits Papiers

Jeu de soirée multijoueur, façon Jackbox : chacun rejoint la salle depuis son
téléphone avec un code à 4 lettres.

## Règles

1. **Écriture** — un thème est tiré au sort parmi les 200 de `themes.js`,
   organisés en 11 familles (🎬 Cinéma, 🍕 Cuisine, 🎵 Musique…) que l'hôte
   active ou non dans le salon avant de lancer. Chaque joueur répond en
   secret. Tant que personne n'a écrit, l'hôte peut retirer un thème qui ne
   plaît pas (« Changer de thème »). Un thème déjà joué ne retombe pas tant
   que le stock n'est pas épuisé.
2. **Distribution** — les papiers sont mélangés et révélés un par un. Pour
   chaque papier, on **débat**, puis **tout le monde vote** pour désigner son
   auteur supposé ; le plus gros vote reçoit le papier (égalité : tirage au
   sort). Un joueur peut se retrouver avec plusieurs papiers devant lui — on
   ne retire jamais un papier déjà posé.
3. **Révélation & scores** —
   - **Vote juste** : chaque joueur qui a désigné le vrai auteur d'un papier
     gagne **+1 point** (l'auteur ne marque pas en votant pour lui-même).
   - **Évasion** : si ton propre papier n'a pas fini devant toi, tu gagnes un
     **bonus** (3 par défaut, réglable par un hôte premium — voir plus bas).
   - Il faut donc viser juste sur les autres… tout en bluffant pour éloigner
     son propre papier.

## Manches et fin de partie

L'hôte choisit le **nombre de manches** dans le salon (1 à 20, 3 par défaut).
La révélation affiche « Manche X / Y », et deux actions s'offrent à l'hôte :

- **Manche suivante** — on enchaîne sur un nouveau thème, les scores
  continuent de s'accumuler. Le bouton disparaît une fois la dernière manche
  jouée.
- **Nouvelle partie** — retour au **salon** avec les mêmes joueurs :
  scores remis à zéro, compteur de manches remis à zéro.

## Arriver et repartir en cours de partie

La salle reste ouverte du début à la fin : **on peut rejoindre une partie
déjà commencée**, avec le code, le lien, le QR code ou la partie rapide.

- Si la manche en est encore à l'écriture, le nouveau venu écrit son papier
  avec les autres : on l'attend.
- Si les votes sont déjà lancés, son papier ne peut plus rejoindre le tas :
  il **regarde la manche finir** (pastille 👀, pas de bouton de vote, et
  personne ne peut lui attribuer un papier), puis il joue la manche suivante.

Dans tous les cas il démarre à **zéro point** — sauf s'il *revient* : celui
qui a quitté en pleine partie (bouton 🚪, réseau coupé, appli fermée)
retrouve **sa place, son score et son papier** en redonnant le même code et
le même pseudo. Sa chaise l'attend tant que la salle vit (dix minutes après
le départ du dernier joueur).

Si *tous* ceux qui jouaient la manche s'en vont et qu'il ne reste que des
arrivants, la manche est classée sans suite : la salle revient au salon (les
scores restent) plutôt que d'attendre des votes qui ne viendront jamais.

## Animations de papier

Le pliage n'intervient qu'à deux moments, toujours autour du même papier :

1. **À l'envoi de sa réponse** — l'écran s'assombrit et la carte d'écriture
   elle-même se plie en quatre (une copie conforme, la réponse tapée
   comprise), puis s'efface.
2. **Avant chaque vote** — autant de papiers pliés que de joueurs
   apparaissent, se mélangent, l'un d'eux est tiré au hasard et se déplie :
   c'est ce papier-là qu'on doit attribuer.

Nulle part ailleurs : la révélation, elle, affiche directement les papiers
et leur verdict. Tout est sauté si le système demande des animations
réduites (`prefers-reduced-motion`).

## Relire les questions

Les 200 questions vivent dans `themes.js`, au milieu du code. Pour les relire
d'une traite, [QUESTIONS.md](QUESTIONS.md) les reprend famille par famille :

```bash
npm run questions
```

Le fichier est **engendré** : corriger une question dedans ne changerait rien
au jeu, c'est `themes.js` qu'il faut modifier, puis relancer la commande.

## Empaqueter pour Android

Le jeu part sur le Play Store en **TWA** (*Trusted Web Activity*) :
l'application n'embarque pas une copie du jeu, elle ouvre le site dans un
Chrome sans barre d'adresse. Une mise à jour du site met donc à jour
l'application.

**Une TWA, pas une WebView.** Google refuse sa propre connexion dans une
WebView (`disallowed_useragent`) : avec Capacitor ou Cordova sans plugin natif,
« Continuer avec Google » ne fonctionnerait pas. Une TWA passe par Chrome
Custom Tabs, et fonctionne.

Tout est dans **[`android/`](android/README.md)** : la configuration Bubblewrap
(`android/twa-manifest.json`) et la marche à suivre, de la clé de signature au
bundle envoyé sur Play. Le projet Gradle lui-même n'est pas versionné — il se
réengendre à partir de la configuration.

Deux choix y sont définitifs et n'appartiennent qu'aux éditeurs : le **nom de
paquet** (`fr.petitspapiers.jeu`, immuable après la première publication) et la
**clé de signature**, qui ne doit jamais entrer dans le dépôt — `.gitignore` la
refuse.

Ce que Play demande et qui est en place : suppression du compte dans
l'application **et** à une adresse publique (`/suppression-compte.html`, à
déclarer dans la console), signalement et masquage entre joueurs, retour
système qui navigue au lieu de quitter l'application, icônes 192/512/*maskable*,
et les textes légaux — mentions légales, confidentialité, conditions — lisibles
sans compte.

Il reste **l'adresse e-mail de contact** à renseigner dans ces textes : elle est
en `[à compléter]` en tête de la section « Pages d'information » de
`public/app.js`. Play refuse une fiche sans contact réel, et le droit d'accès
du RGPD n'aurait nulle part où s'exercer.

## Signaler et masquer

Une salle est une conversation entre inconnus. Un drapeau discret suit chaque
pseudo dans les listes de joueurs — jamais le sien — et ouvre la fenêtre déjà
dépliée sur la personne visée. Deux gestes très différents s'y trouvent :

- **Masquer** ne regarde que soi : les messages de la personne sont repliés,
  tout de suite, sur cet appareil seulement. Rien n'est envoyé.
- **Signaler** part chez nous avec un motif choisi dans une liste fermée. Le
  serveur retrouve lui-même qui signale et recopie le dernier message de la
  personne visée — le client ne peut donc ni accuser à la place d'un autre, ni
  inventer un extrait. Le contenu est recopié parce que la salle aura disparu
  bien avant qu'on lise le signalement.

Les signalements arrivent dans l'onglet **Signalements** de l'administration,
non traités en premier, avec une pastille sur l'onglet tant qu'il en reste.

Chaque signalement emporte **toute la conversation de la salle** telle qu'elle
était à cet instant : chaque message avec son auteur, sa date et son heure, les
arrivées et départs compris — ils disent qui pouvait lire quoi. Sans cette
copie, un signalement lu le lendemain n'aurait plus rien à montrer : la salle
est effacée dix minutes après le départ du dernier joueur.

La capture est **scellée** : une empreinte HMAC calculée avec le secret du
serveur accompagne le texte, et l'administration affiche si le sceau tient
encore. Ce n'est pas du chiffrement, la conversation reste lisible en base ;
c'est de quoi dire qu'elle n'a pas bougé depuis sa capture. Quelqu'un qui
aurait à la fois la base *et* le secret pourrait resceller — une vraie
inaltérabilité demanderait un stockage en écriture seule, que cette base
n'offre pas.

## Installer et lancer (sur n'importe quel PC)

Prérequis : [Node.js](https://nodejs.org/) 18 ou plus (et git).

```bash
git clone https://github.com/OnerAliCan/petits-papiers.git
cd petits-papiers
npm install
npm start
```

Puis ouvrir http://localhost:3000. Pour jouer à plusieurs sur le même Wi-Fi,
les autres ouvrent `http://<ip-de-votre-pc>:3000` (l'adresse IP locale
s'obtient avec `ipconfig` sous Windows, `ip a` sous Linux/Mac).

## Rejoindre une partie

Trois façons : taper le code à 4 lettres, ouvrir le lien d'invitation
(bouton « Inviter des joueurs »), ou scanner le **QR code affiché dans le
salon de l'hôte** — soit avec l'appareil photo du téléphone (le lien s'ouvre
tout seul), soit avec le bouton « Scanner un QR code » de l'écran d'accueil
(scanner intégré : API `BarcodeDetector` native quand elle existe, sinon
repli sur jsQR). Si le QR
pointe vers un autre serveur (ex : nouvelle URL de tunnel), le scanner y
redirige automatiquement.

## Compte joueur (optionnel)

Le bouton **« Continuer avec Google »** est toujours à l'écran d'accueil, sans
rien autour : se connecter ne demande pas d'ouvrir quoi que ce soit d'abord.
Un clic suffit — il n'y a pas de mot de passe à choisir ni à retenir. Le **nom
d'utilisateur devient alors pris** : personne d'autre ne peut créer ou rejoindre
une salle avec, et son propriétaire porte un badge ✔ dans les listes de joueurs. Jouer sans compte reste bien sûr possible (et
recommandé pour les invités d'un soir).

Tout est fait maison, sans dépendance ni coût :

- connexion Google en OAuth 2.0 (voir plus bas), rien d'autre à installer ;
- session dans un cookie HttpOnly signé (HMAC), valable 90 jours ;
- stockage des comptes selon l'hébergement (voir ci-dessous).

### Badges

Trois distinctions, visibles à côté du pseudo dans toutes les listes de
joueurs, et détaillées dans « Mon profil » (les badges non obtenus y
apparaissent en creux) :

| Badge | Qui l'a |
| --- | --- |
| ✔ Nom d'utilisateur authentifié | tout joueur qui joue sous le nom de son compte |
| 👑 Compte premium | les comptes payants — **le paiement n'existe pas encore** |
| 🌱 Pionnier | les 100 premiers comptes créés |

Chaque compte reçoit un **numéro d'inscription** à sa création (`rang`), qui
ne bouge plus ensuite — ni au changement de pseudo, ni quand un autre compte
disparaît. Les comptes créés avant l'arrivée du compteur n'ont pas de numéro :
ce sont les tout premiers inscrits, ils gardent le badge.

En attendant le paiement, **les pionniers portent aussi la couronne** : le
premium leur est promis, et il n'y a encore rien à payer. Deux réglages, par
variable d'environnement :

| Variable | Défaut | Effet |
| --- | --- | --- |
| `PIONNIERS_MAX` | `100` | nombre de places dans le club des pionniers |
| `PIONNIERS_PREMIUM` | `true` | `false` retire la couronne aux pionniers, le jour où le paiement arrive |

Pour offrir le premium à un compte en particulier, il suffit d'ajouter
`"premium": true` à sa fiche (`data/users.json`, ou la clé `user:<pseudo>` en
Redis) — c'est la porte d'entrée qu'utilisera le paiement.

### Ce que le premium ouvre

Les réglages de l'hôte tiennent dans deux accordéons, tous deux **repliés à
l'arrivée dans une salle** : « Paramètres de la salle » (mot de passe, nombre
de manches) puis « Options premium ». Le salon montre d'abord les joueurs et
les thèmes ; le reste ne s'ouvre que si on le demande.

Une salle ouverte par un compte sans couronne affiche les options premium
grisées : elles restent visibles — voir ce qu'on n'a pas est la moitié de
l'intérêt d'un badge — mais le serveur refuse le réglage, pas seulement le
formulaire. Un lien « Débloquez les fonctionnalités premium » y ouvre la
**page premium**, une surcouche consultable sans quitter la salle.

C'est un écran à part entière, pas une surcouche — le comparatif se lisait mal
sur un fond translucide — et il passe devant le salon sans quitter la salle :
« Retour » ramène exactement où on était.

| | Gratuit | Premium |
| --- | --- | --- |
| Familles de thèmes | Les 11 familles du jeu | Des familles en plus |
| Joueurs par salle | 6 | 16 |
| Code de la salle | Généré aléatoirement | Personnalisé |
| Bonus d'évasion | 3 points | Réglable |
| Nombre de manches | 3 | Réglable |
| Minuteur d'écriture | — | Personnalisé |
| Minuteur de vote | — | Personnalisé |
| Badge en salle | ✔ nom d'utilisateur authentifié | 👑 la couronne |

Le bouton **« Débloquer le premium »** est là, mais désactivé : il n'y a rien
à acheter, et rien derrière lui. Il attend le paiement.

Aucune de ces limites n'est en place aujourd'hui : le plafond reste de
`MAX_PLAYERS` joueurs pour tout le monde, et les 11 familles sont ouvertes à
tous. Seul le bonus d'évasion est réellement réservé.

L'accordéon contient le **nombre de manches**, le **bonus d'évasion**, le
**minuteur d'écriture** et le **minuteur de vote**. Quand le minuteur expire, la manche passe au vote avec
les papiers rendus : ceux qui n'ont rien écrit regardent la manche finir, comme
un arrivant en cours de route. Si personne n'a écrit, la salle revient au salon
plutôt que d'ouvrir un vote sur rien.

Le minuteur de vote tranche de la même façon : ne pas voter est une
abstention, pas un veto — sinon un joueur parti chercher à boire bloquerait la
table. Si personne n'a voté, le papier tombe au hasard parmi ceux qui jouent :
le laisser sans propriétaire offrirait le bonus d'évasion à son auteur, donc
récompenserait le silence de toute la table.

Le **plafond de joueurs** et le **code de salle choisi** tiennent au compte qui
a *ouvert* la salle, pas à chacun : une salle ouverte par un premium accueille
seize personnes, invités compris. C'est la seule lecture qui tienne — on ne va
pas refuser le septième joueur d'une soirée parce qu'il n'a pas de compte.

Un code choisi accepte les 26 lettres, contrairement au tirage au sort qui
évite I, L et O : un code imposé doit se dicter sans ambiguïté, un code qu'on a
choisi, on sait comment il s'écrit.
La salle retient si son créateur avait la couronne (`room.premium`) : un
changement d'hôte en cours de partie ne rouvre ni ne ferme ce qui a déjà été
annoncé aux joueurs.

### Mesure d'audience

L'espace d'administration porte un onglet **Mesure** :

- évolution des joueurs, des inscrits et des premium, jour par jour ;
- heure à laquelle les parties commencent ;
- part des inscrits passés premium, DAU/MAU, rétention à J+1 et J+7 ;
- **parties menées à terme**, et à quelle manche lâchent les autres ;
- **joueurs par salle**, de quoi juger si les plafonds annoncés mordent ;
- **chemin d'arrivée** : code tapé, lien, QR, salle au hasard, retour ;
- **familles de thèmes** que les hôtes gardent ou décochent ;
- **thèmes les plus refusés**, rapportés au nombre de fois où ils sont tombés ;
- **inscriptions amenées par une salle**.

Deux de ces mesures répondent à des questions déjà posées ailleurs dans ce
fichier : les tailles de salle disent si le 8/16 du premium a un sens, et les
familles décochées disent lesquelles méritent d'être étoffées — ou réservées.

Tout vient d'une seule table, `evenements` : une arrivée en salle, une partie
lancée puis finie ou abandonnée, un thème tiré ou refusé, une inscription. Rien d'identifiant au-delà du compte, et rien du tout pour les invités,
qui n'y figurent que comme une présence anonyme. **Les visiteurs ne sont pas
comptés** — il faudrait les reconnaître d'une page à l'autre, donc déposer
quelque chose chez eux ou les empreinter, ce qui est hors de proportion ici.

Deux limites à garder en tête :

- **Les séries commencent au jour où la collecte a été posée.** Seules les
  inscriptions se reconstituent en arrière, la date de création ayant toujours
  été gardée.
- **Une partie n'est comptée qu'au lancement**, pas à l'ouverture de la salle :
  un salon où personne n'appuie sur « Lancer » ne figure nulle part.
- **Le premium est daté de l'inscription**, faute de mieux : on sait qui l'a,
  pas depuis quand. La courbe projette donc en arrière la vérité d'aujourd'hui,
  et se corrigera d'elle-même quand le paiement laissera une trace datée.

Les graphiques sont du SVG écrit à la main. La page n'a le droit de charger que
ses propres scripts : une bibliothèque de graphiques venue d'ailleurs serait
bloquée, et deux courbes ne valent pas une dépendance.

### Photo de profil

« Changer l'avatar » ouvre un **recadrage** : la photo se déplace au doigt ou
à la souris derrière un disque, un curseur règle le zoom, et seul le carré
retenu part vers le serveur. Le cadrage est **libre** — le carré peut sortir
de la photo, seul son centre doit y rester. Ce qui déborde reste transparent,
et la pastille laisse voir la couleur du joueur dessous ; exiger que le carré
tienne entièrement dans la photo figeait toute photo carrée — réduit à 128 px, en WebP quand le navigateur
sait l'encoder. La photo d'origine ne quitte jamais l'appareil : tout se passe
dans le navigateur, sur une toile de travail ramenée à 1024 px au plus.

### Où vivent les comptes ?

Trois stockages, dans cet ordre : **MySQL** s'il est configuré, sinon **Redis**,
sinon un **fichier**. Chacun ne prend la main que si le précédent est absent.

> MySQL passe **devant** Redis dès que `MYSQL_HOST` existe, et le jeu ne
> bascule jamais de stockage tout seul. Ne posez donc ces variables en
> production qu'après un `npm run db:check` concluant depuis la machine qui
> héberge le jeu : une base injoignable couperait les comptes qui marchent.

#### MySQL — le stockage voulu

Une base pour **toute la galaxie de jeux** : un joueur a un seul compte, un seul
nom d'utilisateur, un seul avatar, une seule couronne, quel que soit le jeu par
lequel il est arrivé. Deux tables, créées toutes seules au premier démarrage :

| Table | Contenu |
| --- | --- |
| `comptes` | le joueur : pseudo, lien Google, avatar, premium, date |
| `reglages` | ce qui doit être commun à toutes les instances (secret de session) |

**Rien de propre à Petits Papiers n'entre dans `comptes`.** Les scores, les
parties, les préférences d'un jeu donné iront dans leurs propres tables,
reliées à `comptes.id`.

**L'identité d'un compte, c'est son identifiant Google** : il est unique et
obligatoire en base, un compte ne naissant que par une connexion Google. Le
pseudo, lui, peut changer. L'adresse Google est gardée à côté comme recours,
rafraîchie à chaque connexion — un recours qui vieillit ne sert plus.

**Ouvrir une salle demande un compte.** C'est l'hôte qui règle la partie et à
qui la salle se rattache. Rejoindre reste ouvert à tous : un invité d'un soir
n'a pas à se créer un compte pour jouer. Là où la connexion Google n'est pas
configurée, les comptes n'existent pas et la règle ne s'applique pas — en
auto-hébergement, chacun ouvre des salles comme avant.

L'identifiant auto-incrémenté sert aussi de **numéro d'inscription**. Il ne
recule jamais : un compte supprimé ne libère pas son numéro, et un renommage
garde le sien — c'est la même ligne, mise à jour en une écriture. Il peut en
revanche sauter un numéro, MySQL en consommant un même quand l'écriture est
refusée. `PIONNIERS_MAX` borne donc des **numéros**, pas un nombre exact
d'inscrits.

Configuration dans `.env` (voir `.env.example`), jamais dans le code :

```
MYSQL_HOST=…      MYSQL_USER=…      MYSQL_PASSWORD=…
MYSQL_DATABASE=…  MYSQL_PORT=3306   MYSQL_SSL_CA=…
```

**TLS** : les bases managées (Aiven, PlanetScale…) n'acceptent rien d'autre.
`MYSQL_SSL_CA` prend le certificat de l'autorité, soit son contenu PEM soit un
chemin de fichier — la connexion est alors chiffrée *et* le serveur authentifié.
À défaut, `MYSQL_SSL=true` chiffre sans authentifier : ça dépanne, ça ne protège
pas d'un intermédiaire qui se ferait passer pour la base, et le journal le
rappelle à chaque démarrage.

Deux commandes, l'une pour la plomberie, l'autre pour le contenu :

```bash
npm run db:check     # connexion, TLS, création des tables
npm run db:comptes   # qui est inscrit, depuis quand, avec quels badges
```

`db:comptes` n'affiche jamais la valeur d'un réglage — le secret de session en
est un — ni le contenu des avatars, seulement leur taille.

Beaucoup d'hébergements mutualisés ne publient pas leur serveur MySQL : un hôte
en `.mysql.db` ne résout **que depuis l'hébergement**. Si `db:check` répond
`ENOTFOUND` depuis un poste de travail, c'est normal — il faut le lancer depuis
l'hébergement, ou demander l'accès distant au fournisseur.

Base injoignable ? **La soirée continue** : les salles, les votes et les scores
ne dépendent pas d'elle. Seuls les comptes s'effacent — pas de badge, aucun nom
d'utilisateur retenu — et les écritures de profil refusent franchement. Le jeu ne
bascule jamais sur un autre stockage tout seul : des comptes créés ailleurs
disparaîtraient au retour de la base.

#### Les deux replis

- **En auto-hébergement** : dans `data/users.json` (créé automatiquement,
  ignoré par git). Rien à configurer.
- **Sur Vercel** : le disque est en lecture seule, il faut une petite base.
  Le plan gratuit d'**Upstash Redis** via le Marketplace Vercel suffit
  largement : dans le tableau de bord Vercel → onglet *Storage* → *Create
  Database* → *Upstash Redis* (plan Free) → *Connect* au projet. Les
  variables `KV_REST_API_URL` / `KV_REST_API_TOKEN` sont injectées
  automatiquement et le serveur les détecte tout seul au déploiement
  suivant. Sans cette étape, l'authentification fonctionne quand même, mais
  les comptes vivent en mémoire et disparaissent quand l'instance est
  recyclée (comme les salles).

Optionnel : définir une variable d'environnement `AUTH_SECRET` (une longue
chaîne aléatoire) fige la clé de signature des sessions ; sinon le serveur
en génère une et la range là où elle survit (Redis, ou `data/session.key`).

### Connexion Google

C'est la seule façon de se connecter : un clic sur **« Continuer avec
Google »**, on choisit son compte, et le compte joueur est créé (ou
retrouvé). Le pseudo vient du champ « Ton pseudo » de l'accueil s'il est
rempli, sinon du prénom Google (numéroté en cas de collision). Vie privée :
sont lus l'identifiant technique Google (`sub`), le prénom — pour proposer un
pseudo — et l'adresse, gardée comme **recours** pour retrouver un compte quand
l'identifiant technique ne dit rien à un humain. L'adresse n'est jamais montrée
aux autres joueurs, et rien d'autre n'est conservé.

Configuration (gratuite ; sans elle, la carte « Compte joueur » disparaît et
le jeu s'utilise simplement sans compte) :

1. [console.cloud.google.com](https://console.cloud.google.com) → créer un
   projet → *APIs & Services* → *OAuth consent screen* (type External,
   publier l'app) → *Credentials* → *Create credentials* → *OAuth client
   ID* → type **Web application**.
2. Dans *Authorized redirect URIs*, ajouter
   `https://<votre-domaine>/api/auth/google/callback` (et
   `http://localhost:3000/api/auth/google/callback` pour tester en local).
3. Renseigner les variables d'environnement `GOOGLE_CLIENT_ID` et
   `GOOGLE_CLIENT_SECRET` (sur Vercel : *Settings* → *Environment
   Variables*, puis redéployer).

Le flux est le flux serveur OAuth 2.0 classique (code + état signé
anti-CSRF), implémenté dans `auth.js` sans bibliothèque.

## Mise en page

Sur **ordinateur**, la salle s'affiche en trois colonnes façon skribbl.io :
les joueurs à gauche (pseudo, statut de la manche, score, badge d'hôte), la
zone de jeu au centre, la discussion à droite en permanence. Chaque colonne
défile pour son compte, la page elle-même ne bouge pas.

Sur **mobile**, tout revient en une seule colonne : la zone de jeu occupe
l'écran, chaque phase affiche sa propre liste de joueurs, et la discussion
se replie dans un panneau coulissant ouvert par le bouton 💬. La bascule
entre les deux se fait à 1000 px, même en cours de partie (rotation de
l'écran, fenêtre redimensionnée).

## Discussion de salle

Un bouton 💬 (mobile) ou la colonne de droite (ordinateur) donne accès au
fil de discussion de la salle :
il sert à débattre pendant les votes, à chambrer, ou à se coordonner avant
le lancement. Une pastille compte les messages non lus quand le panneau est
fermé (inutile sur ordinateur, où le fil est toujours sous les yeux).

Le fil est **propre à chaque salle** et **entièrement effacé dès que le
dernier joueur quitte la salle** : rien n'est écrit sur disque, rien ne
survit à la partie. Les soixante derniers messages sont conservés le temps
de la partie, ce qui permet à quelqu'un qui revient de retrouver le fil.

## Architecture

- `server.js` — serveur Node.js unique : sert les fichiers statiques, expose
  l'API des comptes (`auth.js`, routes `/api/auth/*`) et gère les salles de
  jeu en mémoire via WebSocket (bibliothèque `ws`). Aucune base de données
  requise (Redis optionnel pour la persistance des comptes).
- `log.js` — le journal : une ligne par action, sur la sortie standard (voir
  « Journal » plus bas).
- `public/` — la PWA (vanilla JS, aucune dépendance front) : manifeste,
  service worker, une seule page qui bascule entre les écrans selon la phase.
  Les effets sonores sont synthétisés en Web Audio (aucun fichier audio) ;
  bouton 🔊/🔇 dans la barre du haut, bouton 🚪 pour quitter la salle.

Reconnexion automatique : si un joueur recharge la page ou perd le réseau, il
reprend sa place dans la partie en cours (identifiant stocké côté client).

Si l'**hôte s'en va** (bouton quitter, réseau coupé, appli fermée), la place
est aussitôt reprise par un des joueurs restants, **tiré au sort** : la partie
ne reste jamais bloquée faute de quelqu'un pour appuyer sur les boutons.

Garde-fous du serveur : 16 joueurs par salle au maximum, une seule salle par
connexion, battement de cœur toutes les 30 s pour repérer les liens morts, et
balayage des salles sans joueur vivant au bout de 10 minutes. Les réponses
statiques portent une `Content-Security-Policy` restreinte au strict
nécessaire (l'app, plus les polices Google).

## Journal (comprendre un bug en production)

Chaque action laisse une ligne sur la sortie standard du serveur — là où
l'hébergeur les collecte (Vercel → onglet *Logs*, `docker logs`, ou la fenêtre
du serveur en auto-hébergement) :

```
2026-08-27T20:14:03.981Z info  room.create sock=94235f24 room=BYJR player=531d7cdb name=Alice rooms=1
2026-08-27T20:14:12.068Z info  vote sock=a535a13b room=BYJR player=102b5f9f name=Bob cible=Alice votes=2/2 paper=1/2
2026-08-27T20:14:12.068Z info  phase.placed room=BYJR paper=1/2 target=Alice counts="{\"Alice\":2}"
2026-08-27T20:14:19.293Z warn  action.refus sock=a535a13b room=BYJR player=102b5f9f type=next reason=pas-hote phase=reveal
```

Le format `clé=valeur` se filtre au grep : `grep room=BYJR`, `grep action.refus`
(ce qu'un joueur a tenté sans effet — la première piste quand « le bouton ne
fait rien »), `grep client.` (ce qui vient des navigateurs).

**Le navigateur écrit dans le même journal.** L'app envoie ses gestes par
paquets sur `/api/log` : clics, écrans traversés, envois WebSocket, messages
affichés, erreurs JavaScript, mise en veille du téléphone. C'est ce qui permet
de reconstituer un « ça a planté chez moi » sans avoir l'appareil en main.

Ce qui n'entre **jamais** dans le journal : le texte des papiers et des
messages (seule leur longueur est notée), les mots de passe de salle, les
avatars, les jetons de session. Les identifiants sont coupés à 8 caractères.

Réglages, tous facultatifs :

| Variable | Effet |
| --- | --- |
| `LOG_LEVEL` | `error`, `warn`, `info` (défaut) ou `debug` — `debug` ajoute chaque message WebSocket reçu, chaque diffusion et chaque fichier servi |
| `LOG_TOKEN` | active `/api/logs?token=…`, qui affiche les dernières lignes gardées en mémoire (pratique quand la console de l'hébergeur n'est pas accessible depuis le téléphone) |
| `LOG_KEEP` | nombre de lignes gardées pour cette page (500 par défaut) |

Toutes les cinq minutes, une ligne `stats` résume l'état du serveur (salles,
joueurs connectés, sockets, mémoire) : c'est ce qu'on regarde en premier quand
« ça rame » ou qu'une salle semble ne jamais disparaître.

## Jouer en ligne

Le jeu est déployé en continu sur Vercel (chaque push sur `main` met la
production à jour automatiquement) :

**https://petits-papiers-golden-efde.vercel.app**

Rien à installer, rien à laisser allumé : on partage l'URL (ou le QR code
du salon) et on joue. Limite à connaître : les salles vivent en mémoire du
serveur — si l'infrastructure recycle l'instance (long moment d'inactivité,
montée en charge), les salles en cours sont perdues et il faut en recréer
une. Pour des soirées entre amis, c'est transparent.

## Alternative : héberger soi-même (tunnel Cloudflare)

Le plus simple pour une soirée : exposer le serveur local via un tunnel
Cloudflare (gratuit, sans compte). Sous Windows :

```bash
powershell -File start-online.ps1
```

Le script lance le serveur si besoin, télécharge `cloudflared` (outil
officiel Cloudflare) au premier lancement, ouvre le tunnel, et copie l'URL
publique `https://xxx.trycloudflare.com` dans le presse-papiers. À partager
avec les joueurs. Limites : le PC doit rester allumé, et l'URL change à
chaque relance du tunnel.

Sous Mac/Linux : installer cloudflared (`brew install cloudflared` ou
paquet distro), puis `npm start` dans un terminal et
`cloudflared tunnel --url http://localhost:3000` dans un autre — l'URL
publique s'affiche dans la sortie.

## Déployer ailleurs

N'importe quel hébergeur Node avec support WebSocket fait aussi l'affaire
(Render, Railway, Fly.io…) : `npm start` comme commande de démarrage, port
fourni via la variable `PORT` (déjà géré). Le HTTPS de l'hébergeur est
nécessaire pour installer la PWA et pour `wss://` (géré automatiquement par
le client).

## Publier sur les stores plus tard

La même base de code peut être enveloppée avec
[Capacitor](https://capacitorjs.com/) pour produire une app iOS + Android
publiable sur les stores, sans réécriture.
