Commit 40c511e7 authored by Kourser's avatar Kourser
Browse files

Bac à sable : une session se partage, et le joueur sait où il est

Cinq manques, tous constatés en jouant une session de découverte.

LA DÉCOUVERTE ÉTAIT SOLITAIRE. Les liens de rôle n'étaient donnés
qu'une fois, sur l'écran de celui qui avait ouvert la session — « gardez
cette page ouverte », disait la note. Personne d'autre ne pouvait
rejoindre la cellule, alors qu'une crise se joue à plusieurs, ce qui est
tout l'objet du produit.

La création ne rend donc plus des liens, mais une ADRESSE. Chacun
l'ouvre, voit les rôles déjà pris, et choisit parmi ceux qui restent.

Conséquence sur les jetons, et c'est la raison de ce découpage : aucun
n'est émis à la création. Ils le sont un par un, à la prise de rôle, et
ne sont montrés qu'à celui qui prend le rôle. Partager l'adresse d'une
session ne distribue donc pas les accès de tout le monde, et la règle du
produit tient toujours — seule l'empreinte du jeton est stockée. Un
premier essai stockait le lien en clair pour pouvoir le redonner : il
fallait contredire cette règle, c'était le signe que le découpage était
mauvais.

Le slug de l'organisation éphémère passe de cinq à seize octets : il
n'ouvrait rien, il ouvre désormais une page où l'on prend un rôle.

Un rôle occupé ne peut pas être repris — le reprendre reviendrait à
mettre dehors celui qui le joue, sans qu'il comprenne pourquoi.

LE JOUEUR ARRIVAIT SANS SAVOIR OÙ IL ÉTAIT. Le dossier de mise en
situation existait pourtant dans les trois kits concernés : il décrit
l'organisation, son activité, ses dépendances. Il était rangé dans
l'onglet Documents, où personne ne pense à le chercher en arrivant. Il
passe maintenant AVANT la fiche réflexe dans le briefing d'accueil, et
l'en-tête garde un bouton pour le relire — c'est en cours de partie
qu'on doute du contexte.

Le briefing s'affiche désormais même sans fiche réflexe : les kits
communaux n'en portaient pas, et le contexte ne doit pas disparaître
avec elles.

LA SESSION EST LIMITÉE DANS LE TEMPS, et cette limite était invisible
une fois entré : le joueur découvrait la fin en perdant son accès. Un
décompte tient l'en-tête, et passe au rouge dans le dernier quart
d'heure.

UN SECOND SCÉNARIO, orienté service public : inondation communale, cinq
rôles, plan communal de sauvegarde. Le kit existait mais n'avait pas de
fiches réflexes, or c'est ce que le bac à sable ouvre en grand — cinq
fiches sont écrites pour ses rôles, qui ne figurent pas au référentiel
cyber du produit.

Le nom du personnage porte l'identification sur la page partagée, pas
son titre : les deux kits n'emploient pas la même convention, et cinq
rôles communaux s'affichaient sous le même intitulé « Cellule de crise ».

La carte de mise en situation était écrasée à trente pixels — titre
visible, texte inatteignable. Un élément flex se laisse comprimer par
défaut ; c'est la fenêtre qui défile.

Recette portée de 130 à 145 cas.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 3d0de287
Loading
Loading
Loading
Loading
+51 −2
Changes for apps/api/src/exercises/play.service.ts: 51 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -9,9 +9,31 @@ import { hashAccessToken } from './access-token';
export interface PlaySession {
  participant: { id: string; displayName: string | null };
  character: { name: string; title: string | null };
  exercise: { id: string; name: string; status: string; playerSkin: string };
  exercise: {
    id: string;
    name: string;
    status: string;
    playerSkin: string;
    /** Echeance de l'acces, quand il en a une. `null` = pas de compte a rebours. */
    expiresAt?: string | null;
    /** Dossier de mise en situation, s'il en existe un. */
    situation?: { name: string; body: string } | null;
  };
}

