/*  === monstrance-playful.jsx ===
    Playful rubric games — builder, memory, quiet light, shepherd, bread from heaven, GamesPage host.
    Loaded as <script type="text/babel" src="js/monstrance-playful.jsx"></script>.
    Cross-file references work because babel-standalone evaluates each script in
    the document's global scope after compile; top-level const/let/function decls
    are visible to subsequent scripts loaded in this order.
*/

const { useState, useEffect, useRef, useCallback } = React;

const MONSTRANCE_BUILD_ASSET_BASE =
  "../images/catholic-kids/games/monstrance-build";
const MONSTRANCE_BUILD_AUDIO =
  "../audio/catholic-kids/monstrance-build-complete.mp3";
const MONSTRANCE_BUILD_TOLERANCE = 24;
const MONSTRANCE_BUILD_STORAGE_KEY = "monstrance-build-complete";
const MONSTRANCE_BUILD_BEST_KEY = "monstrance-build-best-ms-v1";
const MONSTRANCE_BUILD_PIECES = [
  {
    id: "base",
    label: "Base",
    icon: "▰",
    asset: `${MONSTRANCE_BUILD_ASSET_BASE}/monstrance-base-v1.png`,
    clue: "Set the wide foot at the bottom.",
    teach: "The base is wide and steady.",
    slot: { x: 50, y: 82, w: 46, h: 20 },
    art: { x: 50, y: 82, w: 48 },
  },
  {
    id: "stem",
    label: "Stem",
    icon: "┃",
    asset: `${MONSTRANCE_BUILD_ASSET_BASE}/monstrance-stem-v1.png`,
    clue: "Lift the handle above the base.",
    teach: "The stem helps the monstrance stand tall.",
    slot: { x: 50, y: 60, w: 22, h: 42 },
    art: { x: 50, y: 60, w: 28 },
  },
  {
    id: "rays",
    label: "Lunette frame",
    icon: "✺",
    asset: `${MONSTRANCE_BUILD_ASSET_BASE}/monstrance-rays-v1.png`,
    clue: "Place the golden rays near the top.",
    teach: "The rays remind us that Jesus is light.",
    slot: { x: 50, y: 34, w: 52, h: 46 },
    art: { x: 50, y: 35, w: 58 },
  },
  {
    id: "lunette",
    label: "Lunette glass",
    icon: "○",
    asset: `${MONSTRANCE_BUILD_ASSET_BASE}/monstrance-lunette-v1.png`,
    clue: "Set the glass in the middle.",
    teach: "The lunette is the little window where the Host is shown.",
    slot: { x: 50, y: 34, w: 22, h: 24 },
    art: { x: 50, y: 34, w: 25 },
  },
];

const MEMORY_BEST_KEY = "holy-match-best-v1";
const MEMORY_LEVELS = [
  { id: 1, label: "Level 1", pairCount: 4, tease: "Next: two more treasures open." },
  { id: 2, label: "Level 2", pairCount: 6, tease: "Next: the full treasure chest opens." },
  { id: 3, label: "Level 3", pairCount: 8, tease: "Replay to beat your best treasure hunt." },
];
const MEMORY_PAIRS = [
  { id: "cross", symbol: "✝", label: "Cross", mate: "Jesus loves us" },
  { id: "dove", symbol: "🕊", label: "Dove", mate: "Holy Spirit" },
  { id: "chalice", symbol: "♧", label: "Chalice", mate: "Mass cup" },
  { id: "bible", symbol: "▣", label: "Bible", mate: "God's Word" },
  { id: "candle", symbol: "✺", label: "Candle", mate: "Prayer light" },
  { id: "fish", symbol: "><>", label: "Fish", mate: "Jesus sign" },
  { id: "lamb", symbol: "☁", label: "Lamb", mate: "Jesus is gentle" },
  { id: "lily", symbol: "⚜", label: "Lily", mate: "Mary's pure flower" },
];

const QUIET_LIGHT_BEST_KEY = "quiet-light-best-streak-v1";
const LIGHT_PRAYERS = [
  {
    id: "hello",
    icon: "👋",
    label: "Hello, Jesus",
    teach: "A small hello is a real prayer.",
  },
  {
    id: "thanks",
    icon: "🌟",
    label: "Thank You",
    teach: "Gratitude helps children connect the screen to daily life.",
  },
  {
    id: "sorry",
    icon: "🕊",
    label: "I am sorry",
    teach: "A gentle repair word builds social-emotional practice.",
  },
  {
    id: "help",
    icon: "🙏",
    label: "Please help",
    teach: "Asking for help is brave and concrete.",
  },
  {
    id: "love",
    icon: "♥",
    label: "I love You",
    teach: "Short phrases keep cognitive load low for new readers.",
  },
  {
    id: "amen",
    icon: "✺",
    label: "Amen",
    teach: "Amen means, yes, I mean it.",
  },
];

const GUADALUPE_ASSET_BASE = "../images/catholic-kids/games/guadalupe";
const GUADALUPE_AUDIO_BASE = "../audio/grok";
const GUADALUPE_APPARITION_AUDIO_BASE = "../audio/catholic-kids/guadalupe";
const GUADALUPE_CHARACTER_ATLASES = {
  mary: {
    label: "Our Lady of Guadalupe",
    walk: `${GUADALUPE_ASSET_BASE}/mary-walk-4x4-registered-v5.png`,
    talk: `${GUADALUPE_ASSET_BASE}/mary-dialogue-8-v2.png`,
  },
  juan: {
    label: "St. Juan Diego",
    walk: `${GUADALUPE_ASSET_BASE}/juan-diego-walk-4x4-registered-v5.png`,
    walkRoses: `${GUADALUPE_ASSET_BASE}/juan-diego-roses-walk-4x4-registered-v7.png`,
    talk: `${GUADALUPE_ASSET_BASE}/juan-dialogue-8-v2.png`,
  },
  priest: {
    label: "Bishop Zumarraga",
    walk: `${GUADALUPE_ASSET_BASE}/priest-walk-4x4-registered-v5.png`,
    talk: `${GUADALUPE_ASSET_BASE}/priest-dialogue-8-v2.png`,
  },
};
const GUADALUPE_DIRECTIONS = {
  front: 0,
  back: 1,
  left: 2,
  right: 3,
};
const GUADALUPE_WALK_FRAME_SEQUENCE = [1, 0, 2, 0];
const GUADALUPE_WALK_TIMING = {
  fieldSpeed: 17,
  roomSpeed: 24,
  minMs: 420,
  fieldMaxMs: 1500,
  roomMaxMs: 1160,
};
const GUADALUPE_CONTROL_FIRST_STEP_DT = 0.045;
const GUADALUPE_VISIT_SCENES = [
  {
    id: "tepeyac",
    label: "Tepeyac Hill",
    place: "December 9, 1531",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-01-tepeyac-call-v2.png`,
    speaker: "mary",
    line:
      "Juanito, my little son, do not be afraid. I am your mother, and I bring comfort from God.",
    note:
      "The Guadalupe story begins on December 9, 1531, on Tepeyac Hill near Mexico City.",
    actors: [
      { id: "juan", x: 33, y: 75, scale: 0.92, dir: "right", active: true },
      { id: "mary", x: 67, y: 57, scale: 1.08, dir: "front", active: true },
    ],
  },
  {
    id: "message",
    label: "A Chapel Request",
    place: "Tepeyac slope",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-02-chapel-request-v2.png`,
    speaker: "mary",
    line:
      "Go to the bishop. Ask that a little house of prayer be built here, where people can come to my Son.",
    note:
      "The chapel request gives Juan Diego a concrete mission to carry to Church leadership.",
    maryWaypoints: [
      { x: 63, y: 59, dir: "left" },
      { x: 66, y: 57, dir: "front" },
      { x: 60, y: 61, dir: "right" },
      { x: 63, y: 59, dir: "left" },
    ],
    actors: [
      { id: "juan", x: 42, y: 78, scale: 0.9, dir: "front", active: true },
      { id: "mary", x: 63, y: 59, scale: 1.02, dir: "left", active: true },
    ],
  },
  {
    id: "bishop",
    label: "Tell the Bishop",
    place: "Mexico City, 1531",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-03-bishop-room-v2.png`,
    speaker: "priest",
    line:
      "A holy request needs a sign. If this message is from heaven, bring what she gives you.",
    note:
      "Juan Diego carries the request to Bishop Juan de Zumarraga with humility and courage.",
    actors: [
      { id: "juan", x: 42, y: 86, scale: 2.12, mobileX: 36, mobileY: 84, mobileScale: 2.1, dir: "right", active: true },
      { id: "priest", x: 66, y: 85, scale: 2.24, mobileX: 68, mobileY: 83, mobileScale: 2.08, dir: "left", active: true },
    ],
    roomSpots: [
      {
        id: "door",
        label: "Enter",
        x: 55,
        y: 68,
        mobileX: 55,
        mobileY: 66,
        bgPosition: "48% 52%",
        bgScale: 1.06,
        speaker: "juan",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-bishop-door.mp3`,
        line:
          "I step inside with Mary's request: build a chapel where people can come to Jesus.",
        note:
          "Juan Diego first brings the message to Bishop Juan de Zumarraga in Mexico City.",
        find: {
          label: "You noticed",
          title: "Messenger's errand",
          text: "Juan Diego enters with a request from Mary, but he still has to speak humbly and be heard.",
        },
        actors: {
          juan: { x: 45, y: 84, scale: 2.12, mobileX: 38, mobileY: 83, mobileScale: 1.96, dir: "right" },
          priest: { x: 67, y: 87, scale: 2.22, mobileX: 69, mobileY: 84, mobileScale: 1.98, dir: "left" },
        },
      },
      {
        id: "desk",
        label: "Desk",
        x: 71,
        y: 66,
        mobileX: 73,
        mobileY: 62,
        bgPosition: "61% 52%",
        bgScale: 1.11,
        speaker: "priest",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-bishop-desk.mp3`,
        line:
          "Bring the message close. We will look carefully, pray, and ask for a sign.",
        note:
          "The bishop listens, but asks for a sign before approving the chapel request.",
        find: {
          label: "You noticed",
          title: "Careful listening",
          text: "The bishop does not approve the chapel right away. In the story, he asks for a sign.",
        },
        actors: {
          juan: { x: 52, y: 86, scale: 2.12, mobileX: 42, mobileY: 84, mobileScale: 2.06, dir: "right" },
          priest: { x: 69, y: 88, scale: 2.22, mobileX: 68, mobileY: 85, mobileScale: 2.02, dir: "left" },
        },
      },
      {
        id: "candles",
        label: "Candles",
        x: 22,
        y: 65,
        mobileX: 17,
        mobileY: 67,
        bgPosition: "35% 54%",
        bgScale: 1.1,
        speaker: "priest",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-bishop-candles.mp3`,
        line:
          "Before deciding, we pause by the candles and ask God to help us understand.",
        note:
          "The pause by the candles keeps the decision rooted in prayer, not only argument.",
        find: {
          label: "You noticed",
          title: "Prayer before judgment",
          text: "The candles make the room feel like a place for prayerful discernment, not a quick argument.",
        },
        actors: {
          juan: { x: 31, y: 86, scale: 2.12, mobileX: 30, mobileY: 84, mobileScale: 2, dir: "left" },
          priest: { x: 66, y: 87, scale: 2.22, mobileX: 68, mobileY: 84, mobileScale: 2, dir: "left" },
        },
      },
    ],
    goalSpots: ["door", "desk", "candles"],
  },
  {
    id: "roses",
    label: "Winter Roses",
    place: "Frosty Tepeyac",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-04-winter-roses-v2.png`,
    speaker: "mary",
    line:
      "Climb the hill and gather the roses. Carry them carefully in your tilma.",
    note:
      "The winter roses are remembered as the sign Juan Diego gathers before returning to the bishop.",
    actors: [
      { id: "juan", x: 45, y: 78, scale: 0.9, dir: "front", active: true, roses: true },
      { id: "mary", x: 69, y: 59, scale: 1.02, dir: "left", active: false },
    ],
  },
  {
    id: "tilma",
    label: "The Tilma Opens",
    place: "Mexico City, Dec. 12, 1531",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-05-tilma-sign-v2.png`,
    speaker: "priest",
    line:
      "The roses fall, and the tilma shows a sign that points us back to Mary's motherly care.",
    note:
      "Catholics remember the image on the tilma as the sign that confirmed the message.",
    actors: [
      { id: "juan", x: 43, y: 87, scale: 2.12, mobileX: 38, mobileY: 85, mobileScale: 2.12, dir: "front", active: true, roses: true },
      { id: "priest", x: 67, y: 85, scale: 2.24, mobileX: 70, mobileY: 83, mobileScale: 2.1, dir: "left", active: true },
    ],
    roomSpots: [
      {
        id: "roses",
        label: "Roses",
        x: 24,
        y: 61,
        mobileX: 22,
        mobileY: 66,
        bgPosition: "38% 52%",
        bgScale: 1.09,
        speaker: "juan",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-tilma-roses.mp3`,
        line:
          "I carried the roses carefully in my tilma, just as Mary asked.",
        note:
          "The roses are carried in the tilma, a simple cloak worn by Juan Diego.",
        find: {
          label: "You noticed",
          title: "Tilma cloak",
          text: "A tilma was a simple outer cloak. In the tradition, Juan Diego uses it to carry the roses.",
        },
        actors: {
          juan: { x: 39, y: 87, scale: 2.12, mobileX: 35, mobileY: 85, mobileScale: 2.08, dir: "front", roses: true },
          priest: { x: 66, y: 85, scale: 2.22, mobileX: 70, mobileY: 83, mobileScale: 2.08, dir: "left" },
        },
      },
      {
        id: "bishop",
        label: "Bishop",
        x: 65,
        y: 62,
        mobileX: 66,
        mobileY: 59,
        bgPosition: "58% 52%",
        bgScale: 1.1,
        speaker: "priest",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-tilma-bishop.mp3`,
        line:
          "Stand here, Juan Diego. Let us see the sign you were given.",
        note:
          "The bishop witnesses the roses before the tilma image is revealed.",
        find: {
          label: "You noticed",
          title: "A witnessed sign",
          text: "The bishop sees the roses first, then the tilma opens in the next moment.",
        },
        actors: {
          juan: { x: 48, y: 87, scale: 2.12, mobileX: 39, mobileY: 85, mobileScale: 2.1, dir: "front", roses: true },
          priest: { x: 69, y: 85, scale: 2.22, mobileX: 70, mobileY: 83, mobileScale: 2.1, dir: "left" },
        },
      },
      {
        id: "tilma",
        label: "Tilma",
        hidden: true,
        x: 63,
        y: 64,
        mobileX: 62,
        mobileY: 61,
        bgPosition: "72% 49%",
        bgScale: 1.12,
        speaker: "priest",
        audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-tilma-sign.mp3`,
        line:
          "The tilma opens, the roses fall, and the image becomes the sign.",
        note:
          "The sign is remembered as both roses on the floor and an image on the cloth.",
        find: {
          label: "You noticed",
          title: "Roses and image",
          text: "Catholic tradition remembers both the Castilian roses and Mary's image on the tilma.",
        },
        actors: {
          juan: { x: 43, y: 87, scale: 2.9, mobileX: 36, mobileY: 85, mobileScale: 2.1, dir: "front", roses: true },
          priest: { x: 68, y: 85, scale: 2.78, mobileX: 70, mobileY: 83, mobileScale: 2.08, dir: "left" },
        },
      },
    ],
    goalSpots: ["roses", "bishop", "tilma"],
    tilmaRelic: {
      x: 80,
      y: 47,
      mobileX: 78,
      mobileY: 48,
    },
  },
  {
    id: "chapel",
    label: "A Place to Pray",
    place: "Tepeyac chapel",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-06-chapel-dawn-v2.png`,
    speaker: "juan",
    line:
      "The chapel rises on the hill, and everyone remembers: Mary leads us to Jesus.",
    note:
      "The requested chapel becomes a place of prayer on Tepeyac, pointing pilgrims to Jesus.",
    actors: [
      { id: "juan", x: 37, y: 78, scale: 0.88, dir: "right", active: true },
      { id: "mary", x: 67, y: 58, scale: 0.98, dir: "front", active: false },
      { id: "priest", x: 54, y: 79, scale: 0.82, dir: "front", active: false },
    ],
  },
  {
    id: "lourdes",
    label: "Lourdes Path",
    place: "Lourdes, 1858",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-07-lourdes-grotto-v1.png`,
    speaker: "mary",
    line:
      "Bernadette walks to the grotto and sees Mary in quiet prayer.",
    note:
      "In 1858, Bernadette Soubirous reported visits at Massabielle near Lourdes, France.",
    maryWaypoints: [
      { x: 69, y: 50, dir: "front" },
      { x: 72, y: 49, dir: "left" },
      { x: 66, y: 52, dir: "right" },
      { x: 69, y: 50, dir: "front" },
    ],
    actors: [
      { id: "juan", x: 31, y: 84, scale: 0.96, dir: "right", active: true },
      { id: "mary", x: 69, y: 50, scale: 0.98, dir: "front", active: true },
    ],
  },
  {
    id: "lourdes-ask",
    label: "The Spring Request",
    place: "Massabielle grotto",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-07-lourdes-grotto-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    line:
      "Go drink at the spring and wash there. God can bring help from a hidden place.",
    note:
      "Mary's request leads Bernadette to look for a spring that is not obvious yet.",
    maryWaypoints: [
      { x: 69, y: 50, dir: "front" },
      { x: 72, y: 49, dir: "left" },
      { x: 66, y: 52, dir: "right" },
      { x: 69, y: 50, dir: "front" },
    ],
    actors: [
      { id: "juan", x: 36, y: 84, scale: 0.98, dir: "right", active: true },
      { id: "mary", x: 69, y: 50, scale: 0.98, dir: "front", active: true },
    ],
  },
  {
    id: "lourdes-spring",
    label: "Find the Spring",
    place: "Lourdes grotto",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-07-lourdes-grotto-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    line:
      "Look by the rocks, touch the ground, and notice the water God gives.",
    note:
      "Bernadette digs in the grotto, and the spring begins to show.",
    actors: [
      { id: "juan", x: 32, y: 84, scale: 0.98, dir: "right", active: true },
      { id: "mary", x: 69, y: 50, scale: 0.98, dir: "front", active: true },
    ],
    roomSpots: [
      {
        id: "rocks",
        label: "Rocks",
        x: 35,
        y: 66,
        mobileX: 22,
        mobileY: 66,
        speaker: "mary",
        speakerLabel: "Our Lady of Lourdes",
        line: "The grotto rocks are rough, but God can work in simple places.",
        note: "The Lourdes story happens at a rocky grotto called Massabielle.",
        find: {
          label: "You noticed",
          title: "Rocky grotto",
          text: "A grotto is a cave-like place in rock. Bernadette prayed there.",
        },
        actors: {
          juan: { x: 34, y: 84, scale: 1.02, dir: "right" },
        },
      },
      {
        id: "ground",
        label: "Ground",
        x: 52,
        y: 74,
        mobileX: 51,
        mobileY: 72,
        speaker: "mary",
        speakerLabel: "Our Lady of Lourdes",
        line: "Dig gently here. The spring is hidden, but it will come.",
        note: "Bernadette follows the request even before the water is clear.",
        find: {
          label: "You noticed",
          title: "Hidden spring",
          text: "The spring starts small, then becomes a place where pilgrims pray.",
        },
        actors: {
          juan: { x: 48, y: 84, scale: 1.03, dir: "front" },
        },
      },
      {
        id: "water",
        label: "Water",
        x: 70,
        y: 70,
        mobileX: 78,
        mobileY: 68,
        speaker: "mary",
        speakerLabel: "Our Lady of Lourdes",
        line: "The water reminds pilgrims to pray, wash, and trust God's care.",
        note: "Lourdes became known as a place where people ask God for healing.",
        find: {
          label: "You noticed",
          title: "Healing prayers",
          text: "People still visit Lourdes to pray for healing and hope.",
        },
        actors: {
          juan: { x: 62, y: 83, scale: 1.02, dir: "right" },
        },
      },
    ],
    goalSpots: ["rocks", "ground", "water"],
  },
  {
    id: "lourdes-sign",
    label: "The Spring Flows",
    place: "Lourdes, 1858",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-07-lourdes-grotto-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    line:
      "The spring begins to flow, and people come to pray for healing and hope.",
    note:
      "The Lourdes spring is remembered as a sign that points people back to God.",
    actors: [
      { id: "juan", x: 34, y: 84, scale: 0.98, dir: "right", active: true },
      { id: "mary", x: 69, y: 50, scale: 0.98, dir: "front", active: true, apparition: true },
    ],
  },
  {
    id: "fatima",
    label: "Fatima Field Path",
    place: "Fatima, 1917",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-08-fatima-cova-v1.png`,
    speaker: "mary",
    line:
      "The children walk through the field and see Mary shining with peace.",
    note:
      "In 1917, Lucia, Francisco, and Jacinta reported Mary's visits at Cova da Iria in Portugal.",
    maryWaypoints: [
      { x: 59, y: 44, dir: "front" },
      { x: 62, y: 43, dir: "left" },
      { x: 56, y: 46, dir: "right" },
      { x: 59, y: 44, dir: "front" },
    ],
    actors: [
      { id: "juan", x: 24, y: 84, scale: 0.96, dir: "right", active: true },
      { id: "mary", x: 59, y: 44, scale: 1.02, dir: "front", active: true },
    ],
  },
  {
    id: "fatima-ask",
    label: "Pray for Peace",
    place: "Cova da Iria",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-08-fatima-cova-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    line:
      "Pray the Rosary for peace, and stay close to Jesus with brave hearts.",
    note:
      "The Fatima message asks the children to pray for peace and trust God.",
    maryWaypoints: [
      { x: 59, y: 44, dir: "front" },
      { x: 62, y: 43, dir: "left" },
      { x: 56, y: 46, dir: "right" },
      { x: 59, y: 44, dir: "front" },
    ],
    actors: [
      { id: "juan", x: 30, y: 84, scale: 0.96, dir: "right", active: true },
      { id: "mary", x: 59, y: 44, scale: 1.02, dir: "front", active: true },
    ],
  },
  {
    id: "fatima-prayer",
    label: "Prayer in the Field",
    place: "Fatima field",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-08-fatima-cova-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    line:
      "Notice the little oak, the beads, and the people waiting for peace.",
    note:
      "The children share Mary's message with people who gather in the field.",
    actors: [
      { id: "juan", x: 26, y: 84, scale: 0.96, dir: "right", active: true },
      { id: "mary", x: 59, y: 44, scale: 1.02, dir: "front", active: true },
    ],
    roomSpots: [
      {
        id: "oak",
        label: "Oak",
        x: 34,
        y: 66,
        mobileX: 21,
        mobileY: 66,
        speaker: "mary",
        speakerLabel: "Our Lady of Fatima",
        line: "The little oak marks the place where the children came to pray.",
        note: "The Fatima story is tied to a field and a small holm oak.",
        find: {
          label: "You noticed",
          title: "Little oak",
          text: "The children remembered the place and came back to pray.",
        },
        actors: {
          juan: { x: 34, y: 84, scale: 1, dir: "right" },
        },
      },
      {
        id: "rosary",
        label: "Rosary",
        x: 55,
        y: 73,
        mobileX: 50,
        mobileY: 72,
        speaker: "mary",
        speakerLabel: "Our Lady of Fatima",
        line: "The Rosary is a prayer children and families can say together.",
        note: "The Fatima message includes a call to pray the Rosary for peace.",
        find: {
          label: "You noticed",
          title: "Prayer beads",
          text: "Rosary beads help people walk through prayers with Mary and Jesus.",
        },
        actors: {
          juan: { x: 48, y: 84, scale: 1.01, dir: "front" },
        },
      },
      {
        id: "people",
        label: "People",
        x: 72,
        y: 68,
        mobileX: 79,
        mobileY: 67,
        speaker: "mary",
        speakerLabel: "Our Lady of Fatima",
        line: "People gather because they hope God will bring peace.",
        note: "Many people came to the field as the Fatima story spread.",
        find: {
          label: "You noticed",
          title: "A waiting crowd",
          text: "The crowd shows how a small prayer message can reach many people.",
        },
        actors: {
          juan: { x: 62, y: 83, scale: 1, dir: "right" },
        },
      },
    ],
    goalSpots: ["oak", "rosary", "people"],
  },
  {
    id: "fatima-sun",
    label: "The Sun Dances",
    place: "Fatima, Oct. 13, 1917",
    backdrop: `${GUADALUPE_ASSET_BASE}/scene-08-fatima-cova-v1.png`,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    line:
      "The sun seems to dance in the sky, and the people remember Mary's call to pray.",
    note:
      "Catholics remember October 13, 1917, as the day of the Fatima sun sign.",
    actors: [
      { id: "juan", x: 32, y: 84, scale: 0.96, dir: "right", active: true },
      { id: "mary", x: 59, y: 44, scale: 1.02, dir: "front", active: true, apparition: true },
    ],
  },
];

const GUADALUPE_VISIONS = {
  chapel: {
    kicker: "Vision complete",
    title: "Our Lady of Guadalupe",
    words: "Am I not here, I who am your Mother?",
    note: "Mary's words to Juan Diego on Tepeyac Hill. Every apparition leads back to Jesus.",
    detail: "guadalupe",
  },
  "lourdes-sign": {
    kicker: "Vision complete",
    title: "Our Lady of Lourdes",
    words: "I am the Immaculate Conception.",
    note: "Mary's words to Bernadette at the grotto. The spring still calls pilgrims to prayer.",
    detail: "lourdes",
  },
  "fatima-sun": {
    kicker: "Vision complete",
    title: "Our Lady of Fatima",
    words: "Pray the Rosary every day to bring peace.",
    note: "Mary's call to Lucia, Francisco, and Jacinta. The whole route is finished. Amen.",
    detail: "fatima",
  },
};
const GUADALUPE_INTERACT_RADIUS = 17;
const GUADALUPE_TILMA_DESK_TARGET = { x: 66, y: 84 };
const GUADALUPE_CINE_ROSES = [
  { delay: 0.55, dx: -82, fall: 132, spin: -210 },
  { delay: 0.66, dx: 48, fall: 156, spin: 240 },
  { delay: 0.78, dx: -32, fall: 116, spin: -160 },
  { delay: 0.9, dx: 88, fall: 140, spin: 190 },
  { delay: 1.02, dx: -62, fall: 164, spin: 230 },
  { delay: 1.14, dx: 16, fall: 124, spin: -200 },
  { delay: 1.26, dx: 68, fall: 170, spin: 170 },
  { delay: 1.38, dx: -14, fall: 150, spin: -240 },
  { delay: 1.5, dx: 34, fall: 136, spin: 210 },
];
const GUADALUPE_ROSE_PICKUPS = [
  { id: "rose-1", x: 36, y: 78 },
  { id: "rose-2", x: 47, y: 70 },
  { id: "rose-3", x: 58, y: 64 },
  { id: "rose-4", x: 70, y: 58 },
  { id: "rose-5", x: 62, y: 80 },
];
const GUADALUPE_ROSE_PICK_RADIUS = 12;
const GUADALUPE_SUN_CINE_FLECKS = [
  { delay: 0.3, dx: -120, fall: 150, hue: 0 },
  { delay: 0.5, dx: 90, fall: 180, hue: 48 },
  { delay: 0.7, dx: -50, fall: 210, hue: 130 },
  { delay: 0.9, dx: 140, fall: 160, hue: 205 },
  { delay: 1.1, dx: -160, fall: 190, hue: 275 },
  { delay: 1.3, dx: 40, fall: 230, hue: 330 },
];
const GUADALUPE_ROUTE_STORAGE_KEY = "monstrance-guadalupe-route-v1";
const readGuadalupeRouteProgress = () => {
  try {
    const stored = JSON.parse(
      sessionStorage.getItem(GUADALUPE_ROUTE_STORAGE_KEY) || "null",
    );
    if (!stored || typeof stored !== "object") return null;
    return stored;
  } catch (e) {
    return null;
  }
};

const GAME_CARDS = [
  {
    id: "build",
    icon: "✺",
    title: "Build the Monstrance",
    tag: "drag + build",
    blurb:
      "Drag the base, stem, rays, and glass into place, then see the Host glow.",
  },
  {
    id: "memory",
    icon: "♡",
    title: "Holy Match",
    tag: "working memory",
    blurb: "Find picture-and-meaning pairs with calm feedback and no timer.",
  },
  {
    id: "light",
    icon: "☀",
    title: "Quiet Light",
    tag: "self-regulation",
    blurb: "Light six rays by tapping a short prayer sequence in order.",
  },
  {
    id: "guadalupe",
    icon: "🌹",
    title: "Marian Apparitions",
    tag: "learning quest",
    blurb:
      "Explore Guadalupe, Lourdes, and Fatima as Mary keeps leading people to Jesus.",
  },
  {
    id: "shepherd",
    icon: "♱",
    title: "Gather the Sheep",
    tag: "navigation + care",
    blurb: "Move Jesus through the pasture and bring every sheep safely home.",
  },
  {
    id: "bread",
    icon: "◌",
    title: "Bread from Heaven",
    tag: "timing + generosity",
    blurb: "Help Jesus gather bread and fish so everyone can be fed.",
  },
  {
    id: "families",
    icon: "🧺",
    title: "Word Families",
    tag: "drag + sort",
    blurb:
      "Sort holy words, saint friends, and church things into the right baskets.",
  },
];

const MINI_GAME_PROGRESS_STORAGE_KEY = "monstrance-mini-game-progress-v1";
const MINI_GAME_LAST_STORAGE_KEY = "monstrance-mini-game-last-v1";

const isKnownMiniGameId = (id) => GAME_CARDS.some((game) => game.id === id);

const readMiniGameProgress = () => {
  try {
    const validIds = new Set(GAME_CARDS.map((game) => game.id));
    const stored = JSON.parse(
      sessionStorage.getItem(MINI_GAME_PROGRESS_STORAGE_KEY) || "[]",
    );
    if (!Array.isArray(stored)) return [];
    return Array.from(new Set(stored.filter((id) => validIds.has(id))));
  } catch (e) {
    return [];
  }
};

const writeMiniGameProgress = (ids) => {
  try {
    const validIds = new Set(GAME_CARDS.map((game) => game.id));
    const normalizedIds = Array.from(new Set(ids.filter((id) => validIds.has(id))));
    sessionStorage.setItem(MINI_GAME_PROGRESS_STORAGE_KEY, JSON.stringify(normalizedIds));
  } catch (e) {}
};

const readLastMiniGame = () => {
  try {
    const id = localStorage.getItem(MINI_GAME_LAST_STORAGE_KEY);
    if (isKnownMiniGameId(id)) return id;
    localStorage.removeItem(MINI_GAME_LAST_STORAGE_KEY);
    return null;
  } catch (e) {
    return null;
  }
};

const writeLastMiniGame = (id) => {
  if (!isKnownMiniGameId(id)) return;
  try {
    localStorage.setItem(MINI_GAME_LAST_STORAGE_KEY, id);
  } catch (e) {}
};

const WORD_FAMILIES = [
  {
    id: "holy",
    title: "Holy Words",
    icon: "✦",
    hint: "Words we use for God, prayer, and sacred things.",
    success: "These words help us speak with reverence.",
  },
  {
    id: "saints",
    title: "Saints & Helpers",
    icon: "☼",
    hint: "People and heavenly friends who help us love Jesus.",
    success: "Saints and helpers show us how to follow Jesus.",
  },
  {
    id: "things",
    title: "Church Things",
    icon: "◌",
    hint: "Objects you might see at Mass, Adoration, or prayer time.",
    success: "These are things Catholics use to worship and pray.",
  },
];

const WORD_FAMILY_TOKENS = [
  { id: "holy", label: "Holy", family: "holy", sprite: 0, teach: "Holy means set apart for God." },
  { id: "blessed", label: "Blessed", family: "holy", sprite: 1, teach: "Blessed means touched by God's goodness." },
  { id: "amen", label: "Amen", family: "holy", sprite: 2, teach: "Amen means yes, I believe." },
  { id: "adoration", label: "Adoration", family: "holy", sprite: 3, teach: "Adoration is quiet love for Jesus." },
  { id: "mary", label: "Mary", family: "saints", sprite: 4, teach: "Mary is Jesus' mother and our mother too." },
  { id: "saints", label: "Saints", family: "saints", sprite: 5, teach: "Saints are friends of God in heaven." },
  { id: "angel", label: "Angel", family: "saints", sprite: 6, teach: "Angels are God's messengers and helpers." },
  { id: "rosary", label: "Rosary", family: "things", sprite: 7, teach: "A rosary helps us pray with Mary." },
  { id: "monstrance", label: "Monstrance", family: "things", sprite: 8, teach: "A monstrance shows Jesus in the Eucharist." },
  { id: "host", label: "Host", family: "things", sprite: 9, teach: "The Host is Jesus in the Eucharist." },
  { id: "cross", label: "Cross", family: "things", sprite: 10, teach: "The cross reminds us Jesus loves us." },
  { id: "bread", label: "Bread", family: "things", sprite: 11, teach: "At Mass, bread becomes Jesus in the Eucharist." },
];

const ARCADE_GAME_LINKS = [
  {
    icon: "🐑",
    title: "The Good Shepherd",
    tag: "sheep herding",
    href: "games/good-shepherd/",
    blurb: "Guide Jesus through the pasture and gather every lamb safely home.",
  },
  {
    icon: "🥖",
    title: "Galilee Picnic Dash",
    tag: "collect + share",
    href: "games/galilee-picnic/",
    blurb: "Gather loaves and fish, then deliver them to picnic blankets.",
  },
  {
    icon: "🎣",
    title: "Fishers of Galilee",
    tag: "casting timing",
    href: "games/fishers-of-galilee/",
    blurb: "Steer the boat, cast your line, avoid weeds, and fill the baskets.",
  },
  {
    icon: "🧺",
    title: "Manna Corridor Dash",
    tag: "lane runner",
    href: "games/manna-corridor/",
    blurb:
      "Move lanes as gifts rush toward the basket down a Galilee corridor.",
  },
  {
    icon: "👼",
    title: "Guardian Angel Flight",
    tag: "flight + escort",
    href: "games/guardian-angel-flight/",
    blurb: "Fly through clouds, collect prayer stars, and guide hearts home.",
  },
  {
    icon: "📿",
    title: "Rosary Garden",
    tag: "explore + bloom",
    href: "games/rosary-garden/",
    blurb:
      "Find the golden bead in each mystery to bloom the garden, decade by decade.",
  },
  {
    icon: "🌹",
    title: "Mary Visits",
    tag: "Marian quest",
    href: "#/games/guadalupe",
    blurb:
      "Walk through Guadalupe, Lourdes, and Fatima while Mary keeps pointing the story back to Jesus.",
  },
  {
    icon: "🪟",
    title: "Saint Window Workshop",
    tag: "matching virtues",
    href: "games/saint-window/",
    blurb:
      "Restore stained-glass windows by pairing each saint with the loving action they lived.",
  },
];

function GameProgress({ value, total }) {
  return (
    <div className="mini-progress" aria-label={`${value} of ${total} complete`}>
      {Array.from({ length: total }).map((_, i) => (
        <span key={i} className={i < value ? "is-on" : ""} />
      ))}
    </div>
  );
}

function GameCompletionBridge({ done, gameId, onComplete, nextGame, onNextGame }) {
  if (!done || !nextGame) return null;
  const handleNext = () => {
    onComplete?.(gameId);
    onNextGame?.();
  };
  return (
    <button
      type="button"
      className="btn btn-primary mini-next-btn"
      onClick={handleNext}
      aria-label={`Next game: ${nextGame.title}`}
    >
      Next: {nextGame.title}
    </button>
  );
}

const readNumberStorage = (key, fallback = 0) => {
  try {
    const value = Number(localStorage.getItem(key));
    return Number.isFinite(value) && value > 0 ? value : fallback;
  } catch (e) {
    return fallback;
  }
};

const writeNumberStorage = (key, value) => {
  try {
    localStorage.setItem(key, String(value));
  } catch (e) {}
};

// --- Child activity log (local-only) -------------------------------------
// Records meaningful in-game actions to localStorage so a grown-up can review
// what the child did after a play session. Bounded hard so it can never bloat
// local storage: a ring buffer on event COUNT and a separate BYTE budget, plus
// optional per-action throttling for repetitive events. Every access is wrapped
// so logging can never break a game.
// Review later in the browser console on the monstrance page:
//   monstranceLog.summary()  -> counts per game/action
//   monstranceLog.export()   -> pretty JSON of the recent actions
//   monstranceLog.clear()    -> erase the log
const MONSTRANCE_LOG_KEY = "monstrance-activity-log-v1";
const MONSTRANCE_LOG_MAX_EVENTS = 600; // hard cap on stored events (ring buffer)
const MONSTRANCE_LOG_MAX_BYTES = 80 * 1024; // ~80KB byte budget, oldest dropped first
const monstranceLog = (() => {
  let buffer = null;
  const throttleAt = {};
  const load = () => {
    if (buffer) return buffer;
    try {
      const parsed = JSON.parse(
        localStorage.getItem(MONSTRANCE_LOG_KEY) || "[]",
      );
      buffer = Array.isArray(parsed) ? parsed : [];
    } catch (e) {
      buffer = [];
    }
    return buffer;
  };
  const persist = () => {
    try {
      if (buffer.length > MONSTRANCE_LOG_MAX_EVENTS) {
        buffer = buffer.slice(buffer.length - MONSTRANCE_LOG_MAX_EVENTS);
      }
      let payload = JSON.stringify(buffer);
      // Drop the oldest quarter repeatedly until under the byte budget.
      while (payload.length > MONSTRANCE_LOG_MAX_BYTES && buffer.length > 1) {
        buffer = buffer.slice(Math.ceil(buffer.length * 0.25));
        payload = JSON.stringify(buffer);
      }
      localStorage.setItem(MONSTRANCE_LOG_KEY, payload);
    } catch (e) {
      // Quota hit or storage disabled: shrink hard, try once more, else give up.
      try {
        buffer = buffer.slice(Math.ceil(buffer.length * 0.5));
        localStorage.setItem(MONSTRANCE_LOG_KEY, JSON.stringify(buffer));
      } catch (err) {}
    }
  };
  const record = (game, action, detail, throttleMs) => {
    try {
      const now = Date.now();
      if (throttleMs) {
        const k = `${game}:${action}`;
        if (now - (throttleAt[k] || 0) < throttleMs) return;
        throttleAt[k] = now;
      }
      load();
      const event = { t: now, g: game, a: action };
      if (detail !== undefined && detail !== null) event.d = detail;
      buffer.push(event);
      persist();
    } catch (e) {}
  };
  const summary = () => {
    const events = load();
    const byGame = {};
    events.forEach((e) => {
      byGame[e.g] = byGame[e.g] || {};
      byGame[e.g][e.a] = (byGame[e.g][e.a] || 0) + 1;
    });
    return { events: events.length, byGame };
  };
  return {
    record,
    summary,
    all: () => load().slice(),
    export: () => JSON.stringify(load(), null, 2),
    clear: () => {
      buffer = [];
      try {
        localStorage.removeItem(MONSTRANCE_LOG_KEY);
      } catch (e) {}
    },
  };
})();
try {
  if (typeof window !== "undefined") window.monstranceLog = monstranceLog;
} catch (e) {}

const formatShortTime = (ms) => {
  if (!ms) return "--";
  const seconds = Math.max(1, Math.round(ms / 1000));
  const minutes = Math.floor(seconds / 60);
  const rest = seconds % 60;
  return minutes
    ? `${minutes}:${String(rest).padStart(2, "0")}`
    : `${seconds}s`;
};

function WordFamilySprite({ token, small = false }) {
  const col = token.sprite % 4;
  const row = Math.floor(token.sprite / 4);
  return (
    <span
      className={`word-sprite ${small ? "is-small" : ""}`}
      style={{
        backgroundPosition: `${col * 33.333333}% ${row * 50}%`,
      }}
      aria-hidden="true"
    />
  );
}

function MonstranceBuilderGame({ gameId, onComplete, nextGame, onNextGame }) {
  const allPieceIds = MONSTRANCE_BUILD_PIECES.map((piece) => piece.id);
  const storedComplete = () => {
    try {
      return sessionStorage.getItem(MONSTRANCE_BUILD_STORAGE_KEY) === "true";
    } catch (e) {
      return false;
    }
  };
  const [placed, setPlaced] = useState(() =>
    storedComplete() ? allPieceIds : [],
  );
  const [selected, setSelected] = useState(null);
  const [drag, setDrag] = useState(null);
  const [bouncing, setBouncing] = useState(null);
  const [justPlaced, setJustPlaced] = useState(null);
  const [completionPulse, setCompletionPulse] = useState(false);
  const [confirmReset, setConfirmReset] = useState(false);
  const [bestMs, setBestMs] = useState(() => readNumberStorage(MONSTRANCE_BUILD_BEST_KEY, 0));
  const [runStart, setRunStart] = useState(() => (storedComplete() ? null : performance.now()));
  const [elapsedMs, setElapsedMs] = useState(0);
  const [newBest, setNewBest] = useState(false);
  const [round, setRound] = useState(() => (storedComplete() ? 2 : 1));
  const [feedback, setFeedback] = useState(() =>
    storedComplete()
      ? "The monstrance shines, and everyone comes to adore Jesus."
      : "Help get ready for Eucharistic Adoration. Build the monstrance so Jesus can be shown to everyone.",
  );
  const playfieldRef = useRef(null);
  const slotRefs = useRef({});
  const bounceTimerRef = useRef(null);
  const placedTimerRef = useRef(null);
  const audioRef = useRef(null);
  const nextPiece = MONSTRANCE_BUILD_PIECES[placed.length];
  const done = placed.length === MONSTRANCE_BUILD_PIECES.length;
  const displayMs = done ? elapsedMs : runStart ? performance.now() - runStart : 0;

  useEffect(() => {
    try {
      if (done) {
        sessionStorage.setItem(MONSTRANCE_BUILD_STORAGE_KEY, "true");
      } else {
        sessionStorage.removeItem(MONSTRANCE_BUILD_STORAGE_KEY);
      }
    } catch (e) {}
  }, [done]);
  useEffect(() => {
    if (done) onComplete?.(gameId);
  }, [done, gameId, onComplete]);
  useEffect(() => {
    if (done || !runStart) return undefined;
    const timer = window.setInterval(() => {
      setElapsedMs(performance.now() - runStart);
    }, 500);
    return () => window.clearInterval(timer);
  }, [done, runStart]);
  useEffect(() => {
    if (!done) {
      setCompletionPulse(false);
      return;
    }
    setCompletionPulse(true);
    const timer = window.setTimeout(() => setCompletionPulse(false), 1800);
    return () => window.clearTimeout(timer);
  }, [done]);

  useEffect(
    () => () => {
      if (bounceTimerRef.current) window.clearTimeout(bounceTimerRef.current);
      if (placedTimerRef.current) window.clearTimeout(placedTimerRef.current);
    },
    [],
  );

  const pieceById = (id) =>
    MONSTRANCE_BUILD_PIECES.find((piece) => piece.id === id);

  const playCompletionLine = () => {
    const audio = audioRef.current;
    if (!audio) return;
    audio.currentTime = 0;
    audio.play().catch(() => {
      setFeedback("The monstrance is built. Tap Listen to hear the prayer line.");
    });
  };

  const showBounce = (pieceId, message) => {
    setBouncing(pieceId);
    setFeedback(message);
    if (bounceTimerRef.current) window.clearTimeout(bounceTimerRef.current);
    bounceTimerRef.current = window.setTimeout(() => setBouncing(null), 520);
  };

  const slotAtPoint = (clientX, clientY, draggedId) => {
    const matches = MONSTRANCE_BUILD_PIECES.map((piece) => {
      const node = slotRefs.current[piece.id];
      if (!node) return null;
      const rect = node.getBoundingClientRect();
      const inside =
        clientX >= rect.left - MONSTRANCE_BUILD_TOLERANCE &&
        clientX <= rect.right + MONSTRANCE_BUILD_TOLERANCE &&
        clientY >= rect.top - MONSTRANCE_BUILD_TOLERANCE &&
        clientY <= rect.bottom + MONSTRANCE_BUILD_TOLERANCE;
      if (!inside) return null;
      return {
        id: piece.id,
        area: rect.width * rect.height,
      };
    }).filter(Boolean);
    const exact = matches.find((match) => match.id === draggedId);
    if (exact) return exact.id;
    return matches.sort((a, b) => a.area - b.area)[0]?.id ?? null;
  };

  const tryPlace = (pieceId, slotId) => {
    const piece = pieceById(pieceId);
    if (!piece || placed.includes(pieceId) || done) return;
    if (nextPiece && pieceId === nextPiece.id && slotId === pieceId) {
      const nextPlaced = [...placed, pieceId];
      monstranceLog.record("build", "place", pieceId);
      const finished = nextPlaced.length === MONSTRANCE_BUILD_PIECES.length;
      setPlaced(nextPlaced);
      setSelected(null);
      setJustPlaced(pieceId);
      if (placedTimerRef.current) window.clearTimeout(placedTimerRef.current);
      placedTimerRef.current = window.setTimeout(() => setJustPlaced(null), 760);
      setFeedback(
        finished
          ? "The monstrance shines. Jesus is truly present in the Eucharist, and everyone comes to adore."
          : piece.teach,
      );
      if (finished) {
        const finishMs = runStart ? performance.now() - runStart : 0;
        setElapsedMs(finishMs);
        if (finishMs && (!bestMs || finishMs < bestMs)) {
          setBestMs(finishMs);
          setNewBest(true);
          writeNumberStorage(MONSTRANCE_BUILD_BEST_KEY, Math.round(finishMs));
        } else {
          setNewBest(false);
        }
        window.setTimeout(playCompletionLine, 120);
      }
      return;
    }
    showBounce(pieceId, nextPiece ? `The piece floats back. ${nextPiece.clue}` : "The piece floats back to the shelf.");
  };

  const beginDrag = (event, pieceId) => {
    if (placed.includes(pieceId) || done) return;
    const rect = playfieldRef.current?.getBoundingClientRect();
    if (!rect) return;
    event.preventDefault();
    event.currentTarget.setPointerCapture?.(event.pointerId);
    setSelected(pieceId);
    setDrag({
      id: pieceId,
      pointerId: event.pointerId,
      x: event.clientX - rect.left,
      y: event.clientY - rect.top,
      startX: event.clientX,
      startY: event.clientY,
    });
  };

  const moveDrag = (event) => {
    if (!drag || drag.pointerId !== event.pointerId) return;
    const rect = playfieldRef.current?.getBoundingClientRect();
    if (!rect) return;
    event.preventDefault();
    setDrag((value) =>
      value
        ? {
            ...value,
            x: event.clientX - rect.left,
            y: event.clientY - rect.top,
          }
        : value,
    );
  };

  const endDrag = (event) => {
    if (!drag || drag.pointerId !== event.pointerId) return;
    event.preventDefault();
    event.currentTarget.releasePointerCapture?.(event.pointerId);
    const slotId = slotAtPoint(event.clientX, event.clientY, drag.id);
    if (slotId) {
      tryPlace(drag.id, slotId);
    } else if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < 12) {
      const piece = pieceById(drag.id);
      if (nextPiece && drag.id !== nextPiece.id) {
        showBounce(drag.id, `That piece waits its turn. ${nextPiece.clue}`);
      } else {
        setSelected(drag.id);
        setFeedback(`${piece?.label || "That piece"} is ready. Tap its glowing space.`);
      }
    } else {
      showBounce(drag.id, nextPiece ? `Bring it close to the glowing space. ${nextPiece.clue}` : "Bring it close to the glowing space.");
    }
    setDrag(null);
  };

  const cancelDrag = () => {
    if (drag) showBounce(drag.id, "The piece floats back to the shelf.");
    setDrag(null);
  };

  const reset = () => {
    setPlaced([]);
    setSelected(null);
    setDrag(null);
    setBouncing(null);
    setJustPlaced(null);
    setCompletionPulse(false);
    setConfirmReset(false);
    setRunStart(performance.now());
    setElapsedMs(0);
    setNewBest(false);
    setRound((value) => value + 1);
    setFeedback("Round ready: build in order again and see if you can beat your best time.");
    try {
      sessionStorage.removeItem(MONSTRANCE_BUILD_STORAGE_KEY);
    } catch (e) {}
    if (audioRef.current) {
      audioRef.current.pause();
      audioRef.current.currentTime = 0;
    }
  };

  return (
    <article className="game-card monstrance-build-card" data-testid="monstrance-build-game">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Build the Monstrance</h2>
          <p className="game-card-copy">Help get ready for Eucharistic Adoration. A monstrance holds the consecrated Host so we can adore Jesus, truly present.</p>
        </div>
        <div className="game-badges">
          <span className="game-badge">Ages 4-8</span>
          <span className="game-badge">{placed.length}/4</span>
          <span className="game-badge">Best {formatShortTime(bestMs)}</span>
        </div>
      </div>
      <div className="monstrance-build-story">
        <strong>Mission {round}: Procession ready</strong>
        <span>Build in order: base, stem, rays, glass. Jesus says, "I am the bread of life."</span>
        <em>{done ? (newBest ? "New best time. The next rebuild can shine even faster." : "Try a faster, prettier rebuild next.") : `Time ${formatShortTime(displayMs)}`}</em>
      </div>
      <GameProgress value={placed.length} total={MONSTRANCE_BUILD_PIECES.length} />
      <div className="game-area monstrance-build-board">
        <div
          className={`monstrance-build-playfield ${done ? "is-complete" : ""} ${completionPulse ? "is-completing" : ""}`}
          data-testid="monstrance-build-stage"
          ref={playfieldRef}
          style={{
            "--build-tolerance": `${MONSTRANCE_BUILD_TOLERANCE}px`,
          }}
        >
          <div className="monstrance-build-backdrop" aria-hidden="true" />
          {MONSTRANCE_BUILD_PIECES.map((piece, index) => {
            const filled = placed.includes(piece.id);
            const isNext = nextPiece?.id === piece.id;
            return (
              <button
                key={piece.id}
                type="button"
                ref={(node) => {
                  slotRefs.current[piece.id] = node;
                }}
                className={`monstrance-build-slot ${filled ? "is-filled" : ""} ${isNext ? "is-next" : ""} ${justPlaced === piece.id ? "is-just-placed" : ""}`}
                data-testid={`monstrance-slot-${piece.id}`}
                style={{
                  left: `${piece.slot.x}%`,
                  top: `${piece.slot.y}%`,
                  width: `${piece.slot.w}%`,
                  height: `${piece.slot.h}%`,
                  zIndex: index + 1,
                }}
                onClick={() => selected && tryPlace(selected, piece.id)}
                aria-label={`${piece.label} slot`}
              >
                <span className="monstrance-build-slot-icon" aria-hidden="true">{!filled ? piece.icon : ""}</span>
                <span className="monstrance-build-slot-label">{isNext && !filled ? piece.clue : ""}</span>
                {justPlaced === piece.id && <span className="monstrance-build-lock" aria-hidden="true">✓</span>}
              </button>
            );
          })}
          <div className="monstrance-build-art" aria-hidden="true">
            {done && <span className="monstrance-build-rayburst" />}
            {MONSTRANCE_BUILD_PIECES.map((piece, index) =>
              placed.includes(piece.id) ? (
                <img
                  key={piece.id}
                  src={piece.asset}
                  alt=""
                  draggable="false"
                  className={`monstrance-build-piece is-${piece.id} ${justPlaced === piece.id ? "is-just-placed" : ""}`}
                  style={{
                    left: `${piece.art.x}%`,
                    top: `${piece.art.y}%`,
                    width: `${piece.art.w}%`,
                    zIndex: index + 4,
                  }}
                />
              ) : null,
            )}
            {done && <span className="monstrance-build-host" />}
            {done && (
              <span className="monstrance-build-sparkles">
                <span />
                <span />
                <span />
                <span />
                <span />
                <span />
                <span />
                <span />
              </span>
            )}
          </div>
          {drag && (
            <img
              src={pieceById(drag.id)?.asset}
              alt=""
              draggable="false"
              className={`monstrance-build-drag is-${drag.id}`}
              style={{
                left: `${drag.x}px`,
                top: `${drag.y}px`,
              }}
            />
          )}
          {done && (
            <button
              type="button"
              className="monstrance-build-reset"
              data-testid="monstrance-build-reset"
              onClick={() => setConfirmReset(true)}
            >
              Start over
            </button>
          )}
        </div>
        <div className="monstrance-build-shelf" aria-label="Monstrance pieces">
          {MONSTRANCE_BUILD_PIECES.map((piece) => (
            <button
              key={piece.id}
              type="button"
              data-testid={`monstrance-piece-${piece.id}`}
              className={`monstrance-build-shelf-piece ${selected === piece.id ? "is-selected" : ""} ${nextPiece?.id === piece.id ? "is-next" : ""} ${bouncing === piece.id ? "is-bouncing" : ""}`}
              onPointerDown={(event) => beginDrag(event, piece.id)}
              onPointerMove={moveDrag}
              onPointerUp={endDrag}
              onPointerCancel={cancelDrag}
              disabled={placed.includes(piece.id) || done}
            >
              <span className="monstrance-build-thumb" aria-hidden="true">
                <img src={piece.asset} alt="" draggable="false" />
                <span className="monstrance-build-thumb-icon">{piece.icon}</span>
              </span>
              <span><strong>{piece.label}</strong><span>{piece.clue}</span></span>
            </button>
          ))}
        </div>
      </div>
      <div className="monstrance-build-steps" aria-label="Build progress">
        {MONSTRANCE_BUILD_PIECES.map((piece, index) => (
          <span
            key={piece.id}
            className={`monstrance-build-step ${placed.includes(piece.id) ? "is-done" : ""} ${nextPiece?.id === piece.id ? "is-next" : ""}`}
          >
            <span>{index + 1}</span>
            {piece.label}
          </span>
        ))}
      </div>
      <div className="game-feedback" aria-live="polite">{feedback}</div>
      <div className="game-actions">
        {done ? (
          <>
            <button className="btn btn-secondary" onClick={playCompletionLine}>Listen</button>
            <button className="btn btn-secondary" onClick={reset}>Beat best time</button>
            <GameCompletionBridge done={done} gameId={gameId} onComplete={onComplete} nextGame={nextGame} onNextGame={onNextGame} />
          </>
        ) : (
          <span className="game-card-copy">Next: {nextPiece?.clue}</span>
        )}
        <span className="game-card-copy">Grown-up bridge: point to these parts on the big monstrance above.</span>
      </div>
      <audio ref={audioRef} preload="auto" src={MONSTRANCE_BUILD_AUDIO} />
      {confirmReset && (
        <div className="monstrance-build-confirm" role="dialog" aria-modal="true" aria-labelledby="monstrance-reset-title">
          <div className="monstrance-build-confirm-card">
            <h3 id="monstrance-reset-title">Start over?</h3>
            <p>The finished monstrance will return to the shelf pieces.</p>
            <div className="guadalupe-controls">
              <button className="btn btn-secondary" onClick={() => setConfirmReset(false)}>Keep building</button>
              <button className="btn btn-primary" onClick={reset}>Start over</button>
            </div>
          </div>
        </div>
      )}
    </article>
  );
}

