Files
Portfolio-Game/docs/implementation-artifacts/3-1-table-narrator-texts-api-narrateur.md
skycel c572af3072 Add narrator texts infrastructure with API (Story 3.1)
- Create narrator_texts table migration with context/hero_type indexes
- Add NarratorText model with getRandomText() for variant selection
- Add NarratorTextSeeder with 30+ texts for 11 contexts
- Implement vouvoiement (recruteur) vs tutoiement (client/dev)
- Create NarratorController with GET /api/narrator/{context}
- Add useFetchNarratorText composable for frontend

Contexts: intro, transitions, hints, encouragements, contact_unlocked, welcome_back

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 02:45:05 +01:00

19 KiB

Story 3.1: Table narrator_texts et API narrateur

Status: review

Story

As a développeur, I want une infrastructure pour stocker et servir les textes du narrateur, so that le narrateur peut afficher des messages contextuels variés.

Acceptance Criteria

  1. Given les migrations Laravel sont exécutées When php artisan migrate est lancé Then la table narrator_texts est créée (id, context, text_key, variant, timestamps)
  2. And les contextes définis incluent : intro, transition_projects, transition_skills, transition_testimonials, transition_journey, hint, encouragement_25, encouragement_50, encouragement_75, contact_unlocked, welcome_back
  3. And plusieurs variantes par contexte permettent une sélection aléatoire
  4. And les seeders insèrent les textes de base en FR et EN dans la table translations
  5. Given l'API /api/narrator/{context} est appelée When un contexte valide est fourni Then un texte aléatoire parmi les variantes de ce contexte est retourné
  6. And le texte est traduit selon le header Accept-Language
  7. And le ton est adapté au héros (vouvoiement pour Recruteur, tutoiement pour Client/Dev)