/**
 * Dossier de mise en situation : le document qui dit QUI EST L'ORGANISATION.
 *
 * Convention des kits fournis — un dossier « Mise en situation » contenant une
 * piece unique. Il decrit l'entreprise ou la collectivite, son activite, ses
 * dependances : ce qu'un joueur doit savoir AVANT d'endosser un role.
 *
 * Il vivait dans l'onglet Documents, ou personne ne pensait a le chercher en
 * arrivant. Un joueur decouvrait donc sa fiche de poste sans rien savoir de
 * l'organisation qu'il est cense diriger — constate en jouant le bac a sable.
 */
const DOSSIER_MISE_EN_SITUATION = 'Mise en situation';

/** Session « voir comme » remise a l'animateur, portee par un seul onglet. */
export interface ImpersonationSession {
  session: PlaySession;
@@ -76,12 +98,39 @@ export class PlayService {
      session: {
        participant: { id: participant.id, displayName: participant.displayName },
        character: participant.character,
        exercise: participant.exercise,
        exercise: {
          ...participant.exercise,
          expiresAt: participant.tokenExpiresAt?.toISOString() ?? null,
          situation: await this.situation(participant.tenantId, participant.exerciseId),
        },
      },
      sessionToken,
    };
  }

  /**
   * Dossier de mise en situation de l'exercice, ou `null`.
   *
   * Volontairement tolerant : un exercice ecrit a la main n'a aucune raison de
   * suivre la convention des kits, et son absence ne doit rien casser.
   */
  private async situation(
    tenantId: string,
    exerciseId: string,
  ): Promise<{ name: string; body: string } | null> {
    const doc = await this.prisma.forTenant(tenantId).document.findFirst({
      where: {
        exerciseId,
        folder: DOSSIER_MISE_EN_SITUATION,
        // Jamais un document reserve a l'animation : cette surface est joueur.
        audience: 'PLAYERS',
      },
      select: { name: true, body: true },
      orderBy: { createdAt: 'asc' },
    });
    return doc && doc.body.trim() ? doc : null;
  }

  /**
   * Ouvre une session « voir comme » sur un participant, pour un animateur.
   *
+1 −1
Changes for apps/api/src/kits/catalogue.ts: 1 added line, 1 removed line.
Original line number Diff line number Diff line
@@ -50,4 +50,4 @@ export const CATALOGUE: Kit[] = [...VITRINE, ...REMPAR25, ...NON_CYBER];
 * d'une organisation, meme par erreur de requete. Toute ouverture passe par une
 * modification de code, donc par une revue.
 */