function makeMemoryDeck(pairCount = MEMORY_LEVELS[0].pairCount) {
  const cards = MEMORY_PAIRS.slice(0, pairCount).flatMap((pair) => [
    { key: `${pair.id}-symbol`, pair: pair.id, symbol: pair.symbol, label: pair.label },
    { key: `${pair.id}-mate`, pair: pair.id, symbol: "?", label: pair.mate },
  ]);
  for (let i = cards.length - 1; i > 0; i--) {
    const j = Math.floor(Math.random() * (i + 1));
    [cards[i], cards[j]] = [cards[j], cards[i]];
  }
  return cards;
}

function MemoryGame({ gameId, onComplete, nextGame, onNextGame }) {
  const [levelIndex, setLevelIndex] = useState(0);
  const level = MEMORY_LEVELS[levelIndex];
  const pairCount = level.pairCount;
  const levelPairs = MEMORY_PAIRS.slice(0, pairCount);
  const [deck, setDeck] = useState(() => makeMemoryDeck(pairCount));
  const [open, setOpen] = useState([]);
  const [matched, setMatched] = useState([]);
  const [wrong, setWrong] = useState([]);
  const [lastMatched, setLastMatched] = useState(null);
  const [flips, setFlips] = useState(0);
  const [runStart, setRunStart] = useState(() => performance.now());
  const [finishedMs, setFinishedMs] = useState(0);
  const [best, setBest] = useState(() => {
    try {
      return JSON.parse(localStorage.getItem(MEMORY_BEST_KEY) || "{}");
    } catch (e) {
      return {};
    }
  });
  const [newBest, setNewBest] = useState(false);
  const [feedback, setFeedback] = useState("Open the treasure of the Church. Match each holy sign with what it means.");
  const done = matched.length === pairCount;
  useEffect(() => {
    if (done) onComplete?.(gameId);
  }, [done, gameId, onComplete]);
  useEffect(() => {
    resetLevel(levelIndex);
  }, [levelIndex]);
  const saveBest = (nextFlips, nextMs) => {
    const key = String(level.id);
    const previous = best[key];
    const better =
      !previous ||
      nextFlips < previous.flips ||
      (nextFlips === previous.flips && nextMs < previous.ms);
    if (!better) {
      setNewBest(false);
      return;
    }
    const nextBest = { ...best, [key]: { flips: nextFlips, ms: Math.round(nextMs) } };
    setBest(nextBest);
    setNewBest(true);
    try {
      localStorage.setItem(MEMORY_BEST_KEY, JSON.stringify(nextBest));
    } catch (e) {}
  };
  const resetLevel = (nextLevelIndex = levelIndex) => {
    const nextLevel = MEMORY_LEVELS[nextLevelIndex];
    setDeck(makeMemoryDeck(nextLevel.pairCount));
    setOpen([]);
    setMatched([]);
    setWrong([]);
    setLastMatched(null);
    setFlips(0);
    setRunStart(performance.now());
    setFinishedMs(0);
    setNewBest(false);
    setFeedback("Open the treasure of the Church. Match each holy sign with what it means.");
  };
  const reset = () => {
    resetLevel();
  };
  const nextLevel = () => {
    if (levelIndex < MEMORY_LEVELS.length - 1) {
      setLevelIndex((value) => value + 1);
      monstranceLog.record("memory", "levelup");
    } else {
      resetLevel();
    }
  };
  const tap = (index) => {
    const card = deck[index];
    if (
      open.includes(index) ||
      matched.includes(card.pair) ||
      open.length === 2
    )
      return;
    const nextOpen = [...open, index];
    const nextFlips = flips + 1;
    setFlips(nextFlips);
    setOpen(nextOpen);
    if (nextOpen.length === 2) {
      const [a, b] = nextOpen.map((i) => deck[i]);
      if (a.pair === b.pair) {
        const pair = levelPairs.find((p) => p.id === a.pair);
        const nextMatched = [...matched, a.pair];
        const finished = nextMatched.length === pairCount;
        setMatched(nextMatched);
        setLastMatched(a.pair);
        setFeedback(`${pair.label}: ${pair.mate}.`);
        if (finished) {
          const nextMs = performance.now() - runStart;
          setFinishedMs(nextMs);
          saveBest(nextFlips, nextMs);
        }
        setTimeout(() => {
          setOpen([]);
          setLastMatched(null);
        }, 620);
      } else {
        setWrong(nextOpen);
        setFeedback("Not the pair yet. Look, remember, and try again.");
        monstranceLog.record("memory", "miss");
        setTimeout(() => {
          setOpen([]);
          setWrong([]);
        }, 760);
      }
    }
  };
  return (
    <article className="game-card">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Holy Match</h2>
          <p className="game-card-copy">Open the treasure of the Church by remembering holy signs. Some signs point to sacraments, and some are blessed reminders that help us pray.</p>
        </div>
        <div className="game-badges">
          <span className="game-badge">{level.label}</span>
          <span className="game-badge">{matched.length}/{pairCount}</span>
          <span className="game-badge">{flips} flips</span>
        </div>
      </div>
      <div className="memory-story">
        <strong>Treasure hunt</strong>
        <span>Find the cross, dove, chalice, Bible, candle, fish, lamb, and lily. Win by revealing every treasure.</span>
        <em>
          {done
            ? newBest
              ? "New best treasure hunt."
              : level.tease
            : `Best: ${best[String(level.id)] ? `${best[String(level.id)].flips} flips, ${formatShortTime(best[String(level.id)].ms)}` : "not set yet"}`}
        </em>
      </div>
      <GameProgress value={matched.length} total={pairCount} />
      <div className="memory-grid" aria-label="Memory cards">
        {deck.map((card, i) => {
          const isOpen = open.includes(i) || matched.includes(card.pair);
          return (
            <button
              key={card.key}
              className={`memory-card is-${card.pair} ${isOpen ? "is-open" : ""} ${matched.includes(card.pair) ? "is-done" : ""} ${lastMatched === card.pair ? "is-new-match" : ""} ${wrong.includes(i) ? "is-wrong" : ""}`}
              onClick={() => tap(i)}
              disabled={matched.includes(card.pair)}
              aria-label={isOpen ? card.label : `Memory card ${i + 1}, face down`}
            >
              <span className="memory-card-inner">
                <span className="memory-card-face memory-card-front">
                  <span className="memory-card-number">{i + 1}</span>
                  <span className="memory-symbol" aria-hidden="true">✦</span>
                  <span className="memory-card-tap">Tap</span>
                </span>
                <span className="memory-card-face memory-card-back">
                  <span className="memory-symbol" aria-hidden="true">{card.symbol}</span>
                  <span>{card.label}</span>
                </span>
              </span>
              {lastMatched === card.pair && (
                <span className="memory-match-burst" aria-hidden="true">
                  <span />
                  <span />
                  <span />
                </span>
              )}
            </button>
          );
        })}
      </div>
      <div className="game-feedback" aria-live="polite">{done ? `All treasures revealed. ${finishedMs ? `Time ${formatShortTime(finishedMs)}.` : ""}` : feedback}</div>
      <div className="game-actions">
        <button className="btn btn-secondary" onClick={reset}>Shuffle</button>
        {done && <button className="btn btn-primary" onClick={nextLevel}>{levelIndex < MEMORY_LEVELS.length - 1 ? "Next level" : "Beat best"}</button>}
        <GameCompletionBridge done={done} gameId={gameId} onComplete={onComplete} nextGame={nextGame} onNextGame={onNextGame} />
        <span className="game-card-copy">Grown-up bridge: name one symbol, then find it at church or in a picture book.</span>
      </div>
    </article>
  );
}

