Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 9 additions & 9 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ Reportés : file d’attente des fusions hors connexion (supprimée, ADR 0041),
| Rareté des mots (ADR 0040, proposé) : plus haute rareté des briques de la recette la moins rare ; forme au codex et dans la fiche (« Rare ! », « Légendaire ! ») ; `encre_decouverte` de l’équilibrage créditée à la découverte | fait (Content : équilibrage `2026-09-28c`) ; 2 gouttes par découverte vérifiées en hors-prod |
| Image J4 de l’API épinglée dans Infra (`9e48455`), *Plan* puis *Apply* horsprod ; PWA `8f0dbfe03d1c` ; catalogue `d6d91be93d05` paru | fait |
| Démonstration en hors-prod (API) : 3 découvertes → jalon `jal_nde6rzt4` et pli de jalon (philo-, -iste) ; indice niveau 1 : 6 → 1 goutte ; refus `403` (encre, sabliers insuffisants), `409` (état périmé) | fait |
| Enquêtes en cours (App#88) : sous la table, une ligne repliée par carte à trouver dont un indice est acheté ; dépliée, seulement ce qui a été payé (silhouette, définition, sens littéral), jusqu’à la découverte du mot ; ni mot, ni brique, ni bouton d’achat (ADR 0051) | fait en local |
| Déblocage progressif des fascicules ([ADR 0068](https://github.com/lunik/EtymoLogique/blob/master/docs/adr/0068-deblocage-progressif-des-fascicules.md)) : un nouveau joueur n’a que le premier fascicule paru ; le 2ᵉ jalon d’un fascicule débloque le suivant, aussitôt ou à sa parution ; table `fascicule_debloque` (reprise des profils déjà semés) ; graines, jalons, plis (ordinaire et offert) et quête de table limités aux fascicules débloqués ; PWA : jaquette verrouillée avec sa condition (plis, codex, quêtes), « Nouveau fascicule ouvert » sur la table, pastille Corail « nouveau » | fait en local, à déployer en hors-prod |

Écarts assumés : l’annonce du jalon passe par la fiche de découverte, pas par le rituel du pli ; la feuille des indices est un dialogue sur ordinateur comme sur mobile, pas un panneau latéral ; sur un environnement à recharge courte, un sablier vaut un douzième de la recharge (1 h en production).
Expand Down
248 changes: 124 additions & 124 deletions web/package-lock.json

Large diffs are not rendered by default.

5 changes: 4 additions & 1 deletion web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Boutique } from './composants/Boutique'
import { CompteReglages } from './composants/CompteReglages'
import { Connexion } from './composants/Connexion'
import { CarteSeule, Codex } from './composants/Codex'
import { Enquetes } from './composants/Enquetes'
import { Indices } from './composants/Indices'
import { Icone } from './composants/Icone'
import { Navigation, type Onglet } from './composants/Navigation'
Expand All @@ -25,7 +26,7 @@ import { memoriserRelance, memoriserSoutien, souvenirRelance, souvenirSoutien }
import { usePresence } from './services/presence'
import { INACTIVITE_MS, type SouvenirSoutien, ajouterTempsDeJeu, carteMasquee, doitSolliciter, estAbonne, fermerSoutien, masquerCarte } from './services/soutien'
import { compteQuitte } from './services/profil'
import { indiceCourant } from './services/indices'
import { enquetesEnCours, indiceCourant } from './services/indices'
import { TABLE_MIN, decouvertePossible } from './services/jeu'
import { FournisseurDeblocage, useDeblocageEtat } from './services/deblocage'
import { usePartie } from './services/partie'
Expand Down Expand Up @@ -101,6 +102,7 @@ export function App({ api, catalogue }: Props) {
const raisonFusion = bloquee ?? (partie.table.length < TABLE_MIN ? 'Posez deux briques pour fusionner.' : null)
// Sans silhouette à indicer, l'indice n'afficherait rien : le bouton disparaît.
const indiceDisponible = catalogueJeu ? indiceCourant(catalogueJeu, entrees, etat) !== null : false
const enquetes = useMemo(() => (catalogueJeu ? enquetesEnCours(catalogueJeu, entrees, etat) : []), [catalogueJeu, entrees, etat])
const bloque =
catalogueJeu && etat && partie.table.length === 0
? !decouvertePossible(catalogueJeu, etat.reserve, new Set(etat.decouvertes.map((d) => d.mot_id)))
Expand Down Expand Up @@ -351,6 +353,7 @@ export function App({ api, catalogue }: Props) {
</>
)}
</section>
<Enquetes enquetes={enquetes} lireMedia={lireMedia} />
{relance && <RelanceCompte decouvertes={etat.decouvertes.length} reconnexion={quitte} onOuvrir={() => ouvrirConnexion(quitte ? 'connexion' : 'sauvegarde')} onFermer={fermerRelance} />}
{soutienOuvert && <ModaleSoutien onFermer={fermerSoutienModale} />}
</div>
Expand Down
25 changes: 25 additions & 0 deletions web/src/composants/CarteSilhouette.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { useEffect, useRef } from 'react'

import type { EntreeCodex } from '../services/codex'
import { chargerSilhouette } from '../services/silhouettes'
import { bandeau } from './bandeau'

/** La carte du codex sans texte ni bouton : même bandeau, mêmes proportions, silhouette en texture (ADR 0023). */
export function CarteSilhouette({ entree, chemin, lireMedia }: { entree: EntreeCodex; chemin: string; lireMedia: (chemin: string) => Promise<string> }) {
const hote = useRef<HTMLSpanElement>(null)
useEffect(() => {
let actif = true
void chargerSilhouette(chemin, lireMedia).then((svg) => {
if (actif && svg && hote.current) hote.current.replaceChildren(svg)
})
return () => {
actif = false
}
}, [chemin, lireMedia])
return (
<div className="mini indice-carte" data-type={entree.type} style={bandeau(entree.type)} aria-hidden="true">
<span className="m-top" />
<span ref={hote} className="card-art" />
</div>
)
}
42 changes: 42 additions & 0 deletions web/src/composants/Enquetes.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { describe, expect, it } from 'vitest'

import type { EntreeCodex } from '../services/codex'
import type { Enquete } from '../services/indices'
import { Enquetes } from './Enquetes'

const lireMedia = () => Promise.resolve('<svg xmlns="http://www.w3.org/2000/svg"/>')

function enquete(n: number, obtenus: Enquete['obtenus']): Enquete {
const entree = { id: `m${n}`, n, type: 'mot', libelle: 'géologie', resultat: true, trouvee: false, legendaire: false } as EntreeCodex
return { entree, recette: { id: `r${n}`, motId: entree.id, emplacements: [] }, obtenus }
}

describe('Enquetes', () => {
it('ne montre rien sans enquête', () => {
const { container } = render(<Enquetes enquetes={[]} lireMedia={lireMedia} />)
expect(container).toBeEmptyDOMElement()
})

it('résume les indices obtenus, sans jamais nommer le mot ni proposer d’achat', () => {
render(
<Enquetes
enquetes={[enquete(11, [{ niveau: 1, titre: 'Silhouette', contenu: { silhouette: 'a/x.svg' } }, { niveau: 3, titre: 'Définition', contenu: { texte: 'Science de la Terre.' } }])]}
lireMedia={lireMedia}
/>,
)
expect(screen.getByRole('heading', { name: 'Enquête en cours' })).toBeInTheDocument()
expect(screen.getByText('N° 011')).toBeInTheDocument()
expect(screen.getByText(/Silhouette, définition/)).toBeInTheDocument()
expect(screen.queryByText(/géologie/)).not.toBeInTheDocument()
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})

it('replie au-delà de trois enquêtes', () => {
const liste = [1, 2, 3, 4].map((n) => enquete(n, [{ niveau: 2, titre: 'Sens littéral', contenu: { texte: 'étude de la terre' } }]))
render(<Enquetes enquetes={liste} lireMedia={lireMedia} />)
expect(screen.getAllByText(/^N° 00/)).toHaveLength(3)
fireEvent.click(screen.getByRole('button', { name: 'Voir les 4 enquêtes' }))
expect(screen.getAllByText(/^N° 00/)).toHaveLength(4)
})
})
74 changes: 74 additions & 0 deletions web/src/composants/Enquetes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { useState } from 'react'

import type { Enquete } from '../services/indices'
import { CarteSilhouette } from './CarteSilhouette'

/** Enquêtes montrées d'emblée ; les autres se déplient d'un bouton. */
const VISIBLES = 3

interface Props {
enquetes: Enquete[]
/** Lecture vérifiée d'un média du catalogue (silhouettes des cartes). */
lireMedia: (chemin: string) => Promise<string>
}

const numero = (n: number) => `N° ${String(n).padStart(3, '0')}`

/** « Silhouette, définition » : les indices obtenus, le premier avec sa majuscule. */
function resume(enquete: Enquete): string {
return enquete.obtenus.map((o, i) => (i === 0 ? o.titre : o.titre.toLowerCase())).join(', ')
}

/**
* Enquêtes en cours (App#88) : sous la table, une ligne repliée par carte à trouver dont un indice
* est acheté ; dépliée, ce qui a été payé, et rien d'autre (ADR 0051). Aucun bouton d'achat :
* la feuille des indices reste le seul guichet.
*/
export function Enquetes({ enquetes, lireMedia }: Props) {
const [toutes, setToutes] = useState(false)
if (enquetes.length === 0) return null
const montrees = toutes ? enquetes : enquetes.slice(0, VISIBLES)
const reste = enquetes.length - VISIBLES
return (
<section className="panel enquetes" aria-labelledby="enquetes-titre">
<div className="panel-head">
<h2 id="enquetes-titre">{enquetes.length > 1 ? 'Enquêtes en cours' : 'Enquête en cours'}</h2>
<span className="status">{enquetes.length}</span>
</div>
<div className="enquetes-liste">
{montrees.map((enquete) => {
const { entree, obtenus } = enquete
const texte = [...obtenus].reverse().find((o) => o.contenu.texte)
return (
<details key={entree.id} className="enquete" name="enquetes">
<summary>
<span className="enquete-ligne">
<span className="enquete-num">{numero(entree.n)}</span>
<span className="enquete-obtenus"> · {resume(enquete)}</span>
</span>
{texte && <span className="enquete-apercu">{texte.niveau === 2 ? `« ${texte.contenu.texte} »` : texte.contenu.texte}</span>}
</summary>
<div className="enquete-corps">
{obtenus.map(({ niveau, titre, contenu }) =>
contenu.silhouette ? (
<CarteSilhouette key={niveau} entree={entree} chemin={contenu.silhouette} lireMedia={lireMedia} />
) : (
<div key={niveau} className="enquete-texte">
<h3 className="panel-k">{titre}</h3>
{niveau === 3 ? <p className="bf-def">{contenu.texte}</p> : <p className="bf-lit">«&nbsp;{contenu.texte}&nbsp;»</p>}
</div>
),
)}
</div>
</details>
)
})}
</div>
{reste > 0 && (
<button type="button" className="panel-link" aria-expanded={toutes} onClick={() => setToutes(!toutes)}>
{toutes ? 'Replier' : `Voir les ${enquetes.length} enquêtes`}
</button>
)}
</section>
)
}
25 changes: 2 additions & 23 deletions web/src/composants/Indices.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,11 @@
import { useEffect, useRef, useState } from 'react'
import { useState } from 'react'

import type { Etat, ReponseIndice } from '../contrat'
import type { EntreeCodex } from '../services/codex'
import { INDICES, contenuIndice, indiceCourant, indiceSuivant, indicesObtenus } from '../services/indices'
import type { CatalogueJeu } from '../services/jeu'
import { useEnLigne } from '../services/reseau'
import { chargerSilhouette } from '../services/silhouettes'
import { bandeau } from './bandeau'
import { CarteSilhouette } from './CarteSilhouette'
import { useDialogue } from './useDialogue'

interface Props {
Expand All @@ -19,26 +18,6 @@ interface Props {
onFermer: () => void
}

/** La carte du codex sans texte ni bouton : même bandeau, mêmes proportions, silhouette en texture (ADR 0023). */
function CarteSilhouette({ entree, chemin, lireMedia }: { entree: EntreeCodex; chemin: string; lireMedia: Props['lireMedia'] }) {
const hote = useRef<HTMLSpanElement>(null)
useEffect(() => {
let actif = true
void chargerSilhouette(chemin, lireMedia).then((svg) => {
if (actif && svg && hote.current) hote.current.replaceChildren(svg)
})
return () => {
actif = false
}
}, [chemin, lireMedia])
return (
<div className="mini indice-carte" data-type={entree.type} style={bandeau(entree.type)} aria-hidden="true">
<span className="m-top" />
<span ref={hote} className="card-art" />
</div>
)
}

/**
* Indices (I-12, ADR 0051) : une seule silhouette à trouver (indiceCourant), puis trois indices
* (silhouette, définition, sens littéral), achetés dans cet ordre, chacun une fois contre de l'encre.
Expand Down
31 changes: 30 additions & 1 deletion web/src/services/indices.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'
import { GEO, LOGIE, RECETTE, cartes, etat, ex, jeu, manifeste } from '../test/fixtures'
import { construireCodex } from './codex'
import type { EntreeCodex } from './codex'
import { ciblesIndice, contenuIndice, indiceCourant, indicesDisponibles, indicesObtenus } from './indices'
import { ciblesIndice, contenuIndice, enquetesEnCours, indiceCourant, indicesDisponibles, indicesObtenus } from './indices'

describe('indices', () => {
const j = jeu()
Expand Down Expand Up @@ -51,4 +51,33 @@ describe('indices', () => {
expect(indiceCourant(k, entrees, etat({ reserve, indices: [{ recette_id: 'r6', niveau: 3 }] }))?.recette.id).toBe('r7')
expect(indiceCourant(k, entrees, etat({ reserve: [] }))).toBeNull()
})

describe('enquêtes en cours', () => {
const k = jeu()
k.recettes = [
{ id: 'r6', motId: 'm6', emplacements: [[GEO], [LOGIE]] },
{ id: 'r7', motId: 'm7', emplacements: [[LOGIE], [GEO]] },
{ id: 'r9', motId: 'm9', emplacements: [['absente'], [GEO]] },
]
const carte = (id: string, n: number, extra: Partial<EntreeCodex> = {}) =>
({ id, n, resultat: true, trouvee: false, legendaire: false, definition: 'une définition', silhouette: 'a/xx.svg', ...extra }) as EntreeCodex
const entrees = [carte('m9', 9), carte('m7', 7), carte('m6', 6)]

it('liste les cartes dont un indice est acheté, par n°, même hors de portée de la réserve', () => {
const e = etat({ reserve: [], indices: [{ recette_id: 'r9', niveau: 1 }, { recette_id: 'r7', niveau: 3 }, { recette_id: 'r7', niveau: 1 }] })
const liste = enquetesEnCours(k, entrees, e)
expect(liste.map((x) => x.entree.n)).toEqual([7, 9])
// Dans l'ordre d'affichage, et seulement ce qui est payé.
expect(liste[0]!.obtenus.map((o) => o.niveau)).toEqual([1, 3])
expect(liste[1]!.obtenus).toEqual([{ niveau: 1, titre: 'Silhouette', contenu: { silhouette: 'a/xx.svg' } }])
})

it('oublie une carte trouvée, une légendaire et une carte sans indice', () => {
const indices = [{ recette_id: 'r6', niveau: 1 }, { recette_id: 'r7', niveau: 1 }]
const liste = enquetesEnCours(k, [carte('m6', 6, { trouvee: true }), carte('m7', 7, { legendaire: true }), carte('m9', 9)], etat({ indices }))
expect(liste).toEqual([])
expect(enquetesEnCours(k, entrees, etat())).toEqual([])
expect(enquetesEnCours(k, entrees, null)).toEqual([])
})
})
})
29 changes: 29 additions & 0 deletions web/src/services/indices.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,32 @@ export function indicesDisponibles(jeu: CatalogueJeu, entree: EntreeCodex): numb
export function indiceSuivant(jeu: CatalogueJeu, entree: EntreeCodex, obtenus: Set<number>): number | undefined {
return indicesDisponibles(jeu, entree).find((n) => !obtenus.has(n))
}

/** Une enquête : une carte à trouver dont au moins un indice est acheté, et ce qui l'a été. */
export interface Enquete {
entree: EntreeCodex
recette: RecetteJeu
/** Indices obtenus, dans l'ordre d'affichage (`INDICES`), avec leur contenu. */
obtenus: { niveau: number; titre: string; contenu: ContenuIndice }[]
}

/**
* Enquêtes en cours (App#88) : les cartes à trouver dont le joueur a acheté au moins un indice,
* par n° croissant, jusqu'à la découverte du mot. Seul ce qui a été payé s'y lit (ADR 0051) :
* ni le mot, ni ses briques, ni s'il se forme avec la réserve. Jamais une légendaire ; un
* fascicule verrouillé n'a déjà plus de silhouette dans le codex.
*/
export function enquetesEnCours(jeu: CatalogueJeu, entrees: EntreeCodex[], etat: Etat | null): Enquete[] {
if (!etat?.indices?.length) return []
return ciblesIndice(jeu, entrees)
.map(({ entree, recette }) => {
const achetes = indicesObtenus(etat, recette.id)
const obtenus = INDICES.filter((i) => achetes.has(i.niveau)).flatMap(({ niveau, titre }) => {
const contenu = contenuIndice(jeu, entree, niveau)
return contenu ? [{ niveau, titre, contenu }] : []
})
return { entree, recette, obtenus }
})
.filter((e) => e.obtenus.length > 0)
.sort((a, b) => a.entree.n - b.entree.n)
}
Loading
Loading