export const SANDBOX_KIT_IDS: readonly string[] = ['vitrine-pme-rancongiciel'];
export const SANDBOX_KIT_IDS: readonly string[] = ['vitrine-pme-rancongiciel', 'inondation-communale'];
+85 −0
Changes for apps/api/src/kits/data/inondation-communale.json: 85 added lines, 0 removed lines.
Original line number Diff line number Diff line
@@ -255,5 +255,90 @@
      "body": "La décrue est amorcée. Préparez le retour à la normale : réintégration des habitants, constats d'assurance, sécurisation des bâtiments et RETEX. Fin de l'exercice.",
      "senderRef": "prefecture"
    }
  ],
  "reflexSheets": [
    {
      "role": "Directeur des opérations de secours",
      "characterRef": "dos",
      "mission": "Diriger. Le maire est directeur des opérations de secours sur sa commune tant que {{prefecture}} n’a pas pris le relais. Fixer le cap, arbitrer entre mise à l’abri, évacuation et maintien sur place, et assumer ces décisions.",
      "risks": "Décider sans vue d’ensemble de la montée des eaux, tarder à alerter la population, laisser partir des équipes sur un secteur qui se coupe, ne plus savoir qui a été prévenu ni quand.",
      "reflexes": [
        "J’active le plan communal de sauvegarde et je le dis explicitement : chacun doit savoir qu’on y est.",
        "Je fais ouvrir la main courante dès la première minute, et je m’assure qu’elle est tenue sans discontinuer.",
        "Je fixe le rythme des points de situation, et je m’y tiens même quand tout va vite.",
        "Je décide de l’alerte à la population sur ce que je sais, sans attendre une certitude qui ne viendra pas.",
        "Je désigne nommément qui parle aux médias et aux habitants, et je rappelle que personne d’autre ne le fait.",
        "Je préviens {{prefecture}} tôt, même sans demande de renfort : elle doit voir venir.",
        "Je fais recenser les personnes vulnérables du secteur menacé avant que l’accès ne devienne difficile.",
        "Je ne laisse partir aucune équipe sans point de retour ni heure de rappel.",
        "Je dis ce que je ne sais pas, plutôt que de laisser croire que je le sais."
      ],
      "position": 0
    },
    {
      "role": "Communication",
      "characterRef": "communication",
      "mission": "Informer les habitants, vite et sans ambiguïté. Tenir une parole publique unique, cohérente avec ce que fait la cellule, et corriger sans délai ce qui circule de faux.",
      "risks": "Consignes contradictoires d’un canal à l’autre, silence qui laisse la rumeur occuper la place, promesse de délai que la crue ne respectera pas, ton administratif qui ne fait pas agir.",
      "reflexes": [
        "J’écris des consignes qui se traduisent en gestes : ce qu’il faut faire, où, et maintenant.",
        "Je publie sur tous les canaux de la commune en même temps, et je date chaque message.",
        "Je ne diffuse aucun chiffre ni aucune heure que la cellule n’a pas validés.",
        "Je surveille ce qui circule sur les réseaux et je corrige les fausses informations en les citant.",
        "Je prévois le canal des gens qui n’ont ni internet ni smartphone : porte-à-porte, haut-parleur, affichage.",
        "Je dis quand aura lieu le prochain point, pour que le silence entre deux messages ne soit pas lu comme un abandon.",
        "Je fais relire au directeur des opérations de secours tout message qui engage une décision.",
        "Je garde une copie horodatée de chaque publication : elle servira au retour d’expérience."
      ],
      "position": 1
    },
    {
      "role": "Logistique & sécurité",
      "characterRef": "logistique-securite",
      "mission": "Rendre les décisions applicables : moyens, barrières, hébergement, ravitaillement. Sécuriser les accès et protéger ceux qui interviennent.",
      "risks": "Ouvrir un hébergement sans savoir le tenir dans la durée, envoyer des agents sur une voie déjà coupée, épuiser les équipes faute de relève, perdre le compte du matériel engagé.",
      "reflexes": [
        "Je tiens à jour la liste des voies coupées et je la fais connaître à toute la cellule.",
        "Je fais poser les barrières avant que la route ne devienne infranchissable, pas après.",
        "J’ouvre le lieu d’hébergement en vérifiant d’abord qu’il est hors d’eau et accessible.",
        "Je prévois l’eau, les couvertures et les sanitaires pour la nuit, pas seulement pour l’heure qui vient.",
        "J’organise la relève des équipes dès le début : une crue dure plus longtemps qu’une garde.",
        "Je note ce qui est engagé et où, pour pouvoir le récupérer et le justifier ensuite.",
        "Je refuse toute intervention dans l’eau sans équipement adapté, quelle que soit l’urgence invoquée."
      ],
      "position": 2
    },
    {
      "role": "Cellule terrain",
      "characterRef": "cellule-terrain",
      "mission": "Voir, et faire remonter ce qui est vu. Alerter les habitants sur place, mettre à l’abri les personnes vulnérables, et renseigner la cellule sur l’état réel du terrain.",
      "risks": "Prendre un risque personnel qui transforme un secouriste en victime, remonter une impression pour un fait, rester injoignable, oublier un signalement d’habitant.",
      "reflexes": [
        "Je ne m’engage jamais dans une zone dont je ne vois pas le fond, à pied comme en véhicule.",
        "Je distingue clairement, dans chaque compte rendu, ce que j’ai vu de ce qu’on m’a rapporté.",
        "Je donne ma position et l’heure à chaque message : une information sans lieu ni heure ne sert à rien.",
        "Je vais d’abord aux adresses des personnes vulnérables recensées, dans l’ordre donné par la cellule.",
        "Je note le nom et l’adresse de chaque personne mise à l’abri, et je les transmets.",
        "Je signale immédiatement toute voie qui vient d’être coupée, avant même d’avoir fini ma tournée.",
        "Je rends compte de mon retour : la cellule doit savoir que je suis rentré."
      ],
      "position": 3
    },
    {
      "role": "Secrétariat / main courante",
      "characterRef": "secretariat",
      "mission": "Tenir la mémoire de la crise. Consigner les faits, les décisions, leur heure et leur auteur, et rendre cette trace consultable pendant la crise autant qu’après.",
      "risks": "Trous dans la chronologie au moment où tout s’accélère, décisions notées sans leur auteur, notes illisibles pour qui prend la suite, mélange entre ce qui est décidé et ce qui est seulement envisagé.",
      "reflexes": [
        "Je note l’heure de chaque entrée, sans exception : une main courante sans heures ne prouve rien.",
        "J’écris qui a décidé quoi, nommément.",
        "Je sépare les faits, les décisions et les hypothèses : les confondre rendra le retour d’expérience faux.",
        "Je consigne aussi ce qui a été décidé de NE PAS faire, et pourquoi.",
        "Je relis à voix haute les décisions importantes pour que la cellule les confirme.",
        "Je continue à écrire quand tout s’accélère : c’est précisément là que la trace manquera.",
        "Je prépare le point de situation suivant à partir de la main courante, pas de ma mémoire."
      ],
      "position": 4
    }
  ]
}
+56 −4
Changes for apps/api/src/sandbox/sandbox.controller.ts: 56 added lines, 4 removed lines.
Original line number Diff line number Diff line
import { Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from '@nestjs/common';
import {
  Body,
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  Param,
  Post,
  UseGuards,
} from '@nestjs/common';
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { RateLimit, RateLimitGuard } from '../common/rate-limit.guard';
import { SandboxService } from './sandbox.service';

const MINUTE = 60_000;

/** Choix du scenario, a la creation d'une session. */
class CreateSandboxDto {
  /** Identifiant de kit. Inconnu ou absent : le premier scenario s'applique. */
  @IsOptional()
  @IsString()
  @MaxLength(64)
  scenarioId?: string;
}

/** Prise d'un role sur une session existante. */
class ClaimRoleDto {
  @IsString()
  @MaxLength(64)
  characterId!: string;
}

/**
 * Bac a sable public (EV-7). Aucune authentification, donc plafonnee.
 *
@@ -17,7 +43,7 @@ const MINUTE = 60_000;
export class SandboxController {
  constructor(private readonly sandbox: SandboxService) {}

  /** Le bac a sable est-il ouvert sur cette instance ? */
  /** Le bac a sable est-il ouvert, et sur quels scenarios ? */
  @Get()
  @RateLimit(60, 10 * MINUTE)
  state() {
@@ -31,7 +57,33 @@ export class SandboxController {
  @Post()
  @RateLimit(3, 60 * MINUTE)
  @HttpCode(HttpStatus.CREATED)
  create() {
    return this.sandbox.create();
  create(@Body() dto: CreateSandboxDto) {
    return this.sandbox.create(dto.scenarioId);
  }

  /**
   * Page d'une session, adressee par son identifiant public.
   *
   * Plafond LARGE : c'est la page qu'une cellule entiere ouvre en meme temps,
   * puis rafraichit pour voir les roles se remplir. La trop serrer punirait
   * l'usage normal — le geste couteux est la creation, pas la lecture.
   */
  @Get(':session')
  @RateLimit(120, 10 * MINUTE)
  session(@Param('session') slug: string) {
    return this.sandbox.session(slug);
  }

  /**
   * Prend un role libre et rend son lien de jeu.
   *
   * Plafonne plus serre que la lecture : c'est une ecriture, et elle consomme
   * une ressource rare — il n'y a qu'un exemplaire de chaque role.
   */
  @Post(':session/roles')
  @RateLimit(20, 10 * MINUTE)
  @HttpCode(HttpStatus.CREATED)
  claim(@Param('session') slug: string, @Body() dto: ClaimRoleDto) {
    return this.sandbox.claim(slug, dto.characterId);
  }
}
+227 −60

File changed.

Preview size limit exceeded, changes collapsed.

Loading