Tasks / Subtasks

  • Task 1: Créer la migration table narrator_texts (AC: #1, #2, #3)

    • Créer migration create_narrator_texts_table
    • Colonnes : id, context (string), text_key (string), variant (integer), hero_type (enum nullable: recruteur, client, dev), timestamps
    • Index sur context pour le filtrage
    • Index composite sur (context, hero_type) pour les requêtes
  • Task 2: Créer le Model NarratorText (AC: #3)

    • Créer app/Models/NarratorText.php
    • Définir les fillable : context, text_key, variant, hero_type
    • Scope scopeForContext($query, $context) pour filtrer par contexte
    • Scope scopeForHero($query, $heroType) pour filtrer par héros
    • Méthode statique getRandomText($context, $heroType = null) pour récupérer un texte aléatoire
  • Task 3: Créer le Seeder des textes narrateur (AC: #4)

    • Créer database/seeders/NarratorTextSeeder.php
    • Créer les textes pour chaque contexte avec 2-3 variantes
    • Créer des variantes spécifiques par héros (vouvoiement recruteur, tutoiement client/dev)
    • Ajouter les traductions FR et EN directement dans le seeder
  • Task 4: Créer l'endpoint API narrateur (AC: #5, #6, #7)

    • Créer app/Http/Controllers/Api/NarratorController.php
    • Méthode getText($context) pour récupérer un texte aléatoire
    • Paramètre query optionnel ?hero=recruteur|client|dev
    • Joindre les traductions selon Accept-Language
    • Retourner 404 si contexte invalide
  • Task 5: Créer le composable useFetchNarratorText (AC: #5)

    • Créer frontend/app/composables/useFetchNarratorText.ts
    • Accepter le contexte et le type de héros en paramètres
    • Fonction fetchText async avec gestion d'erreurs
  • Task 6: Tests et validation

    • Migration exécutée
    • Seeding des données réussi
    • Build frontend validé

Dev Notes

Migration narrator_texts

<?php
// database/migrations/2026_02_04_000002_create_narrator_texts_table.php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('narrator_texts', function (Blueprint $table) {
            $table->id();
            $table->string('context');
            $table->string('text_key');
            $table->integer('variant')->default(1);
            $table->enum('hero_type', ['recruteur', 'client', 'dev'])->nullable();
            $table->timestamps();

            $table->index('context');
            $table->index(['context', 'hero_type']);
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('narrator_texts');
    }
};

Model NarratorText

<?php
// api/app/Models/NarratorText.php

namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class NarratorText extends Model
{
    protected $fillable = [
        'context',
        'text_key',
        'variant',
        'hero_type',
    ];

    public function scopeForContext($query, string $context)
    {
        return $query->where('context', $context);
    }

    public function scopeForHero($query, ?string $heroType)
    {
        if ($heroType) {
            return $query->where(function ($q) use ($heroType) {
                $q->where('hero_type', $heroType)
                  ->orWhereNull('hero_type');
            });
        }
        return $query->whereNull('hero_type');
    }

    public static function getRandomText(string $context, ?string $heroType = null): ?self
    {
        $query = static::forContext($context);

        if ($heroType) {
            // Priorité aux textes spécifiques au héros, sinon textes génériques
            $heroSpecific = (clone $query)->where('hero_type', $heroType)->inRandomOrder()->first();
            if ($heroSpecific) {
                return $heroSpecific;
            }
        }

        return $query->whereNull('hero_type')->inRandomOrder()->first();
    }
}

Contextes du narrateur

Contexte Description Variantes
intro Message d'accueil initial 3 par héros
transition_projects Arrivée sur la page Projets 2 génériques
transition_skills Arrivée sur la page Compétences 2 génériques
transition_testimonials Arrivée sur la page Témoignages 2 génériques
transition_journey Arrivée sur la page Parcours 2 génériques
hint Indices si inactif > 30s 3 génériques
encouragement_25 Progression à 25% 2 génériques
encouragement_50 Progression à 50% 2 génériques
encouragement_75 Progression à 75% 2 génériques
contact_unlocked Déblocage du contact 2 génériques
welcome_back Retour d'un visiteur 2 génériques

Seeder des textes narrateur

<?php
// database/seeders/NarratorTextSeeder.php

namespace Database\Seeders;

use App\Models\NarratorText;
use App\Models\Translation;
use Illuminate\Database\Seeder;

class NarratorTextSeeder extends Seeder
{
    public function run(): void
    {
        $texts = [
            // INTRO - Recruteur (vouvoiement)
            ['context' => 'intro', 'text_key' => 'narrator.intro.recruteur.1', 'variant' => 1, 'hero_type' => 'recruteur'],
            ['context' => 'intro', 'text_key' => 'narrator.intro.recruteur.2', 'variant' => 2, 'hero_type' => 'recruteur'],

            // INTRO - Client/Dev (tutoiement)
            ['context' => 'intro', 'text_key' => 'narrator.intro.casual.1', 'variant' => 1, 'hero_type' => 'client'],
            ['context' => 'intro', 'text_key' => 'narrator.intro.casual.1', 'variant' => 1, 'hero_type' => 'dev'],
            ['context' => 'intro', 'text_key' => 'narrator.intro.casual.2', 'variant' => 2, 'hero_type' => 'client'],
            ['context' => 'intro', 'text_key' => 'narrator.intro.casual.2', 'variant' => 2, 'hero_type' => 'dev'],

            // TRANSITIONS
            ['context' => 'transition_projects', 'text_key' => 'narrator.transition.projects.1', 'variant' => 1, 'hero_type' => null],
            ['context' => 'transition_projects', 'text_key' => 'narrator.transition.projects.2', 'variant' => 2, 'hero_type' => null],
            ['context' => 'transition_skills', 'text_key' => 'narrator.transition.skills.1', 'variant' => 1, 'hero_type' => null],
            ['context' => 'transition_skills', 'text_key' => 'narrator.transition.skills.2', 'variant' => 2, 'hero_type' => null],
            ['context' => 'transition_testimonials', 'text_key' => 'narrator.transition.testimonials.1', 'variant' => 1, 'hero_type' => null],
            ['context' => 'transition_journey', 'text_key' => 'narrator.transition.journey.1', 'variant' => 1, 'hero_type' => null],

            // HINTS
            ['context' => 'hint', 'text_key' => 'narrator.hint.1', 'variant' => 1, 'hero_type' => null],
            ['context' => 'hint', 'text_key' => 'narrator.hint.2', 'variant' => 2, 'hero_type' => null],
            ['context' => 'hint', 'text_key' => 'narrator.hint.3', 'variant' => 3, 'hero_type' => null],

            // ENCOURAGEMENTS
            ['context' => 'encouragement_25', 'text_key' => 'narrator.encouragement.25.1', 'variant' => 1, 'hero_type' => null],
            ['context' => 'encouragement_50', 'text_key' => 'narrator.encouragement.50.1', 'variant' => 1, 'hero_type' => null],
            ['context' => 'encouragement_75', 'text_key' => 'narrator.encouragement.75.1', 'variant' => 1, 'hero_type' => null],

            // CONTACT UNLOCKED
            ['context' => 'contact_unlocked', 'text_key' => 'narrator.contact_unlocked.1', 'variant' => 1, 'hero_type' => null],

            // WELCOME BACK
            ['context' => 'welcome_back', 'text_key' => 'narrator.welcome_back.1', 'variant' => 1, 'hero_type' => null],
            ['context' => 'welcome_back', 'text_key' => 'narrator.welcome_back.2', 'variant' => 2, 'hero_type' => null],
        ];

        foreach ($texts as $data) {
            NarratorText::create($data);
        }

        // Traductions
        $translations = [
            // Intro Recruteur (vouvoiement)
            ['key' => 'narrator.intro.recruteur.1', 'fr' => "Bienvenue, voyageur... Vous voilà arrivé en terre inconnue. Un développeur mystérieux se cache quelque part ici. Saurez-vous le trouver ?", 'en' => "Welcome, traveler... You have arrived in unknown lands. A mysterious developer hides somewhere here. Will you be able to find them?"],
            ['key' => 'narrator.intro.recruteur.2', 'fr' => "Ah, un visiteur distingué... Je sens que vous cherchez quelqu'un de particulier. Laissez-moi vous guider dans cette aventure.", 'en' => "Ah, a distinguished visitor... I sense you're looking for someone special. Let me guide you through this adventure."],

            // Intro Client/Dev (tutoiement)
            ['key' => 'narrator.intro.casual.1', 'fr' => "Tiens tiens... Un nouveau venu ! Tu tombes bien, j'ai quelqu'un à te présenter. Mais d'abord, un peu d'exploration s'impose...", 'en' => "Well well... A newcomer! You're just in time, I have someone to introduce you to. But first, a bit of exploration is in order..."],
            ['key' => 'narrator.intro.casual.2', 'fr' => "Salut l'ami ! Bienvenue dans mon monde. Tu cherches le développeur qui a créé tout ça ? Suis-moi, je connais le chemin...", 'en' => "Hey friend! Welcome to my world. Looking for the developer who created all this? Follow me, I know the way..."],

            // Transitions
            ['key' => 'narrator.transition.projects.1', 'fr' => "Voici les créations du développeur... Chaque projet raconte une histoire. Laquelle vas-tu explorer ?", 'en' => "Here are the developer's creations... Each project tells a story. Which one will you explore?"],
            ['key' => 'narrator.transition.projects.2', 'fr' => "Bienvenue dans la galerie des projets. C'est ici que le code prend vie...", 'en' => "Welcome to the project gallery. This is where code comes to life..."],
            ['key' => 'narrator.transition.skills.1', 'fr' => "L'arbre des compétences... Chaque branche représente un savoir acquis au fil du temps.", 'en' => "The skill tree... Each branch represents knowledge acquired over time."],
            ['key' => 'narrator.transition.skills.2', 'fr' => "Voici les outils de notre ami développeur. Impressionnant, n'est-ce pas ?", 'en' => "Here are our developer friend's tools. Impressive, isn't it?"],
            ['key' => 'narrator.transition.testimonials.1', 'fr' => "D'autres voyageurs sont passés par ici avant toi. Écoute leurs histoires...", 'en' => "Other travelers have passed through here before you. Listen to their stories..."],
            ['key' => 'narrator.transition.journey.1', 'fr' => "Le chemin parcouru... Chaque étape a façonné le développeur que tu cherches.", 'en' => "The path traveled... Each step has shaped the developer you're looking for."],

            // Hints
            ['key' => 'narrator.hint.1', 'fr' => "Tu sembles perdu... N'hésite pas à explorer les différentes zones !", 'en' => "You seem lost... Don't hesitate to explore the different areas!"],
            ['key' => 'narrator.hint.2', 'fr' => "Psst... Il reste encore tant de choses à découvrir ici...", 'en' => "Psst... There's still so much to discover here..."],
            ['key' => 'narrator.hint.3', 'fr' => "La carte peut t'aider à naviguer. Clique dessus !", 'en' => "The map can help you navigate. Click on it!"],

            // Encouragements
            ['key' => 'narrator.encouragement.25.1', 'fr' => "Beau début ! Tu as exploré un quart du territoire. Continue comme ça...", 'en' => "Great start! You've explored a quarter of the territory. Keep it up..."],
            ['key' => 'narrator.encouragement.50.1', 'fr' => "À mi-chemin ! Tu commences vraiment à connaître cet endroit.", 'en' => "Halfway there! You're really starting to know this place."],
            ['key' => 'narrator.encouragement.75.1', 'fr' => "Impressionnant ! Plus que quelques zones et tu auras tout vu...", 'en' => "Impressive! Just a few more areas and you'll have seen everything..."],

            // Contact unlocked
            ['key' => 'narrator.contact_unlocked.1', 'fr' => "Tu as assez exploré pour mériter une rencontre... Le chemin vers le développeur est maintenant ouvert !", 'en' => "You've explored enough to deserve a meeting... The path to the developer is now open!"],

            // Welcome back
            ['key' => 'narrator.welcome_back.1', 'fr' => "Te revoilà ! Tu m'avais manqué... On reprend là où on s'était arrêtés ?", 'en' => "You're back! I missed you... Shall we pick up where we left off?"],
            ['key' => 'narrator.welcome_back.2', 'fr' => "Tiens, un visage familier ! Content de te revoir, voyageur.", 'en' => "Well, a familiar face! Good to see you again, traveler."],
        ];

        foreach ($translations as $t) {
            Translation::firstOrCreate(
                ['lang' => 'fr', 'key_name' => $t['key']],
                ['value' => $t['fr']]
            );
            Translation::firstOrCreate(
                ['lang' => 'en', 'key_name' => $t['key']],
                ['value' => $t['en']]
            );
        }
    }
}

Controller API

<?php
// api/app/Http/Controllers/Api/NarratorController.php

namespace App\Http\Controllers\Api;

use App\Http\Controllers\Controller;
use App\Models\NarratorText;
use App\Models\Translation;
use Illuminate\Http\Request;

class NarratorController extends Controller
{
    private const VALID_CONTEXTS = [
        'intro',
        'transition_projects',
        'transition_skills',
        'transition_testimonials',
        'transition_journey',
        'hint',
        'encouragement_25',
        'encouragement_50',
        'encouragement_75',
        'contact_unlocked',
        'welcome_back',
    ];

    public function getText(Request $request, string $context)
    {
        if (!in_array($context, self::VALID_CONTEXTS)) {
            return response()->json([
                'error' => [
                    'code' => 'INVALID_CONTEXT',
                    'message' => 'Invalid narrator context',
                    'valid_contexts' => self::VALID_CONTEXTS,
                ]
            ], 404);
        }

        $lang = $request->header('Accept-Language', 'fr');
        $heroType = $request->query('hero');

        // Valider hero_type
        if ($heroType && !in_array($heroType, ['recruteur', 'client', 'dev'])) {
            $heroType = null;
        }

        $narratorText = NarratorText::getRandomText($context, $heroType);

        if (!$narratorText) {
            return response()->json([
                'error' => [
                    'code' => 'NO_TEXT_FOUND',
                    'message' => 'No narrator text found for this context',
                ]
            ], 404);
        }

        $text = Translation::getTranslation($narratorText->text_key, $lang);

        return response()->json([
            'data' => [
                'context' => $context,
                'text' => $text,
                'variant' => $narratorText->variant,
                'heroType' => $narratorText->hero_type,
            ],
            'meta' => [
                'lang' => $lang,
            ],
        ]);
    }
}
// api/routes/api.php
Route::get('/narrator/{context}', [NarratorController::class, 'getText']);

Composable useFetchNarratorText

// frontend/app/composables/useFetchNarratorText.ts
type NarratorContext =
  | 'intro'
  | 'transition_projects'
  | 'transition_skills'
  | 'transition_testimonials'
  | 'transition_journey'
  | 'hint'
  | 'encouragement_25'
  | 'encouragement_50'
  | 'encouragement_75'
  | 'contact_unlocked'
  | 'welcome_back'

type HeroType = 'recruteur' | 'client' | 'dev'

interface NarratorTextResponse {
  data: {
    context: string
    text: string
    variant: number
    heroType: HeroType | null
  }
  meta: { lang: string }
}

export function useFetchNarratorText() {
  const config = useRuntimeConfig()
  const { locale } = useI18n()

  async function fetchText(context: NarratorContext, heroType?: HeroType) {
    const url = heroType
      ? `/narrator/${context}?hero=${heroType}`
      : `/narrator/${context}`

    return await $fetch<NarratorTextResponse>(url, {
      baseURL: config.public.apiUrl,
      headers: {
        'X-API-Key': config.public.apiKey,
        'Accept-Language': locale.value,
      },
    })
  }

  return { fetchText }
}

Dépendances

Cette story nécessite :

  • Story 1.2 : Table translations et système de traduction

Cette story prépare pour :

  • Story 3.2 : Composant NarratorBubble (consomme l'API)
  • Story 3.3 : Textes contextuels (utilise les contextes)

Project Structure Notes

Fichiers à créer :

api/
├── app/Models/
│   └── NarratorText.php                    # CRÉER
├── app/Http/Controllers/Api/
│   └── NarratorController.php              # CRÉER
└── database/
    ├── migrations/
    │   └── 2026_02_04_000002_create_narrator_texts_table.php  # CRÉER
    └── seeders/
        └── NarratorTextSeeder.php          # CRÉER

frontend/app/composables/
└── useFetchNarratorText.ts                 # CRÉER

References

  • [Source: docs/planning-artifacts/epics.md#Story-3.1]
  • [Source: docs/planning-artifacts/ux-design-specification.md#NarratorBubble]
  • [Source: docs/planning-artifacts/ux-design-specification.md#Hero-System]
  • [Source: docs/brainstorming-gamification-2026-01-26.md#Narrateur]

Technical Requirements

Requirement Value Source
Contextes 11 types différents Epics
Variantes 2-3 par contexte Epics
Ton héros vouvoiement/tutoiement UX Spec
API endpoint GET /api/narrator/{context} Architecture

Dev Agent Record

Agent Model Used

{{agent_model_name_version}}

Debug Log References

Completion Notes List

Change Log

Date Change Author
2026-02-04 Story créée avec contexte complet SM Agent
2026-02-07 Implémentation complète: table, model, seeder, API, composable Claude Opus 4.5

File List