function QuietLightGame({ gameId, onComplete, nextGame, onNextGame }) {
  const [lit, setLit] = useState([]);
  const [roundTarget, setRoundTarget] = useState(1);
  const [bestStreak, setBestStreak] = useState(() =>
    readNumberStorage(QUIET_LIGHT_BEST_KEY, 0),
  );
  const [wrong, setWrong] = useState(null);
  const [justLit, setJustLit] = useState(null);
  const [phase, setPhase] = useState("watch");
  const [previewIdx, setPreviewIdx] = useState(-1);
  const [watchKey, setWatchKey] = useState(0);
  const watchTimers = useRef([]);
  const [feedback, setFeedback] = useState(
    "Watch the prayer-lights glow in order, then tap them back the same way.",
  );
  const next = lit.length;
  const done =
    roundTarget === LIGHT_PRAYERS.length && lit.length === LIGHT_PRAYERS.length;
  const clearWatchTimers = () => {
    watchTimers.current.forEach((id) => window.clearTimeout(id));
    watchTimers.current = [];
  };
  useEffect(() => {
    if (done) onComplete?.(gameId);
  }, [done, gameId, onComplete]);
  useEffect(() => {
    // Flash the growing prayer order, then let the child repeat it from memory.
    if (done) return undefined;
    clearWatchTimers();
    setLit([]);
    setWrong(null);
    setJustLit(null);
    setPreviewIdx(-1);
    setPhase("watch");
    setFeedback("Watch closely: this is the prayer order to remember.");
    const stepMs = 640;
    for (let k = 0; k < roundTarget; k++) {
      watchTimers.current.push(
        window.setTimeout(() => setPreviewIdx(k), 350 + k * stepMs),
      );
      watchTimers.current.push(
        window.setTimeout(
          () => setPreviewIdx(-1),
          350 + k * stepMs + stepMs * 0.55,
        ),
      );
    }
    watchTimers.current.push(
      window.setTimeout(
        () => {
          setPreviewIdx(-1);
          setPhase("input");
          setFeedback(
            "Now tap the prayer-lights in the same order, from memory.",
          );
        },
        400 + roundTarget * stepMs,
      ),
    );
    return clearWatchTimers;
  }, [roundTarget, watchKey, done]);
  const completeRound = (nextLit) => {
    const streak = nextLit.length;
    if (streak > bestStreak) {
      setBestStreak(streak);
      writeNumberStorage(QUIET_LIGHT_BEST_KEY, streak);
    }
    if (streak === LIGHT_PRAYERS.length) {
      setFeedback(
        "The sanctuary glows. Jesus calls us to be the light of the world.",
      );
      return;
    }
    setFeedback(
      `Good quiet heart. Now watch ${streak + 1} prayer-lights in a row.`,
    );
    monstranceLog.record("light", "round_clear", streak);
    window.setTimeout(() => {
      setRoundTarget((value) => Math.min(LIGHT_PRAYERS.length, value + 1));
    }, 820);
  };
  const tap = (id, index) => {
    if (phase !== "input" || lit.includes(id) || done) return;
    if (index === next) {
      const nextLit = [...lit, id];
      setLit(nextLit);
      setJustLit(id);
      setFeedback(LIGHT_PRAYERS[index].teach);
      setWrong(null);
      setTimeout(() => setJustLit(null), 640);
      if (nextLit.length === roundTarget) completeRound(nextLit);
    } else {
      setWrong(id);
      setPhase("watch");
      setFeedback("Almost! Watch the order once more, then try again.");
      monstranceLog.record("light", "miss", roundTarget);
      setTimeout(() => {
        setWrong(null);
        setWatchKey((value) => value + 1);
      }, 760);
    }
  };
  const reset = () => {
    setRoundTarget(1);
    setWatchKey((value) => value + 1);
    setFeedback("Watch the prayer-lights glow in order, then tap them back.");
  };
  return (
    <article className="game-card">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Quiet Light</h2>
          <p className="game-card-copy">
            Light the way to Jesus with a quiet heart. Prayer is the life of the
            heart, and God's word is a lamp for our feet.
          </p>
        </div>
        <div className="game-badges">
          <span className="game-badge">Round {roundTarget}</span>
          <span className="game-badge">
            {lit.length}/{roundTarget}
          </span>
          <span className="game-badge">Best {bestStreak}</span>
        </div>
      </div>
      <div className="light-story">
        <strong>Prayer-light challenge</strong>
        <span>
          Remember the growing order. Missing one only dims the try, so begin
          the round again.
        </span>
        <em>
          {done
            ? "The sanctuary glows."
            : phase === "watch"
              ? "Watch and remember the prayer order."
              : "Tap the prayer-lights in order, from memory."}
        </em>
      </div>
      <GameProgress
        value={done ? LIGHT_PRAYERS.length : roundTarget - 1}
        total={LIGHT_PRAYERS.length}
      />
      <div
        className={`light-grid ${done ? "is-complete" : ""}`}
        aria-label="Prayer rays"
        style={{ "--light-progress-glow": `${0.06 + lit.length * 0.045}` }}
      >
        {LIGHT_PRAYERS.map((ray, i) => {
          const rayOrder = i + 1;
          const inRound = i < roundTarget;
          return (
            <button
              key={ray.id}
              className={`light-ray ${lit.includes(ray.id) ? "is-lit" : ""} ${phase === "watch" && previewIdx === i ? "is-next is-just-lit" : ""} ${!inRound ? "is-locked" : ""} ${justLit === ray.id ? "is-just-lit" : ""} ${wrong === ray.id ? "is-wrong" : ""}`}
              style={{
                "--ray-order": `${rayOrder}`,
                "--ray-glow-size": `${18 + rayOrder * 4}px`,
                "--ray-glow-alpha": `${0.48 + rayOrder * 0.055}`,
                "--ray-shine-alpha": `${0.28 + rayOrder * 0.08}`,
                "--ray-shine-scale": `${0.84 + rayOrder * 0.035}`,
                "--ray-shine-rotate": `${rayOrder * 4}deg`,
                "--ray-shine-rotate-end": `${rayOrder * 4 + 360}deg`,
                "--ray-complete-delay": `${rayOrder * 90}ms`,
              }}
              onClick={() => tap(ray.id, i)}
              disabled={lit.includes(ray.id) || !inRound || phase !== "input"}
            >
              <span className="light-ray-number">
                {lit.includes(ray.id)
                  ? "✓"
                  : phase === "watch" && previewIdx === i
                    ? i + 1
                    : inRound
                      ? "•"
                      : "?"}
              </span>
              <span className="light-ray-icon" aria-hidden="true">
                {ray.icon}
              </span>
              <strong>{ray.label}</strong>
            </button>
          );
        })}
      </div>
      <div className="game-feedback" aria-live="polite">
        {done
          ? "The sanctuary glows. Take one quiet breath and say Amen."
          : feedback}
      </div>
      <div className="game-actions">
        <button className="btn btn-secondary" onClick={reset}>
          Start round 1
        </button>
        <GameCompletionBridge
          done={done}
          gameId={gameId}
          onComplete={onComplete}
          nextGame={nextGame}
          onNextGame={onNextGame}
        />
        <span className="game-card-copy">
          Offline bridge: say the same six tiny prayers with fingers instead of
          a screen.
        </span>
      </div>
    </article>
  );
}

const guadalupeSheetPosition = (col, row, cols, rows) =>
  `${cols <= 1 ? 0 : (col / (cols - 1)) * 100}% ${rows <= 1 ? 0 : (row / (rows - 1)) * 100}%`;

