# Conversations in the dashboard and the API

A spec for `masterpieceornot_dashboard`. It asks that repo to take over the
game's **conversations** — everything a character says to the player and
everything the player can say back — the way it already owns artworks, fakes and
UI labels.

Today the game ships one conversation as a file it carries with it,
`assets/dialogue/guide.json` in `D:\dev\isitart`. That file is the source of
truth for the shape described here; read it alongside this document. Once the
endpoint below exists, the game will fetch its conversations instead and keep the
file only as a fallback for an unreachable server.

Nothing here changes an existing endpoint. `/api/langues`, `/api/libelles`,
`/api/run` and the rest stay exactly as they are.

---

## 1. What a conversation is

One conversation is one character's whole script. The guide's — the man at the
front desk of the museum — is the only one so far, and it has two kinds of text
in it.

**Fixed lines**, each with a name the game asks for by hand. There are fourteen:

| key | when the game says it |
|---|---|
| `intro` | the opening of the first gallery of a game: he explains the test |
| `skip` | the label on the button that skips that opening |
| `opening` | the first time the player walks up to him |
| `opening_again` | every later time |
| `opening_called` | when he arrives after being called on the player's phone |
| `exit_open` | the whole conversation once the last fake is named |
| `exit_thanks` | the player's one reply to that |
| `concerned` | he came over himself because calls are going wrong |
| `concerned_yes` | the player accepted the offer of help |
| `concerned_no` | the player refused it |
| `yes` / `no` | the two replies to `concerned` |
| `bye` | the player's "that is all" on the question list |
| `farewell` | his answer to it |

**Topics** — the questions the player can put to him, each with its answer and
the conditions under which it is worth offering. Thirteen at the moment. Every
field:

| field | type | meaning |
|---|---|---|
| `id` | string | stable reference, e.g. `what`, `mistakes`. The game never shows it; it identifies the topic in save-less state (asked already, asked twice) |
| `q` | text | the question, in the player's voice |
| `a` | text | his answer |
| `a_if_zero` | text, optional | the answer to use when the token named by `zero` is 0. "Not one of them is wrong" reads as an answer; "0 of them are wrong" reads as a form |
| `zero` | string, optional | which token `a_if_zero` keys off: one of the token names in §2 |
| `a_again` | text, optional | what he says the second time this is asked in one conversation |
| `when` | enum, optional | `started` = only once the player has judged something; `mistakes` = only while at least one call is wrong; empty = always |
| `scope` | enum, optional | `run` = about this particular game (how many pieces, how it is going). Held back until the general questions have been asked, or until the player has plainly started playing |
| `basics` | bool | how-to-play. Disappears once the player has called two pieces: a man who keeps offering to explain the rules to someone already playing is a man you stop talking to |
| `repeatable` | bool | stays on the list after being answered, instead of dropping off for the rest of the conversation |
| `leads` | bool | asking it a second time makes him walk the player to something (currently: the nearest piece they called wrong) |

Order matters: topics are offered in the order they are stored, so the dashboard
needs a position and a way to reorder.

## 2. Tokens

Answers may carry `{tokens}` that the game fills in at the moment it speaks:

`{total}` `{fakes}` `{found}` `{judged}` `{left}` `{wrong}` `{right}`

They are the run as it stands — pieces in the gallery, fakes among them, fakes
named, pieces called, pieces left, calls wrong, calls right. **The API must never
try to fill these in.** They travel as literal text and the game substitutes.
Same rule as the `{0}` placeholders in `/api/libelles`: a translation keeps the
token exactly, and only the words around it change.

---

## 3. Data model

Four tables, following the shape `libelles` / `libelle_traductions` and
`variantes` / `variante_traductions` already use: **English lives in the main
row, every other language in a `_traductions` row, and an empty translation means
"fall back to English"**.

```sql
-- One script, one character.
CREATE TABLE IF NOT EXISTS conversations (
    id         INT UNSIGNED NOT NULL AUTO_INCREMENT,
    code       VARCHAR(64)  NOT NULL,            -- what the game asks for: 'guide'
    label      VARCHAR(128) NOT NULL,            -- dashboard only: 'Museum guide'
    active     TINYINT(1)   NOT NULL DEFAULT 1,
    created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (id),
    UNIQUE KEY uq_code (code)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- The fixed lines. One row per (conversation, key).
CREATE TABLE IF NOT EXISTS conversation_lignes (
    id              INT UNSIGNED NOT NULL AUTO_INCREMENT,
    conversation_id INT UNSIGNED NOT NULL,
    cle             VARCHAR(64)  NOT NULL,       -- 'intro', 'farewell', …
    texte           TEXT         NOT NULL,       -- English
    position        SMALLINT UNSIGNED NOT NULL DEFAULT 0,
    PRIMARY KEY (id),
    UNIQUE KEY uq_conv_cle (conversation_id, cle),
    CONSTRAINT fk_ligne_conv FOREIGN KEY (conversation_id)
        REFERENCES conversations (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS conversation_ligne_traductions (
    ligne_id   INT UNSIGNED NOT NULL,
    lang       VARCHAR(10)  NOT NULL,
    texte      TEXT,
    updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (ligne_id, lang),
    CONSTRAINT fk_lignetrad_ligne FOREIGN KEY (ligne_id)
        REFERENCES conversation_lignes (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

-- The questions. Everything that is not text is machinery and is never translated.
CREATE TABLE IF NOT EXISTS conversation_sujets (
    id              INT UNSIGNED NOT NULL AUTO_INCREMENT,
    conversation_id INT UNSIGNED NOT NULL,
    ref             VARCHAR(64)  NOT NULL,       -- the topic's `id`: 'what', 'mistakes'
    position        SMALLINT UNSIGNED NOT NULL DEFAULT 0,
    question        TEXT NOT NULL,               -- English `q`
    reponse         TEXT NOT NULL,               -- English `a`
    reponse_zero    TEXT,                        -- English `a_if_zero`
    reponse_encore  TEXT,                        -- English `a_again`
    jeton_zero      VARCHAR(32)  NOT NULL DEFAULT '',   -- `zero`
    condition_quand VARCHAR(32)  NOT NULL DEFAULT '',   -- `when`: '', 'started', 'mistakes'
    portee          VARCHAR(32)  NOT NULL DEFAULT '',   -- `scope`: '' or 'run'
    basiques        TINYINT(1)   NOT NULL DEFAULT 0,    -- `basics`
    repetable       TINYINT(1)   NOT NULL DEFAULT 0,    -- `repeatable`
    mene            TINYINT(1)   NOT NULL DEFAULT 0,    -- `leads`
    PRIMARY KEY (id),
    UNIQUE KEY uq_conv_ref (conversation_id, ref),
    CONSTRAINT fk_sujet_conv FOREIGN KEY (conversation_id)
        REFERENCES conversations (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;

CREATE TABLE IF NOT EXISTS conversation_sujet_traductions (
    sujet_id       INT UNSIGNED NOT NULL,
    lang           VARCHAR(10)  NOT NULL,
    question       TEXT,
    reponse        TEXT,
    reponse_zero   TEXT,
    reponse_encore TEXT,
    updated_at     DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
    PRIMARY KEY (sujet_id, lang),
    CONSTRAINT fk_sujettrad_sujet FOREIGN KEY (sujet_id)
        REFERENCES conversation_sujets (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
```

**Why not put the text in `libelles`.** It looks tempting: the game already
resolves its dialogue through `tr(english)`, so the strings would ride the
existing translation table for free. Two things stop it. `libelles.cle` is
`VARCHAR(255)` with a UNIQUE index, and several answers are longer than that (the
`intro` line is about 230 characters and the longest answer is close to 300) —
the key would have to become a TEXT column with a hashed index, which is a change
to a table the whole UI depends on. And an answer is not a label: it has an
`a_if_zero` twin, a `when`, a `scope`. Its own table says what it is.

**Seeding.** Write `scripts/seed-conversations.php` next to `seed-libelles.php`,
reading `assets/dialogue/guide.json` from the game repo (or a copy committed
here) and creating the `guide` conversation with its fourteen lines and thirteen
topics. Idempotent, like the other seeds: match on `code` / `ref` and update
rather than duplicate.

---

## 4. Dashboard pages

Add one item to the sidebar in `partials/header.php`, between Languages and Test:

```php
['Conversations', 'conversations.php', 'fa-comments'],
```

### `dashboard/conversations.php` — the list

One row per conversation: code, label, how many lines and topics, how many
languages are fully translated, active toggle, and links to edit and delete.
A form at the top to add one (code + label). Same PRG-with-flash pattern and
`csrfCheck()` as `langues.php`.

**Bulk actions.** A checkbox per row, a select-all in the header, and a button
bar under the table with at least:

- **Generate translations** — runs the generator (§5) over every checked
  conversation, then flashes how many strings were filled in and for which
  languages.
- **Activate** / **Deactivate**.

### `dashboard/conversation.php?id=…` — the editor

Two sections, both editable in place:

1. **Lines.** A row per fixed line: the key (read-only for the fourteen known
   ones), a textarea for the English, and a language tab strip like the artwork
   editor's — one tab per active language, each with its own textarea, saving to
   `conversation_ligne_traductions`. Empty means "fall back to English", and the
   field should say so in its placeholder.
2. **Topics.** A row per topic, reorderable (up/down buttons like
   `langueDeplacer`), with: `ref`, question, answer, the optional zero-answer and
   its token, the optional second answer, and the four flags as checkboxes /
   selects. Same language tabs over the four text fields.

At the top of the page: a **Generate translations** button (§5) that fills in
every empty translation for this conversation, and a note saying it only fills
blanks and never overwrites what a human typed.

**Validation worth having** (the game will thank you):
- `ref` and `cle` unique within a conversation, `[a-z0-9_]+`.
- If `a_if_zero` is filled, `zero` must name one of the seven tokens in §2.
- Warn on any `{token}` that is not one of the seven — a typo there reaches the
  player as literal braces.
- Warn on an em dash (—) in any text field. The game's rule is that no text the
  player reads contains one; use a comma, a colon or a full stop.

---

## 5. Generating translations