const GUADALUPE_PLAYER_STARTS = {
  tepeyac: { x: 25, y: 82, scale: 0.98, dir: "right" },
  message: { x: 31, y: 82, scale: 0.94, dir: "right" },
  bishop: { x: 55, y: 66, scale: 1.5, mobileX: 55, mobileY: 68, mobileScale: 1.36, dir: "front" },
  roses: { x: 28, y: 83, scale: 0.98, dir: "right", roses: true },
  tilma: { x: 37, y: 87, scale: 2.84, dir: "front", roses: true },
  chapel: { x: 29, y: 82, scale: 0.92, dir: "right" },
  lourdes: { x: 31, y: 84, scale: 0.96, dir: "right" },
  "lourdes-ask": { x: 36, y: 84, scale: 0.98, dir: "right" },
  "lourdes-spring": { x: 32, y: 84, scale: 0.98, dir: "right" },
  "lourdes-sign": { x: 34, y: 84, scale: 0.98, dir: "right" },
  fatima: { x: 24, y: 84, scale: 0.96, dir: "right" },
  "fatima-ask": { x: 30, y: 84, scale: 0.96, dir: "right" },
  "fatima-prayer": { x: 26, y: 84, scale: 0.96, dir: "right" },
  "fatima-sun": { x: 32, y: 84, scale: 0.96, dir: "right" },
};
const GUADALUPE_OBJECTIVES = {
  tepeyac: "Walk Tepeyac's path and listen for Mary's call.",
  message: "Stay near Mary long enough to receive the chapel mission.",
  bishop: "Enter the room, visit the desk, then pause by the candles.",
  roses: "Climb the cold hill and gather roses for Juan's tilma.",
  tilma: "Bring the roses to the bishop, then inspect the tilma sign.",
  chapel: "Walk to the chapel site where the prayer request takes shape.",
  lourdes: "Follow the grotto path to Bernadette's prayer place.",
  "lourdes-ask": "Walk close to Mary and listen for the spring request.",
  "lourdes-spring": "Tap the rocks, ground, and water to discover the hidden spring.",
  "lourdes-sign": "Walk to the spring and notice the water flowing.",
  fatima: "Walk toward the oak field and listen for Mary's peace message.",
  "fatima-ask": "Walk close to Mary and listen for the peace prayer.",
  "fatima-prayer": "Tap the oak, Rosary, and people to discover the prayer message.",
  "fatima-sun": "Walk under the sky and notice the sun sign.",
};
const GUADALUPE_SCENE_TOKENS = {
  tepeyac: { mark: "CALL", label: "Mary's call" },
  message: { mark: "ASK", label: "Chapel request" },
  bishop: { mark: "ROOM", label: "Bishop's room" },
  roses: { mark: "ROSE", label: "Winter roses" },
  tilma: { mark: "SIGN", label: "Tilma sign" },
  chapel: { mark: "PRAY", label: "Prayer place" },
  lourdes: { mark: "GROTTO", label: "Lourdes grotto" },
  "lourdes-ask": { mark: "ASK", label: "Spring request" },
  "lourdes-spring": { mark: "TASK", label: "Hidden spring" },
  "lourdes-sign": { mark: "WATER", label: "Spring flows" },
  fatima: { mark: "PEACE", label: "Fatima peace" },
  "fatima-ask": { mark: "ASK", label: "Peace request" },
  "fatima-prayer": { mark: "PRAY", label: "Prayer field" },
  "fatima-sun": { mark: "SUN", label: "Sun sign" },
};
const GUADALUPE_DISCOVERIES = {
  tepeyac: {
    target: { x: 71, y: 58 },
    radius: 7,
    speaker: "mary",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-tepeyac.mp3`,
    line:
      "Juanito, my little son, do not be afraid. I am your mother, and I bring comfort from God.",
    note:
      "December 9, 1531: the Guadalupe story begins with Juan Diego on Tepeyac Hill.",
    revealMary: true,
  },
  message: {
    target: { x: 63, y: 59 },
    radius: 8,
    speaker: "mary",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-message.mp3`,
    line:
      "Ask the bishop for a little house of prayer here, where people can come to my Son.",
    note:
      "The message becomes a task: Juan Diego must carry the chapel request to the bishop.",
  },
  roses: {
    target: { x: 58, y: 72 },
    radius: 10,
    speaker: "mary",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-roses.mp3`,
    line:
      "Gather the roses blooming on the cold hill. Carry them carefully in your tilma.",
    note:
      "The winter roses become the sign Juan Diego carries back in his tilma.",
  },
  chapel: {
    target: { x: 58, y: 76 },
    radius: 10,
    speaker: "juan",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-chapel.mp3`,
    line:
      "The chapel rises on the hill, and everyone remembers: Mary leads us to Jesus.",
    note:
      "The chapel request points the story forward to prayer, comfort, and love for Jesus.",
  },
  lourdes: {
    target: { x: 69, y: 50 },
    radius: 8,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-lourdes.mp3`,
    line:
      "I am here to help you pray and trust God.",
    note:
      "Lourdes is remembered as a place of prayer where many people ask God for healing.",
    revealMary: true,
  },
  "lourdes-ask": {
    target: { x: 69, y: 50 },
    radius: 8,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    audio: `${GUADALUPE_APPARITION_AUDIO_BASE}/lourdes-ask.mp3`,
    line:
      "Go drink at the spring and wash there. God can bring help from a hidden place.",
    note:
      "Mary's request gives Bernadette a simple action at the grotto.",
  },
  "lourdes-sign": {
    target: { x: 52, y: 74 },
    radius: 10,
    speaker: "mary",
    speakerLabel: "Our Lady of Lourdes",
    audio: `${GUADALUPE_APPARITION_AUDIO_BASE}/lourdes-sign.mp3`,
    line:
      "The spring begins to flow, and people come to pray for healing and hope.",
    note:
      "The Lourdes spring is remembered as a sign that points people back to God.",
  },
  fatima: {
    target: { x: 59, y: 44 },
    radius: 8,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    audio: `${GUADALUPE_AUDIO_BASE}/guadalupe-fatima.mp3`,
    line:
      "Pray for peace, and stay close to Jesus.",
    note:
      "At Fatima, the children shared Mary's call to pray the Rosary and trust God.",
    revealMary: true,
  },
  "fatima-ask": {
    target: { x: 59, y: 44 },
    radius: 8,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    audio: `${GUADALUPE_APPARITION_AUDIO_BASE}/fatima-ask.mp3`,
    line:
      "Pray the Rosary for peace, and stay close to Jesus with brave hearts.",
    note:
      "The Fatima message asks the children to pray for peace and trust God.",
  },
  "fatima-sun": {
    target: { x: 59, y: 44 },
    radius: 10,
    speaker: "mary",
    speakerLabel: "Our Lady of Fatima",
    audio: `${GUADALUPE_APPARITION_AUDIO_BASE}/fatima-sun.mp3`,
    line:
      "The sun seems to dance in the sky, and the people remember Mary's call to pray.",
    note:
      "Catholics remember October 13, 1917, as the day of the Fatima sun sign.",
  },
};
const GUADALUPE_SCENE_D_SIGNS = {
  tilma: {
    type: "tilma-drag",
    date: "December 12, 1531",
    prompt: "Walk to the bishop's desk and open the tilma.",
    caption:
      "The roses fall, and the image on the tilma confirms the chapel request.",
    dragDistance: 78,
  },
  "lourdes-sign": {
    type: "ground-taps",
    target: { x: 52, y: 74, mobileX: 51, mobileY: 72 },
    requiredTaps: 3,
    date: "February 11, 1858",
    prompt: "Tap the ground three times where Bernadette dug.",
    caption: "The spring flows at Lourdes and pilgrims come to pray.",
  },
  "fatima-sun": {
    type: "sky-drag",
    target: { x: 77, y: 24, mobileX: 83, mobileY: 30 },
    date: "October 13, 1917",
    prompt: "Drag the sun across the sky to make it whirl.",
    caption: "The sun sign helps people remember Mary's call to prayer.",
    dragDistance: 84,
  },
};
const GUADALUPE_MARY_WAYPOINTS = [
  { x: 72, y: 56, dir: "front" },
  { x: 76, y: 53, dir: "left" },
  { x: 66, y: 60, dir: "right" },
  { x: 70, y: 57, dir: "front" },
];
const GUADALUPE_EMPTY_MOVE = { up: false, down: false, left: false, right: false };
const GUADALUPE_DEPTH_PROFILES = {
  tepeyac: { farY: 52, nearY: 86, min: 0.94, max: 1.02, mobileMin: 0.94, mobileMax: 1.02 },
  message: { farY: 54, nearY: 86, min: 0.9, max: 1, mobileMin: 0.9, mobileMax: 1 },
  roses: { farY: 56, nearY: 86, min: 0.92, max: 1.02, mobileMin: 0.92, mobileMax: 1.02 },
  chapel: { farY: 58, nearY: 86, min: 0.88, max: 0.98, mobileMin: 0.88, mobileMax: 0.98 },
  lourdes: { farY: 50, nearY: 88, min: 0.92, max: 1.06, mobileMin: 0.92, mobileMax: 1.06 },
  fatima: { farY: 45, nearY: 88, min: 0.9, max: 1.04, mobileMin: 0.9, mobileMax: 1.04 },
  bishop: { farY: 64, nearY: 89, min: 1.46, max: 2.6, mobileMin: 1.34, mobileMax: 2.0 },
  tilma: { farY: 78, nearY: 89, min: 2.1, max: 2.9, mobileMin: 1.8, mobileMax: 2.12 },
};
Object.assign(GUADALUPE_DEPTH_PROFILES, {
  "lourdes-ask": GUADALUPE_DEPTH_PROFILES.lourdes,
  "lourdes-spring": GUADALUPE_DEPTH_PROFILES.lourdes,
  "lourdes-sign": GUADALUPE_DEPTH_PROFILES.lourdes,
  "fatima-ask": GUADALUPE_DEPTH_PROFILES.fatima,
  "fatima-prayer": GUADALUPE_DEPTH_PROFILES.fatima,
  "fatima-sun": GUADALUPE_DEPTH_PROFILES.fatima,
});
const GUADALUPE_WALKABLE_LANES = {
  tepeyac: [
    { x: 14, minY: 82, maxY: 89 },
    { x: 28, minY: 77, maxY: 89 },
    { x: 44, minY: 72, maxY: 89 },
    { x: 58, minY: 64, maxY: 87 },
    { x: 68, minY: 58, maxY: 84 },
    { x: 76, minY: 55, maxY: 81 },
    { x: 86, minY: 58, maxY: 78 },
  ],
  lourdes: [
    { x: 14, minY: 82, maxY: 89 },
    { x: 30, minY: 76, maxY: 89 },
    { x: 46, minY: 67, maxY: 88 },
    { x: 60, minY: 56, maxY: 84 },
    { x: 72, minY: 49, maxY: 78 },
    { x: 86, minY: 52, maxY: 76 },
  ],
  fatima: [
    { x: 14, minY: 80, maxY: 89 },
    { x: 30, minY: 75, maxY: 89 },
    { x: 46, minY: 64, maxY: 87 },
    { x: 60, minY: 44, maxY: 82 },
    { x: 74, minY: 49, maxY: 79 },
    { x: 86, minY: 56, maxY: 80 },
  ],
};
Object.assign(GUADALUPE_WALKABLE_LANES, {
  "lourdes-ask": GUADALUPE_WALKABLE_LANES.lourdes,
  "lourdes-spring": GUADALUPE_WALKABLE_LANES.lourdes,
  "lourdes-sign": GUADALUPE_WALKABLE_LANES.lourdes,
  "fatima-ask": GUADALUPE_WALKABLE_LANES.fatima,
  "fatima-prayer": GUADALUPE_WALKABLE_LANES.fatima,
  "fatima-sun": GUADALUPE_WALKABLE_LANES.fatima,
});
const guadalupeClamp = (value, min, max) => Math.min(max, Math.max(min, value));
const guadalupeDistance = (a, b) => Math.hypot(a.x - b.x, a.y - b.y);
const guadalupeDirectionFromDelta = (dx, dy, fallback = "front") => {
  if (Math.abs(dx) < 0.2 && Math.abs(dy) < 0.2) return fallback;
  if (Math.abs(dx) >= Math.abs(dy)) return dx >= 0 ? "right" : "left";
  return dy >= 0 ? "front" : "back";
};
const guadalupeWalkDuration = (distance, mode = "field") => {
  const speed = mode === "room" ? GUADALUPE_WALK_TIMING.roomSpeed : GUADALUPE_WALK_TIMING.fieldSpeed;
  const maxMs = mode === "room" ? GUADALUPE_WALK_TIMING.roomMaxMs : GUADALUPE_WALK_TIMING.fieldMaxMs;
  return Math.round(guadalupeClamp((distance / speed) * 1000, GUADALUPE_WALK_TIMING.minMs, maxMs));
};
const guadalupeInterpolateLane = (points, x, key) => {
  if (x <= points[0].x) return points[0][key];
  for (let index = 1; index < points.length; index += 1) {
    const previous = points[index - 1];
    const next = points[index];
    if (x <= next.x) {
      const amount = (x - previous.x) / (next.x - previous.x);
      return previous[key] + (next[key] - previous[key]) * amount;
    }
  }
  return points[points.length - 1][key];
};
const guadalupeDepthScale = (sceneId, y, fallback, mobile = false) => {
  const profile = GUADALUPE_DEPTH_PROFILES[sceneId];
  if (!profile) return fallback;
  const amount = guadalupeClamp((y - profile.farY) / (profile.nearY - profile.farY), 0, 1);
  const min = mobile ? profile.mobileMin : profile.min;
  const max = mobile ? profile.mobileMax : profile.max;
  return min + (max - min) * amount;
};
const guadalupeWalkablePoint = (sceneId, x, y) => {
  const lane = GUADALUPE_WALKABLE_LANES[sceneId];
  if (lane) {
    const laneX = guadalupeClamp(x, lane[0].x, lane[lane.length - 1].x);
    const minY = guadalupeInterpolateLane(lane, laneX, "minY");
    const maxY = guadalupeInterpolateLane(lane, laneX, "maxY");
    return { x: laneX, y: guadalupeClamp(y, minY, maxY) };
  }
  if (!["bishop", "tilma"].includes(sceneId)) return { x, y };
  const floorX = guadalupeClamp(x, 18, 76);
  let floorY = guadalupeClamp(y, sceneId === "bishop" ? 64 : 78, 89);
  if (sceneId === "bishop" && floorY < 79) {
    return { x: guadalupeClamp(floorX, 49, 58), y: floorY };
  }
  if (floorX > 58 && floorY < 84) floorY = 84;
  return { x: floorX, y: floorY };
};
const guadalupeRoomRouteWaypoint = (sceneId, from, targetPoint, targetPatch) => {
  if (sceneId !== "bishop" || !targetPatch) return null;
  const fromMobileX = from.mobileX ?? from.x;
  const fromMobileY = from.mobileY ?? from.y;
  const targetMobileX = targetPatch.mobileX ?? targetPoint.x;
  const targetMobileY = targetPatch.mobileY ?? targetPoint.y;
  const crossesLeftCorner =
    fromMobileY < 79 &&
    targetMobileY >= 79 &&
    targetMobileX < 49;
  if (!crossesLeftCorner) return null;
  const waypointX = guadalupeClamp(from.x, 49, 58);
  const waypointY = 84;
  const waypointMobileX = guadalupeClamp(fromMobileX, 49, 58);
  const waypointMobileY = 84;
  return {
    point: guadalupeWalkablePoint(sceneId, waypointX, waypointY),
    patch: {
      mobileX: waypointMobileX,
      mobileY: waypointMobileY,
      scale: guadalupeDepthScale(sceneId, waypointY, from.scale),
      mobileScale: guadalupeDepthScale(sceneId, waypointMobileY, from.mobileScale ?? from.scale, true),
      dir: guadalupeDirectionFromDelta(waypointX - from.x, waypointY - from.y, from.dir),
    },
  };
};
const guadalupeActorStart = (sceneId, actor) => {
  const nextScale = guadalupeDepthScale(sceneId, actor.y, actor.scale);
  const nextMobileScale = guadalupeDepthScale(sceneId, actor.mobileY ?? actor.y, actor.mobileScale ?? actor.scale, true);
  return { ...actor, scale: nextScale, mobileScale: nextMobileScale, sceneId, walkMs: 0 };
};

const mergeGuadalupeRoomActor = (actor, roomSpot) => {
  const roomActor = roomSpot?.actors?.[actor.id];
  return roomActor ? { ...actor, ...roomActor } : actor;
};

function GuadalupeSceneActor({ actor, frame }) {
  const atlas = GUADALUPE_CHARACTER_ATLASES[actor.id];
  const col = GUADALUPE_DIRECTIONS[actor.dir] ?? GUADALUPE_DIRECTIONS.front;
  const row = actor.walking ? GUADALUPE_WALK_FRAME_SEQUENCE[frame % GUADALUPE_WALK_FRAME_SEQUENCE.length] : 0;
  const walkAtlas = actor.roses && atlas.walkRoses ? atlas.walkRoses : atlas.walk;
  return (
    <span
      className={`guadalupe-actor is-${actor.id} ${actor.walking ? "is-walking" : ""} ${actor.controlled ? "is-controlled-walking" : ""} ${actor.apparition ? "is-apparition" : ""} ${actor.revealed === false ? "is-veiled" : ""}`}
      style={{
        "--actor-x": `${actor.x}%`,
        "--actor-y": `${actor.y}%`,
        "--actor-mobile-x": `${actor.mobileX ?? actor.x}%`,
        "--actor-mobile-y": `${actor.mobileY ?? actor.y}%`,
        "--actor-scale": actor.scale,
        "--actor-mobile-scale": actor.mobileScale ?? actor.scale,
        "--actor-opacity": actor.opacity ?? 1,
        "--actor-depth": actor.y,
        "--actor-move-ms": `${actor.walkMs ?? (actor.controlled ? 90 : 520)}ms`,
        backgroundImage: `url("${walkAtlas}")`,
        backgroundPosition: guadalupeSheetPosition(col, row, 4, 4),
        zIndex: Math.round(actor.y),
      }}
      role="img"
      aria-label={atlas.label}
    />
  );
}

function GuadalupeTilmaCloak({ large = false, progress = null }) {
  const reveal = progress === null ? null : guadalupeClamp(progress, 0, 1);
  const revealStyle =
    reveal === null
      ? undefined
      : {
          "--tilma-reveal-clip": `${Math.round((1 - reveal) * 72)}%`,
          "--tilma-reveal-opacity": (0.16 + reveal * 0.84).toFixed(2),
          "--tilma-fold-shift": `${Math.round(reveal * -24)}px`,
        };
  return (
    <span
      className={`guadalupe-tilma-cloak ${large ? "is-large" : ""} ${reveal !== null ? "is-revealing" : ""}`}
      style={revealStyle}
      role={large ? "img" : undefined}
      aria-label={large ? "Tilma cloak with the image of Mary" : undefined}
      aria-hidden={large ? undefined : "true"}
    >
      <span className="guadalupe-tilma-fold is-left" />
      <span className="guadalupe-tilma-fold is-right" />
      <span className="guadalupe-tilma-image">
        <span className="guadalupe-tilma-halo" />
        <span className="guadalupe-tilma-robe" />
        <span className="guadalupe-tilma-mantle" />
        <span className="guadalupe-tilma-face" />
        <span className="guadalupe-tilma-hair" />
        <span className="guadalupe-tilma-hands" />
        <span className="guadalupe-tilma-sash" />
        <span className="guadalupe-tilma-moon" />
        <span className="guadalupe-tilma-angel" />
        <span className="guadalupe-tilma-rays" />
      </span>
    </span>
  );
}

/* --- Marian detail scenes ------------------------------------------------
   Faustina-desk-style close-look scenes for each apparition. Vector art so
   the zoom stays sharp; zoom is a CSS transform (no rAF) so it works in
   headless verification too. */

const marianRayPaths = (
  cx,
  cy,
  rx0,
  ry0,
  rxLong,
  ryLong,
  rxShort,
  ryShort,
  count,
  halfWidth,
) => {
  const rays = [];
  for (let i = 0; i < count; i += 1) {
    const angle = (i / count) * Math.PI * 2;
    const long = i % 2 === 0;
    const rx1 = long ? rxLong : rxShort;
    const ry1 = long ? ryLong : ryShort;
    const p1x = cx + Math.cos(angle - halfWidth) * rx0;
    const p1y = cy + Math.sin(angle - halfWidth) * ry0;
    const p2x = cx + Math.cos(angle + halfWidth) * rx0;
    const p2y = cy + Math.sin(angle + halfWidth) * ry0;
    const tx = cx + Math.cos(angle) * rx1;
    const ty = cy + Math.sin(angle) * ry1;
    rays.push(
      `M${p1x.toFixed(1)} ${p1y.toFixed(1)} L${tx.toFixed(1)} ${ty.toFixed(1)} L${p2x.toFixed(1)} ${p2y.toFixed(1)} Z`,
    );
  }
  return rays;
};

const MARIAN_TILMA_RAYS = marianRayPaths(
  220,
  288,
  112,
  192,
  168,
  268,
  144,
  234,
  46,
  0.05,
);
const MARIAN_FATIMA_RAYS = marianRayPaths(
  220,
  148,
  88,
  88,
  134,
  134,
  112,
  112,
  28,
  0.06,
);
const MARIAN_TILMA_STARS = [
  [204, 128, 0.9],
  [236, 126, 0.8],
  [220, 112, 0.7],
  [172, 232, 1],
  [182, 286, 0.9],
  [166, 330, 1.05],
  [186, 372, 0.9],
  [172, 416, 1],
  [190, 444, 0.8],
  [268, 232, 1],
  [258, 286, 0.9],
  [274, 330, 1.05],
  [254, 372, 0.9],
  [268, 416, 1],
  [250, 444, 0.8],
];
const MARIAN_ROCK_BLOBS = [
  [60, 140, 44, 20, 0.16, true],
  [150, 96, 56, 22, 0.12, true],
  [312, 110, 48, 20, 0.14, true],
  [396, 180, 40, 24, 0.12, true],
  [50, 300, 36, 26, 0.2, false],
  [396, 320, 38, 28, 0.18, false],
  [66, 430, 44, 26, 0.16, false],
  [386, 452, 46, 26, 0.16, false],
  [210, 540, 60, 20, 0.2, false],
  [330, 546, 44, 18, 0.16, false],
  [90, 210, 30, 18, 0.14, true],
  [368, 250, 28, 16, 0.14, true],
];

function GuadalupeTilmaDetailArt() {
  return (
    <svg
      className="marian-detail-svg"
      viewBox="0 0 440 580"
      role="img"
      aria-label="The image of Our Lady of Guadalupe on Juan Diego's tilma"
    >
      <defs>
        <linearGradient id="mdg-cloth" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stopColor="#f2e7c8" />
          <stop offset="55%" stopColor="#e6d4ab" />
          <stop offset="100%" stopColor="#d8c194" />
        </linearGradient>
        <radialGradient id="mdg-glow" cx="50%" cy="48%" r="60%">
          <stop offset="0%" stopColor="rgba(255, 243, 205, 0.98)" />
          <stop offset="62%" stopColor="rgba(255, 233, 168, 0.55)" />
          <stop offset="100%" stopColor="rgba(255, 233, 168, 0)" />
        </radialGradient>
        <linearGradient id="mdg-ray" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#f6d06a" />
          <stop offset="100%" stopColor="#dd9d2e" />
        </linearGradient>
        <linearGradient id="mdg-mantle" x1="0" y1="0" x2="1" y2="1">
          <stop offset="0%" stopColor="#4e8f85" />
          <stop offset="100%" stopColor="#2c5c55" />
        </linearGradient>
        <linearGradient id="mdg-robe" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#cf7469" />
          <stop offset="100%" stopColor="#a54f45" />
        </linearGradient>
        <radialGradient id="mdg-skin" cx="50%" cy="40%" r="70%">
          <stop offset="0%" stopColor="#dcae84" />
          <stop offset="100%" stopColor="#c08d64" />
        </radialGradient>
        <pattern
          id="mdg-weave"
          width="7"
          height="7"
          patternUnits="userSpaceOnUse"
        >
          <path
            d="M0 3.5 H7"
            stroke="rgba(122, 92, 48, 0.14)"
            strokeWidth="1"
          />
          <path
            d="M3.5 0 V7"
            stroke="rgba(122, 92, 48, 0.09)"
            strokeWidth="1"
          />
        </pattern>
        <pattern
          id="mdg-brocade"
          width="30"
          height="30"
          patternUnits="userSpaceOnUse"
        >
          <circle cx="15" cy="15" r="2.4" fill="#d9a441" opacity="0.6" />
          <ellipse
            cx="15"
            cy="8.6"
            rx="2.1"
            ry="3.4"
            fill="#d9a441"
            opacity="0.42"
          />
          <ellipse
            cx="15"
            cy="21.4"
            rx="2.1"
            ry="3.4"
            fill="#d9a441"
            opacity="0.42"
          />
          <ellipse
            cx="8.6"
            cy="15"
            rx="3.4"
            ry="2.1"
            fill="#d9a441"
            opacity="0.42"
          />
          <ellipse
            cx="21.4"
            cy="15"
            rx="3.4"
            ry="2.1"
            fill="#d9a441"
            opacity="0.42"
          />
        </pattern>
        <path
          id="mdg-star"
          d="M0 -5.2 L1.3 -1.3 L5.2 0 L1.3 1.3 L0 5.2 L-1.3 1.3 L-5.2 0 L-1.3 -1.3 Z"
        />
      </defs>

      {/* tilma cloth */}
      <rect
        x="10"
        y="8"
        width="420"
        height="564"
        rx="14"
        fill="url(#mdg-cloth)"
      />
      <rect
        x="10"
        y="8"
        width="420"
        height="564"
        rx="14"
        fill="url(#mdg-weave)"
      />
      <path
        d="M220 8 V572"
        stroke="rgba(122, 92, 48, 0.22)"
        strokeWidth="2.6"
        strokeDasharray="9 6"
      />
      <rect
        x="10"
        y="8"
        width="420"
        height="564"
        rx="14"
        fill="none"
        stroke="rgba(107, 78, 40, 0.5)"
        strokeWidth="3"
      />

      {/* golden mandorla */}
      <ellipse cx="220" cy="288" rx="176" ry="262" fill="url(#mdg-glow)" />
      <g fill="url(#mdg-ray)" opacity="0.92">
        {MARIAN_TILMA_RAYS.map((d, index) => (
          <path key={index} d={d} />
        ))}
      </g>

      {/* mantle silhouette (behind robe) */}
      <path
        d="M220 108 C 184 108 167 134 163 166 C 157 222 149 302 145 382 C 142 432 148 464 170 472 L 270 472 C 292 464 298 432 295 382 C 291 302 283 222 277 166 C 273 134 256 108 220 108 Z"
        fill="url(#mdg-mantle)"
        stroke="#e3b23c"
        strokeWidth="3"
      />
      {/* robe */}
      <path
        d="M220 166 C 200 166 190 182 186 204 C 178 258 172 330 170 396 C 168 438 178 462 198 466 L 242 466 C 262 462 272 438 270 396 C 268 330 262 258 254 204 C 250 182 240 166 220 166 Z"
        fill="url(#mdg-robe)"
      />
      <path
        d="M220 166 C 200 166 190 182 186 204 C 178 258 172 330 170 396 C 168 438 178 462 198 466 L 242 466 C 262 462 272 438 270 396 C 268 330 262 258 254 204 C 250 182 240 166 220 166 Z"
        fill="url(#mdg-brocade)"
      />
      <path
        d="M198 462 C 204 448 208 428 208 404"
        fill="none"
        stroke="rgba(122, 48, 40, 0.5)"
        strokeWidth="2.4"
        strokeLinecap="round"
      />
      <path
        d="M242 462 C 236 448 232 428 232 404"
        fill="none"
        stroke="rgba(122, 48, 40, 0.5)"
        strokeWidth="2.4"
        strokeLinecap="round"
      />

      {/* mantle front drapes with stars */}
      <path
        d="M187 200 C 178 258 170 330 166 396 C 164 434 170 462 188 470 L 208 470 C 197 452 193 430 193 400 C 193 336 199 262 205 208 C 207 192 213 178 221 170 C 203 172 192 184 187 200 Z"
        fill="url(#mdg-mantle)"
        stroke="#e3b23c"
        strokeWidth="2.6"
      />
      <path
        d="M253 200 C 262 258 270 330 274 396 C 276 434 270 462 252 470 L 232 470 C 243 452 247 430 247 400 C 247 336 241 262 235 208 C 233 192 227 178 219 170 C 237 172 248 184 253 200 Z"
        fill="url(#mdg-mantle)"
        stroke="#e3b23c"
        strokeWidth="2.6"
      />
      {/* hood */}
      <path
        d="M220 106 C 191 106 175 127 173 154 C 171 175 177 196 187 208 L 197 199 C 190 187 186 170 188 153 C 190 131 202 121 220 121 C 238 121 250 131 252 153 C 254 170 250 187 243 199 L 253 208 C 263 196 269 175 267 154 C 265 127 249 106 220 106 Z"
        fill="url(#mdg-mantle)"
        stroke="#e3b23c"
        strokeWidth="2.6"
      />
      <g fill="#f2c14e" stroke="rgba(146, 96, 22, 0.6)" strokeWidth="0.6">
        {MARIAN_TILMA_STARS.map(([x, y, s], index) => (
          <use
            key={index}
            href="#mdg-star"
            transform={`translate(${x} ${y}) scale(${s})`}
          />
        ))}
      </g>

      {/* face */}
      <g transform="rotate(-5 220 170)">
        <ellipse cx="220" cy="170" rx="21" ry="25" fill="url(#mdg-skin)" />
        <path
          d="M199 164 C 199 141 207 129 220 129 C 233 129 241 141 241 164 C 235 150 228 145 220 145 C 212 145 205 150 199 164 Z"
          fill="#33241b"
        />
        <path
          d="M200 162 C 198 176 199 186 203 192 L 206 178 Z"
          fill="#33241b"
        />
        <path
          d="M240 162 C 242 176 241 186 237 192 L 234 178 Z"
          fill="#33241b"
        />
        <path
          d="M206 161 Q 212 156 217 159"
          fill="none"
          stroke="#4a3527"
          strokeWidth="1.7"
          strokeLinecap="round"
        />
        <path
          d="M234 161 Q 228 156 223 159"
          fill="none"
          stroke="#4a3527"
          strokeWidth="1.7"
          strokeLinecap="round"
        />
        <path
          d="M207 169 Q 212 173 217 170"
          fill="none"
          stroke="#2f221a"
          strokeWidth="1.9"
          strokeLinecap="round"
        />
        <path
          d="M233 169 Q 228 173 223 170"
          fill="none"
          stroke="#2f221a"
          strokeWidth="1.9"
          strokeLinecap="round"
        />
        <path
          d="M220 170 Q 223 178 219 182"
          fill="none"
          stroke="rgba(122, 78, 48, 0.65)"
          strokeWidth="1.3"
          strokeLinecap="round"
        />
        <path
          d="M213 189 Q 220 194 228 189"
          fill="none"
          stroke="#8a4a3d"
          strokeWidth="2"
          strokeLinecap="round"
        />
        <ellipse
          cx="207"
          cy="180"
          rx="4.4"
          ry="3"
          fill="rgba(213, 121, 90, 0.32)"
        />
        <ellipse
          cx="234"
          cy="180"
          rx="4.4"
          ry="3"
          fill="rgba(213, 121, 90, 0.32)"
        />
      </g>

      {/* brooch */}
      <ellipse
        cx="220"
        cy="212"
        rx="6"
        ry="7"
        fill="#e8b23a"
        stroke="#8a5a14"
        strokeWidth="1"
      />
      <path
        d="M220 208 V216 M216.5 212 H223.5"
        stroke="#5a3a0c"
        strokeWidth="1.3"
        strokeLinecap="round"
      />

      {/* sleeves meeting at the chest */}
      <path
        d="M196 236 C 200 246 206 254 213 258 L 213 266 C 204 264 196 256 191 246 Z"
        fill="url(#mdg-robe)"
        stroke="#e3b23c"
        strokeWidth="1.4"
      />
      <path
        d="M244 236 C 240 246 234 254 227 258 L 227 266 C 236 264 244 256 249 246 Z"
        fill="url(#mdg-robe)"
        stroke="#e3b23c"
        strokeWidth="1.4"
      />
      {/* praying hands, fingertips up */}
      <path
        d="M215 238 C 210 246 208 257 211 265 C 213 271 227 271 229 265 C 232 257 230 246 225 238 C 222 231 218 231 215 238 Z"
        fill="url(#mdg-skin)"
        stroke="rgba(122, 78, 48, 0.5)"
        strokeWidth="1.2"
      />
      <path
        d="M217 236 L 216.4 258 M220 233.5 V259 M223 236 L 223.6 258"
        stroke="rgba(122, 78, 48, 0.5)"
        strokeWidth="1"
        strokeLinecap="round"
      />
      <path
        d="M212 254 Q 220 258 228 254"
        stroke="rgba(122, 78, 48, 0.45)"
        strokeWidth="1.1"
        strokeLinecap="round"
        fill="none"
      />

      {/* black maternity sash */}
      <path
        d="M198 290 L 242 290 L 240 303 L 200 303 Z"
        fill="#43333c"
        stroke="#2c1f26"
        strokeWidth="1"
      />
      <path
        d="M200 292 L 240 292"
        stroke="rgba(255, 255, 255, 0.14)"
        strokeWidth="1.6"
        strokeLinecap="round"
      />
      <ellipse
        cx="212"
        cy="287"
        rx="7"
        ry="4.4"
        fill="#544250"
        transform="rotate(-22 212 287)"
      />
      <ellipse
        cx="228"
        cy="287"
        rx="7"
        ry="4.4"
        fill="#544250"
        transform="rotate(22 228 287)"
      />
      <circle cx="220" cy="289" r="3.4" fill="#2c1f26" />
      <path
        d="M214 303 C 212 310 212 317 214 322 L 218 321 C 217 315 217 309 218 303 Z M226 303 C 228 310 228 317 226 322 L 222 321 C 223 315 223 309 222 303 Z"
        fill="#43333c"
      />

      {/* crescent moon */}
      <path
        d="M148 478 A 80 52 0 0 0 292 478 A 96 68 0 0 1 148 478 Z"
        fill="#574439"
        stroke="#3c2d24"
        strokeWidth="2"
      />

      {/* cherub angel */}
      <g>
        <path
          d="M198 516 C 176 505 155 505 142 519 C 158 520 170 526 181 535 Z"
          fill="#b23a3a"
        />
        <path
          d="M199 521 C 181 513 164 514 154 524 C 167 525 176 530 185 537 Z"
          fill="#ece4d2"
        />
        <path
          d="M201 527 C 188 521 176 522 168 529 C 178 530 185 534 192 539 Z"
          fill="#3f7a4a"
        />
        <path
          d="M242 516 C 264 505 285 505 298 519 C 282 520 270 526 259 535 Z"
          fill="#b23a3a"
        />
        <path
          d="M241 521 C 259 513 276 514 286 524 C 273 525 264 530 255 537 Z"
          fill="#ece4d2"
        />
        <path
          d="M239 527 C 252 521 264 522 272 529 C 262 530 255 534 248 539 Z"
          fill="#3f7a4a"
        />
        <path
          d="M206 546 C 206 534 212 528 220 528 C 228 528 234 534 234 546 Z"
          fill="#ece4d2"
        />
        <circle cx="220" cy="517" r="11.5" fill="url(#mdg-skin)" />
        <path
          d="M209 514 C 209 505 214 501 220 501 C 226 501 231 505 231 514 C 227 509 224 507 220 507 C 216 507 213 509 209 514 Z"
          fill="#2e2018"
        />
        <circle cx="215.6" cy="517" r="1.4" fill="#2f221a" />
        <circle cx="224.4" cy="517" r="1.4" fill="#2f221a" />
        <path
          d="M216 523 Q 220 526 224 523"
          fill="none"
          stroke="#8a4a3d"
          strokeWidth="1.4"
          strokeLinecap="round"
        />
        <path
          d="M206 535 C 200 528 194 520 190 510 M234 535 C 240 528 246 520 250 510"
          stroke="url(#mdg-skin)"
          strokeWidth="5"
          strokeLinecap="round"
          fill="none"
        />
      </g>

      {/* fallen roses */}
      {[
        [88, 542, 0],
        [220, 556, 24],
        [352, 540, -18],
      ].map(([x, y, r], index) => (
        <g key={index} transform={`translate(${x} ${y}) rotate(${r})`}>
          <ellipse
            cx="-9"
            cy="4"
            rx="8"
            ry="3.6"
            fill="#3f7a4a"
            transform="rotate(-28)"
          />
          <ellipse
            cx="9"
            cy="4"
            rx="8"
            ry="3.6"
            fill="#356941"
            transform="rotate(28)"
          />
          <circle r="8.6" fill="#c8354f" />
          <path
            d="M-4.4 -1.6 A 4.8 4.8 0 1 0 4 -2.6"
            fill="none"
            stroke="#8f2136"
            strokeWidth="1.6"
            strokeLinecap="round"
          />
          <circle r="2.2" fill="#8f2136" />
        </g>
      ))}
    </svg>
  );
}

function LourdesGrottoDetailArt() {
  return (
    <svg
      className="marian-detail-svg"
      viewBox="0 0 440 580"
      role="img"
      aria-label="Our Lady of Lourdes in the grotto of Massabielle"
    >
      <defs>
        <linearGradient id="mdl-sky" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#2c3a55" />
          <stop offset="100%" stopColor="#5a6d8c" />
        </linearGradient>
        <linearGradient id="mdl-rock" x1="0" y1="0" x2="0.6" y2="1">
          <stop offset="0%" stopColor="#5d554f" />
          <stop offset="55%" stopColor="#4a423c" />
          <stop offset="100%" stopColor="#332c27" />
        </linearGradient>
        <radialGradient id="mdl-niche" cx="50%" cy="42%" r="65%">
          <stop offset="0%" stopColor="#efdcae" />
          <stop offset="52%" stopColor="#bda583" />
          <stop offset="100%" stopColor="#453d37" />
        </radialGradient>
        <radialGradient id="mdl-halo" cx="50%" cy="40%" r="60%">
          <stop offset="0%" stopColor="rgba(255, 248, 220, 0.95)" />
          <stop offset="100%" stopColor="rgba(255, 248, 220, 0)" />
        </radialGradient>
        <linearGradient id="mdl-gown" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#fdfbf4" />
          <stop offset="100%" stopColor="#ddd6c4" />
        </linearGradient>
        <linearGradient id="mdl-water" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#9fd8ec" />
          <stop offset="100%" stopColor="#4a90b8" />
        </linearGradient>
        <radialGradient id="mdl-skin" cx="50%" cy="40%" r="70%">
          <stop offset="0%" stopColor="#efc7a4" />
          <stop offset="100%" stopColor="#d8a87e" />
        </radialGradient>
      </defs>

      <rect width="440" height="580" fill="url(#mdl-sky)" />
      {/* rock face with niche */}
      <path
        d="M0 580 L0 96 C 52 66 96 46 150 38 C 200 31 260 30 306 40 C 360 51 404 74 440 104 L 440 580 Z"
        fill="url(#mdl-rock)"
      />
      {MARIAN_ROCK_BLOBS.map(([x, y, rx, ry, o, light], index) => (
        <ellipse
          key={index}
          cx={x}
          cy={y}
          rx={rx}
          ry={ry}
          fill={light ? "#8a7c6d" : "#221c18"}
          opacity={o * 0.7}
          transform={`rotate(${(index % 5) * 14 - 28} ${x} ${y})`}
        />
      ))}
      {/* rock crevices and facet highlights */}
      <g fill="none" strokeLinecap="round">
        <path
          d="M30 150 C 58 196 66 250 54 310 C 48 344 50 380 62 412"
          stroke="#241d18"
          strokeWidth="5"
          opacity="0.4"
        />
        <path
          d="M404 168 C 384 216 380 268 392 322 C 398 352 396 392 384 428"
          stroke="#241d18"
          strokeWidth="5"
          opacity="0.4"
        />
        <path
          d="M96 66 C 120 84 134 108 140 138 M330 62 C 316 86 308 110 306 134"
          stroke="#241d18"
          strokeWidth="4"
          opacity="0.35"
        />
        <path
          d="M76 470 C 120 494 168 508 220 512 M366 466 C 336 486 304 498 272 504"
          stroke="#241d18"
          strokeWidth="4.6"
          opacity="0.4"
        />
        <path
          d="M42 132 C 66 172 74 224 66 282 M390 190 C 376 232 372 278 380 326"
          stroke="#8a7c6d"
          strokeWidth="2.4"
          opacity="0.35"
        />
        <path
          d="M110 456 C 152 478 196 490 244 494"
          stroke="#8a7c6d"
          strokeWidth="2.2"
          opacity="0.3"
        />
      </g>
      {/* niche */}
      <ellipse cx="220" cy="256" rx="112" ry="158" fill="url(#mdl-niche)" />
      <path
        d="M110 300 A 112 158 0 0 1 220 98 A 112 158 0 0 1 330 300"
        fill="none"
        stroke="#221c18"
        strokeWidth="7"
        opacity="0.55"
      />
      {/* ivy along the niche rim */}
      {[
        [128, 158, -30],
        [162, 116, -12],
        [220, 100, 0],
        [280, 118, 14],
        [314, 160, 30],
        [116, 220, -40],
      ].map(([x, y, r], index) => (
        <g key={index} transform={`translate(${x} ${y}) rotate(${r})`}>
          <ellipse cx="-7" cy="0" rx="7" ry="3.6" fill="#3f6a44" />
          <ellipse cx="7" cy="1" rx="7" ry="3.6" fill="#4d7d52" />
          <ellipse cx="0" cy="6" rx="6.4" ry="3.2" fill="#35583a" />
        </g>
      ))}
      {/* eglantine rose bush at the niche base */}
      <g transform="translate(288 372)">
        <path
          d="M0 0 C -8 -14 -6 -30 4 -40 M0 0 C 8 -12 20 -18 32 -16"
          fill="none"
          stroke="#4a6a3e"
          strokeWidth="3"
          strokeLinecap="round"
        />
        <ellipse
          cx="2"
          cy="-24"
          rx="6"
          ry="3.4"
          fill="#4d7d52"
          transform="rotate(-40 2 -24)"
        />
        <ellipse
          cx="18"
          cy="-14"
          rx="6"
          ry="3.4"
          fill="#3f6a44"
          transform="rotate(20 18 -14)"
        />
        <circle cx="6" cy="-40" r="5" fill="#f2e3b8" />
        <circle cx="6" cy="-40" r="1.8" fill="#d9a441" />
        <circle cx="32" cy="-16" r="4.4" fill="#f2e3b8" />
        <circle cx="32" cy="-16" r="1.6" fill="#d9a441" />
      </g>

      {/* halo glow behind Mary */}
      <ellipse cx="220" cy="262" rx="88" ry="142" fill="url(#mdl-halo)" />

      {/* Mary of Lourdes */}
      <g>
        {/* gown */}
        <path
          d="M220 196 C 206 196 198 208 195 226 C 188 264 180 318 174 362 C 170 388 182 398 198 400 L 242 400 C 258 398 270 388 266 362 C 260 318 252 264 245 226 C 242 208 234 196 220 196 Z"
          fill="url(#mdl-gown)"
        />
        <path
          d="M200 398 C 206 380 209 356 209 328 M240 398 C 234 380 231 356 231 328 M220 400 C 220 384 220 366 220 346"
          stroke="rgba(122, 112, 92, 0.4)"
          strokeWidth="2"
          strokeLinecap="round"
          fill="none"
        />
        {/* veil */}
        <path
          d="M220 138 C 198 138 187 154 185 176 C 182 214 180 258 178 300 C 177 318 184 326 194 322 C 200 319 203 310 204 300 C 206 258 209 214 212 184 C 214 168 216 158 220 154 C 224 158 226 168 228 184 C 231 214 234 258 236 300 C 237 310 240 319 246 322 C 256 326 263 318 262 300 C 260 258 258 214 255 176 C 253 154 242 138 220 138 Z"
          fill="#fdfbf4"
          stroke="rgba(150, 140, 118, 0.6)"
          strokeWidth="1.6"
        />
        {/* face framed by the veil */}
        <ellipse cx="220" cy="172" rx="17.5" ry="21" fill="url(#mdl-skin)" />
        <path
          d="M203 168 C 203 150 210 141 220 141 C 230 141 237 150 237 168 C 232 156 226 152 220 152 C 214 152 208 156 203 168 Z"
          fill="#4a3527"
        />
        <path
          d="M209 165 Q 214 161 218 163"
          fill="none"
          stroke="#4a3527"
          strokeWidth="1.5"
          strokeLinecap="round"
        />
        <path
          d="M231 165 Q 226 161 222 163"
          fill="none"
          stroke="#4a3527"
          strokeWidth="1.5"
          strokeLinecap="round"
        />
        <circle cx="213" cy="171" r="1.8" fill="#3a2c22" />
        <circle cx="227" cy="171" r="1.8" fill="#3a2c22" />
        <path
          d="M220 172 Q 222 178 219 181"
          fill="none"
          stroke="rgba(150, 100, 66, 0.6)"
          strokeWidth="1.1"
          strokeLinecap="round"
        />
        <path
          d="M213 187 Q 220 192 227 187"
          fill="none"
          stroke="#b06a52"
          strokeWidth="1.8"
          strokeLinecap="round"
        />
        <ellipse
          cx="209"
          cy="180"
          rx="3.6"
          ry="2.4"
          fill="rgba(224, 138, 108, 0.35)"
        />
        <ellipse
          cx="231"
          cy="180"
          rx="3.6"
          ry="2.4"
          fill="rgba(224, 138, 108, 0.35)"
        />
        {/* arms folded toward the joined hands */}
        <path
          d="M196 234 C 202 244 209 250 215 252 M244 234 C 238 244 231 250 225 252"
          stroke="rgba(122, 112, 92, 0.5)"
          strokeWidth="2.4"
          strokeLinecap="round"
          fill="none"
        />
        {/* praying hands pointed upward */}
        <path
          d="M216 226 C 212 236 211 248 214 255 C 216 260 224 260 226 255 C 229 248 228 236 224 226 C 222 220 218 220 216 226 Z"
          fill="url(#mdl-skin)"
          stroke="rgba(150, 100, 66, 0.55)"
          strokeWidth="1.1"
        />
        <path
          d="M218 224 L 217.6 246 M220 222 V247 M222 224 L 222.4 246"
          stroke="rgba(150, 100, 66, 0.5)"
          strokeWidth="0.9"
          strokeLinecap="round"
        />
        {/* white rosary hanging from her arm */}
        <path
          d="M228 250 C 240 266 244 286 236 300 C 230 310 222 312 218 306"
          fill="none"
          stroke="rgba(74, 58, 40, 0.6)"
          strokeWidth="5"
          strokeLinecap="round"
          strokeDasharray="0.1 7.4"
        />
        <path
          d="M228 250 C 240 266 244 286 236 300 C 230 310 222 312 218 306"
          fill="none"
          stroke="#fdfaf2"
          strokeWidth="3.4"
          strokeLinecap="round"
          strokeDasharray="0.1 7.4"
        />
        <path
          d="M218 306 L 213 318 M209 313 L 218 320"
          stroke="rgba(74, 58, 40, 0.7)"
          strokeWidth="4.2"
          strokeLinecap="round"
        />
        <path
          d="M218 306 L 213 318 M209 313 L 218 320"
          stroke="#e8b23a"
          strokeWidth="2.6"
          strokeLinecap="round"
        />
        {/* blue sash */}
        <path
          d="M199 260 C 212 267 228 267 241 260 L 241 276 C 228 283 212 283 199 276 Z"
          fill="#7da3e0"
          stroke="#4a6fae"
          strokeWidth="1.2"
        />
        <path
          d="M210 278 C 205 310 203 336 206 358 C 212 353 217 348 218 341 C 217 320 217 298 220 280 Z"
          fill="#7da3e0"
          stroke="#4a6fae"
          strokeWidth="1.2"
        />
        {/* bare feet with golden roses */}
        <ellipse cx="209" cy="398" rx="7.5" ry="4.4" fill="url(#mdl-skin)" />
        <ellipse cx="231" cy="398" rx="7.5" ry="4.4" fill="url(#mdl-skin)" />
        {[
          [209, 394],
          [231, 394],
        ].map(([x, y], index) => (
          <g key={index} transform={`translate(${x} ${y})`}>
            <circle r="6.6" fill="#e8b23a" stroke="#a8771c" strokeWidth="1.1" />
            <path
              d="M-2.8 -1 A 3 3 0 1 0 2.6 -1.6"
              fill="none"
              stroke="#a8771c"
              strokeWidth="1.2"
              strokeLinecap="round"
            />
            <circle r="1.4" fill="#a8771c" />
          </g>
        ))}
      </g>

      {/* the spring */}
      <path
        d="M52 448 C 62 472 74 490 88 500"
        fill="none"
        stroke="#9fd8ec"
        strokeWidth="5"
        strokeLinecap="round"
        opacity="0.85"
      />
      <ellipse
        cx="102"
        cy="522"
        rx="74"
        ry="27"
        fill="url(#mdl-water)"
        stroke="#2f5f7d"
        strokeWidth="2"
      />
      <ellipse
        cx="102"
        cy="520"
        rx="48"
        ry="15"
        fill="none"
        stroke="rgba(255, 255, 255, 0.55)"
        strokeWidth="2"
      />
      <ellipse
        cx="102"
        cy="519"
        rx="24"
        ry="7"
        fill="none"
        stroke="rgba(255, 255, 255, 0.45)"
        strokeWidth="1.6"
      />
      {[
        [66, 508, 0.9],
        [128, 512, 0.7],
        [98, 530, 0.8],
      ].map(([x, y, s], index) => (
        <path
          key={index}
          d={`M${x} ${y - 5 * s} L${x + 1.4 * s} ${y - 1.4 * s} L${x + 5 * s} ${y} L${x + 1.4 * s} ${y + 1.4 * s} L${x} ${y + 5 * s} L${x - 1.4 * s} ${y + 1.4 * s} L${x - 5 * s} ${y} L${x - 1.4 * s} ${y - 1.4 * s} Z`}
          fill="#ffffff"
          opacity="0.85"
        />
      ))}

      {/* votive candles */}
      <rect x="296" y="538" width="104" height="10" rx="4" fill="#4a3527" />
      {[
        [312, 34],
        [336, 46],
        [360, 40],
        [384, 30],
      ].map(([x, h], index) => (
        <g key={index}>
          <circle
            cx={x}
            cy={538 - h - 6}
            r="13"
            fill="rgba(255, 214, 130, 0.28)"
          />
          <rect
            x={x - 5}
            y={538 - h}
            width="10"
            height={h}
            rx="2.4"
            fill="#f4ead4"
            stroke="#c9bda0"
            strokeWidth="1"
          />
          <path
            d={`M${x} ${538 - h - 13} C ${x + 4} ${538 - h - 8} ${x + 3} ${538 - h - 3} ${x} ${538 - h - 2} C ${x - 3} ${538 - h - 3} ${x - 4} ${538 - h - 8} ${x} ${538 - h - 13} Z`}
            fill="#f2b13c"
          />
        </g>
      ))}
    </svg>
  );
}

function FatimaSunDetailArt() {
  return (
    <svg
      className="marian-detail-svg"
      viewBox="0 0 440 580"
      role="img"
      aria-label="Our Lady of Fatima above the holm oak while the sun dances"
    >
      <defs>
        <linearGradient id="mdf-sky" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#1e2b4d" />
          <stop offset="55%" stopColor="#6d5573" />
          <stop offset="100%" stopColor="#d9925a" />
        </linearGradient>
        <radialGradient id="mdf-sun" cx="50%" cy="50%" r="50%">
          <stop offset="0%" stopColor="#fffdf2" />
          <stop offset="55%" stopColor="#ffe9a8" />
          <stop offset="100%" stopColor="#f2b13c" />
        </radialGradient>
        <radialGradient id="mdf-sunglow" cx="50%" cy="50%" r="50%">
          <stop offset="0%" stopColor="rgba(255, 233, 168, 0.9)" />
          <stop offset="100%" stopColor="rgba(255, 233, 168, 0)" />
        </radialGradient>
        <linearGradient id="mdf-gown" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#ffffff" />
          <stop offset="100%" stopColor="#e3ddcd" />
        </linearGradient>
        <radialGradient id="mdf-skin" cx="50%" cy="40%" r="70%">
          <stop offset="0%" stopColor="#efc7a4" />
          <stop offset="100%" stopColor="#d8a87e" />
        </radialGradient>
        <linearGradient id="mdf-field" x1="0" y1="0" x2="0" y2="1">
          <stop offset="0%" stopColor="#5d6e3d" />
          <stop offset="100%" stopColor="#3c4a28" />
        </linearGradient>
      </defs>

      <rect width="440" height="580" fill="url(#mdf-sky)" />
      {/* the dancing sun */}
      <circle cx="220" cy="148" r="150" fill="url(#mdf-sunglow)" />
      <g className="mdf-sun-spin" opacity="0.9">
        <g fill="#f2c14e">
          {MARIAN_FATIMA_RAYS.map((d, index) => (
            <path key={index} d={d} />
          ))}
        </g>
        {["#e25555", "#e2a13c", "#ead06b", "#7fb069", "#6d93d1", "#9b6dc9"].map(
          (color, index) => (
            <circle
              key={color}
              cx="220"
              cy="148"
              r={104 - index * 3.4}
              fill="none"
              stroke={color}
              strokeWidth="3.2"
              opacity="0.4"
            />
          ),
        )}
      </g>
      <circle cx="220" cy="148" r="84" fill="url(#mdf-sun)" />

      {/* clouds */}
      {[
        [64, 96],
        [372, 130],
        [50, 300],
        [396, 320],
      ].map(([x, y], index) => (
        <g key={index} fill="rgba(255, 250, 238, 0.5)">
          <ellipse cx={x} cy={y} rx="34" ry="12" />
          <ellipse cx={x + 20} cy={y - 8} rx="22" ry="10" />
          <ellipse cx={x - 22} cy={y - 6} rx="18" ry="8" />
        </g>
      ))}

      {/* Mary of Fatima */}
      <g>
        {/* mantle with gold edge */}
        <path
          d="M220 176 C 196 176 184 194 181 216 C 173 262 164 316 158 362 C 154 392 166 408 186 412 L 254 412 C 274 408 286 392 282 362 C 276 316 267 262 259 216 C 256 194 244 176 220 176 Z"
          fill="url(#mdf-gown)"
          stroke="#d9a441"
          strokeWidth="3.4"
        />
        {/* mantle inner shading */}
        <path
          d="M186 410 C 192 384 196 350 197 312 M254 410 C 248 384 244 350 243 312"
          stroke="rgba(139, 148, 168, 0.5)"
          strokeWidth="2.6"
          strokeLinecap="round"
          fill="none"
        />
        {/* inner tunic showing through the open mantle */}
        <path
          d="M220 200 C 210 200 204 210 202 226 C 198 268 196 320 196 366 C 196 392 204 406 220 406 C 236 406 244 392 244 366 C 244 320 242 268 240 226 C 238 210 230 200 220 200 Z"
          fill="#f4efe2"
          stroke="rgba(150, 140, 118, 0.55)"
          strokeWidth="1.4"
        />
        <path
          d="M202 226 C 198 268 196 320 196 366 C 196 392 204 406 220 406 M238 226 C 242 268 244 320 244 366 C 244 392 236 406 220 406"
          stroke="#d9a441"
          strokeWidth="2"
          strokeLinecap="round"
          fill="none"
        />
        <path
          d="M211 246 L 220 258 L 229 246"
          stroke="#d9a441"
          strokeWidth="2.2"
          strokeLinecap="round"
          strokeLinejoin="round"
          fill="none"
        />
        {/* hood */}
        <path
          d="M220 174 C 204 174 195 186 193 202 C 191 216 195 230 202 238 L 210 231 C 205 223 203 212 205 202 C 207 189 212 184 220 184 C 228 184 233 189 235 202 C 237 212 235 223 230 231 L 238 238 C 245 230 249 216 247 202 C 245 186 236 174 220 174 Z"
          fill="url(#mdf-gown)"
          stroke="#d9a441"
          strokeWidth="2.4"
        />
        {/* face */}
        <ellipse cx="220" cy="212" rx="15.5" ry="19" fill="url(#mdf-skin)" />
        <path
          d="M205 208 C 205 193 211 185 220 185 C 229 185 235 193 235 208 C 230 197 225 194 220 194 C 215 194 210 197 205 208 Z"
          fill="#3a2c22"
        />
        <path
          d="M210 206 Q 214 202 218 204"
          fill="none"
          stroke="#4a3527"
          strokeWidth="1.4"
          strokeLinecap="round"
        />
        <path
          d="M230 206 Q 226 202 222 204"
          fill="none"
          stroke="#4a3527"
          strokeWidth="1.4"
          strokeLinecap="round"
        />
        <circle cx="214" cy="212" r="1.7" fill="#3a2c22" />
        <circle cx="226" cy="212" r="1.7" fill="#3a2c22" />
        <path
          d="M220 212 Q 222 218 219 220"
          fill="none"
          stroke="rgba(150, 100, 66, 0.6)"
          strokeWidth="1"
          strokeLinecap="round"
        />
        <path
          d="M214 226 Q 220 230 226 226"
          fill="none"
          stroke="#b06a52"
          strokeWidth="1.7"
          strokeLinecap="round"
        />
        {/* joined hands with rosary */}
        <path
          d="M215 276 C 212 288 213 297 220 301 C 227 297 228 288 225 276 C 224 270 216 270 215 276 Z"
          fill="url(#mdf-skin)"
          stroke="rgba(150, 100, 66, 0.5)"
          strokeWidth="1.1"
        />
        <path
          d="M214 296 C 204 314 202 334 212 348 C 220 358 230 356 234 346 C 240 330 236 312 226 296"
          fill="none"
          stroke="rgba(122, 105, 72, 0.55)"
          strokeWidth="5"
          strokeLinecap="round"
          strokeDasharray="0.1 7"
        />
        <path
          d="M214 296 C 204 314 202 334 212 348 C 220 358 230 356 234 346 C 240 330 236 312 226 296"
          fill="none"
          stroke="#fdfaf2"
          strokeWidth="3.4"
          strokeLinecap="round"
          strokeDasharray="0.1 7"
        />
        <path
          d="M222 352 L 222 364 M216.5 357.5 L 227.5 357.5"
          stroke="rgba(122, 105, 72, 0.7)"
          strokeWidth="4.2"
          strokeLinecap="round"
        />
        <path
          d="M222 352 L 222 364 M216.5 357.5 L 227.5 357.5"
          stroke="#e8b23a"
          strokeWidth="2.6"
          strokeLinecap="round"
        />
        {/* gold star on the tunic hem */}
        <path
          d="M220 388 L222.6 395.4 L230.4 395.6 L224.2 400.2 L226.4 407.6 L220 403.2 L213.6 407.6 L215.8 400.2 L209.6 395.6 L217.4 395.4 Z"
          fill="#e8b23a"
          stroke="#a8771c"
          strokeWidth="1"
          transform="translate(220 398) scale(1.45) translate(-220 -398)"
        />
        {/* feet on the cloud */}
        <ellipse cx="211" cy="412" rx="6.6" ry="3.8" fill="url(#mdf-skin)" />
        <ellipse cx="229" cy="412" rx="6.6" ry="3.8" fill="url(#mdf-skin)" />
      </g>
      {/* little cloud under her feet */}
      <g fill="#f6f1e4">
        <ellipse cx="220" cy="424" rx="52" ry="14" />
        <ellipse cx="188" cy="418" rx="24" ry="10" />
        <ellipse cx="252" cy="418" rx="24" ry="10" />
        <ellipse cx="220" cy="414" rx="30" ry="11" />
      </g>

      {/* field */}
      <path
        d="M0 486 C 80 468 160 462 220 466 C 300 470 380 478 440 492 L 440 580 L 0 580 Z"
        fill="url(#mdf-field)"
      />
      {/* holm oak */}
      <g>
        <path
          d="M146 522 C 146 504 148 490 152 478 C 156 490 158 504 158 522 Z"
          fill="#5a4030"
        />
        <circle cx="132" cy="474" r="20" fill="#3c5a34" />
        <circle cx="156" cy="462" r="24" fill="#476b3e" />
        <circle cx="176" cy="478" r="18" fill="#3c5a34" />
        <circle cx="152" cy="482" r="16" fill="#527a48" />
      </g>
      {/* Lucia, Francisco, and Jacinta looking up */}
      {[
        [252, 522, "#b23a3a", true],
        [288, 530, "#4a6fae", false],
        [320, 524, "#c98a3c", true],
      ].map(([x, y, color, scarf], index) => (
        <g key={index} transform={`translate(${x} ${y})`}>
          <path d="M-11 22 C -11 8 -6 0 0 0 C 6 0 11 8 11 22 Z" fill={color} />
          <circle cx="0" cy="-8" r="8" fill="#eabf99" transform="rotate(-14)" />
          {scarf ? (
            <path
              d="M-8 -10 C -8 -18 -4 -21 0 -21 C 4 -21 8 -18 8 -10 C 8 -6 5 -4 3 -5 C 5 -9 3 -13 0 -13 C -3 -13 -5 -9 -3 -5 C -5 -4 -8 -6 -8 -10 Z"
              fill="#f1e8d4"
              transform="rotate(-14)"
            />
          ) : (
            <path
              d="M-7 -12 C -7 -17 -3 -20 0 -20 C 3 -20 7 -17 7 -12 C 4 -15 -4 -15 -7 -12 Z"
              fill="#3a2c22"
              transform="rotate(-14)"
            />
          )}
          <circle
            cx="-2.6"
            cy="-9.4"
            r="1.1"
            fill="#3a2c22"
            transform="rotate(-14)"
          />
          <circle
            cx="2.2"
            cy="-10.6"
            r="1.1"
            fill="#3a2c22"
            transform="rotate(-14)"
          />
          <path
            d="M-1.6 8 C -3 12 -2 15 0 16 C 2 15 3 12 1.6 8 Z"
            fill="#eabf99"
          />
        </g>
      ))}
    </svg>
  );
}

const MARIAN_DETAIL_SCENES = {
  guadalupe: {
    kicker: "Look closer",
    title: "The Tilma of Guadalupe",
    intro:
      "Juan Diego opens his cloak, and there she is. Tap a golden circle to zoom in on a wonder.",
    outro:
      "You found every wonder. The real tilma still hangs in Mexico City, nearly 500 years later.",
    art: GuadalupeTilmaDetailArt,
    hotspots: [
      {
        id: "face",
        label: "Her kind face",
        caption:
          "Mary bows her head gently, like a mother watching her little one. Juan Diego said her face was full of love.",
        x: 50,
        y: 29,
        zoom: 3,
      },
      {
        id: "stars",
        label: "A mantle of stars",
        caption:
          "Gold stars cover her blue-green mantle, like the night sky over Tepeyac hill.",
        x: 38,
        y: 57,
        zoom: 2.6,
      },
      {
        id: "sash",
        label: "The black sash",
        caption:
          "The bow tied at her waist told everyone she was expecting a baby: Jesus.",
        x: 52,
        y: 50,
        zoom: 2.9,
      },
      {
        id: "moon",
        label: "The moon and the angel",
        caption:
          "She stands on the crescent moon while a little angel with eagle wings carries her.",
        x: 50,
        y: 87,
        zoom: 2.3,
      },
      {
        id: "rays",
        label: "Rays like the sun",
        caption:
          "Golden light bursts out all around her, brighter than the winter sun.",
        x: 76,
        y: 38,
        zoom: 2,
      },
    ],
  },
  lourdes: {
    kicker: "Look closer",
    title: "The Lady of the Grotto",
    intro:
      "Bernadette sees a beautiful Lady in the rock at Massabielle. Tap a golden circle to zoom in.",
    outro:
      "You found every wonder. Pilgrims still light candles and drink from the spring at Lourdes.",
    art: LourdesGrottoDetailArt,
    hotspots: [
      {
        id: "face",
        label: "Her gentle smile",
        caption:
          "Bernadette said the beautiful Lady smiled at her like no one ever had.",
        x: 50,
        y: 29,
        zoom: 3,
      },
      {
        id: "sash",
        label: "The blue sash",
        caption:
          "A sky-blue ribbon was tied at her waist. Bernadette never forgot that color.",
        x: 44,
        y: 52,
        zoom: 2.8,
      },
      {
        id: "rosary",
        label: "The white rosary",
        caption:
          "A white rosary with a golden cross hung from her arm while she prayed with Bernadette.",
        x: 55,
        y: 49,
        zoom: 2.8,
      },
      {
        id: "roses",
        label: "Golden roses",
        caption: "On each bare foot sat a shining golden rose.",
        x: 50,
        y: 68,
        zoom: 2.9,
      },
      {
        id: "spring",
        label: "The hidden spring",
        caption:
          "Bernadette dug in the dirt, and a healing spring bubbled up. It still flows today.",
        x: 23,
        y: 89,
        zoom: 2.3,
      },
    ],
  },
  fatima: {
    kicker: "Look closer",
    title: "The Lady of the Sun",
    intro:
      "Three children see Mary shining above a little oak tree. Tap a golden circle to zoom in.",
    outro:
      "You found every wonder. Fatima still reminds families everywhere to pray for peace.",
    art: FatimaSunDetailArt,
    hotspots: [
      {
        id: "sun",
        label: "The dancing sun",
        caption:
          "On October 13, 1917, the sun spun and danced in the sky while a huge crowd watched.",
        x: 72,
        y: 20,
        zoom: 2,
      },
      {
        id: "face",
        label: "Brighter than the sun",
        caption:
          "Lucia said the Lady shone like a light brighter than the sun, full of peace.",
        x: 50,
        y: 37,
        zoom: 3,
      },
      {
        id: "rosary",
        label: "The shining rosary",
        caption:
          "She asked the children to pray the Rosary every day for peace in the world.",
        x: 50,
        y: 56,
        zoom: 2.8,
      },
      {
        id: "star",
        label: "The star on her dress",
        caption: "Near the hem of her dress glowed a little golden star.",
        x: 50,
        y: 69,
        zoom: 2.9,
      },
      {
        id: "oak",
        label: "The little oak",
        caption:
          "Mary appeared above a small holm oak, where Lucia, Francisco, and Jacinta knelt to pray.",
        x: 52,
        y: 88,
        zoom: 2.2,
      },
    ],
  },
};

function MarianDetailScene({ detail, foundIds, onFind, onClose }) {
  const [activeId, setActiveId] = useState(null);
  const active = activeId
    ? detail.hotspots.find((spot) => spot.id === activeId)
    : null;
  const allFound = detail.hotspots.every((spot) => foundIds.includes(spot.id));
  useEffect(() => {
    const onKey = (event) => {
      if (event.key !== "Escape") return;
      event.stopPropagation();
      if (activeId) setActiveId(null);
      else onClose();
    };
    window.addEventListener("keydown", onKey, true);
    return () => window.removeEventListener("keydown", onKey, true);
  });
  const ArtComponent = detail.art;
  return (
    <div
      className="marian-detail-overlay"
      role="dialog"
      aria-modal="true"
      aria-label={`${detail.title}: look closer`}
    >
      <header className="marian-detail-top">
        <div>
          <span className="marian-detail-kicker">{detail.kicker}</span>
          <h3>{detail.title}</h3>
        </div>
        <button
          type="button"
          className="marian-detail-close"
          onClick={onClose}
          aria-label="Back to the story"
        >
          ✕
        </button>
      </header>
      <div className={`marian-detail-stage ${active ? "is-zoomed" : ""}`}>
        <div className="marian-detail-frame">
          <div
            className="marian-detail-art"
            style={{
              "--md-zoom": active ? active.zoom : 1,
              "--md-ox": `${active ? active.x : 50}%`,
              "--md-oy": `${active ? active.y : 50}%`,
            }}
          >
            <ArtComponent />
          </div>
          {!active && (
            <div className="marian-detail-spots">
              {detail.hotspots.map((spot) => (
                <button
                  key={spot.id}
                  type="button"
                  className={`marian-detail-spot ${foundIds.includes(spot.id) ? "is-found" : ""}`}
                  style={{ left: `${spot.x}%`, top: `${spot.y}%` }}
                  onClick={() => {
                    setActiveId(spot.id);
                    onFind(spot.id);
                  }}
                  aria-label={`Look closer: ${spot.label}`}
                >
                  <span>{foundIds.includes(spot.id) ? "✓" : "+"}</span>
                </button>
              ))}
            </div>
          )}
        </div>
      </div>
      <footer className="marian-detail-caption">
        {active ? (
          <React.Fragment>
            <div className="marian-detail-caption-copy">
              <span>{active.label}</span>
              <p>{active.caption}</p>
            </div>
            <button
              type="button"
              className="btn btn-primary"
              onClick={() => setActiveId(null)}
            >
              See it all
            </button>
          </React.Fragment>
        ) : (
          <React.Fragment>
            <div
              className={`marian-detail-caption-copy ${allFound ? "is-complete" : ""}`}
            >
              <span>
                {allFound
                  ? "You found every wonder!"
                  : `Wonders found: ${detail.hotspots.filter((spot) => foundIds.includes(spot.id)).length} of ${detail.hotspots.length}`}
              </span>
              <p>{allFound ? detail.outro : detail.intro}</p>
            </div>
            <button
              type="button"
              className="btn btn-secondary"
              onClick={onClose}
            >
              Back to the story
            </button>
          </React.Fragment>
        )}
      </footer>
    </div>
  );
}

function GuadalupeVisitGame({
  gameId,
  onComplete,
  nextGame,
  onNextGame,
  onExitGame,
}) {
  const [sceneIndex, setSceneIndex] = useState(() => {
    const stored = readGuadalupeRouteProgress()?.sceneIndex;
    return Number.isInteger(stored)
      ? guadalupeClamp(stored, 0, GUADALUPE_VISIT_SCENES.length - 1)
      : 0;
  });
  const [roomSpotId, setRoomSpotId] = useState(null);
  const [tilmaModalOpen, setTilmaModalOpen] = useState(false);
  const [walkFrame, setWalkFrame] = useState(0);
  const [player, setPlayer] = useState(
    guadalupeActorStart("tepeyac", GUADALUPE_PLAYER_STARTS.tepeyac),
  );
  const [playerMoving, setPlayerMoving] = useState(false);
  const [controlMoving, setControlMoving] = useState(false);
  const [moveInput, setMoveInput] = useState(GUADALUPE_EMPTY_MOVE);
  const [priestMoving, setPriestMoving] = useState(false);
  const [priestWalkMs, setPriestWalkMs] = useState(null);
  const [message, setMessage] = useState(null);
  const [foundScenes, setFoundScenes] = useState(
    () => readGuadalupeRouteProgress()?.foundScenes ?? {},
  );
  const [roomFinds, setRoomFinds] = useState(
    () => readGuadalupeRouteProgress()?.roomFinds ?? {},
  );
  const [maryFound, setMaryFound] = useState(
    () => !!readGuadalupeRouteProgress()?.maryFound,
  );
  const [maryStep, setMaryStep] = useState(0);
  const [signProgress, setSignProgress] = useState({});
  const [activeSignDrag, setActiveSignDrag] = useState(null);
  const [visionOpen, setVisionOpen] = useState(false);
  const [visionSeen, setVisionSeen] = useState(
    () => readGuadalupeRouteProgress()?.visionSeen ?? {},
  );
  const [tilmaCinematicAt, setTilmaCinematicAt] = useState(null);
  const [rosesPicked, setRosesPicked] = useState([]);
  const [sunCinematicActive, setSunCinematicActive] = useState(false);
  const [detailSceneId, setDetailSceneId] = useState(null);
  const [detailFound, setDetailFound] = useState({});
  const tilmaCinematicTimer = useRef(null);
  const roseMessageTimer = useRef(null);
  const sunCinematicTimer = useRef(null);
  const worldRef = useRef(null);
  const discoveryAudioRef = useRef(null);
  const tilmaAudioRef = useRef(null);
  const discoveryTimer = useRef(null);
  const playerTimer = useRef(null);
  const priestTimer = useRef(null);
  const walkLatencyRef = useRef(null);
  const activeSignDragRef = useRef(null);
  const scene = GUADALUPE_VISIT_SCENES[sceneIndex];
  const directedSign = GUADALUPE_SCENE_D_SIGNS[scene.id];
  const rawSignProgress = signProgress[scene.id] ?? 0;
  const signProgressRatio = directedSign
    ? directedSign.type === "ground-taps"
      ? guadalupeClamp(rawSignProgress / (directedSign.requiredTaps ?? 1), 0, 1)
      : guadalupeClamp(rawSignProgress, 0, 1)
    : 0;
  const signGestureStyle = directedSign?.target
    ? {
        "--sign-x": `${directedSign.target.x}%`,
        "--sign-y": `${directedSign.target.y}%`,
        "--sign-mobile-x": `${directedSign.target.mobileX ?? directedSign.target.x}%`,
        "--sign-mobile-y": `${directedSign.target.mobileY ?? directedSign.target.y}%`,
        "--sign-progress": signProgressRatio,
        "--sign-opacity": (0.28 + signProgressRatio * 0.72).toFixed(2),
        "--water-width": `${Math.round(24 + signProgressRatio * 58)}px`,
        "--water-height": `${Math.round(10 + signProgressRatio * 30)}px`,
        "--sun-rotation": `${Math.round(signProgressRatio * 760)}deg`,
      }
    : null;
  const roomSpots = scene.roomSpots ?? [];
  const roomSpot = roomSpotId
    ? (roomSpots.find((spot) => spot.id === roomSpotId) ?? null)
    : null;
  const roomGoalSpots = scene.goalSpots ?? [];
  const completedRoomSpots = roomFinds[scene.id] ?? [];
  const roomGoalSpotItems = roomGoalSpots
    .map((id) => roomSpots.find((spot) => spot.id === id))
    .filter(Boolean);
  const completedRoomGoalCount = roomGoalSpots.filter((id) =>
    completedRoomSpots.includes(id),
  ).length;
  const pendingRoomSpot = roomGoalSpotItems.find(
    (spot) => !completedRoomSpots.includes(spot.id),
  );
  const sceneDone = !!foundScenes[scene.id];
  const isFirst = sceneIndex === 0;
  const isLast = sceneIndex === GUADALUPE_VISIT_SCENES.length - 1;
  const basePlayer = scene.actors.find((actor) => actor.id === "juan") ?? {
    id: "juan",
  };
  const playerWalking = playerMoving || controlMoving;
  const playerActor = {
    ...basePlayer,
    ...player,
    id: "juan",
    walking: playerWalking,
    controlled: controlMoving,
  };
  const sideActors = scene.actors
    .filter((actor) => actor.id !== "juan" && actor.id !== "mary")
    .map((actor) => {
      const merged = mergeGuadalupeRoomActor(actor, roomSpot);
      return {
        ...merged,
        walking: priestMoving && merged.id === "priest",
        walkMs:
          priestMoving && merged.id === "priest" ? priestWalkMs : merged.walkMs,
      };
    });
  const maryBase = scene.actors.find((actor) => actor.id === "mary");
  const discovery = GUADALUPE_DISCOVERIES[scene.id];
  const autoDiscovery = directedSign ? null : discovery;
  const roseQuest = scene.id === "roses";
  const unpickedRoses = roseQuest
    ? GUADALUPE_ROSE_PICKUPS.filter((rose) => !rosesPicked.includes(rose.id))
    : [];
  const roseQuestActive = roseQuest && !sceneDone && unpickedRoses.length > 0;
  const nearestUnpickedRose = roseQuestActive
    ? unpickedRoses
        .map((rose) => ({ rose, distance: guadalupeDistance(player, rose) }))
        .sort((a, b) => a.distance - b.distance)[0].rose
    : null;
  const hasMaryWaypoints = !!scene.maryWaypoints;
  const usesDefaultMaryWaypoints = ["tepeyac", "message", "roses"].includes(
    scene.id,
  );
  const maryWaypoints = hasMaryWaypoints
    ? scene.maryWaypoints
    : usesDefaultMaryWaypoints
      ? GUADALUPE_MARY_WAYPOINTS
      : null;
  const maryWaypoint = maryWaypoints
    ? maryWaypoints[maryStep % maryWaypoints.length]
    : maryBase;
  const maryScene =
    maryBase &&
    (maryBase.apparition || hasMaryWaypoints || usesDefaultMaryWaypoints);
  const maryRevealed =
    !discovery?.revealMary ||
    sceneDone ||
    (scene.id === "tepeyac" && maryFound);
  const maryActor = maryScene
    ? {
        ...maryBase,
        ...maryWaypoint,
        scale: maryBase.scale,
        mobileScale: maryBase.mobileScale ?? maryBase.scale,
        apparition: true,
        revealed: maryRevealed,
        opacity: maryRevealed ? 1 : 0.16,
        walking: false,
      }
    : null;
  const discoveryTarget = roseQuestActive
    ? { x: nearestUnpickedRose.x, y: nearestUnpickedRose.y }
    : directedSign
      ? null
      : (autoDiscovery?.target ??
        (maryActor ? { x: maryActor.x, y: maryActor.y } : null));
  const messageSpeaker = message
    ? GUADALUPE_CHARACTER_ATLASES[message.speaker]
    : null;
  const messageSpeakerLabel = message?.speakerLabel ?? messageSpeaker?.label;
  const foundCount = GUADALUPE_VISIT_SCENES.filter(
    (visitScene) => foundScenes[visitScene.id],
  ).length;
  const routeDone = foundCount === GUADALUPE_VISIT_SCENES.length;
  const distanceToDiscovery = discoveryTarget
    ? guadalupeDistance(player, discoveryTarget)
    : null;
  const discoveryRadius = autoDiscovery?.radius ?? 8;
  const roomProgress = roomGoalSpots.length
    ? completedRoomGoalCount / roomGoalSpots.length
    : 0;
  const trailSignal = sceneDone
    ? 1
    : roomGoalSpots.length
      ? roomProgress
      : distanceToDiscovery === null
        ? 0
        : guadalupeClamp(
            1 - Math.max(0, distanceToDiscovery - discoveryRadius) / 42,
            0,
            1,
          );
  const trailSignalLabel = sceneDone
    ? "Clue secured"
    : roomGoalSpots.length
      ? pendingRoomSpot
        ? `Next: ${pendingRoomSpot.label}`
        : "Actions complete"
      : trailSignal > 0.84
        ? "Hold still"
        : trailSignal > 0.58
          ? "Very warm"
          : trailSignal > 0.28
            ? "Warmer"
            : "Faint trail";
  const canAdvance = sceneDone && !message && !tilmaModalOpen && !visionOpen;
  const sceneVision = GUADALUPE_VISIONS[scene.id];
  const tilmaCinematicActive =
    !!tilmaCinematicAt && !tilmaModalOpen && !sceneDone;
  const cinematicActive = tilmaCinematicActive || sunCinematicActive;
  const nearbySpot =
    message || tilmaModalOpen || visionOpen || cinematicActive || playerMoving
      ? null
      : (roomSpots
          .filter(
            (spot) => !spot.hidden && !completedRoomSpots.includes(spot.id),
          )
          .map((spot) => ({ spot, distance: guadalupeDistance(player, spot) }))
          .filter((entry) => entry.distance <= GUADALUPE_INTERACT_RADIUS)
          .sort((a, b) => a.distance - b.distance)[0]?.spot ?? null);
  const showTilmaRelic =
    scene.tilmaRelic &&
    (!roomGoalSpots.length ||
      completedRoomSpots.includes("bishop") ||
      sceneDone);
  const directedSignReady =
    directedSign && (!roomGoalSpots.length || showTilmaRelic);
  const isRoomSpotLocked = () => false;
  const questFeedback = (() => {
    if (message) {
      return {
        label: sceneDone ? "Token found" : "Story moment",
        line: sceneDone
          ? "Tap Close to unlock the next area."
          : "Listen, then tap Close to keep walking.",
      };
    }
    if (sceneDone) {
      return {
        label: "Area clear",
        line: isLast ? "All story tokens are found." : "Next area unlocked.",
      };
    }
    if (roseQuestActive) {
      return {
        label: `${rosesPicked.length}/${GUADALUPE_ROSE_PICKUPS.length} roses`,
        line: rosesPicked.length
          ? "Beautiful! Walk to the next glowing rose."
          : "Walk to each glowing rose to gather it.",
      };
    }
    if (roomGoalSpots.length) {
      if (pendingRoomSpot?.hidden && showTilmaRelic) {
        return {
          label: `${completedRoomGoalCount}/${roomGoalSpots.length} actions`,
          line:
            directedSign?.prompt ?? "Open the tilma sign to finish the room.",
        };
      }
      return {
        label: `${completedRoomGoalCount}/${roomGoalSpots.length} actions`,
        line: pendingRoomSpot
          ? "Walk to a glowing ring, then press E or tap it."
          : "All actions found.",
      };
    }
    if (directedSignReady) {
      if (directedSign.type === "ground-taps") {
        const tapsLeft = Math.max(
          0,
          (directedSign.requiredTaps ?? 3) - rawSignProgress,
        );
        return {
          label: `${rawSignProgress}/${directedSign.requiredTaps ?? 3} taps`,
          line:
            tapsLeft > 1
              ? `Tap the ground ${tapsLeft} more times.`
              : "Tap the ground one more time.",
        };
      }
      return {
        label: "Sign gesture",
        line: directedSign.prompt,
      };
    }
    if (playerWalking) {
      return {
        label: "Walking",
        line:
          trailSignal > 0.58
            ? "The trail is warm. Slow near the light."
            : "Keep following the path.",
      };
    }
    if (trailSignal > 0.84) {
      return { label: "Close", line: "Stand still in the warm light." };
    }
    if (trailSignal > 0.28) {
      return { label: "Signal rising", line: "You are getting closer." };
    }
    return {
      label: "Explore",
      line: GUADALUPE_OBJECTIVES[scene.id],
    };
  })();
  const gamebarPrompt = message
    ? sceneDone
      ? "Close the message to claim the token."
      : "Listen, then keep exploring."
    : tilmaModalOpen
      ? "Close the tilma sign to claim the token."
      : sceneDone
        ? isLast
          ? "Tell the route again from the start."
          : "Token secured. Next area is ready."
        : roseQuestActive
          ? `${rosesPicked.length}/${GUADALUPE_ROSE_PICKUPS.length} roses gathered. Walk close to a rose to pick it up.`
          : directedSignReady && directedSign.type === "ground-taps"
            ? `${rawSignProgress}/${directedSign.requiredTaps ?? 3} ground taps complete.`
            : directedSignReady
              ? directedSign.prompt
              : roomGoalSpots.length
                ? `${completedRoomGoalCount}/${roomGoalSpots.length} actions complete. ${pendingRoomSpot ? `Walk near ${pendingRoomSpot.label} and press E.` : (directedSign?.prompt ?? "Finish the sign.")}`
                : "Follow the signal and pause near the clue.";

  const markSceneDone = () => {
    setFoundScenes((value) => ({ ...value, [scene.id]: true }));
    monstranceLog.record("guadalupe", "scene_found", scene.id, 1500);
  };

  useEffect(() => {
    if (routeDone) onComplete?.(gameId);
  }, [routeDone, gameId, onComplete]);

  useEffect(() => {
    try {
      sessionStorage.setItem(
        GUADALUPE_ROUTE_STORAGE_KEY,
        JSON.stringify({
          sceneIndex,
          foundScenes,
          roomFinds,
          visionSeen,
          maryFound,
        }),
      );
    } catch (e) {}
  }, [sceneIndex, foundScenes, roomFinds, visionSeen, maryFound]);

  const markRoomSpotFound = (spotId) => {
    const nextIds = Array.from(new Set([...completedRoomSpots, spotId]));
    setRoomFinds((value) => ({
      ...value,
      [scene.id]: Array.from(new Set([...(value[scene.id] ?? []), spotId])),
    }));
    const roomComplete =
      roomGoalSpots.length && roomGoalSpots.every((id) => nextIds.includes(id));
    if (roomComplete) {
      markSceneDone();
    }
    return roomComplete;
  };

  const beginWalkLatencySample = useCallback(
    (source) => {
      const actorNode = worldRef.current?.querySelector(
        ".guadalupe-actor.is-juan",
      );
      const rect = actorNode?.getBoundingClientRect();
      if (!rect) return;
      walkLatencyRef.current = {
        source,
        sceneId: scene.id,
        start: window.performance.now(),
        x: rect.left,
        y: rect.top,
      };
    },
    [scene.id],
  );

  const setDirectionActive = useCallback((direction, active) => {
    setMoveInput((value) => {
      if (value[direction] === active) return value;
      return { ...value, [direction]: active };
    });
  }, []);

  const stopMoveInput = useCallback(() => {
    setMoveInput(GUADALUPE_EMPTY_MOVE);
  }, []);

  const directionButtonProps = (direction) => ({
    onPointerDown: (event) => {
      event.preventDefault();
      event.stopPropagation();
      if (message) return;
      event.currentTarget.setPointerCapture?.(event.pointerId);
      if (!Object.values(moveInput).some(Boolean))
        beginWalkLatencySample(`pointer:${direction}`);
      setDirectionActive(direction, true);
    },
    onPointerUp: (event) => {
      event.preventDefault();
      event.stopPropagation();
      setDirectionActive(direction, false);
    },
    onPointerCancel: () => setDirectionActive(direction, false),
    onPointerLeave: () => setDirectionActive(direction, false),
    onContextMenu: (event) => event.preventDefault(),
    disabled: !!message || visionOpen || cinematicActive,
  });

  useEffect(() => {
    if (!playerWalking && !priestMoving) {
      setWalkFrame(0);
      return undefined;
    }
    const id = window.setInterval(() => {
      setWalkFrame((frame) => (frame + 1) % 4);
    }, 190);
    return () => window.clearInterval(id);
  }, [playerWalking, priestMoving]);

  useEffect(() => {
    if (!controlMoving || !walkLatencyRef.current) return undefined;
    let frameId = 0;
    const tick = () => {
      const sample = walkLatencyRef.current;
      if (!sample) return;
      const actorNode = worldRef.current?.querySelector(
        ".guadalupe-actor.is-juan",
      );
      const rect = actorNode?.getBoundingClientRect();
      if (
        rect &&
        (Math.abs(rect.left - sample.x) >= 1 ||
          Math.abs(rect.top - sample.y) >= 1)
      ) {
        const entry = {
          source: sample.source,
          sceneId: sample.sceneId,
          latencyMs:
            Math.round((window.performance.now() - sample.start) * 10) / 10,
          from: {
            x: Math.round(sample.x * 10) / 10,
            y: Math.round(sample.y * 10) / 10,
          },
          to: {
            x: Math.round(rect.left * 10) / 10,
            y: Math.round(rect.top * 10) / 10,
          },
          walkMs: 90,
        };
        window.__guadalupeWalkLatency = window.__guadalupeWalkLatency || [];
        window.__guadalupeWalkLatency.push(entry);
        window.dispatchEvent(
          new CustomEvent("guadalupe-walk-latency", { detail: entry }),
        );
        console.info("[guadalupe-walk-latency]", entry);
        walkLatencyRef.current = null;
        return;
      }
      frameId = window.requestAnimationFrame(tick);
    };
    frameId = window.requestAnimationFrame(tick);
    return () => window.cancelAnimationFrame(frameId);
  }, [controlMoving]);

  useEffect(() => {
    setRoomSpotId(null);
    setTilmaModalOpen(false);
    setMessage(null);
    setMaryStep(0);
    setActiveSignDrag(null);
    setVisionOpen(false);
    setTilmaCinematicAt(null);
    activeSignDragRef.current = null;
    setPlayer(
      guadalupeActorStart(
        scene.id,
        GUADALUPE_PLAYER_STARTS[scene.id] ?? basePlayer,
      ),
    );
    setPlayerMoving(false);
    setControlMoving(false);
    stopMoveInput();
    setPriestMoving(false);
    setPriestWalkMs(null);
    setRosesPicked([]);
    setSunCinematicActive(false);
    window.clearTimeout(playerTimer.current);
    window.clearTimeout(priestTimer.current);
    window.clearTimeout(tilmaCinematicTimer.current);
    window.clearTimeout(roseMessageTimer.current);
    window.clearTimeout(sunCinematicTimer.current);
  }, [sceneIndex]);

  useEffect(() => {
    if (!maryScene) return undefined;
    const id = window.setInterval(() => {
      setMaryStep((value) => value + 1);
    }, 2600);
    return () => window.clearInterval(id);
  }, [maryScene, scene.id]);

  useEffect(() => {
    window.clearTimeout(discoveryTimer.current);
    if (
      !autoDiscovery ||
      !discoveryTarget ||
      roseQuestActive ||
      sceneDone ||
      playerWalking ||
      player.sceneId !== scene.id ||
      message
    )
      return undefined;
    if (guadalupeDistance(player, discoveryTarget) > autoDiscovery.radius)
      return undefined;

    discoveryTimer.current = window.setTimeout(() => {
      if (autoDiscovery.revealMary) setMaryFound(true);
      stopMoveInput();
      markSceneDone();
      setMessage({
        speaker: autoDiscovery.speaker,
        speakerLabel: autoDiscovery.speakerLabel,
        line: autoDiscovery.line,
        note: autoDiscovery.note,
        audio: autoDiscovery.audio,
      });
    }, 520);

    return () => window.clearTimeout(discoveryTimer.current);
  }, [
    player.x,
    player.y,
    playerWalking,
    scene.id,
    discoveryTarget?.x,
    discoveryTarget?.y,
    roseQuestActive,
    sceneDone,
    message,
    stopMoveInput,
  ]);

  useEffect(() => {
    if (
      !sceneVision ||
      !sceneDone ||
      message ||
      tilmaModalOpen ||
      visionOpen ||
      visionSeen[scene.id]
    )
      return undefined;
    const id = window.setTimeout(() => {
      stopMoveInput();
      setVisionOpen(true);
    }, 460);
    return () => window.clearTimeout(id);
  }, [
    sceneVision,
    sceneDone,
    message,
    tilmaModalOpen,
    visionOpen,
    visionSeen,
    scene.id,
    stopMoveInput,
  ]);

  useEffect(() => {
    if (!message?.audio || !discoveryAudioRef.current) return undefined;
    discoveryAudioRef.current.currentTime = 0;
    discoveryAudioRef.current.play?.().catch(() => {});
    return undefined;
  }, [message?.audio]);

  useEffect(() => {
    if (!tilmaModalOpen || !roomSpot?.audio || !tilmaAudioRef.current)
      return undefined;
    tilmaAudioRef.current.currentTime = 0;
    tilmaAudioRef.current.play?.().catch(() => {});
    return undefined;
  }, [tilmaModalOpen, roomSpot?.audio]);

  useEffect(() => {
    const keyMap = {
      ArrowUp: "up",
      ArrowDown: "down",
      ArrowLeft: "left",
      ArrowRight: "right",
    };
    const shouldIgnoreTarget = (target) => {
      const tag = target?.tagName;
      return (
        tag === "INPUT" ||
        tag === "TEXTAREA" ||
        tag === "SELECT" ||
        target?.isContentEditable
      );
    };
    const handleKeyDown = (event) => {
      const direction = keyMap[event.key];
      if (
        !direction ||
        message ||
        tilmaModalOpen ||
        visionOpen ||
        cinematicActive ||
        shouldIgnoreTarget(event.target)
      )
        return;
      event.preventDefault();
      if (!Object.values(moveInput).some(Boolean))
        beginWalkLatencySample(`key:${direction}`);
      setDirectionActive(direction, true);
    };
    const handleKeyUp = (event) => {
      const direction = keyMap[event.key];
      if (!direction) return;
      event.preventDefault();
      setDirectionActive(direction, false);
    };
    window.addEventListener("keydown", handleKeyDown);
    window.addEventListener("keyup", handleKeyUp);
    return () => {
      window.removeEventListener("keydown", handleKeyDown);
      window.removeEventListener("keyup", handleKeyUp);
    };
  }, [
    beginWalkLatencySample,
    message,
    moveInput,
    setDirectionActive,
    tilmaModalOpen,
    visionOpen,
    cinematicActive,
  ]);

  useEffect(() => {
    if (tilmaModalOpen || visionOpen || cinematicActive) {
      stopMoveInput();
      setControlMoving(false);
      return undefined;
    }
    const horizontal = (moveInput.right ? 1 : 0) - (moveInput.left ? 1 : 0);
    const vertical = (moveInput.down ? 1 : 0) - (moveInput.up ? 1 : 0);
    if (!horizontal && !vertical) {
      setControlMoving(false);
      return undefined;
    }

    setControlMoving(true);
    let frameId = 0;
    let lastTime =
      window.performance.now() - GUADALUPE_CONTROL_FIRST_STEP_DT * 1000;
    const tick = (time) => {
      const dt = guadalupeClamp((time - lastTime) / 1000, 0, 0.045);
      lastTime = time;
      const length = Math.hypot(horizontal, vertical) || 1;
      setPlayer((value) => {
        if (value.sceneId !== scene.id) return value;
        const profile = GUADALUPE_DEPTH_PROFILES[scene.id];
        const depthAmount = profile
          ? guadalupeClamp(
              (value.y - profile.farY) / (profile.nearY - profile.farY),
              0,
              1,
            )
          : 0.5;
        const stepX = (horizontal / length) * (10.8 + depthAmount * 4.4) * dt;
        const stepY = (vertical / length) * (8.2 + depthAmount * 3.5) * dt;
        const rawX = guadalupeClamp(value.x + stepX, 14, 86);
        const rawY = guadalupeClamp(value.y + stepY, 48, 89);
        const { x: nextX, y: nextY } = guadalupeWalkablePoint(
          scene.id,
          rawX,
          rawY,
        );
        const nextDir = guadalupeDirectionFromDelta(
          horizontal,
          vertical,
          value.dir,
        );
        return {
          ...value,
          x: nextX,
          y: nextY,
          scale: guadalupeDepthScale(scene.id, nextY, value.scale),
          mobileScale: guadalupeDepthScale(
            scene.id,
            nextY,
            value.mobileScale ?? value.scale,
            true,
          ),
          dir: nextDir,
          walkMs: 90,
        };
      });
      frameId = window.requestAnimationFrame(tick);
    };
    frameId = window.requestAnimationFrame(tick);
    return () => {
      window.cancelAnimationFrame(frameId);
      setControlMoving(false);
    };
  }, [
    moveInput,
    scene.id,
    tilmaModalOpen,
    visionOpen,
    cinematicActive,
    stopMoveInput,
  ]);

  useEffect(() => {
    if (!nearbySpot) return undefined;
    const handleInteractKey = (event) => {
      if (event.key !== "e" && event.key !== "E" && event.key !== "Enter")
        return;
      const tag = event.target?.tagName;
      if (
        tag === "INPUT" ||
        tag === "TEXTAREA" ||
        tag === "SELECT" ||
        event.target?.isContentEditable
      )
        return;
      event.preventDefault();
      chooseRoomSpot(nearbySpot);
    };
    window.addEventListener("keydown", handleInteractKey);
    return () => window.removeEventListener("keydown", handleInteractKey);
  });

  useEffect(() => {
    if (!tilmaModalOpen || detailSceneId) return undefined;
    const closeOnEscape = (event) => {
      if (event.key === "Escape") setTilmaModalOpen(false);
    };
    window.addEventListener("keydown", closeOnEscape);
    return () => window.removeEventListener("keydown", closeOnEscape);
  }, [tilmaModalOpen, detailSceneId]);

  const movePlayerTo = (x, y, options = {}) => {
    const rawTargetX = guadalupeClamp(options.playerPatch?.x ?? x, 14, 86);
    const rawTargetY = guadalupeClamp(options.playerPatch?.y ?? y, 48, 89);
    const targetPoint = guadalupeWalkablePoint(
      scene.id,
      rawTargetX,
      rawTargetY,
    );
    const distance = Math.hypot(
      targetPoint.x - player.x,
      targetPoint.y - player.y,
    );
    const duration =
      options.duration ??
      guadalupeWalkDuration(distance, options.playerPatch ? "room" : "field");
    const applyPlayerMove = (point, patch, moveMs) => {
      setPlayer((value) => {
        const { x: nextX, y: nextY } = guadalupeWalkablePoint(
          scene.id,
          point.x,
          point.y,
        );
        return {
          ...value,
          ...(patch ?? {}),
          sceneId: scene.id,
          x: nextX,
          y: nextY,
          scale:
            patch?.scale ?? guadalupeDepthScale(scene.id, nextY, value.scale),
          mobileScale:
            patch?.mobileScale ??
            guadalupeDepthScale(
              scene.id,
              patch?.mobileY ?? nextY,
              value.mobileScale ?? value.scale,
              true,
            ),
          dir:
            patch?.dir ??
            guadalupeDirectionFromDelta(
              nextX - value.x,
              nextY - value.y,
              value.dir,
            ),
          walkMs: moveMs,
        };
      });
    };
    const completeMove = () => {
      setPlayerMoving(false);
      if (options.message) {
        if (options.completeScene !== false) markSceneDone();
        setMessage(options.message);
      }
    };
    const roomWaypoint = guadalupeRoomRouteWaypoint(
      scene.id,
      player,
      targetPoint,
      options.playerPatch,
    );
    if (roomWaypoint) {
      const firstDistance = Math.hypot(
        roomWaypoint.point.x - player.x,
        roomWaypoint.point.y - player.y,
      );
      const firstDuration = guadalupeWalkDuration(firstDistance, "room");
      const secondDistance = Math.hypot(
        targetPoint.x - roomWaypoint.point.x,
        targetPoint.y - roomWaypoint.point.y,
      );
      const secondDuration = guadalupeWalkDuration(secondDistance, "room");
      const totalDuration = firstDuration + secondDuration;
      applyPlayerMove(roomWaypoint.point, roomWaypoint.patch, firstDuration);
      setPlayerMoving(true);
      window.clearTimeout(playerTimer.current);
      playerTimer.current = window.setTimeout(() => {
        applyPlayerMove(targetPoint, options.playerPatch, secondDuration);
        playerTimer.current = window.setTimeout(completeMove, secondDuration);
      }, firstDuration);
      return totalDuration;
    }
    applyPlayerMove(targetPoint, options.playerPatch, duration);
    setPlayerMoving(true);
    window.clearTimeout(playerTimer.current);
    playerTimer.current = window.setTimeout(completeMove, duration);
    return duration;
  };

  const chooseRoomSpot = (spot) => {
    if (isRoomSpotLocked(spot)) return;
    setRoomSpotId(spot.id);
    const roomComplete = roomGoalSpots.length
      ? markRoomSpotFound(spot.id)
      : true;
    const roomJuan = spot.actors?.juan ?? {};
    const roomPriest = spot.actors?.priest;
    const walkDuration = movePlayerTo(
      roomJuan.x ?? spot.x,
      roomJuan.y ?? spot.y,
      {
        playerPatch: roomJuan,
        message: {
          speaker: spot.speaker,
          speakerLabel: spot.speakerLabel,
          line: spot.line,
          note: spot.note,
          find: spot.find,
          audio: spot.audio,
        },
        completeScene: roomComplete,
      },
    );
    if (roomPriest) {
      setPriestMoving(true);
      setPriestWalkMs(walkDuration);
      window.clearTimeout(priestTimer.current);
      priestTimer.current = window.setTimeout(() => {
        setPriestMoving(false);
        setPriestWalkMs(null);
      }, walkDuration);
    }
  };

  const openTilmaModal = () => {
    const tilmaSpot = roomSpots.find((spot) => spot.id === "tilma");
    if (tilmaSpot) {
      setRoomSpotId(tilmaSpot.id);
      const roomComplete = roomGoalSpots.length
        ? markRoomSpotFound(tilmaSpot.id)
        : true;
      if (roomComplete || !roomGoalSpots.length) markSceneDone();
    }
    setTilmaModalOpen(true);
  };

  const beginTilmaCinematic = () => {
    if (scene.id !== "tilma" || sceneDone || tilmaCinematicAt) return;
    stopMoveInput();
    setSignProgress((value) => ({ ...value, [scene.id]: 1 }));
    setTilmaCinematicAt({ x: player.x, y: player.y });
    window.clearTimeout(tilmaCinematicTimer.current);
    tilmaCinematicTimer.current = window.setTimeout(() => {
      openTilmaModal();
    }, 2700);
  };

  useEffect(() => {
    if (
      scene.id !== "tilma" ||
      sceneDone ||
      tilmaCinematicAt ||
      message ||
      tilmaModalOpen ||
      playerMoving ||
      !["roses", "bishop"].every((id) => completedRoomSpots.includes(id))
    )
      return undefined;
    if (guadalupeDistance(player, GUADALUPE_TILMA_DESK_TARGET) > 13)
      return undefined;
    const id = window.setTimeout(beginTilmaCinematic, 350);
    return () => window.clearTimeout(id);
  });

  const finishDirectedSignMessage = () => {
    markSceneDone();
    setMessage({
      speaker: discovery?.speaker ?? scene.speaker,
      speakerLabel: discovery?.speakerLabel ?? scene.speakerLabel,
      line: discovery?.line ?? scene.line,
      note: discovery?.note ?? scene.note,
      audio: discovery?.audio,
      find: {
        label: "Date",
        title: directedSign.date,
        text: directedSign.caption,
      },
    });
  };

  const beginSunCinematic = () => {
    if (sceneDone || sunCinematicActive) return;
    stopMoveInput();
    setSunCinematicActive(true);
    window.clearTimeout(sunCinematicTimer.current);
    sunCinematicTimer.current = window.setTimeout(() => {
      setSunCinematicActive(false);
      finishDirectedSignMessage();
    }, 3000);
  };

  const completeDirectedSign = () => {
    if (!directedSign || sceneDone) return;
    stopMoveInput();
    setSignProgress((value) => ({
      ...value,
      [scene.id]:
        directedSign.type === "ground-taps"
          ? (directedSign.requiredTaps ?? 3)
          : 1,
    }));
    if (directedSign.type === "tilma-drag") {
      beginTilmaCinematic();
      return;
    }
    if (directedSign.type === "sky-drag") {
      beginSunCinematic();
      return;
    }
    finishDirectedSignMessage();
  };

  const pickRose = (rose) => {
    if (!roseQuestActive || rosesPicked.includes(rose.id) || message) return;
    const next = [...rosesPicked, rose.id];
    setRosesPicked(next);
    if (next.length === GUADALUPE_ROSE_PICKUPS.length) {
      stopMoveInput();
      window.clearTimeout(roseMessageTimer.current);
      roseMessageTimer.current = window.setTimeout(() => {
        markSceneDone();
        setMessage({
          speaker: discovery?.speaker ?? scene.speaker,
          speakerLabel: discovery?.speakerLabel,
          line: discovery?.line ?? scene.line,
          note: discovery?.note ?? scene.note,
          audio: discovery?.audio,
        });
      }, 700);
    }
  };

  const handleRoseTap = (rose) => {
    if (!roseQuestActive || message || rosesPicked.includes(rose.id)) return;
    if (guadalupeDistance(player, rose) <= GUADALUPE_ROSE_PICK_RADIUS) {
      pickRose(rose);
      return;
    }
    stopMoveInput();
    movePlayerTo(rose.x, Math.min(rose.y + 4, 89));
  };

  useEffect(() => {
    if (!roseQuestActive || message || player.sceneId !== scene.id) return;
    const near = unpickedRoses.find(
      (rose) => guadalupeDistance(player, rose) <= GUADALUPE_ROSE_PICK_RADIUS,
    );
    if (near) pickRose(near);
  });

  const dragProgressFromEvent = (gesture, event) => {
    const dx = event.clientX - gesture.startX;
    const dy = event.clientY - gesture.startY;
    const distance =
      directedSign?.type === "tilma-drag"
        ? Math.max(0, dy)
        : Math.hypot(dx, dy);
    return guadalupeClamp(distance / (directedSign?.dragDistance ?? 78), 0, 1);
  };

  const beginSignDrag = (event) => {
    if (!directedSign || sceneDone || message || playerMoving) return;
    event.preventDefault();
    event.stopPropagation();
    stopMoveInput();
    event.currentTarget.setPointerCapture?.(event.pointerId);
    const gesture = {
      sceneId: scene.id,
      type: directedSign.type,
      startX: event.clientX,
      startY: event.clientY,
    };
    activeSignDragRef.current = gesture;
    setActiveSignDrag(gesture);
    setSignProgress((value) => ({
      ...value,
      [scene.id]: Math.max(value[scene.id] ?? 0, 0.05),
    }));
  };

  const updateSignDrag = (event) => {
    const gesture = activeSignDragRef.current ?? activeSignDrag;
    if (!directedSign || !gesture || gesture.sceneId !== scene.id) return;
    event.preventDefault();
    event.stopPropagation();
    const nextProgress = dragProgressFromEvent(gesture, event);
    setSignProgress((value) => ({
      ...value,
      [scene.id]: Math.max(value[scene.id] ?? 0, nextProgress),
    }));
  };

  const finishSignDrag = (event) => {
    const gesture = activeSignDragRef.current ?? activeSignDrag;
    if (!directedSign || !gesture || gesture.sceneId !== scene.id) return;
    event.preventDefault();
    event.stopPropagation();
    const nextProgress = dragProgressFromEvent(gesture, event);
    activeSignDragRef.current = null;
    setActiveSignDrag(null);
    setSignProgress((value) => ({
      ...value,
      [scene.id]: Math.max(value[scene.id] ?? 0, nextProgress),
    }));
    if (nextProgress >= 0.96) completeDirectedSign();
  };

  const handleGroundSignTap = (event) => {
    if (
      !directedSign ||
      directedSign.type !== "ground-taps" ||
      sceneDone ||
      message ||
      playerMoving
    )
      return;
    event.preventDefault();
    event.stopPropagation();
    stopMoveInput();
    const requiredTaps = directedSign.requiredTaps ?? 3;
    const nextTaps = Math.min(requiredTaps, rawSignProgress + 1);
    setSignProgress((value) => ({ ...value, [scene.id]: nextTaps }));
    if (nextTaps >= requiredTaps) completeDirectedSign();
  };

  useEffect(() => {
    if (!activeSignDrag) return undefined;
    const handleMove = (event) => updateSignDrag(event);
    const handleUp = (event) => finishSignDrag(event);
    window.addEventListener("pointermove", handleMove);
    window.addEventListener("pointerup", handleUp);
    window.addEventListener("mousemove", handleMove);
    window.addEventListener("mouseup", handleUp);
    return () => {
      window.removeEventListener("pointermove", handleMove);
      window.removeEventListener("pointerup", handleUp);
      window.removeEventListener("mousemove", handleMove);
      window.removeEventListener("mouseup", handleUp);
    };
  }, [activeSignDrag]);

  const handleWorldPointerDown = (event) => {
    if (
      message ||
      tilmaModalOpen ||
      visionOpen ||
      cinematicActive ||
      event.target.closest("button")
    )
      return;
    const rect = worldRef.current?.getBoundingClientRect();
    if (!rect) return;
    const x = ((event.clientX - rect.left) / rect.width) * 100;
    const y = ((event.clientY - rect.top) / rect.height) * 100;
    stopMoveInput();
    movePlayerTo(x, y);
  };

  const goNext = () => {
    setSceneIndex((value) => (value + 1) % GUADALUPE_VISIT_SCENES.length);
  };
  const goBack = () => {
    setSceneIndex((value) => Math.max(0, value - 1));
  };
  const restart = () => {
    setSceneIndex(0);
    setFoundScenes({});
    setRoomFinds({});
    setMaryFound(false);
    setMessage(null);
    setSignProgress({});
    setActiveSignDrag(null);
    setVisionOpen(false);
    setVisionSeen({});
    setTilmaCinematicAt(null);
    setRosesPicked([]);
    setSunCinematicActive(false);
    setDetailSceneId(null);
    setDetailFound({});
    window.clearTimeout(tilmaCinematicTimer.current);
    window.clearTimeout(roseMessageTimer.current);
    window.clearTimeout(sunCinematicTimer.current);
    activeSignDragRef.current = null;
    try {
      sessionStorage.removeItem(GUADALUPE_ROUTE_STORAGE_KEY);
    } catch (e) {}
  };
  const closeVision = () => {
    setVisionSeen((value) => ({ ...value, [scene.id]: true }));
    monstranceLog.record("guadalupe", "vision", scene.id, 1500);
    setVisionOpen(false);
    if (!isLast) goNext();
  };
  const openDetailScene = (id) => {
    if (!MARIAN_DETAIL_SCENES[id]) return;
    stopMoveInput();
    setDetailSceneId(id);
    monstranceLog.record("guadalupe", "detail", id, 1200);
  };
  const closeDetailScene = () => setDetailSceneId(null);
  const markDetailFound = (spotId) => {
    if (!detailSceneId) return;
    setDetailFound((value) => {
      const current = value[detailSceneId] ?? [];
      if (current.includes(spotId)) return value;
      return { ...value, [detailSceneId]: [...current, spotId] };
    });
  };

  useEffect(() => {
    window.__marianDetail = {
      open: (id) => openDetailScene(id),
      close: () => setDetailSceneId(null),
      active: () => detailSceneId,
      found: () => detailFound,
    };
    return () => {
      delete window.__marianDetail;
    };
  });

  return (
    <article
      className="guadalupe-mystery-shell"
      style={{
        "--guadalupe-bg": `url("${scene.backdrop}")`,
        "--guadalupe-bg-position": roomSpot?.bgPosition ?? "center",
        "--guadalupe-bg-scale": Math.min(roomSpot?.bgScale ?? 1, 1.04),
      }}
    >
      <header className="guadalupe-hud">
        <div>
          <p className="guadalupe-kicker">Marian Apparitions</p>
          <h2>{scene.label}</h2>
        </div>
        <div className="guadalupe-count">
          <strong>{sceneIndex + 1}</strong>
          <span>/ {GUADALUPE_VISIT_SCENES.length}</span>
        </div>
      </header>

      <div
        ref={worldRef}
        className="guadalupe-world"
        aria-label={`${scene.label} exploration scene`}
        onPointerDown={handleWorldPointerDown}
      >
        <div className="guadalupe-backdrop" aria-hidden="true" />
        <div className="guadalupe-atmosphere" aria-hidden="true" />
        <div className="guadalupe-game-hud">
          <span>{questFeedback.label}</span>
          <strong>{questFeedback.line}</strong>
          {!!roomGoalSpotItems.length && (
            <div
              className="guadalupe-quest-steps"
              aria-label="Scene action progress"
            >
              {roomGoalSpotItems.map((spot) => {
                const complete = completedRoomSpots.includes(spot.id);
                const next = pendingRoomSpot?.id === spot.id;
                return (
                  <span
                    key={spot.id}
                    className={`guadalupe-quest-step ${complete ? "is-complete" : ""} ${next ? "is-next" : ""}`}
                  >
                    {complete ? "✓" : "•"} {spot.label}
                  </span>
                );
              })}
            </div>
          )}
        </div>
        <div
          className="guadalupe-trail-meter"
          aria-label={`Trail signal: ${trailSignalLabel}`}
        >
          <span>Trail Signal</span>
          <strong>{trailSignalLabel}</strong>
          <i aria-hidden="true">
            <b style={{ width: `${Math.round(trailSignal * 100)}%` }} />
          </i>
        </div>
        {discoveryTarget && !sceneDone && trailSignal > 0.12 && (
          <span
            className={`guadalupe-search-glow is-${scene.id}`}
            style={{
              "--glow-x": `${discoveryTarget.x}%`,
              "--glow-y": `${discoveryTarget.y}%`,
              "--glow-opacity": (0.18 + trailSignal * 0.66).toFixed(2),
            }}
            aria-hidden="true"
          />
        )}
        {directedSignReady &&
          !sceneDone &&
          directedSign.type === "ground-taps" && (
            <button
              type="button"
              className="guadalupe-sign-gesture is-ground-taps"
              style={signGestureStyle}
              onPointerDown={handleGroundSignTap}
              disabled={!!message || playerMoving}
              aria-label={`${directedSign.prompt} ${rawSignProgress} of ${directedSign.requiredTaps ?? 3} taps complete`}
            >
              <span className="guadalupe-sign-water" aria-hidden="true" />
              {rawSignProgress > 0 && (
                <span
                  key={rawSignProgress}
                  className="guadalupe-sign-splash"
                  aria-hidden="true"
                />
              )}
              <strong>
                {rawSignProgress}/{directedSign.requiredTaps ?? 3}
              </strong>
              <em>Tap ground</em>
            </button>
          )}
        {scene.id === "lourdes-sign" && sceneDone && directedSign?.target && (
          <span
            className="guadalupe-spring-flow"
            style={{
              "--sign-x": `${directedSign.target.x}%`,
              "--sign-y": `${directedSign.target.y}%`,
            }}
            aria-hidden="true"
          >
            <i />
            <i />
            <i />
          </span>
        )}
        {scene.id === "fatima-sun" &&
          sceneDone &&
          !sunCinematicActive &&
          directedSign?.target && (
            <span
              className="guadalupe-sun-rest"
              style={{
                "--sign-x": `${directedSign.target.x}%`,
                "--sign-y": `${directedSign.target.y}%`,
              }}
              aria-hidden="true"
            />
          )}
        {directedSignReady &&
          !sceneDone &&
          !sunCinematicActive &&
          directedSign.type === "sky-drag" && (
            <button
              type="button"
              className="guadalupe-sign-gesture is-sky-drag"
              style={signGestureStyle}
              onPointerDown={beginSignDrag}
              onPointerMove={updateSignDrag}
              onPointerUp={finishSignDrag}
              onPointerCancel={() => {
                activeSignDragRef.current = null;
                setActiveSignDrag(null);
              }}
              onMouseDown={beginSignDrag}
              onMouseMove={updateSignDrag}
              onMouseUp={finishSignDrag}
              disabled={!!message || playerMoving}
              aria-label={directedSign.prompt}
            >
              <span className="guadalupe-sign-sun" aria-hidden="true" />
              <strong>Drag sun</strong>
            </button>
          )}
        {directedSign?.date && sceneDone && !message && !tilmaModalOpen && (
          <div className="guadalupe-sign-caption" aria-live="polite">
            <span>{directedSign.date}</span>
            <strong>{directedSign.caption}</strong>
          </div>
        )}
        <GuadalupeSceneActor actor={playerActor} frame={walkFrame} />
        {sideActors.map((actor) => (
          <GuadalupeSceneActor
            key={`${scene.id}-${actor.id}`}
            actor={actor}
            frame={walkFrame}
          />
        ))}
        {maryActor && (
          <GuadalupeSceneActor actor={maryActor} frame={walkFrame} />
        )}
        {roseQuest &&
          !sceneDone &&
          GUADALUPE_ROSE_PICKUPS.map((rose) => {
            const picked = rosesPicked.includes(rose.id);
            return (
              <button
                key={rose.id}
                type="button"
                className={`guadalupe-rose-pickup ${picked ? "is-picked" : ""}`}
                style={{
                  "--rose-x": `${rose.x}%`,
                  "--rose-y": `${rose.y}%`,
                }}
                onClick={() => handleRoseTap(rose)}
                disabled={picked || !!message}
                aria-label={
                  picked ? "Rose gathered" : "Walk here to gather a rose"
                }
              >
                <i aria-hidden="true" />
                <span aria-hidden="true">🌹</span>
              </button>
            );
          })}
        {sunCinematicActive && directedSign?.target && (
          <div
            className="guadalupe-sun-cinematic"
            style={{
              "--cine-sun-x": `${directedSign.target.x}%`,
              "--cine-sun-y": `${directedSign.target.y}%`,
            }}
            aria-hidden="true"
          >
            <span className="guadalupe-sun-cine-sky" />
            <span className="guadalupe-sun-cine-sun" />
            {GUADALUPE_SUN_CINE_FLECKS.map((fleck, index) => (
              <span
                key={index}
                className="guadalupe-sun-cine-fleck"
                style={{
                  "--fleck-delay": `${fleck.delay}s`,
                  "--fleck-dx": `${fleck.dx}px`,
                  "--fleck-fall": `${fleck.fall}px`,
                  "--fleck-hue": fleck.hue,
                }}
              />
            ))}
          </div>
        )}
        {roomSpots
          .filter((spot) => !spot.hidden)
          .map((spot) => {
            const spotComplete = completedRoomSpots.includes(spot.id);
            const spotNear = nearbySpot?.id === spot.id;
            return (
              <button
                key={spot.id}
                className={`guadalupe-room-hotspot ${roomSpot?.id === spot.id ? "is-active" : ""} ${spotComplete ? "is-complete" : ""} ${spotNear ? "is-near" : ""}`}
                style={{
                  "--spot-x": `${spot.x}%`,
                  "--spot-y": `${spot.y}%`,
                  "--spot-mobile-x": `${spot.mobileX ?? spot.x}%`,
                  "--spot-mobile-y": `${spot.mobileY ?? spot.y}%`,
                }}
                onClick={() => chooseRoomSpot(spot)}
                aria-pressed={roomSpot?.id === spot.id}
                aria-label={`${spot.label}${spot.find?.title ? `: ${spot.find.title}` : ""}`}
                disabled={
                  !!message || playerMoving || tilmaModalOpen || visionOpen
                }
              >
                <i aria-hidden="true" />
                <span>{spot.label}</span>
              </button>
            );
          })}
        {nearbySpot && (
          <button
            type="button"
            className="guadalupe-interact-prompt"
            style={{
              "--prompt-x": `${nearbySpot.x}%`,
              "--prompt-y": `${nearbySpot.y}%`,
              "--prompt-mobile-x": `${nearbySpot.mobileX ?? nearbySpot.x}%`,
              "--prompt-mobile-y": `${nearbySpot.mobileY ?? nearbySpot.y}%`,
            }}
            onClick={() => chooseRoomSpot(nearbySpot)}
            aria-label={`Look at ${nearbySpot.label}`}
          >
            <kbd aria-hidden="true">E</kbd>
            <span>Look: {nearbySpot.label}</span>
          </button>
        )}
        {showTilmaRelic && !sceneDone && !tilmaCinematicAt && (
          <button
            type="button"
            className="guadalupe-tilma-find is-drag-gesture"
            style={{
              "--tilma-x": `${scene.tilmaRelic.x}%`,
              "--tilma-y": `${scene.tilmaRelic.y}%`,
              "--tilma-mobile-x": `${scene.tilmaRelic.mobileX ?? scene.tilmaRelic.x}%`,
              "--tilma-mobile-y": `${scene.tilmaRelic.mobileY ?? scene.tilmaRelic.y}%`,
              "--sign-progress": signProgressRatio,
              "--tilma-drag-height": `${Math.round(24 + signProgressRatio * 36)}px`,
            }}
            onPointerDown={beginSignDrag}
            onPointerMove={updateSignDrag}
            onPointerUp={finishSignDrag}
            onPointerCancel={() => {
              activeSignDragRef.current = null;
              setActiveSignDrag(null);
            }}
            onMouseDown={beginSignDrag}
            onMouseMove={updateSignDrag}
            onMouseUp={finishSignDrag}
            onDragStart={(event) => event.preventDefault()}
            aria-label={
              directedSign?.prompt ?? "Drag the tilma corner downward"
            }
            disabled={!!message || playerMoving || tilmaModalOpen}
          >
            <GuadalupeTilmaCloak progress={signProgressRatio} />
            <span>{signProgressRatio >= 0.96 ? "Open" : "Drag down"}</span>
          </button>
        )}
        {tilmaCinematicAt && scene.id === "tilma" && (
          <div
            className="guadalupe-tilma-cinematic"
            style={{
              "--cine-x": `${tilmaCinematicAt.x}%`,
              "--cine-y": `${tilmaCinematicAt.y}%`,
            }}
            aria-hidden="true"
          >
            <span className="guadalupe-cine-burst" />
            <span className="guadalupe-cine-cloak">
              <GuadalupeTilmaCloak progress={1} />
            </span>
            {GUADALUPE_CINE_ROSES.map((rose, index) => (
              <span
                key={index}
                className="guadalupe-cine-rose"
                style={{
                  "--rose-delay": `${rose.delay}s`,
                  "--rose-dx": `${rose.dx}px`,
                  "--rose-fall": `${rose.fall}px`,
                  "--rose-spin": `${rose.spin}deg`,
                }}
              >
                🌹
              </span>
            ))}
          </div>
        )}
        <div
          className="guadalupe-dpad"
          role="group"
          aria-label="Move Juan Diego"
        >
          <button
            type="button"
            className="is-up"
            title="Move up"
            aria-label="Move up"
            {...directionButtonProps("up")}
          >
            ↑
          </button>
          <button
            type="button"
            className="is-left"
            title="Move left"
            aria-label="Move left"
            {...directionButtonProps("left")}
          >
            ←
          </button>
          <button
            type="button"
            className="is-down"
            title="Move down"
            aria-label="Move down"
            {...directionButtonProps("down")}
          >
            ↓
          </button>
          <button
            type="button"
            className="is-right"
            title="Move right"
            aria-label="Move right"
            {...directionButtonProps("right")}
          >
            →
          </button>
        </div>
        {message && messageSpeaker && (
          <section
            className="guadalupe-dialogue is-popup"
            role="dialog"
            aria-modal="true"
            aria-live="polite"
            aria-label={`${messageSpeakerLabel} message`}
          >
            <span
              className={`guadalupe-dialogue-portrait is-${message.speaker}`}
              style={{
                backgroundImage: `url("${messageSpeaker.talk}")`,
                backgroundPosition: guadalupeSheetPosition(0, 0, 8, 1),
              }}
              role="img"
              aria-label={`${messageSpeakerLabel} speaking`}
            />
            <div className="guadalupe-dialogue-copy">
              <span>{messageSpeakerLabel}</span>
              <p>{message.line}</p>
              <em>{message.note}</em>
              {message.find && (
                <div className="guadalupe-dialogue-find">
                  <span>{message.find.label ?? "You noticed"}</span>
                  <strong>{message.find.title}</strong>
                  <p>{message.find.text}</p>
                </div>
              )}
              {message.audio && (
                <audio
                  ref={discoveryAudioRef}
                  className="guadalupe-voice"
                  preload="auto"
                  src={message.audio}
                />
              )}
            </div>
            <div className="guadalupe-controls">
              {message.audio && (
                <button
                  className="btn btn-secondary guadalupe-voice-replay"
                  onClick={() => {
                    const audio = discoveryAudioRef.current;
                    if (!audio) return;
                    audio.currentTime = 0;
                    audio.play?.().catch(() => {});
                  }}
                >
                  Replay voice
                </button>
              )}
              <button
                className="btn btn-primary guadalupe-popup-close"
                aria-label="Close message and keep walking"
                onClick={() => setMessage(null)}
              >
                Close
              </button>
            </div>
          </section>
        )}
        <div className="guadalupe-place-card">
          <span>{scene.place}</span>
          <strong>{scene.label}</strong>
        </div>
      </div>

      <section className="guadalupe-gamebar">
        <div className="guadalupe-gamebar-main">
          <span>
            {message
              ? "Listen"
              : playerWalking
                ? "Walking"
                : sceneDone
                  ? "Unlocked"
                  : "Explore"}
          </span>
          <strong>{gamebarPrompt}</strong>
          <div
            className="guadalupe-token-tray"
            aria-label={`${foundCount} of ${GUADALUPE_VISIT_SCENES.length} scene clues found`}
          >
            {GUADALUPE_VISIT_SCENES.map((visitScene) => {
              const token = GUADALUPE_SCENE_TOKENS[visitScene.id];
              const found = !!foundScenes[visitScene.id];
              const active = visitScene.id === scene.id;
              return (
                <span
                  key={visitScene.id}
                  className={`guadalupe-token ${found ? "is-found" : ""} ${active ? "is-active" : ""}`}
                  title={token.label}
                >
                  {found ? token.mark : active ? "..." : ""}
                </span>
              );
            })}
          </div>
        </div>
        <nav
          className="guadalupe-controls"
          aria-label="Guadalupe game controls"
        >
          <button className="btn btn-secondary" onClick={onExitGame}>
            Mini games
          </button>
          <button
            className="btn btn-secondary"
            onClick={goBack}
            disabled={isFirst || !!message}
          >
            Back
          </button>
          <button
            className="btn btn-primary"
            onClick={goNext}
            disabled={!canAdvance}
          >
            {isLast ? "Tell again" : "Next area"}
          </button>
          {!isFirst && (
            <button
              className="btn btn-secondary"
              onClick={restart}
              disabled={!!message}
            >
              Start
            </button>
          )}
          <GameCompletionBridge
            done={routeDone}
            gameId={gameId}
            onComplete={onComplete}
            nextGame={nextGame}
            onNextGame={onNextGame}
          />
        </nav>
      </section>
      {tilmaModalOpen && (
        <div
          className="guadalupe-tilma-modal"
          role="dialog"
          aria-modal="true"
          aria-labelledby="guadalupe-tilma-title"
        >
          <div className="guadalupe-tilma-modal-card">
            <div className="guadalupe-tilma-modal-art">
              <button
                type="button"
                className="marian-tilma-zoom"
                onClick={() => openDetailScene("guadalupe")}
                aria-label="Zoom in on the tilma image"
              >
                <GuadalupeTilmaDetailArt />
                <span className="marian-tilma-zoom-hint" aria-hidden="true">
                  🔍 Zoom in
                </span>
              </button>
            </div>
            <div className="guadalupe-tilma-modal-copy">
              <span>Tilma sign</span>
              <h3 id="guadalupe-tilma-title">The cloak opens with the image</h3>
              {directedSign?.date && (
                <div className="guadalupe-sign-caption is-modal">
                  <span>{directedSign.date}</span>
                  <strong>{directedSign.caption}</strong>
                </div>
              )}
              <p>
                Mexico City, December 12, 1531: Juan Diego opens his tilma
                before Bishop Zumarraga; roses fall, and Catholic tradition
                remembers Mary's image on the cloth.
              </p>
              <p>
                The sign confirms the Tepeyac chapel request and points people
                toward Jesus.
              </p>
              {roomSpot?.find && (
                <div className="guadalupe-dialogue-find is-tilma">
                  <span>{roomSpot.find.label ?? "You noticed"}</span>
                  <strong>{roomSpot.find.title}</strong>
                  <p>{roomSpot.find.text}</p>
                </div>
              )}
              {roomSpot?.audio && (
                <audio
                  ref={tilmaAudioRef}
                  className="guadalupe-voice"
                  preload="auto"
                  src={roomSpot.audio}
                />
              )}
              <div className="guadalupe-controls">
                {roomSpot?.audio && (
                  <button
                    className="btn btn-secondary guadalupe-voice-replay"
                    onClick={() => {
                      const audio = tilmaAudioRef.current;
                      if (!audio) return;
                      audio.currentTime = 0;
                      audio.play?.().catch(() => {});
                    }}
                  >
                    Replay voice
                  </button>
                )}
                <button
                  className="btn btn-primary"
                  onClick={() => setTilmaModalOpen(false)}
                >
                  OK
                </button>
              </div>
            </div>
          </div>
        </div>
      )}
      {visionOpen && sceneVision && (
        <div
          className="guadalupe-vision-overlay"
          role="dialog"
          aria-modal="true"
          aria-label={`${sceneVision.title} vision`}
        >
          <div className="guadalupe-vision-rays" aria-hidden="true" />
          <div className="guadalupe-vision-card">
            <span className="guadalupe-vision-kicker">
              {sceneVision.kicker}
            </span>
            <h3>{sceneVision.title}</h3>
            <p className="guadalupe-vision-words">
              &ldquo;{sceneVision.words}&rdquo;
            </p>
            <em>{sceneVision.note}</em>
            <div className="guadalupe-controls">
              {sceneVision.detail && (
                <button
                  className="btn btn-secondary marian-detail-open"
                  onClick={() => openDetailScene(sceneVision.detail)}
                >
                  🔍 Look closer
                </button>
              )}
              <button className="btn btn-primary" onClick={closeVision}>
                {isLast ? "Amen" : "Continue the story"}
              </button>
            </div>
          </div>
        </div>
      )}
      {detailSceneId && MARIAN_DETAIL_SCENES[detailSceneId] && (
        <MarianDetailScene
          detail={MARIAN_DETAIL_SCENES[detailSceneId]}
          foundIds={detailFound[detailSceneId] ?? []}
          onFind={markDetailFound}
          onClose={closeDetailScene}
        />
      )}
    </article>
  );
}

const SHEPHERD_START = { x: 2, y: 4 };
const SHEPHERD_GATE = { x: 2, y: 0 };
const SHEEP_START = [
  { id: "lamb-1", x: 0, y: 1, pose: 1 },
  { id: "lamb-2", x: 4, y: 1, pose: 2 },
  { id: "lamb-3", x: 1, y: 3, pose: 3 },
  { id: "lamb-4", x: 3, y: 3, pose: 4 },
];
const sameSpot = (a, b) => a.x === b.x && a.y === b.y;
const directionFromMove = (dx, dy) => {
  if (dx < 0) return "left";
  if (dx > 0) return "right";
  if (dy < 0) return "up";
  return "down";
};

function ShepherdGame({ gameId, onComplete, nextGame, onNextGame }) {
  const shepherdRoundSheep = (roundNumber) => {
    const extras = [
      { id: "lamb-5", x: 0, y: 4, pose: 2 },
      { id: "lamb-6", x: 4, y: 4, pose: 3 },
      { id: "lamb-7", x: 2, y: 2, pose: 1 },
    ];
    return [
      ...SHEEP_START,
      ...extras.slice(0, Math.min(extras.length, roundNumber - 1)),
    ];
  };
  const readShepherdBest = (key, fallback) => {
    try {
      const value = Number(localStorage.getItem(key));
      return Number.isFinite(value) && value > 0 ? value : fallback;
    } catch (e) {
      return fallback;
    }
  };
  const [round, setRound] = useState(1); // fresh visit always warms up from round 1
  const [bestRound, setBestRound] = useState(() =>
    readShepherdBest("monstrance-shepherd-best-round", 1),
  );
  const [fewestMoves, setFewestMoves] = useState(() =>
    readShepherdBest("monstrance-shepherd-fewest-moves", 0),
  );
  const roundSheep = shepherdRoundSheep(round);
  const [jesus, setJesus] = useState(SHEPHERD_START);
  const [direction, setDirection] = useState("down");
  const [sheep, setSheep] = useState(() => shepherdRoundSheep(round));
  const [steps, setSteps] = useState(0);
  const [feedback, setFeedback] = useState("Be the Good Shepherd's helper. Bring every lost sheep safely home.");
  const [foundSpot, setFoundSpot] = useState(null);
  const [gateCelebration, setGateCelebration] = useState(false);
  const shepherdCompleteRef = useRef(false);
  const gathered = roundSheep.length - sheep.length;
  const atGate = sameSpot(jesus, SHEPHERD_GATE);
  const done = sheep.length === 0 && atGate;
  useEffect(() => {
    if (!done || shepherdCompleteRef.current) return undefined;
    shepherdCompleteRef.current = true;
    setGateCelebration(true);
    const nextBestRound = Math.max(bestRound, round);
    const nextFewestMoves = fewestMoves === 0 ? steps : Math.min(fewestMoves, steps);
    setBestRound(nextBestRound);
    setFewestMoves(nextFewestMoves);
    try {
      localStorage.setItem("monstrance-shepherd-best-round", String(nextBestRound));
      localStorage.setItem("monstrance-shepherd-fewest-moves", String(nextFewestMoves));
      localStorage.setItem("monstrance-shepherd-round", String(round + 1));
    } catch (e) {
      // Local storage is optional; the game still plays without it.
    }
    onComplete?.(gameId);
    const id = window.setTimeout(() => setGateCelebration(false), 1100);
    return () => window.clearTimeout(id);
  }, [done, gameId, onComplete, bestRound, round, fewestMoves, steps]);
  const canMove = (dx, dy) => {
    const next = { x: jesus.x + dx, y: jesus.y + dy };
    return next.x >= 0 && next.x < 5 && next.y >= 0 && next.y < 5;
  };
  const move = (dx, dy) => {
    if (!canMove(dx, dy) || done) return;
    const next = { x: jesus.x + dx, y: jesus.y + dy };
    const found = sheep.find((item) => sameSpot(item, next));
    setDirection(directionFromMove(dx, dy));
    setJesus(next);
    setSteps((value) => value + 1);
    if (found) {
      setSheep((items) => items.filter((item) => item.id !== found.id));
      setFoundSpot(next);
      window.setTimeout(() => setFoundSpot(null), 760);
      setFeedback(
        "The lost sheep is found. Keep helping until every sheep is safe.",
      );
      monstranceLog.record("shepherd", "sheep");
    } else if (sheep.length === 0 && sameSpot(next, SHEPHERD_GATE)) {
      setFeedback("All the sheep are safe, and heaven rejoices!");
    } else if (sheep.length === 0) {
      setFeedback("All gathered. Now bring them home to the gate at the top.");
    } else {
      setFeedback("Keep looking. Choose the next nearest sheep.");
    }
  };
  const [walkTarget, setWalkTarget] = useState(null);
  useEffect(() => {
    if (!walkTarget || done) return undefined;
    if (sameSpot(jesus, walkTarget)) {
      setWalkTarget(null);
      return undefined;
    }
    const id = window.setTimeout(() => {
      const dx = walkTarget.x === jesus.x ? 0 : walkTarget.x > jesus.x ? 1 : -1;
      const dy = dx !== 0 ? 0 : walkTarget.y > jesus.y ? 1 : -1;
      move(dx, dy);
    }, 180);
    return () => window.clearTimeout(id);
  }, [walkTarget, jesus, done]);
  const moveToCell = (x, y) => {
    const dx = x - jesus.x;
    const dy = y - jesus.y;
    if (dx === 0 && dy === 0) return;
    if (Math.abs(dx) + Math.abs(dy) === 1) {
      setWalkTarget(null);
      move(dx, dy);
      return;
    }
    setWalkTarget({ x, y });
  };
  const reset = () => {
    setJesus(SHEPHERD_START);
    setDirection("down");
    setSheep(shepherdRoundSheep(round));
    setSteps(0);
    setWalkTarget(null);
    setFoundSpot(null);
    setGateCelebration(false);
    shepherdCompleteRef.current = false;
    setFeedback(
      "Let's try again. The Good Shepherd keeps looking for every sheep.",
    );
  };
  const nextRound = () => {
    const next = round + 1;
    setRound(next);
    setJesus(SHEPHERD_START);
    setDirection("down");
    setSheep(shepherdRoundSheep(next));
    setSteps(0);
    setWalkTarget(null);
    setFoundSpot(null);
    setGateCelebration(false);
    shepherdCompleteRef.current = false;
    setFeedback(
      "A new sheep wandered farther away. Bring every lost sheep home!",
    );
    monstranceLog.record("shepherd", "next_round", next);
  };
  useEffect(() => {
    const h = (e) => {
      const key = e.key.toLowerCase();
      if (e.key === "ArrowUp" || key === "w") {
        e.preventDefault();
        setWalkTarget(null);
        move(0, -1);
      }
      if (e.key === "ArrowDown" || key === "s") {
        e.preventDefault();
        setWalkTarget(null);
        move(0, 1);
      }
      if (e.key === "ArrowLeft" || key === "a") {
        e.preventDefault();
        setWalkTarget(null);
        move(-1, 0);
      }
      if (e.key === "ArrowRight" || key === "d") {
        e.preventDefault();
        setWalkTarget(null);
        move(1, 0);
      }
    };
    window.addEventListener("keydown", h);
    return () => window.removeEventListener("keydown", h);
  }, [jesus, sheep, done]);
  return (
    <article className="game-card">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Gather the Sheep</h2>
          <p className="game-card-copy shepherd-storyline">
            Jesus is the Good Shepherd. Be His helper: bring every lost sheep
            safely home, and rejoice when none are lost.
          </p>
        </div>
        <div className="game-badges">
          <span className="game-badge">Round {round}</span>
          <span className="game-badge">{gathered}/{roundSheep.length} sheep</span>
          <span className="game-badge">{steps} steps</span>
        </div>
      </div>
      <div className="shepherd-challenge-strip">
        <span>Best round: {bestRound}</span>
        <span>Fewest moves: {fewestMoves || "try it"}</span>
        <span>Next round adds another wandering sheep.</span>
      </div>
      <GameProgress
        value={gathered + (done ? 1 : 0)}
        total={roundSheep.length + 1}
      />
      <div className="shepherd-progress-pips" aria-hidden="true">
        {roundSheep.map((item, index) => (
          <span
            key={item.id}
            className={`shepherd-progress-pip ${index < gathered ? "is-filled" : ""}`}
          >
            🐑
          </span>
        ))}
        <span className={`shepherd-progress-pip shepherd-progress-gate ${done ? "is-filled" : ""}`}>
          ⌂
        </span>
      </div>
      <div className="game-area shepherd-layout">
        <div className="shepherd-board" aria-label="Pasture board">
          {Array.from({ length: 25 }).map((_, index) => {
            const x = index % 5;
            const y = Math.floor(index / 5);
            const hasJesus = jesus.x === x && jesus.y === y;
            const hasGate = SHEPHERD_GATE.x === x && SHEPHERD_GATE.y === y;
            const lamb = sheep.find((item) => item.x === x && item.y === y);
            const isStep = Math.abs(x - jesus.x) + Math.abs(y - jesus.y) === 1;
            const isTarget =
              walkTarget && walkTarget.x === x && walkTarget.y === y;
            const isFoundSpot =
              foundSpot && foundSpot.x === x && foundSpot.y === y;
            const isGateReady = sheep.length === 0 && hasGate && !done;
            const isGateCelebration = gateCelebration && hasGate;
            return (
              <button
                key={`${x}-${y}`}
                className={`pasture-cell shepherd-cell ${isStep ? "is-step shepherd-cell-step" : ""} ${isTarget ? "is-target shepherd-cell-target" : ""} ${isFoundSpot ? "shepherd-cell-found" : ""} ${isGateReady ? "shepherd-cell-gate-ready" : ""} ${isGateCelebration ? "shepherd-cell-gate-celebrate" : ""}`}
                onClick={() => moveToCell(x, y)}
                aria-label={
                  lamb
                    ? `Walk to the sheep at space ${x + 1}, ${y + 1}`
                    : hasGate
                      ? `Walk to the gate at space ${x + 1}, ${y + 1}`
                      : `Walk to space ${x + 1}, ${y + 1}`
                }
                disabled={hasJesus || done}
              >
                {hasJesus && (
                  <span
                    key={`shepherd-jesus-${steps}`}
                    className={`jesus-player shepherd-jesus shepherd-jesus-walking dir-${direction} ${foundSpot ? "shepherd-jesus-happy" : ""} ${done ? "shepherd-jesus-home" : ""}`}
                    aria-label="Jesus"
                  />
                )}
                {!hasJesus && lamb && (
                  <span
                    className={`sheep-token shepherd-sheep pose-${lamb.pose}`}
                    aria-label="Sheep"
                  >
                    <span className="shepherd-sheep-face" aria-hidden="true" />
                    <span className="shepherd-sheep-bell" aria-hidden="true" />
                  </span>
                )}
                {!hasJesus && !lamb && hasGate && (
                  <span className="gate-token shepherd-gate" aria-label="Gate">
                    ⌂
                  </span>
                )}
              </button>
            );
          })}
          {done && (
            <div className="shepherd-safe-celebration" aria-hidden="true">
              <span className="shepherd-safe-sun">✦</span>
              <strong>Heaven rejoices</strong>
              <span className="shepherd-safe-flock">all sheep safe</span>
            </div>
          )}
        </div>
        <div className="shepherd-controls">
          <strong>Move Jesus</strong>
          <div className="move-pad" aria-label="Movement controls">
            <span />
            <button
              className="move-btn shepherd-move-btn"
              onClick={() => move(0, -1)}
              disabled={!canMove(0, -1)}
              aria-label="Move up"
            >
              ↑
            </button>
            <span />
            <button
              className="move-btn shepherd-move-btn"
              onClick={() => move(-1, 0)}
              disabled={!canMove(-1, 0)}
              aria-label="Move left"
            >
              ←
            </button>
            <button
              className="move-btn shepherd-move-btn"
              onClick={reset}
              aria-label="Reset pasture position"
            >
              ↺
            </button>
            <button
              className="move-btn shepherd-move-btn"
              onClick={() => move(1, 0)}
              disabled={!canMove(1, 0)}
              aria-label="Move right"
            >
              →
            </button>
            <span />
            <button
              className="move-btn shepherd-move-btn"
              onClick={() => move(0, 1)}
              disabled={!canMove(0, 1)}
              aria-label="Move down"
            >
              ↓
            </button>
            <span />
          </div>
          <p className="game-card-copy">
            Tap any space and Jesus walks there. Arrows or W A S D work too.
          </p>
        </div>
      </div>
      <div className="game-feedback" aria-live="polite">
        {done
          ? "All the sheep are safe, and heaven rejoices! Try the next round with one more sheep."
          : feedback}
      </div>
      <div className="game-actions">
        <button className="btn btn-secondary" onClick={reset}>
          Try this round again
        </button>
        {done && (
          <button className="btn btn-primary shepherd-next-round" onClick={nextRound}>
            Next pasture round
          </button>
        )}
        <button className="btn btn-secondary" onClick={() => {
          setRound(1);
          setJesus(SHEPHERD_START);
          setDirection("down");
          setSheep(shepherdRoundSheep(1));
          setSteps(0);
          setWalkTarget(null);
          setFoundSpot(null);
          setGateCelebration(false);
          shepherdCompleteRef.current = false;
          setFeedback("Bring every lost sheep safely home.");
        }}>
          Start at round 1
        </button>
        <GameCompletionBridge
          done={done}
          gameId={gameId}
          onComplete={onComplete}
          nextGame={nextGame}
          onNextGame={onNextGame}
        />
        <span className="game-card-copy">
          Grown-up bridge: read Luke 15:4-7 later and ask who Jesus goes looking
          for.
        </span>
      </div>
    </article>
  );
}

function BreadFromHeavenGame({ gameId, onComplete, nextGame, onNextGame }) {
  const readBreadBest = (key, fallback) => {
    try {
      const value = Number(localStorage.getItem(key));
      return Number.isFinite(value) && value > 0 ? value : fallback;
    } catch (e) {
      return fallback;
    }
  };
  const [round, setRound] = useState(1); // fresh visit always warms up from round 1
  const [bestRound, setBestRound] = useState(() =>
    readBreadBest("monstrance-bread-best-round", 1),
  );
  const [missed, setMissed] = useState(0);
  const target = Math.min(20, 10 + round * 2);
  const fallBoost = Math.min(4, (round - 1) * 0.55);
  const [playerX, setPlayerX] = useState(50);
  const playerRef = useRef(50);
  const catchAreaRef = useRef(null);
  const [gifts, setGifts] = useState([]);
  const [caught, setCaught] = useState(0);
  const [running, setRunning] = useState(true);
  const [feedback, setFeedback] = useState("God feeds His people. Catch the good bread from heaven and fill the basket to share.");
  const [catchBurst, setCatchBurst] = useState(0);
  const breadCompleteRef = useRef(false);
  const done = caught >= target;
  useEffect(() => {
    if (!done || breadCompleteRef.current) return;
    breadCompleteRef.current = true;
    const nextBestRound = Math.max(bestRound, round);
    setBestRound(nextBestRound);
    try {
      localStorage.setItem("monstrance-bread-best-round", String(nextBestRound));
      localStorage.setItem("monstrance-bread-round", String(round + 1));
    } catch (e) {
      // Local storage is optional; the game still plays without it.
    }
    onComplete?.(gameId);
  }, [done, gameId, onComplete, bestRound, round]);
  const movePlayer = (delta) => {
    setPlayerX((value) => Math.max(8, Math.min(92, value + delta)));
  };
  const movePlayerToPointer = (event) => {
    const rect = catchAreaRef.current?.getBoundingClientRect();
    if (!rect || done) return;
    event.preventDefault();
    const x = ((event.clientX - rect.left) / rect.width) * 100;
    setPlayerX(Math.max(8, Math.min(92, x)));
  };
  useEffect(() => {
    playerRef.current = playerX;
  }, [playerX]);
  const reset = () => {
    setPlayerX(50);
    setGifts([]);
    setCaught(0);
    setMissed(0);
    setRunning(true);
    setCatchBurst(0);
    breadCompleteRef.current = false;
    setFeedback("Try again. Missed bread is okay; keep filling the basket.");
  };
  const nextRound = () => {
    const next = round + 1;
    setRound(next);
    setPlayerX(50);
    setGifts([]);
    setCaught(0);
    setMissed(0);
    setRunning(true);
    setCatchBurst(0);
    breadCompleteRef.current = false;
    setFeedback(
      "The bread falls a little faster. Fill the basket again to share.",
    );
    monstranceLog.record("bread", "next_round", next);
  };
  useEffect(() => {
    const h = (e) => {
      const key = e.key.toLowerCase();
      if (e.key === "ArrowLeft" || key === "a") {
        e.preventDefault();
        movePlayer(-8);
      }
      if (e.key === "ArrowRight" || key === "d") {
        e.preventDefault();
        movePlayer(8);
      }
      if (e.key === " ") {
        e.preventDefault();
        setRunning((v) => !v);
      }
    };
    window.addEventListener("keydown", h);
    return () => window.removeEventListener("keydown", h);
  }, []);
  useEffect(() => {
    if (!running || done) return;
    const tick = window.setInterval(() => {
      setGifts((current) => {
        let gained = 0;
        const moved = current
          .map((gift) => ({ ...gift, y: gift.y + gift.speed + fallBoost }))
          .filter((gift) => {
            const isCaught = gift.y > 78 && Math.abs(gift.x - playerRef.current) < 11;
            if (isCaught) {
              gained += 1;
              return false;
            }
            if (gift.y >= 106) {
              setMissed((value) => value + 1);
              monstranceLog.record("bread", "miss", null, 1500);
              if (Math.random() < 0.2) {
                setFeedback(
                  "A missed piece is okay. Try again and keep sharing.",
                );
              }
              return false;
            }
            return true;
          });
        if (gained > 0) {
          setCaught((value) => Math.min(target, value + gained));
          setCatchBurst((value) => value + gained);
          window.setTimeout(() => setCatchBurst(0), 620);
          setFeedback(gained > 1 ? "Good catch. The basket is filling for everyone." : "Caught one gift. God feeds His people.");
        }
        if (Math.random() < Math.min(0.72, 0.52 + round * 0.025)) {
          moved.push({
            id: `${Date.now()}-${Math.random()}`,
            x: 10 + Math.random() * 80,
            y: -6,
            speed: 4.8 + Math.random() * 2.4,
            kind: Math.floor(Math.random() * 6),
          });
        }
        return moved;
      });
    }, 360);
    return () => window.clearInterval(tick);
  }, [running, done, fallBoost, round, target]);
  useEffect(() => {
    if (done) {
      setRunning(false);
      setFeedback("The basket is full - God feeds His people!");
    }
  }, [done]);
  return (
    <article className="game-card">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Bread from Heaven</h2>
          <p className="game-card-copy bread-storyline">
            God rained down bread from heaven for His people. Jesus is the living
            bread from heaven; fill the basket so there is bread to share.
          </p>
        </div>
        <div className="game-badges">
          <span className="game-badge">Round {round}</span>
          <span className="game-badge">{caught}/{target} gifts</span>
          <span className="game-badge">{running ? "Playing" : "Paused"}</span>
        </div>
      </div>
      <div className="bread-challenge-strip">
        <span>Best round: {bestRound}</span>
        <span>Missed: {missed}</span>
        <span>Next round falls faster.</span>
      </div>
      <GameProgress value={caught} total={target} />
      <div className="bread-score-strip" aria-hidden="true">
        {Array.from({ length: target }).map((_, index) => (
          <span
            key={index}
            className={`bread-score-dot ${index < caught ? "is-filled" : ""}`}
          />
        ))}
      </div>
      <div
        className="bread-catch-area"
        style={{ "--player-x": `${playerX}%` }}
        aria-label="Bread from Heaven play area"
        ref={catchAreaRef}
        onPointerDown={(event) => {
          event.currentTarget.setPointerCapture?.(event.pointerId);
          movePlayerToPointer(event);
        }}
        onPointerMove={(event) => {
          if (event.buttons === 1) movePlayerToPointer(event);
        }}
      >
        <span className="bread-target-lane" aria-hidden="true" />
        {gifts.map((gift) => (
          <span
            key={gift.id}
            className={`falling-gift bread-falling-gift bread-gift-drift-${gift.kind % 2} kind-${gift.kind}`}
            style={{ "--x": `${gift.x}%`, "--y": `${gift.y}%`, "--gift-delay": `${gift.kind * 70}ms` }}
            aria-hidden="true"
          />
        ))}
        {catchBurst > 0 && <span className="bread-catch-pop" aria-hidden="true">+{catchBurst}</span>}
        {done && (
          <span className="bread-finish-banner" aria-hidden="true">
            <strong>God feeds His people</strong>
            <span>full basket</span>
          </span>
        )}
        {done && (
          <span className="bread-finale" aria-hidden="true">
            <span className="bread-finale-glow" />
            <span className="bread-finale-baskets">🧺 🧺 🧺</span>
          </span>
        )}
        <span className={`bread-catcher ${catchBurst > 0 ? "is-catching" : ""} ${done ? "bread-catcher-happy" : ""}`} aria-label="Jesus" />
        <span className={`bread-basket ${catchBurst > 0 ? "is-catching" : ""} ${done ? "bread-basket-full" : ""}`} aria-label="Basket" />
      </div>
      <div className="game-feedback" aria-live="polite">{feedback}</div>
      <div className="bread-controls">
        <button className="move-btn bread-control-btn" onClick={() => movePlayer(-8)} aria-label="Move left">←</button>
        <button className="btn btn-secondary bread-control-btn" onClick={() => setRunning((v) => !v)}>{running ? "Pause" : "Play"}</button>
        <button className="move-btn bread-control-btn" onClick={() => movePlayer(8)} aria-label="Move right">→</button>
        <button className="btn btn-secondary bread-control-btn" onClick={reset}>Try again</button>
        {done && <button className="btn btn-primary bread-control-btn" onClick={nextRound}>Next bread round</button>}
        <GameCompletionBridge done={done} gameId={gameId} onComplete={onComplete} nextGame={nextGame} onNextGame={onNextGame} />
        <span className="game-card-copy">Use arrows or A/D. Space pauses.</span>
      </div>
    </article>
  );
}

function WordFamilyQuiz({ gameId, onComplete, nextGame, onNextGame }) {
  const readWordBest = (key, fallback) => {
    try {
      const value = Number(localStorage.getItem(key));
      return Number.isFinite(value) && value > 0 ? value : fallback;
    } catch (e) {
      return fallback;
    }
  };
  const [level, setLevel] = useState(() => readWordBest("monstrance-word-family-level", 1));
  const [elapsed, setElapsed] = useState(0);
  const [bestTime, setBestTime] = useState(() => readWordBest("monstrance-word-family-best-time", 0));
  const [streak, setStreak] = useState(0);
  const [bestStreak, setBestStreak] = useState(() => readWordBest("monstrance-word-family-best-streak", 0));
  const tokenCount = Math.min(WORD_FAMILY_TOKENS.length, 6 + (level - 1) * 3);
  const activeTokens = WORD_FAMILY_TOKENS.slice(0, tokenCount);
  const [placed, setPlaced] = useState({});
  const [selected, setSelected] = useState(null);
  const [wrong, setWrong] = useState(null);
  const [justPlaced, setJustPlaced] = useState(null);
  const [feedback, setFeedback] = useState("Gather the word-friends into families so we can read God's good words.");
  const wordCompleteRef = useRef(false);
  const sortedCount = Object.keys(placed).length;
  const done = sortedCount === activeTokens.length;
  useEffect(() => {
    if (!done || wordCompleteRef.current) return;
    wordCompleteRef.current = true;
    const nextBestTime = bestTime === 0 ? elapsed : Math.min(bestTime, elapsed);
    const nextStreak = streak + 1;
    const nextBestStreak = Math.max(bestStreak, nextStreak);
    setBestTime(nextBestTime);
    setStreak(nextStreak);
    setBestStreak(nextBestStreak);
    try {
      localStorage.setItem("monstrance-word-family-best-time", String(nextBestTime));
      localStorage.setItem("monstrance-word-family-best-streak", String(nextBestStreak));
      localStorage.setItem("monstrance-word-family-level", String(level + 1));
    } catch (e) {
      // Local storage is optional; the game still plays without it.
    }
    onComplete?.(gameId);
  }, [done, gameId, onComplete, bestTime, elapsed, streak, bestStreak, level]);
  useEffect(() => {
    if (done) return undefined;
    const id = window.setInterval(() => setElapsed((value) => value + 1), 1000);
    return () => window.clearInterval(id);
  }, [done]);
  const remaining = activeTokens.filter((token) => !placed[token.id]);
  const tokensForFamily = (familyId) =>
    activeTokens.filter((token) => placed[token.id] === familyId);
  const selectedToken = activeTokens.find((token) => token.id === selected);

  const placeToken = (tokenId, familyId) => {
    const token = activeTokens.find((item) => item.id === tokenId);
    if (!token || placed[token.id]) return;
    if (token.family === familyId) {
      setPlaced((current) => ({ ...current, [token.id]: familyId }));
      setFeedback("Good sorting. This word-friend found its family.");
      monstranceLog.record("families", "sort_ok", token.label);
      setSelected(null);
      setWrong(null);
      setJustPlaced(token.id);
      setTimeout(() => setJustPlaced(null), 760);
    } else {
      const family = WORD_FAMILIES.find((item) => item.id === token.family);
      setWrong(`${token.id}-${familyId}`);
      monstranceLog.record("families", "sort_wrong", token.label);
      setFeedback(`Almost. "${token.label}" belongs with ${family.title}.`);
      setStreak(0);
      setTimeout(() => setWrong(null), 620);
    }
  };

  const reset = () => {
    setPlaced({});
    setSelected(null);
    setWrong(null);
    setJustPlaced(null);
    setElapsed(0);
    wordCompleteRef.current = false;
    setFeedback("Try again. God's word is a lamp; sort the word-friends again.");
  };
  const nextLevel = () => {
    setLevel((value) => value + 1);
    monstranceLog.record("families", "next_level");
    setPlaced({});
    setSelected(null);
    setWrong(null);
    setJustPlaced(null);
    setElapsed(0);
    wordCompleteRef.current = false;
    setFeedback(tokenCount < WORD_FAMILY_TOKENS.length
      ? "New word-friends are joining this level."
      : "Beat your best time with all the word-friends.");
  };

  return (
    <article className="game-card">
      <div className="game-card-head">
        <div>
          <h2 className="game-card-title">Word Families</h2>
          <p className="game-card-copy word-family-storyline">
            God's word is a lamp to our feet. Sort the word-friends into
            families so one day we can read Scripture and prayers.
          </p>
        </div>
        <div className="game-badges">
          <span className="game-badge">Level {level}</span>
          <span className="game-badge">{sortedCount}/{activeTokens.length}</span>
          <span className="game-badge">{elapsed}s</span>
        </div>
      </div>
      <div className="word-family-challenge-strip">
        <span>Best time: {bestTime ? `${bestTime}s` : "try it"}</span>
        <span>Best streak: {bestStreak}</span>
        <span>{tokenCount < WORD_FAMILY_TOKENS.length ? "Next level adds more words." : "Next level is a speed challenge."}</span>
      </div>
      <GameProgress value={sortedCount} total={activeTokens.length} />
      <div className="word-family-helper" aria-live="polite">
        {selectedToken ? (
          <>
            <span className="word-family-helper-art" aria-hidden="true">
              <WordFamilySprite token={selectedToken} small={true} />
            </span>
            <strong>{selectedToken.label}</strong>
            <span>Tap its matching basket.</span>
          </>
        ) : (
          <span>Pick one big card, then choose its basket.</span>
        )}
      </div>
      <div className="word-family-layout">
        <div className={`word-token-bank ${done ? "word-family-bank-complete" : ""}`} aria-label="Cards to sort">
          {remaining.map((token) => (
            <button
              key={token.id}
              type="button"
              data-testid={`word-token-${token.id}`}
              className={`word-token word-family-token ${selected === token.id ? "is-selected word-family-token-selected" : ""}`}
              draggable="true"
              onClick={() => {
                setSelected(selected === token.id ? null : token.id);
                setFeedback(selected === token.id ? "Choose another card to sort." : `Now tap the basket for "${token.label}".`);
              }}
              onDragStart={(event) => {
                event.dataTransfer.setData("text/plain", token.id);
                event.dataTransfer.effectAllowed = "move";
                setSelected(token.id);
              }}
            >
              <span className="word-token-art" aria-hidden="true">
                <WordFamilySprite token={token} />
              </span>
              <span className="word-token-label">{token.label}</span>
            </button>
          ))}
          {remaining.length === 0 && (
            <div className="word-bank-empty">All cards are sorted.</div>
          )}
        </div>
        <div className="word-family-zones">
          {WORD_FAMILIES.map((family) => {
            const familyTokens = tokensForFamily(family.id);
            const isWrong = wrong && wrong.endsWith(`-${family.id}`);
            return (
              <button
                key={family.id}
                type="button"
                data-testid={`word-family-${family.id}`}
                className={`word-family-zone ${selected ? "is-ready word-family-zone-ready" : ""} ${isWrong ? "is-wrong" : ""} ${familyTokens.some((token) => token.id === justPlaced) ? "word-family-zone-success" : ""}`}
                aria-label={selectedToken
                  ? `Sort ${selectedToken.label} into ${family.title}`
                  : `${family.title}. ${family.hint}. ${familyTokens.length} cards sorted.`}
                onClick={() => selected && placeToken(selected, family.id)}
                onDragOver={(event) => {
                  event.preventDefault();
                  event.dataTransfer.dropEffect = "move";
                }}
                onDrop={(event) => {
                  event.preventDefault();
                  placeToken(event.dataTransfer.getData("text/plain"), family.id);
                }}
              >
                <span className="word-family-head">
                  <span className="word-family-icon" aria-hidden="true">{family.icon}</span>
                  <span>
                    <strong>{family.title}</strong>
                    <em>{family.hint}</em>
                  </span>
                  <span className="word-family-count" aria-hidden="true">
                    {familyTokens.length}
                  </span>
                </span>
                {familyTokens.some((token) => token.id === justPlaced) && (
                  <span className="word-family-snap-spark" aria-hidden="true">
                    snap
                  </span>
                )}
                <span className="word-family-sorted" aria-label={`${familyTokens.length} cards sorted into ${family.title}`}>
                  {familyTokens.length === 0 ? (
                    <span className="word-family-placeholder">Drop cards here</span>
                  ) : familyTokens.map((token) => (
                    <span key={token.id} className={`word-family-pill ${justPlaced === token.id ? "word-family-pill-new" : ""}`}>
                      <WordFamilySprite token={token} small={true} />
                      <span>{token.label}</span>
                    </span>
                  ))}
                </span>
              </button>
            );
          })}
        </div>
      </div>
      {done && (
        <div className="word-family-complete" aria-hidden="true">
          <strong>You sorted all the words!</strong>
          <span>God's word is a lamp to our feet.</span>
        </div>
      )}
      <div className="game-feedback" aria-live="polite">{done ? "You sorted all the words! Try the next level to beat your best time." : feedback}</div>
      <div className="game-actions">
        <button className="btn btn-secondary" onClick={reset}>Mix again</button>
        {done && <button className="btn btn-primary word-family-next-level" onClick={nextLevel}>Next word level</button>}
        <GameCompletionBridge done={done} gameId={gameId} onComplete={onComplete} nextGame={nextGame} onNextGame={onNextGame} />
        <span className="game-card-copy">Grown-up bridge: ask, "Which family would this word join at church?"</span>
      </div>
    </article>
  );
}

function GamesPage({ palette, onCyclePalette, initialGame }) {
  const hasExplicitGameRoute = GAME_CARDS.some((game) => game.id === initialGame);
  const [resumedMiniGameId] = useState(() =>
    hasExplicitGameRoute ? null : readLastMiniGame(),
  );
  const initialGameId = hasExplicitGameRoute ? initialGame : null;
  const [active, setActive] = useState(initialGameId);
  const [completedGames, setCompletedGames] = useState(readMiniGameProgress);
  const [gameResetKey, setGameResetKey] = useState(0);
  const stageRef = useRef(null);
  useEffect(() => {
    setActive(initialGameId);
    writeLastMiniGame(initialGameId);
  }, [initialGameId]);
  useEffect(() => {
    if (!hasExplicitGameRoute) return undefined;
    const frame = window.requestAnimationFrame(() => {
      stageRef.current?.scrollIntoView({
        behavior: "auto",
        block: "start",
      });
      stageRef.current?.focus({ preventScroll: true });
    });
    return () => window.cancelAnimationFrame(frame);
  }, [hasExplicitGameRoute, initialGameId]);
  const activeCard = GAME_CARDS.find((game) => game.id === active);
  const completionCount = completedGames.length;
  const allGamesComplete = completionCount === GAME_CARDS.length;
  const nextUnfinishedGame = GAME_CARDS.find((game) => !completedGames.includes(game.id));
  const resumedMiniGame = resumedMiniGameId && active === resumedMiniGameId
    ? GAME_CARDS.find((game) => game.id === resumedMiniGameId)
    : null;
  const markGameComplete = useCallback((id) => {
    setCompletedGames((current) => {
      if (current.includes(id)) return current;
      const next = [...current, id];
      writeMiniGameProgress(next);
      return next;
    });
    monstranceLog.record(id, "complete", null, 1500); // throttle guards effect refire
  }, []);
  const nextGameForActive = (() => {
    const activeIndex = GAME_CARDS.findIndex((game) => game.id === active);
    const ordered = [
      ...GAME_CARDS.slice(activeIndex + 1),
      ...GAME_CARDS.slice(0, activeIndex + 1),
    ];
    return (
      ordered.find(
        (game) => game.id !== active && !completedGames.includes(game.id),
      ) || null
    );
  })();
  const selectGame = (id, options = {}) => {
    setActive(id);
    monstranceLog.record(id, "open", null, 500);
    writeLastMiniGame(id);
    if ((window.location.hash || "").startsWith("#/games")) {
      const localUrl = new URL(window.location.href);
      localUrl.hash = `#/games/${id}`;
      window.history.replaceState(
        null,
        "",
        `${localUrl.pathname}${localUrl.search}${localUrl.hash}`,
      );
    }
    if (options.scrollToTop) {
      window.requestAnimationFrame(() => {
        window.scrollTo({ top: 0, left: 0, behavior: "auto" });
      });
    }
    if (options.scrollToStage) {
      window.requestAnimationFrame(() => {
        const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches;
        stageRef.current?.scrollIntoView({
          behavior: reduceMotion ? "auto" : "smooth",
          block: "start",
        });
        stageRef.current?.focus({ preventScroll: true });
        window.requestAnimationFrame(() => {
          stageRef.current?.focus({ preventScroll: true });
        });
      });
    }
  };
  const resetGameProgress = () => {
    setCompletedGames([]);
    setGameResetKey((value) => value + 1);
    writeMiniGameProgress([]);
    monstranceLog.record("hub", "reset_all");
    try {
      sessionStorage.removeItem(MONSTRANCE_BUILD_STORAGE_KEY);
    } catch (e) {}
  };
  const goToNextGame = () => {
    if (nextGameForActive)
      selectGame(nextGameForActive.id, { scrollToStage: true });
  };
  const backToTiles = () => {
    setActive(null);
    if ((window.location.hash || "").startsWith("#/games")) {
      const localUrl = new URL(window.location.href);
      localUrl.hash = "#/games";
      window.history.replaceState(
        null,
        "",
        `${localUrl.pathname}${localUrl.search}${localUrl.hash}`,
      );
    }
    window.requestAnimationFrame(() => {
      window.scrollTo({ top: 0, left: 0, behavior: "auto" });
    });
  };
  const leaveGuadalupeGame = backToTiles;
  const isGuadalupe = active === "guadalupe";
  const gameOpen = !!activeCard;
  return (
    <div className={`sky-scene faith-page-bg ${isGuadalupe ? "is-guadalupe-scene" : ""}`}>
      <CloudComposition showSparkles={true} />
      <div className={`games-page ${isGuadalupe ? "is-guadalupe-immersive" : ""}`}>
        {!isGuadalupe && (
          <TopBar
            readAloud={false}
            onToggleReadAloud={() => {}}
            onShowAudioSetup={() => {}}
            palette={palette}
            onCyclePalette={onCyclePalette}
            showAudioControls={false}
          />
        )}
        <main className={`games-shell ${isGuadalupe ? "is-guadalupe-shell" : ""}`}>
          {!isGuadalupe && !gameOpen && (
            <section className="mini-tile-section" aria-label="Choose a mini game">
              <div className="mini-tile-head">
                <div className="stop-kicker">Pick a game</div>
                <h1 className="stop-title mini-tile-heading">Catholic Mini Games</h1>
                <div
                  className="mini-game-session-progress"
                >
                  <span
                    className="mini-game-session-status"
                    role="status"
                    aria-live="polite"
                    aria-label={`${completionCount} of ${GAME_CARDS.length} mini games completed this visit${resumedMiniGame ? `. Resumed: ${resumedMiniGame.title}` : nextUnfinishedGame ? `. Next: ${nextUnfinishedGame.title}` : ""}`}
                  >
                    <strong>{completionCount}/{GAME_CARDS.length}</strong>
                    {" "}
                    <span>
                      {allGamesComplete
                        ? "all mini games complete this visit"
                        : "mini games completed this visit"}
                    </span>
                    {nextUnfinishedGame && completionCount > 0 && (
                      <>
                        {" "}
                        <span className="mini-game-next-label">
                          Next: {nextUnfinishedGame.title}
                        </span>
                      </>
                    )}
                    {resumedMiniGame && completionCount === 0 && (
                      <>
                        {" "}
                        <span className="mini-game-next-label">
                          Resumed: {resumedMiniGame.title}
                        </span>
                      </>
                    )}
                  </span>
                  {completionCount > 0 && (
                    <span className="mini-game-session-actions">
                      {nextUnfinishedGame && (
                        <button
                          type="button"
                          onClick={() => selectGame(nextUnfinishedGame.id, { scrollToStage: true })}
                          aria-label={`Continue with ${nextUnfinishedGame.title}`}
                        >
                          Continue
                        </button>
                      )}
                      <button
                        type="button"
                        onClick={resetGameProgress}
                        aria-label="Reset mini game visit progress"
                      >
                        Reset
                      </button>
                    </span>
                  )}
                </div>
              </div>
              <div className="mini-tile-grid">
                {GAME_CARDS.map((game) => {
                  const complete = completedGames.includes(game.id);
                  const isNextUp =
                    completionCount > 0 && nextUnfinishedGame?.id === game.id;
                  return (
                    <button
                      key={game.id}
                      type="button"
                      className={`mini-tile ${complete ? "is-complete" : ""} ${isNextUp ? "is-next" : ""}`}
                      onClick={() => selectGame(game.id, { scrollToTop: true })}
                    >
                      <span className="mini-tile-icon" aria-hidden="true">{game.icon}</span>
                      <strong className="mini-tile-title">{game.title}</strong>
                      <span className="mini-tile-blurb">{game.blurb}</span>
                      <span className="mini-tile-tag">
                        {complete ? "Done ✓" : isNextUp ? "Next up" : game.tag}
                      </span>
                    </button>
                  );
                })}
              </div>
            </section>
          )}
          {!isGuadalupe && !gameOpen && (
            <section className="games-hero is-tiles">
              <div className="games-copy">
                <div className="stop-kicker">Rubric-built play</div>
                <h2 className="mini-tile-grownup-title">Built for ages 4-8</h2>
                <p className="stop-copy">
                  Short symbol and story games plus full playable arcade games for ages 4-8: concrete visuals, large taps, immediate feedback, no ads, and a tiny grown-up bridge after play.
                </p>
                <div className="rubric-strip" aria-label="Rubric notes">
                  <div className="rubric-chip"><strong>Active</strong>Tap, remember, build.</div>
                  <div className="rubric-chip"><strong>Scaffolded</strong>Hints answer the next step.</div>
                  <div className="rubric-chip"><strong>Meaningful</strong>Every symbol teaches one idea.</div>
                  <div className="rubric-chip"><strong>Social</strong>Each game ends with a co-play prompt.</div>
                </div>
              </div>
            </section>
          )}
          {!isGuadalupe && gameOpen && (
            <div className="mini-game-topbar">
              <button
                type="button"
                className="btn btn-secondary mini-game-back"
                onClick={backToTiles}
              >
                ← All games
              </button>
              <span className="mini-game-topbar-title">
                <i aria-hidden="true">{activeCard.icon}</i>
                {activeCard.title}
              </span>
              <span className="mini-game-topbar-count">
                {completionCount}/{GAME_CARDS.length} done
              </span>
            </div>
          )}
          {gameOpen && (
          <section ref={stageRef} tabIndex="-1" className={`games-stage ${isGuadalupe ? "is-guadalupe-stage" : ""}`} aria-label={activeCard.title}>
            {active === "build" && <MonstranceBuilderGame key={gameResetKey} gameId="build" onComplete={markGameComplete} nextGame={nextGameForActive} onNextGame={goToNextGame} />}
            {active === "memory" && <MemoryGame key={gameResetKey} gameId="memory" onComplete={markGameComplete} nextGame={nextGameForActive} onNextGame={goToNextGame} />}
            {active === "light" && <QuietLightGame key={gameResetKey} gameId="light" onComplete={markGameComplete} nextGame={nextGameForActive} onNextGame={goToNextGame} />}
            {active === "guadalupe" && <GuadalupeVisitGame key={gameResetKey} gameId="guadalupe" onComplete={markGameComplete} nextGame={nextGameForActive} onNextGame={goToNextGame} onExitGame={leaveGuadalupeGame} />}
            {active === "shepherd" && <ShepherdGame key={gameResetKey} gameId="shepherd" onComplete={markGameComplete} nextGame={nextGameForActive} onNextGame={goToNextGame} />}
            {active === "bread" && <BreadFromHeavenGame key={gameResetKey} gameId="bread" onComplete={markGameComplete} nextGame={nextGameForActive} onNextGame={goToNextGame} />}
            {active === "families" && <WordFamilyQuiz key={gameResetKey} gameId="families" onComplete={markGameComplete} nextGame={nextGameForActive} onNextGame={goToNextGame} />}
          </section>
          )}
          {!isGuadalupe && !gameOpen && (
            <>
              <section className="arcade-link-section" aria-label="Full playable Catholic games">
                <div>
                  <div className="stop-kicker">Full playable games</div>
                  <h2 className="arcade-link-title">Catholic Kids Arcade</h2>
                  <p className="stop-copy">
                    These open as their own game pages, so the controls and canvas can use the whole screen.
                  </p>
                </div>
                <div className="arcade-link-grid">
                  {ARCADE_GAME_LINKS.map((game) => (
                    <a key={game.href} className="arcade-link-card" href={game.href}>
                      <span className="arcade-link-icon" aria-hidden="true">{game.icon}</span>
                      <span className="arcade-link-copy">
                        <strong>{game.title}</strong>
                        <em>{game.blurb}</em>
                      </span>
                      <span className="arcade-link-tag">{game.tag}</span>
                    </a>
                  ))}
                </div>
              </section>
              <nav className="faith-nav" aria-label="Game navigation">
                <a className="btn btn-secondary" href="#top">← Monstrance home</a>
                <a className="btn btn-secondary" href="#journey">Faith Journey</a>
                <a className="btn btn-secondary" href="../../games.html?category=faith">All Arcade Games</a>
              </nav>
            </>
          )}
        </main>
      </div>
    </div>
  );
}