The machinery already exists: `libellesTraduireTout()` in
`lib/includes/libelles.inc.php` walks every label, collects the ones with no
translation in each active language, and hands them to
`claudeTraduireLibelles()` in one call. Do the same for conversations.

```php
// lib/includes/conversations.inc.php
function conversationTraduire($conversationId) { … }   // one conversation
function conversationsTraduire(array $ids)     { … }   // the bulk action
```

Rules, matching the existing behaviour:

- Only **empty** translations are filled. A value a human has typed is never
  touched.
- Only **active, non-default** languages (`languesLister(true)`, minus
  `is_default`).
- One Claude call for as many strings as fit, not one per string.
- `claudeConfigure()` decides whether the button is shown at all, exactly as the
  artwork page does with **Generate hint**.

**The prompt needs to differ from the labels one.** `claudeTraduireLibelles`
tells the model these are short UI strings — buttons and HUD lines. Dialogue is
not that. Write a sibling, `claudeTraduireDialogues($items, $langLabels)`, whose
system prompt says:

- these are lines of **spoken dialogue** in a museum game: a guide talking to a
  visitor, and the visitor's replies. Translate them as speech, not as labels;
- keep `{total}`, `{fakes}`, `{found}`, `{judged}`, `{left}`, `{wrong}`,
  `{right}` **exactly** as they appear, only the words around them change;
- keep the register: the guide is polite, dry, and helpful without being fussy;
- **never use an em dash (—)** in the output. Commas, colons and full stops
  instead. This is a hard rule of the game's text;
- reply with only the JSON object, shaped as in `claudeTraduireLibelles`.

Same retry and JSON-extraction handling as the existing helper: the response is
parsed with a `{.*}` match before `json_decode`, and 429/5xx are retried.

---

## 6. The endpoints

### `GET /api/conversations`

The catalogue: every active conversation, without its text. Cheap enough for the
game to call at boot.

```json
{ "conversations": [ { "code": "guide", "label": "Museum guide" } ] }
```

### `GET /api/conversations/{code}?lang=<code>`

One whole conversation, resolved into one language. This is the endpoint the
game actually uses.

```json
{
  "code": "guide",
  "lang": "fr",
  "lines": {
    "intro": "Bienvenue ! …",
    "skip": "Passer",
    "opening": "Bienvenue au musée. Que puis-je pour vous ?",
    "…": "…"
  },
  "topics": [
    {
      "id": "what",
      "q": "Que suis-je censé faire ici ?",
      "a": "Trouvez tous les faux …",
      "basics": true
    },
    {
      "id": "progress",
      "q": "Où en suis-je ?",
      "a": "Vous en avez jugé {judged} sur {total} …",
      "a_if_zero": "Vous n'en avez encore jugé aucune. Il y en a {total} à voir.",
      "zero": "judged",
      "scope": "run"
    }
  ]
}
```

Rules, all of them the same as the endpoints that exist:

- **`lang` resolution** through `langueResoudre()`: missing, unknown or inactive
  falls back to the default (English). The resolved code is echoed back as
  `lang`.
- **Fallback is per string, not per conversation.** A topic whose French answer
  is empty comes back with the English answer, so no field is ever blank because
  somebody has not finished translating. Exactly how `/api/run` treats a fake's
  `description` and `hint`.
- **Optional fields are omitted when empty**, rather than sent as `""` or
  `false`: `a_if_zero`, `a_again`, `zero`, `when`, `scope`, and the three flags.
  The game reads a missing field as "no".
- **Order is the stored order.** `topics` is an array, not an object.
- Unknown `code`, or an inactive conversation: **404** with
  `{"error":"unknown conversation"}`, the same shape the other endpoints use for
  a missing artwork.
- No token needed, like every other read endpoint.

### Documentation

Add a section to `api.md` next to `GET /api/libelles`, in the same voice: what it
returns, the `lang` rules, and a `curl` example. `dashboard/api.php` (the API
tester page) should get the two new endpoints in its list.

---

## 7. What the game does with it

Done, on this side, as of the endpoint going live. For reference, so the shape
above is not read in a vacuum (`D:\dev\isitart`):

- `autoload/convos.gd` holds the scripts. At boot, after `/api/langues` settles
  the locale, it fetches `GET /api/conversations/{code}?lang=<current>` for every
  character it knows about, and refetches on a language change.
- The server's `lines` object is flattened onto the top level beside `topics`,
  which is the shape the panel reads and the shape `assets/dialogue/guide.json`
  is in. One shape inside the game, whichever source it came from.
- **The file in the build is the fallback**, not dead weight: a script the server
  does not send falls back to it, so an old, slow or absent server leaves a
  museum that talks. The boot gate never fails on a conversation.
- `scenes/ui/dialogue.gd` asks `Convos.script_for("guide")` and nothing else. It
  still puts each line through `tr()`, which does nothing to text the server has
  already translated and localises the English fallback if the label table has
  it.
- Everything else stays in the game: which question is offered when, what he does
  after speaking, who walks where.

That division is the point of the split: a writer can reword the guide, add a
question, or translate the lot without touching the game, and a programmer can
change what the guide *does* without touching the dashboard.
