kgames

KGames — free keyboard games for kids
git clone https://www.keyboard.games/code/kgames.git
Log | Files | Refs | README | LICENSE

lib.js (50260B)


      1 // Story Stamp — pure game logic (no DOM, no audio). Dual-export for unit tests.
      2 //
      3 // Contents:
      4 //   PHONEMES / GRAPHEMES / SEQUENCE  — the synthetic-phonics teaching content
      5 //   TOPICS / WORDS                   — decodable book titles with a picture
      6 //   PATRONS                          — the library regulars (one per word, by hash)
      7 //   segmentation helpers             — graphemes / phonemes / keys for a word
      8 //   scheduler                        — mastery tracking + spaced repetition
      9 //   the queue                        — who is waiting at the desk, and for what
     10 //   blend timeline                   — timing plan for sounding a word out
     11 //   profile store                    — on-device, multi-child progress
     12 (function (root, factory) {
     13   if (typeof module === 'object' && module.exports) module.exports = factory();
     14   else root.SS = factory();
     15 }(typeof self !== 'undefined' ? self : this, function () {
     16   'use strict';
     17 
     18   const DAY = 24 * 60 * 60 * 1000;
     19   const MIN = 60 * 1000;
     20 
     21   // ── Sounds ─────────────────────────────────────────────────────────────
     22   // type: 'stop' = short burst (never stretched), 'cont' = consonant that can
     23   // be held, 'vowel'. stretch: the clip is a long sustained recording and the
     24   // player may cut it to the length a blend pass asks for; a sound without it
     25   // (a stop, a glide like /w/, a diphthong like /aʊ/) always plays whole.
     26   // home: the grapheme that teaches the sound, for words whose spelling
     27   // overrides a sound (book: 'oo' says /ʊ/, which 'oo' also teaches).
     28   const PHONEMES = {
     29     s:   { ipa: 's',  type: 'cont',  stretch: true  },
     30     a:   { ipa: 'æ',  type: 'vowel', stretch: true  },
     31     t:   { ipa: 't',  type: 'stop'                  },
     32     p:   { ipa: 'p',  type: 'stop'                  },
     33     i:   { ipa: 'ɪ',  type: 'vowel', stretch: true  },
     34     n:   { ipa: 'n',  type: 'cont',  stretch: true  },
     35     m:   { ipa: 'm',  type: 'cont',  stretch: true  },
     36     d:   { ipa: 'd',  type: 'stop'                  },
     37     g:   { ipa: 'g',  type: 'stop'                  },
     38     o:   { ipa: 'ɑ',  type: 'vowel', stretch: true  },
     39     k:   { ipa: 'k',  type: 'stop'                  },
     40     e:   { ipa: 'ɛ',  type: 'vowel', stretch: true  },
     41     u:   { ipa: 'ʌ',  type: 'vowel', stretch: true  },
     42     r:   { ipa: 'ɹ',  type: 'cont',  stretch: true  },
     43     h:   { ipa: 'h',  type: 'cont'                  },
     44     b:   { ipa: 'b',  type: 'stop'                  },
     45     f:   { ipa: 'f',  type: 'cont',  stretch: true  },
     46     l:   { ipa: 'l',  type: 'cont',  stretch: true  },
     47     j:   { ipa: 'dʒ', type: 'stop'                  },
     48     v:   { ipa: 'v',  type: 'cont',  stretch: true  },
     49     w:   { ipa: 'w',  type: 'cont'                  },
     50     y:   { ipa: 'j',  type: 'cont'                  },
     51     z:   { ipa: 'z',  type: 'cont',  stretch: true  },
     52     sh:  { ipa: 'ʃ',  type: 'cont',  stretch: true  },
     53     ch:  { ipa: 'tʃ', type: 'stop'                  },
     54     th:  { ipa: 'θ',  type: 'cont',  stretch: true  },
     55     ng:  { ipa: 'ŋ',  type: 'cont',  stretch: true  },
     56     ee:  { ipa: 'iː', type: 'vowel', stretch: true  },
     57     oo:  { ipa: 'uː', type: 'vowel', stretch: true  },
     58     uu:  { ipa: 'ʊ',  type: 'vowel', stretch: true, home: 'oo' },
     59     oa:  { ipa: 'oʊ', type: 'vowel'                 },
     60     ar:  { ipa: 'ɑɹ', type: 'vowel'                 },
     61     or:  { ipa: 'ɔɹ', type: 'vowel'                 },
     62     er:  { ipa: 'ɝ',  type: 'vowel', stretch: true  },
     63     ai:  { ipa: 'eɪ', type: 'vowel'                 },
     64     igh: { ipa: 'aɪ', type: 'vowel'                 },
     65     ow:  { ipa: 'aʊ', type: 'vowel'                 },
     66     oi:  { ipa: 'ɔɪ', type: 'vowel'                 },
     67   };
     68 
     69   // Spelling unit -> phoneme ids. Doubled consonants (ll, ss, gg, ff, zz, cc,
     70   // bb, dd, mm, nn, pp, rr, tt) are handled by graphemePhonemes().
     71   const GRAPHEMES = {
     72     s: ['s'], a: ['a'], t: ['t'], p: ['p'], i: ['i'], n: ['n'], m: ['m'], d: ['d'],
     73     g: ['g'], o: ['o'], c: ['k'], k: ['k'], ck: ['k'], e: ['e'], u: ['u'], r: ['r'],
     74     h: ['h'], b: ['b'], f: ['f'], l: ['l'], j: ['j'], v: ['v'], w: ['w'], x: ['k', 's'],
     75     y: ['y'], z: ['z'], qu: ['k', 'w'], sh: ['sh'], ch: ['ch'], tch: ['ch'], th: ['th'],
     76     ng: ['ng'], wh: ['w'], ee: ['ee'], oo: ['oo'], oa: ['oa'], ar: ['ar'], or: ['or'],
     77     oar: ['or'], er: ['er'], ai: ['ai'], ay: ['ai'], igh: ['igh'], ie: ['igh'],
     78     ow: ['ow'], ou: ['ow'], oi: ['oi'], oy: ['oi'],
     79   };
     80   // Alternative spellings map to the grapheme that teaches the sound.
     81   const BASE = { tch: 'ch', wh: 'w', oar: 'or', ay: 'ai', ie: 'igh', ou: 'ow', oy: 'oi' };
     82 
     83   // Teaching order (Letters and Sounds phase 2 -> 3, continuants early).
     84   const SEQUENCE = [
     85     's', 'a', 't', 'p', 'i', 'n',           // the classic opening six
     86     'm', 'd', 'g', 'o', 'c', 'k', 'ck', 'e', 'u', 'r', 'h', 'b', 'f', 'l',
     87     'j', 'v', 'w', 'x', 'y', 'z', 'qu',
     88     'sh', 'ch', 'th', 'ng', 'ee', 'oo', 'oa', 'ar', 'or', 'er', 'ai', 'igh', 'ow', 'oi',
     89   ];
     90   const STARTING_GRAPHEMES = 6;   // s a t p i n are known from the first visit
     91 
     92   // ── Topics ─────────────────────────────────────────────────────────────
     93   // What the kids at the desk ask for. art: Fluent Emoji asset name.
     94   const TOPICS = [
     95     { id: 'animals', name: 'animals',  art: 'Paw prints',      color: '#F4A261' },
     96     { id: 'bugs',    name: 'bugs',     art: 'Bug',             color: '#8AC926' },
     97     { id: 'sea',     name: 'the sea',  art: 'Water wave',      color: '#1D7CF2' },
     98     { id: 'space',   name: 'space',    art: 'Rocket',          color: '#6A4C93' },
     99     { id: 'dinos',   name: 'dinosaurs',art: 'T-rex',           color: '#2A9D8F' },
    100     { id: 'wheels',  name: 'things that go', art: 'Fire engine', color: '#E63946' },
    101     { id: 'food',    name: 'food',     art: 'Pizza',           color: '#FF924C' },
    102     { id: 'magic',   name: 'magic',    art: 'Crystal ball',    color: '#9B5DE5' },
    103     { id: 'play',    name: 'playtime', art: 'Teddy bear',      color: '#FFD23F' },
    104     { id: 'silly',   name: 'silly stuff', art: 'Zany face',    color: '#FF5DA2' },
    105     { id: 'outside', name: 'outdoors', art: 'Deciduous tree',  color: '#06A77D' },
    106     { id: 'home',    name: 'home',     art: 'House',           color: '#2EC4B6' },
    107   ];
    108   const TOPIC_BY_ID = {};
    109   TOPICS.forEach((t) => { TOPIC_BY_ID[t.id] = t; });
    110   const ANY_TOPIC = { id: 'any', name: 'a good book', art: 'Books', color: '#7A6C5D' };
    111 
    112   // ── Words ──────────────────────────────────────────────────────────────
    113   // g: graphemes joined by '-'; art: Fluent Emoji asset (3D); t: topics;
    114   // p: phoneme override (space-separated) when a grapheme is ambiguous
    115   // (book: 'oo' -> /ʊ/, robot: 'o' -> /oʊ/).
    116   const WORDS = [
    117     // s a t p i n
    118     { w: 'ant',      g: 'a-n-t',          art: 'Ant',                 t: ['bugs', 'animals'] },
    119     { w: 'pants',    g: 'p-a-n-t-s',      art: 'Jeans',               t: ['play', 'home'] },
    120     { w: 'nap',      g: 'n-a-p',          art: 'Sleeping face',       t: ['silly', 'home'] },
    121     { w: 'tap',      g: 't-a-p',          art: 'Potable water',       t: ['home'] },
    122     { w: 'pin',      g: 'p-i-n',          art: 'Bowling',             t: ['play'] },
    123     { w: 'snap',     g: 's-n-a-p',        art: 'Crocodile',           t: ['animals', 'silly'] },
    124     { w: 'pan',      g: 'p-a-n',          art: 'Cooking',             t: ['home', 'food'] },
    125     { w: 'tin',      g: 't-i-n',          art: 'Canned food',         t: ['food', 'home'] },
    126     { w: 'spin',     g: 's-p-i-n',        art: 'Cyclone',             t: ['silly', 'play'] },
    127     // + m d g o c k ck
    128     { w: 'map',      g: 'm-a-p',          art: 'World map',           t: ['outside', 'play'] },
    129     { w: 'mad',      g: 'm-a-d',          art: 'Angry face',          t: ['silly'] },
    130     { w: 'sad',      g: 's-a-d',          art: 'Crying face',         t: ['silly'] },
    131     { w: 'dad',      g: 'd-a-d',          art: 'Man/Default',         t: ['home'] },
    132     { w: 'mom',      g: 'm-o-m',          art: 'Woman/Default',       t: ['home'] },
    133     { w: 'dog',      g: 'd-o-g',          art: 'Dog',                 t: ['animals', 'home'] },
    134     { w: 'pig',      g: 'p-i-g',          art: 'Pig',                 t: ['animals'] },
    135     { w: 'pot',      g: 'p-o-t',          art: 'Pot of food',         t: ['home', 'food'] },
    136     { w: 'cat',      g: 'c-a-t',          art: 'Cat',                 t: ['animals', 'home'] },
    137     { w: 'cap',      g: 'c-a-p',          art: 'Billed cap',          t: ['play', 'home'] },
    138     { w: 'can',      g: 'c-a-n',          art: 'Canned food',         t: ['food'] },
    139     { w: 'kid',      g: 'k-i-d',          art: 'Child/Default',       t: ['home', 'play'] },
    140     { w: 'sock',     g: 's-o-ck',         art: 'Socks',               t: ['home', 'silly'] },
    141     { w: 'camp',     g: 'c-a-m-p',        art: 'Camping',             t: ['outside', 'play'] },
    142     { w: 'dig',      g: 'd-i-g',          art: 'Pick',                t: ['dinos', 'outside'] },
    143     { w: 'mask',     g: 'm-a-s-k',        art: 'Performing arts',     t: ['play', 'magic'] },
    144     { w: 'moo',      g: 'm-oo',           art: 'Cow face',            t: ['silly', 'animals'] },
    145     // + e u
    146     { w: 'pen',      g: 'p-e-n',          art: 'Pen',                 t: ['home'] },
    147     { w: 'ten',      g: 't-e-n',          art: 'Keycap 10',           t: ['play'] },
    148     { w: 'net',      g: 'n-e-t',          art: 'Goal net',            t: ['play', 'sea'] },
    149     { w: 'egg',      g: 'e-gg',           art: 'Egg',                 t: ['food', 'dinos', 'bugs'] },
    150     { w: 'gem',      g: 'g-e-m',          art: 'Gem stone',           t: ['magic'] },
    151     { w: 'tent',     g: 't-e-n-t',        art: 'Tent',                t: ['outside', 'play'] },
    152     { w: 'nest',     g: 'n-e-s-t',        art: 'Nest with eggs',      t: ['dinos', 'animals', 'outside'] },
    153     { w: 'sun',      g: 's-u-n',          art: 'Sun',                 t: ['space', 'outside'] },
    154     { w: 'cup',      g: 'c-u-p',          art: 'Cup with straw',      t: ['home', 'food'] },
    155     { w: 'nut',      g: 'n-u-t',          art: 'Peanuts',             t: ['food'] },
    156     { w: 'cut',      g: 'c-u-t',          art: 'Scissors',            t: ['home', 'play'] },
    157     { w: 'mug',      g: 'm-u-g',          art: 'Hot beverage',        t: ['home', 'food'] },
    158     { w: 'duck',     g: 'd-u-ck',         art: 'Duck',                t: ['animals', 'outside'] },
    159     { w: 'bug',      g: 'b-u-g',          art: 'Bug',                 t: ['bugs', 'animals'] },
    160     { w: 'sunset',   g: 's-u-n-s-e-t',    art: 'Sunset',              t: ['outside', 'space'] },
    161     { w: 'pumpkin',  g: 'p-u-m-p-k-i-n',  art: 'Jack-o-lantern',      t: ['food', 'magic'] },
    162     { w: 'insect',   g: 'i-n-s-e-c-t',    art: 'Beetle',              t: ['bugs'] },
    163     { w: 'magnet',   g: 'm-a-g-n-e-t',    art: 'Magnet',              t: ['play', 'magic'] },
    164     { w: 'cactus',   g: 'c-a-c-t-u-s',    art: 'Cactus',              t: ['outside'] },
    165     { w: 'comet',    g: 'c-o-m-e-t',      art: 'Comet',               t: ['space'] },
    166     { w: 'tennis',   g: 't-e-nn-i-s',     art: 'Tennis',              t: ['play'] },
    167     { w: 'ticket',   g: 't-i-ck-e-t',     art: 'Ticket',              t: ['play', 'wheels'] },
    168     { w: 'kitten',   g: 'k-i-tt-e-n',     art: 'Cat face',            t: ['animals', 'home'] },
    169     { w: 'octopus',  g: 'o-c-t-o-p-u-s',  art: 'Octopus',             t: ['sea', 'animals'] },
    170     // + r h b f l
    171     { w: 'rat',      g: 'r-a-t',          art: 'Rat',                 t: ['animals'] },
    172     { w: 'ram',      g: 'r-a-m',          art: 'Ram',                 t: ['animals'] },
    173     { w: 'run',      g: 'r-u-n',          art: 'Person running/Default', t: ['play'] },
    174     { w: 'rock',     g: 'r-o-ck',         art: 'Rock',                t: ['outside', 'space', 'dinos'] },
    175     { w: 'crab',     g: 'c-r-a-b',        art: 'Crab',                t: ['sea', 'animals'] },
    176     { w: 'drum',     g: 'd-r-u-m',        art: 'Drum',                t: ['play'] },
    177     { w: 'truck',    g: 't-r-u-ck',       art: 'Delivery truck',      t: ['wheels'] },
    178     { w: 'dress',    g: 'd-r-e-ss',       art: 'Dress',               t: ['play', 'home'] },
    179     { w: 'rex',      g: 'r-e-x',          art: 'T-rex',               t: ['dinos'] },
    180     { w: 'rocket',   g: 'r-o-ck-e-t',     art: 'Rocket',              t: ['space', 'wheels'] },
    181     { w: 'carrot',   g: 'c-a-rr-o-t',     art: 'Carrot',              t: ['food'] },
    182     { w: 'rabbit',   g: 'r-a-bb-i-t',     art: 'Rabbit',              t: ['animals'] },
    183     { w: 'parrot',   g: 'p-a-rr-o-t',     art: 'Parrot',              t: ['animals'] },
    184     { w: 'hat',      g: 'h-a-t',          art: 'Top hat',             t: ['play', 'magic', 'home'] },
    185     { w: 'hen',      g: 'h-e-n',          art: 'Chicken',             t: ['animals'] },
    186     { w: 'hot',      g: 'h-o-t',          art: 'Hot face',            t: ['silly'] },
    187     { w: 'hug',      g: 'h-u-g',          art: 'People hugging',      t: ['silly', 'home'] },
    188     { w: 'hut',      g: 'h-u-t',          art: 'Hut',                 t: ['outside'] },
    189     { w: 'ham',      g: 'h-a-m',          art: 'Cut of meat',         t: ['food'] },
    190     { w: 'hand',     g: 'h-a-n-d',        art: 'Raised hand/Default', t: ['home', 'silly'] },
    191     { w: 'helmet',   g: 'h-e-l-m-e-t',    art: 'Rescue workers helmet', t: ['wheels', 'play'] },
    192     { w: 'hotdog',   g: 'h-o-t-d-o-g',    art: 'Hot dog',             t: ['food', 'silly'] },
    193     { w: 'bus',      g: 'b-u-s',          art: 'Bus',                 t: ['wheels'] },
    194     { w: 'bed',      g: 'b-e-d',          art: 'Bed',                 t: ['home'] },
    195     { w: 'bag',      g: 'b-a-g',          art: 'Handbag',             t: ['home'] },
    196     { w: 'bat',      g: 'b-a-t',          art: 'Bat',                 t: ['animals', 'magic'] },
    197     { w: 'bin',      g: 'b-i-n',          art: 'Wastebasket',         t: ['home'] },
    198     { w: 'cab',      g: 'c-a-b',          art: 'Taxi',                t: ['wheels'] },
    199     { w: 'bell',     g: 'b-e-ll',         art: 'Bell',                t: ['home', 'play'] },
    200     { w: 'brick',    g: 'b-r-i-ck',       art: 'Brick',               t: ['play', 'home'] },
    201     { w: 'box',      g: 'b-o-x',          art: 'Package',             t: ['home'] },
    202     { w: 'basket',   g: 'b-a-s-k-e-t',    art: 'Basket',              t: ['home'] },
    203     { w: 'bucket',   g: 'b-u-ck-e-t',     art: 'Bucket',              t: ['play', 'sea'] },
    204     { w: 'blossom',  g: 'b-l-o-ss-o-m',   art: 'Cherry blossom',      t: ['outside'] },
    205     { w: 'big',      g: 'b-i-g',          art: 'Sauropod',            t: ['dinos'] },
    206     { w: 'cub',      g: 'c-u-b',          art: 'Bear',                t: ['animals'] },
    207     { w: 'gift',     g: 'g-i-f-t',        art: 'Wrapped gift',        t: ['play', 'home'] },
    208     { w: 'frog',     g: 'f-r-o-g',        art: 'Frog',                t: ['animals', 'outside'] },
    209     { w: 'fog',      g: 'f-o-g',          art: 'Fog',                 t: ['outside'] },
    210     { w: 'flag',     g: 'f-l-a-g',        art: 'Triangular flag',     t: ['play'] },
    211     { w: 'fossil',   g: 'f-o-ss-i-l',     art: 'Bone',                t: ['dinos'] },
    212     { w: 'muffin',   g: 'm-u-ff-i-n',     art: 'Cupcake',             t: ['food'] },
    213     { w: 'log',      g: 'l-o-g',          art: 'Wood',                t: ['outside'] },
    214     { w: 'lock',     g: 'l-o-ck',         art: 'Locked',              t: ['home'] },
    215     { w: 'clock',    g: 'c-l-o-ck',       art: 'Alarm clock',         t: ['home'] },
    216     { w: 'plug',     g: 'p-l-u-g',        art: 'Electric plug',       t: ['home'] },
    217     { w: 'milk',     g: 'm-i-l-k',        art: 'Glass of milk',       t: ['food', 'home'] },
    218     { w: 'plant',    g: 'p-l-a-n-t',      art: 'Potted plant',        t: ['home', 'outside'] },
    219     { w: 'sled',     g: 's-l-e-d',        art: 'Sled',                t: ['play', 'wheels'] },
    220     { w: 'lemon',    g: 'l-e-m-o-n',      art: 'Lemon',               t: ['food'] },
    221     { w: 'melon',    g: 'm-e-l-o-n',      art: 'Melon',               t: ['food'] },
    222     { w: 'lollipop', g: 'l-o-ll-i-p-o-p', art: 'Lollipop',            t: ['food', 'silly'] },
    223     { w: 'doll',     g: 'd-o-ll',         art: 'Nesting dolls',       t: ['play'] },
    224     { w: 'elf',      g: 'e-l-f',          art: 'Person elf/Default',  t: ['magic'] },
    225     { w: 'lamp',     g: 'l-a-m-p',        art: 'Light bulb',          t: ['home'] },
    226     { w: 'laptop',   g: 'l-a-p-t-o-p',    art: 'Laptop',              t: ['home'] },
    227     { w: 'camel',    g: 'c-a-m-e-l',      art: 'Camel',               t: ['animals'] },
    228     { w: 'lips',     g: 'l-i-p-s',        art: 'Mouth',               t: ['silly', 'home'] },
    229     // + j v w x y z qu
    230     { w: 'jet',      g: 'j-e-t',          art: 'Small airplane',      t: ['wheels', 'space'] },
    231     { w: 'jam',      g: 'j-a-m',          art: 'Jar',                 t: ['food'] },
    232     { w: 'jump',     g: 'j-u-m-p',        art: 'Person cartwheeling/Default', t: ['play', 'silly'] },
    233     { w: 'van',      g: 'v-a-n',          art: 'Minibus',             t: ['wheels'] },
    234     { w: 'vest',     g: 'v-e-s-t',        art: 'Safety vest',         t: ['home', 'wheels'] },
    235     { w: 'web',      g: 'w-e-b',          art: 'Spider web',          t: ['bugs', 'magic'] },
    236     { w: 'swim',     g: 's-w-i-m',        art: 'Person swimming/Default', t: ['sea', 'play'] },
    237     { w: 'wig',      g: 'w-i-g',          art: 'Womans hat',          t: ['silly', 'magic'] },
    238     { w: 'window',   g: 'w-i-n-d-ow',     art: 'Window',              t: ['home'], p: 'w i n d oa' },
    239     { w: 'witch',    g: 'w-i-tch',        art: 'Woman mage/Default',  t: ['magic'] },
    240     { w: 'sandwich', g: 's-a-n-d-w-i-ch', art: 'Sandwich',            t: ['food'] },
    241     { w: 'fox',      g: 'f-o-x',          art: 'Fox',                 t: ['animals'] },
    242     { w: 'six',      g: 's-i-x',          art: 'Keycap 6',            t: ['play'] },
    243     { w: 'ax',       g: 'a-x',            art: 'Axe',                 t: ['outside'] },
    244     { w: 'ox',       g: 'o-x',            art: 'Ox',                  t: ['animals'] },
    245     { w: 'yum',      g: 'y-u-m',          art: 'Face savoring food',  t: ['silly', 'food'] },
    246     { w: 'yuck',     g: 'y-u-ck',         art: 'Nauseated face',      t: ['silly'] },
    247     { w: 'yoyo',     g: 'y-o-y-o',        art: 'Yo-yo',               t: ['play'], p: 'y oa y oa' },
    248     { w: 'zap',      g: 'z-a-p',          art: 'High voltage',        t: ['magic', 'silly'] },
    249     { w: 'zip',      g: 'z-i-p',          art: 'Zipper-mouth face',   t: ['silly', 'home'] },
    250     { w: 'buzz',     g: 'b-u-zz',         art: 'Honeybee',            t: ['bugs', 'silly'] },
    251     { w: 'quiz',     g: 'qu-i-z',         art: 'Red question mark',   t: ['play', 'silly'] },
    252     { w: 'squid',    g: 's-qu-i-d',       art: 'Squid',               t: ['sea', 'animals'] },
    253     { w: 'quack',    g: 'qu-a-ck',        art: 'Duck',                t: ['silly', 'animals'] },
    254     // + sh ch th ng
    255     { w: 'ship',     g: 'sh-i-p',         art: 'Ship',                t: ['sea', 'wheels'] },
    256     { w: 'fish',     g: 'f-i-sh',         art: 'Fish',                t: ['sea', 'animals', 'food'] },
    257     { w: 'shell',    g: 'sh-e-ll',        art: 'Spiral shell',        t: ['sea'] },
    258     { w: 'dish',     g: 'd-i-sh',         art: 'Fork and knife with plate', t: ['home', 'food'] },
    259     { w: 'brush',    g: 'b-r-u-sh',       art: 'Toothbrush',          t: ['home'] },
    260     { w: 'shrimp',   g: 'sh-r-i-m-p',     art: 'Shrimp',              t: ['sea', 'food'] },
    261     { w: 'mushroom', g: 'm-u-sh-r-oo-m',  art: 'Mushroom',            t: ['outside', 'magic', 'food'] },
    262     { w: 'chick',    g: 'ch-i-ck',        art: 'Baby chick',          t: ['animals'] },
    263     { w: 'hatch',    g: 'h-a-tch',        art: 'Hatching chick',      t: ['dinos', 'animals'] },
    264     { w: 'chips',    g: 'ch-i-p-s',       art: 'French fries',        t: ['food'] },
    265     { w: 'chicken',  g: 'ch-i-ck-e-n',    art: 'Poultry leg',         t: ['food'] },
    266     { w: 'lunch',    g: 'l-u-n-ch',       art: 'Bento box',           t: ['food'] },
    267     { w: 'chest',    g: 'ch-e-s-t',       art: 'Card file box',       t: ['magic', 'home'] },
    268     { w: 'bath',     g: 'b-a-th',         art: 'Bathtub',             t: ['home', 'silly'] },
    269     { w: 'moth',     g: 'm-o-th',         art: 'Butterfly',           t: ['bugs'] },
    270     { w: 'thumb',    g: 'th-u-m-b',       art: 'Thumbs up/Default',   t: ['silly', 'home'], p: 'th u m -' },
    271     { w: 'ring',     g: 'r-i-ng',         art: 'Ring',                t: ['magic', 'home'] },
    272     { w: 'king',     g: 'k-i-ng',         art: 'Prince/Default',      t: ['magic'] },
    273     { w: 'wing',     g: 'w-i-ng',         art: 'Wing',                t: ['animals', 'wheels'] },
    274     { w: 'bang',     g: 'b-a-ng',         art: 'Collision',           t: ['silly'] },
    275     { w: 'sing',     g: 's-i-ng',         art: 'Microphone',          t: ['play', 'silly'] },
    276     { w: 'hiss',     g: 'h-i-ss',         art: 'Snake',               t: ['animals', 'silly'] },
    277     { w: 'skunk',    g: 's-k-u-n-k',      art: 'Skunk',               t: ['animals', 'silly'] },
    278     { w: 'mammoth',  g: 'm-a-mm-o-th',    art: 'Mammoth',             t: ['dinos', 'animals'] },
    279     { w: 'dragon',   g: 'd-r-a-g-o-n',    art: 'Dragon',              t: ['magic', 'dinos'] },
    280     { w: 'goblin',   g: 'g-o-b-l-i-n',    art: 'Goblin',              t: ['magic'] },
    281     { w: 'magic',    g: 'm-a-g-i-c',      art: 'Magic wand',          t: ['magic'] },
    282     { w: 'sloth',    g: 's-l-o-th',       art: 'Sloth',               t: ['animals'], p: 's l oa th' },
    283     { w: 'hippo',    g: 'h-i-pp-o',       art: 'Hippopotamus',        t: ['animals'], p: 'h i p oa' },
    284     { w: 'flamingo', g: 'f-l-a-m-i-ng-o', art: 'Flamingo',            t: ['animals'], p: 'f l a m i ng oa' },
    285     { w: 'mango',    g: 'm-a-ng-o',       art: 'Mango',               t: ['food'], p: 'm a ng oa' },
    286     { w: 'robot',    g: 'r-o-b-o-t',      art: 'Robot',               t: ['space', 'magic', 'play'], p: 'r oa b o t' },
    287     { w: 'pickup',   g: 'p-i-ck-u-p',     art: 'Pickup truck',        t: ['wheels'] },
    288     { w: 'tram',     g: 't-r-a-m',        art: 'Tram',                t: ['wheels'] },
    289     { w: 'planet',   g: 'p-l-a-n-e-t',    art: 'Ringed planet',       t: ['space'] },
    290     { w: 'lizard',   g: 'l-i-z-ar-d',     art: 'Lizard',              t: ['animals', 'dinos'] },
    291     // + ee oo oa
    292     { w: 'bee',      g: 'b-ee',           art: 'Honeybee',            t: ['bugs', 'animals'] },
    293     { w: 'tree',     g: 't-r-ee',         art: 'Deciduous tree',      t: ['outside'] },
    294     { w: 'feet',     g: 'f-ee-t',         art: 'Foot/Default',        t: ['home', 'silly'] },
    295     { w: 'sheep',    g: 'sh-ee-p',        art: 'Ewe',                 t: ['animals'] },
    296     { w: 'teeth',    g: 't-ee-th',        art: 'Tooth',               t: ['home', 'silly'] },
    297     { w: 'seed',     g: 's-ee-d',         art: 'Seedling',            t: ['outside'] },
    298     { w: 'jeep',     g: 'j-ee-p',         art: 'Sport utility vehicle', t: ['wheels'] },
    299     { w: 'queen',    g: 'qu-ee-n',        art: 'Princess/Default',    t: ['magic'] },
    300     { w: 'deer',     g: 'd-ee-r',         art: 'Deer',                t: ['animals', 'outside'] },
    301     { w: 'sleep',    g: 's-l-ee-p',       art: 'Sleeping face',       t: ['home', 'silly'] },
    302     { w: 'speedboat',g: 's-p-ee-d-b-oa-t',art: 'Speedboat',           t: ['sea', 'wheels'] },
    303     { w: 'wheel',    g: 'wh-ee-l',        art: 'Wheel',               t: ['wheels'] },
    304     { w: 'moon',     g: 'm-oo-n',         art: 'Crescent moon',       t: ['space', 'outside'] },
    305     { w: 'boot',     g: 'b-oo-t',         art: 'Hiking boot',         t: ['play', 'outside'] },
    306     { w: 'spoon',    g: 's-p-oo-n',       art: 'Spoon',               t: ['home', 'food'] },
    307     { w: 'balloon',  g: 'b-a-ll-oo-n',    art: 'Balloon',             t: ['play', 'silly'] },
    308     { w: 'raccoon',  g: 'r-a-cc-oo-n',    art: 'Raccoon',             t: ['animals'] },
    309     { w: 'boo',      g: 'b-oo',           art: 'Ghost',               t: ['magic', 'silly'] },
    310     { w: 'poop',     g: 'p-oo-p',         art: 'Pile of poo',         t: ['silly'] },
    311     { w: 'boom',     g: 'b-oo-m',         art: 'Collision',           t: ['silly', 'space'] },
    312     { w: 'scooter',  g: 's-c-oo-t-er',    art: 'Kick scooter',        t: ['wheels', 'play'] },
    313     { w: 'rooster',  g: 'r-oo-s-t-er',    art: 'Rooster',             t: ['animals'] },
    314     { w: 'book',     g: 'b-oo-k',         art: 'Open book',           t: ['home', 'play'], p: 'b uu k' },
    315     { w: 'hook',     g: 'h-oo-k',         art: 'Hook',                t: ['sea'], p: 'h uu k' },
    316     { w: 'foot',     g: 'f-oo-t',         art: 'Foot/Default',        t: ['home', 'silly'], p: 'f uu t' },
    317     { w: 'cook',     g: 'c-oo-k',         art: 'Cook/Default',        t: ['food', 'home'], p: 'k uu k' },
    318     { w: 'woof',     g: 'w-oo-f',         art: 'Dog face',            t: ['silly', 'animals'], p: 'w uu f' },
    319     { w: 'boat',     g: 'b-oa-t',         art: 'Motor boat',          t: ['sea', 'wheels'] },
    320     { w: 'goat',     g: 'g-oa-t',         art: 'Goat',                t: ['animals'] },
    321     { w: 'coat',     g: 'c-oa-t',         art: 'Coat',                t: ['play', 'home'] },
    322     { w: 'soap',     g: 's-oa-p',         art: 'Soap',                t: ['home'] },
    323     { w: 'road',     g: 'r-oa-d',         art: 'Motorway',            t: ['wheels', 'outside'] },
    324     { w: 'toast',    g: 't-oa-s-t',       art: 'Bread',               t: ['food'] },
    325     { w: 'toad',     g: 't-oa-d',         art: 'Frog',                t: ['animals', 'outside'] },
    326     { w: 'sailboat', g: 's-ai-l-b-oa-t',  art: 'Sailboat',            t: ['sea', 'wheels'] },
    327     // + ar or er
    328     { w: 'star',     g: 's-t-ar',         art: 'Star',                t: ['space', 'magic'] },
    329     { w: 'car',      g: 'c-ar',           art: 'Automobile',          t: ['wheels'] },
    330     { w: 'cart',     g: 'c-ar-t',         art: 'Shopping cart',       t: ['wheels', 'home'] },
    331     { w: 'shark',    g: 'sh-ar-k',        art: 'Shark',               t: ['sea', 'animals'] },
    332     { w: 'park',     g: 'p-ar-k',         art: 'National park',       t: ['outside'] },
    333     { w: 'jar',      g: 'j-ar',           art: 'Jar',                 t: ['home', 'food'] },
    334     { w: 'arm',      g: 'ar-m',           art: 'Flexed biceps/Default', t: ['silly', 'home'] },
    335     { w: 'art',      g: 'ar-t',           art: 'Artist palette',      t: ['play'] },
    336     { w: 'garlic',   g: 'g-ar-l-i-c',     art: 'Garlic',              t: ['food'] },
    337     { w: 'tractor',  g: 't-r-a-c-t-or',   art: 'Tractor',             t: ['wheels'] },
    338     { w: 'scarf',    g: 's-c-ar-f',       art: 'Scarf',               t: ['play', 'home'] },
    339     { w: 'target',   g: 't-ar-g-e-t',     art: 'Bullseye',            t: ['play'] },
    340     { w: 'corn',     g: 'c-or-n',         art: 'Ear of corn',         t: ['food'] },
    341     { w: 'horn',     g: 'h-or-n',         art: 'Postal horn',         t: ['play', 'wheels'] },
    342     { w: 'fork',     g: 'f-or-k',         art: 'Fork and knife',      t: ['home', 'food'] },
    343     { w: 'storm',    g: 's-t-or-m',       art: 'Cloud with lightning and rain', t: ['outside'] },
    344     { w: 'popcorn',  g: 'p-o-p-c-or-n',   art: 'Popcorn',             t: ['food', 'silly'] },
    345     { w: 'roar',     g: 'r-oar',          art: 'Lion',                t: ['animals', 'dinos', 'silly'] },
    346     { w: 'doctor',   g: 'd-o-c-t-or',     art: 'Health worker/Default', t: ['home'] },
    347     { w: 'monster',  g: 'm-o-n-s-t-er',   art: 'Alien monster',       t: ['magic', 'silly', 'space'] },
    348     { w: 'hammer',   g: 'h-a-mm-er',      art: 'Hammer',              t: ['home'] },
    349     { w: 'ladder',   g: 'l-a-dd-er',      art: 'Ladder',              t: ['home', 'wheels'] },
    350     { w: 'butter',   g: 'b-u-tt-er',      art: 'Butter',              t: ['food'] },
    351     { w: 'pepper',   g: 'p-e-pp-er',      art: 'Hot pepper',          t: ['food'] },
    352     { w: 'hamster',  g: 'h-a-m-s-t-er',   art: 'Hamster',             t: ['animals', 'home'] },
    353     { w: 'lobster',  g: 'l-o-b-s-t-er',   art: 'Lobster',             t: ['sea', 'food'] },
    354     { w: 'otter',    g: 'o-tt-er',        art: 'Otter',               t: ['animals', 'sea'] },
    355     { w: 'thunder',  g: 'th-u-n-d-er',    art: 'Cloud with lightning', t: ['outside'] },
    356     { w: 'soccer',   g: 's-o-cc-er',      art: 'Soccer ball',         t: ['play'] },
    357     { w: 'helicopter', g: 'h-e-l-i-c-o-p-t-er', art: 'Helicopter',    t: ['wheels'] },
    358     { w: 'printer',  g: 'p-r-i-n-t-er',   art: 'Printer',             t: ['home'] },
    359     { w: 'lantern',  g: 'l-a-n-t-er-n',   art: 'Red paper lantern',   t: ['magic', 'home'] },
    360     { w: 'oyster',   g: 'oy-s-t-er',      art: 'Oyster',              t: ['sea', 'food'] },
    361     // + ai igh ow oi
    362     { w: 'rain',     g: 'r-ai-n',         art: 'Cloud with rain',     t: ['outside'] },
    363     { w: 'train',    g: 't-r-ai-n',       art: 'Locomotive',          t: ['wheels'] },
    364     { w: 'snail',    g: 's-n-ai-l',       art: 'Snail',               t: ['bugs', 'animals'] },
    365     { w: 'chain',    g: 'ch-ai-n',        art: 'Chains',              t: ['home'] },
    366     { w: 'brain',    g: 'b-r-ai-n',       art: 'Brain',               t: ['silly', 'magic'] },
    367     { w: 'crayon',   g: 'c-r-ay-o-n',     art: 'Crayon',              t: ['play'] },
    368     { w: 'rainbow',  g: 'r-ai-n-b-ow',    art: 'Rainbow',             t: ['outside', 'magic'], p: 'r ai n b oa' },
    369     { w: 'paint',    g: 'p-ai-n-t',       art: 'Paintbrush',          t: ['play'] },
    370     { w: 'night',    g: 'n-igh-t',        art: 'Night with stars',    t: ['space', 'outside'] },
    371     { w: 'light',    g: 'l-igh-t',        art: 'Light bulb',          t: ['home'] },
    372     { w: 'lightning',g: 'l-igh-t-n-i-ng', art: 'High voltage',        t: ['outside', 'magic'] },
    373     { w: 'flashlight', g: 'f-l-a-sh-l-igh-t', art: 'Flashlight',      t: ['outside', 'magic'] },
    374     { w: 'pie',      g: 'p-ie',           art: 'Pie',                 t: ['food'] },
    375     { w: 'cow',      g: 'c-ow',           art: 'Cow',                 t: ['animals'] },
    376     { w: 'owl',      g: 'ow-l',           art: 'Owl',                 t: ['animals', 'outside'] },
    377     { w: 'clown',    g: 'c-l-ow-n',       art: 'Clown face',          t: ['silly', 'magic'] },
    378     { w: 'crown',    g: 'c-r-ow-n',       art: 'Crown',               t: ['magic'] },
    379     { w: 'cloud',    g: 'c-l-ou-d',       art: 'Cloud',               t: ['outside', 'space'] },
    380     { w: 'mouth',    g: 'm-ou-th',        art: 'Mouth',               t: ['silly', 'home'] },
    381     { w: 'cowboy',   g: 'c-ow-b-oy',      art: 'Cowboy hat face',     t: ['play', 'magic'] },
    382     { w: 'snow',     g: 's-n-ow',         art: 'Snowflake',           t: ['outside'], p: 's n oa' },
    383     { w: 'coin',     g: 'c-oi-n',         art: 'Coin',                t: ['home', 'magic'] },
    384     { w: 'boy',      g: 'b-oy',           art: 'Boy/Default',         t: ['home'] },
    385     { w: 'toy',      g: 't-oy',           art: 'Teddy bear',          t: ['play'] },
    386     { w: 'oink',     g: 'oi-n-k',         art: 'Pig face',            t: ['silly', 'animals'] },
    387     { w: 'toilet',   g: 't-oi-l-e-t',     art: 'Toilet',              t: ['home', 'silly'] },
    388   ];
    389   const WORD_BY_NAME = {};
    390   WORDS.forEach((w) => { WORD_BY_NAME[w.w] = w; });
    391 
    392   // ── Patrons ────────────────────────────────────────────────────────────
    393   // The island kids who come to the desk. voice: {pitch, rate} for speech
    394   // synthesis so each regular sounds different. art: Fluent Emoji asset.
    395   const PATRONS = [
    396     { id: 'ember',    name: 'Ember',      kind: 'a little dragon',   art: 'Dragon face',  color: '#E63946', voice: { pitch: 0.8, rate: 0.95 } },
    397     { id: 'pip',      name: 'Pip',        kind: 'a parrot',          art: 'Parrot',       color: '#2EC4B6', voice: { pitch: 1.6, rate: 1.15 } },
    398     { id: 'reggie',   name: 'Reggie',     kind: 'a polite lizard',   art: 'Lizard',       color: '#1D7CF2', voice: { pitch: 0.9, rate: 0.85 } },
    399     { id: 'clawdia',  name: 'Clawdia',    kind: 'a coconut crab',    art: 'Crab',         color: '#F4A261', voice: { pitch: 0.7, rate: 1.0  } },
    400     { id: 'otto',     name: 'Otto',       kind: 'an octopus',        art: 'Octopus',      color: '#9B5DE5', voice: { pitch: 1.2, rate: 0.95 } },
    401     { id: 'marina',   name: 'Marina',     kind: 'a mermaid',         art: 'Person merpeople/Default', color: '#2EC4B6', voice: { pitch: 1.3, rate: 1.0 } },
    402     { id: 'zephyr',   name: 'Zephyr',     kind: 'a genie',           art: 'Person genie',        color: '#1D7CF2', voice: { pitch: 1.0, rate: 1.1  } },
    403     { id: 'shelby',   name: 'Shelby',     kind: 'a turtle',          art: 'Turtle',       color: '#06A77D', voice: { pitch: 0.75, rate: 0.8 } },
    404     { id: 'flossie',  name: 'Flossie',    kind: 'a flamingo',        art: 'Flamingo',     color: '#FF7AA2', voice: { pitch: 1.5, rate: 1.05 } },
    405     { id: 'hopper',   name: 'Hopper',     kind: 'a frog',            art: 'Frog',         color: '#8AC926', voice: { pitch: 1.1, rate: 1.2  } },
    406     { id: 'hoot',     name: 'Hoot',       kind: 'an owl',            art: 'Owl',          color: '#FFD23F', voice: { pitch: 0.85, rate: 0.9  } },
    407     { id: 'splash',   name: 'Splash',     kind: 'a dolphin',         art: 'Dolphin',      color: '#1D7CF2', voice: { pitch: 1.4, rate: 1.1  } },
    408     { id: 'nova',     name: 'Nova',       kind: 'a unicorn',         art: 'Unicorn',      color: '#FF5DA2', voice: { pitch: 1.35, rate: 1.0 } },
    409     { id: 'bolt',     name: 'Bolt',       kind: 'a robot',           art: 'Robot',        color: '#7A6C5D', voice: { pitch: 0.6, rate: 1.05 } },
    410     { id: 'wisp',     name: 'Wisp',       kind: 'a friendly ghost',  art: 'Ghost',        color: '#9B5DE5', voice: { pitch: 1.25, rate: 0.9 } },
    411     { id: 'koko',     name: 'Koko',       kind: 'a koala',           art: 'Koala',        color: '#7A6C5D', voice: { pitch: 1.05, rate: 0.85 } },
    412   ];
    413 
    414   // Pictures the scene itself uses (window, decor, the reading finger), on top of the words'.
    415   const UI_ART = ['Bird', 'Sailboat', 'Palm tree', 'Backhand index pointing up/Default', 'Potted plant', 'Light bulb', 'Fish', 'Tropical fish', 'Cat', 'Globe showing americas', 'Telescope', 'Sauropod', 'Rocket', 'Penguin', 'Koala', 'Panda', 'Lion', 'Tiger face', 'Bear', 'Alien', 'Child/Default', 'Girl/Default', 'Boy/Default'];
    416 
    417   const AVATARS = ['Child/Default', 'Girl/Default', 'Boy/Default', 'Cat face', 'Dog face', 'Rabbit face', 'Panda', 'Lion', 'Tiger face', 'Penguin', 'Bear', 'Alien', 'Unicorn', 'Dragon face'];
    418 
    419   // ── Deterministic hashing (same word -> same regular every time) ───────
    420   function hashStr(s) {
    421     let h = 2166136261;
    422     for (let i = 0; i < s.length; i++) { h ^= s.charCodeAt(i); h = Math.imul(h, 16777619) >>> 0; }
    423     return h >>> 0;
    424   }
    425   function patronFor(word) { return PATRONS[hashStr(word) % PATRONS.length]; }
    426 
    427   function mulberry32(seed) {
    428     let a = seed >>> 0;
    429     return function () {
    430       a = (a + 0x6D2B79F5) >>> 0;
    431       let t = a;
    432       t = Math.imul(t ^ (t >>> 15), t | 1);
    433       t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
    434       return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
    435     };
    436   }
    437 
    438   // ── Segmentation ───────────────────────────────────────────────────────
    439   function graphemes(word) { return word.g.split('-'); }
    440 
    441   function graphemePhonemes(g) {
    442     if (GRAPHEMES[g]) return GRAPHEMES[g];
    443     if (g.length === 2 && g[0] === g[1] && GRAPHEMES[g[0]]) return GRAPHEMES[g[0]];   // ll, ss, gg …
    444     throw new Error('unknown grapheme: ' + g);
    445   }
    446 
    447   // Phoneme ids for a word, one entry per grapheme (x -> ['k','s'] is one entry).
    448   function phonemeUnits(word) {
    449     const gs = graphemes(word);
    450     if (word.p) {
    451       const ps = word.p.split(' ');
    452       if (ps.length !== gs.length) throw new Error('phoneme override length mismatch: ' + word.w);
    453       return ps.map((p) => (p === '-' ? [] : [p]));
    454     }
    455     return gs.map(graphemePhonemes);
    456   }
    457 
    458   // Which teaching-sequence grapheme a spelling unit needs (ll -> l, ck -> ck, ay -> ai).
    459   function baseGrapheme(g) {
    460     if (BASE[g]) return BASE[g];
    461     if (SEQUENCE.indexOf(g) >= 0) return g;
    462     if (g.length === 2 && g[0] === g[1]) return g[0];
    463     return g;
    464   }
    465 
    466   // The grapheme that teaches a phoneme (k -> c, uu -> oo, ai -> ai).
    467   function homeGrapheme(p) {
    468     const ph = PHONEMES[p];
    469     if (ph && ph.home) return ph.home;
    470     return SEQUENCE.find((g) => GRAPHEMES[g] && GRAPHEMES[g].length === 1 && GRAPHEMES[g][0] === p) || null;
    471   }
    472 
    473   // Every spelling unit is known, and (for override words) so is the grapheme
    474   // that teaches each sound actually used (robot needs 'oa' as well as 'o').
    475   function wordIsAvailable(word, known) {
    476     if (!graphemes(word).every((g) => known.indexOf(baseGrapheme(g)) >= 0)) return false;
    477     if (word.p) return [].concat(...phonemeUnits(word)).every((p) => known.indexOf(homeGrapheme(p)) >= 0);
    478     return true;
    479   }
    480 
    481   function availableWords(known) { return WORDS.filter((w) => wordIsAvailable(w, known)); }
    482 
    483   // ── Profiles ───────────────────────────────────────────────────────────
    484   const STORE_KEY  = 'kg-story-stamp-v1';
    485   const MAX_PROFILES = 6;
    486 
    487   function newProfile(name, avatar, now, id) {
    488     return {
    489       id: id || ('p' + now.toString(36) + Math.floor(Math.random() * 1e6).toString(36)),
    490       name: name || 'Librarian',
    491       avatar: avatar || AVATARS[0],
    492       created: now,
    493       introduced: SEQUENCE.slice(0, STARTING_GRAPHEMES),
    494       graphemes: {},    // g -> { hits, misses }
    495       words: {},        // w -> { reps, lapses, streak, interval, due, last, seen }
    496       orders: 0,        // books checked out, ever
    497       recent: [],       // first-try results of the last few books
    498       sinceIntro: 0,
    499       lastWord: null,
    500       topics: {},       // topic id -> times chosen
    501     };
    502   }
    503 
    504   function loadStore(storage) {
    505     let data = null;
    506     try { data = JSON.parse(storage.getItem(STORE_KEY)); } catch (_) {}
    507     if (!data || !Array.isArray(data.profiles)) data = { version: 1, profiles: [], last: null };
    508     data.profiles.forEach((p) => { if (!p.topics) p.topics = {}; });
    509     return data;
    510   }
    511   function saveStore(storage, data) {
    512     try { storage.setItem(STORE_KEY, JSON.stringify(data)); } catch (_) {}
    513   }
    514 
    515   // ── Mastery + scheduling ───────────────────────────────────────────────
    516   const INTERVALS_DAYS = [1, 3, 7, 14, 30, 60];
    517   const MASTERED_STREAK = 2;
    518   const RETURN_MODE_UNLOCK = 5;    // mastered words before "returns" (typing by ear) appear
    519   const BOOKS_PER_DAY = 6;         // a natural stopping point
    520 
    521   function wordRecord(profile, w) { return profile.words[w] || null; }
    522   function isMastered(rec) { return !!rec && rec.streak >= MASTERED_STREAK; }
    523   // Words a profile has met that still exist in WORDS (a saved profile must
    524   // survive a word being removed from the list in a later version).
    525   function servedWords(profile) { return Object.keys(profile.words).filter((w) => WORD_BY_NAME[w]); }
    526   function masteredWords(profile) { return servedWords(profile).filter((w) => isMastered(profile.words[w])); }
    527   function shakyWords(profile) { return servedWords(profile).filter((w) => !isMastered(profile.words[w])); }
    528 
    529   // Apply the result of one checked-out book. firstTry = no wrong keys / wrong picks.
    530   function recordOrder(profile, w, firstTry, now) {
    531     const rec = profile.words[w] || { reps: 0, lapses: 0, streak: 0, interval: 0, due: now, last: null, seen: 0 };
    532     rec.seen += 1;
    533     rec.last = now;
    534     if (firstTry) {
    535       rec.reps += 1;
    536       rec.streak += 1;
    537       rec.interval = INTERVALS_DAYS[Math.min(rec.streak - 1, INTERVALS_DAYS.length - 1)];
    538       rec.due = now + rec.interval * DAY;
    539     } else {
    540       rec.lapses += 1;
    541       rec.streak = 0;
    542       rec.interval = 0;
    543       rec.due = now + 5 * MIN;        // the regular wanders back a little later
    544     }
    545     profile.words[w] = rec;
    546     profile.orders += 1;
    547     profile.sinceIntro += 1;
    548     profile.lastWord = w;
    549     profile.recent = profile.recent.concat([!!firstTry]).slice(-8);
    550     return rec;
    551   }
    552 
    553   function recordGrapheme(profile, g, hit) {
    554     const key = baseGrapheme(g);
    555     const s = profile.graphemes[key] || { hits: 0, misses: 0 };
    556     if (hit) s.hits += 1; else s.misses += 1;
    557     profile.graphemes[key] = s;
    558   }
    559 
    560   function recordTopic(profile, topicId) {
    561     if (!profile.topics) profile.topics = {};
    562     profile.topics[topicId] = (profile.topics[topicId] || 0) + 1;
    563   }
    564 
    565   function dueWords(profile, now) {
    566     return servedWords(profile)
    567       .filter((w) => profile.words[w].due <= now)
    568       .sort((a, b) => profile.words[a].due - profile.words[b].due);
    569   }
    570 
    571   function unseenWords(profile) {
    572     return availableWords(profile.introduced).map((w) => w.w).filter((w) => !profile.words[w]);
    573   }
    574 
    575   function nextGrapheme(profile) {
    576     return SEQUENCE.find((g) => profile.introduced.indexOf(g) < 0) || null;
    577   }
    578 
    579   // Mastery-based pacing: a new sound arrives when the child is fluent with the
    580   // current pool (recent books mostly first-try) or the pool has run dry.
    581   function shouldIntroduce(profile) {
    582     if (!nextGrapheme(profile)) return false;
    583     if (unseenWords(profile).length === 0) return true;
    584     if (profile.sinceIntro < 3) return false;
    585     const r = profile.recent.slice(-5);
    586     if (r.length < 4) return false;
    587     const acc = r.filter(Boolean).length / r.length;
    588     return acc >= 0.8;
    589   }
    590 
    591   function introduceNext(profile) {
    592     const g = nextGrapheme(profile);
    593     if (!g) return null;
    594     profile.introduced = profile.introduced.concat([g]);
    595     profile.sinceIntro = 0;
    596     return g;
    597   }
    598 
    599   // Pick the next book. Returns { word, mode, review } — mode is 'read'
    600   // (the request slip shows the word, the child decodes it) or 'return'
    601   // (the patron says the word, the child types its sounds). `filter` narrows
    602   // the candidates (used for topics); `exclude` are words already queued.
    603   function pickNext(profile, now, rng, filter, exclude) {
    604     rng = rng || Math.random;
    605     filter = filter || (() => true);
    606     exclude = exclude || [];
    607     const ok = (w) => w !== profile.lastWord && exclude.indexOf(w) < 0 && filter(WORD_BY_NAME[w]);
    608     const due = dueWords(profile, now).filter(ok);
    609     const fresh = unseenWords(profile).filter(ok);
    610     const newest = profile.introduced[profile.introduced.length - 1];
    611     let w = null, review = false;
    612 
    613     if (due.length && (fresh.length === 0 || rng() < 0.6)) {
    614       w = due[Math.floor(rng() * Math.min(due.length, 3))];   // one of the most overdue
    615       review = true;
    616     } else if (fresh.length) {
    617       const withNewest = fresh.filter((x) => graphemes(WORD_BY_NAME[x]).map(baseGrapheme).indexOf(newest) >= 0);
    618       const pool = withNewest.length ? withNewest : fresh;
    619       w = pool[Math.floor(rng() * pool.length)];
    620     } else {
    621       // nothing due and nothing new: practise the least recently served word
    622       let pool = servedWords(profile).filter(ok);
    623       if (!pool.length) pool = servedWords(profile).filter((x) => exclude.indexOf(x) < 0 && filter(WORD_BY_NAME[x]));
    624       if (!pool.length) pool = availableWords(profile.introduced).map((x) => x.w).filter((x) => exclude.indexOf(x) < 0 && filter(WORD_BY_NAME[x]));
    625       if (!pool.length) return null;
    626       pool.sort((a, b) => ((profile.words[a] || {}).last || 0) - ((profile.words[b] || {}).last || 0));
    627       w = pool[0];
    628       review = !!profile.words[w];
    629     }
    630 
    631     let mode = 'read';
    632     const rec = profile.words[w];
    633     if (rec && masteredWords(profile).length >= RETURN_MODE_UNLOCK && rng() < 0.55) mode = 'return';
    634     return { word: WORD_BY_NAME[w], mode, review };
    635   }
    636 
    637   // Topics that currently have a book the child could be asked for.
    638   function topicsWithWords(profile) {
    639     const avail = availableWords(profile.introduced);
    640     return TOPICS.filter((t) => avail.some((w) => w.t.indexOf(t.id) >= 0));
    641   }
    642 
    643   // Who is waiting at the desk: up to `n` patrons, each wanting a different
    644   // topic, each with a book picked by the scheduler. When too few topics are
    645   // open yet, the extra patrons just want "a good book".
    646   function buildQueue(profile, now, rng, n) {
    647     rng = rng || Math.random;
    648     n = n || 3;
    649     const topics = topicsWithWords(profile).slice();
    650     // Favour topics the child has picked before, but keep everything in play.
    651     const weight = (t) => 1 + Math.min(3, (profile.topics && profile.topics[t.id]) || 0) * 0.5;
    652     const queue = [];
    653     const used = [];
    654     while (queue.length < n) {
    655       let topic = ANY_TOPIC;
    656       if (topics.length) {
    657         const total = topics.reduce((s, t) => s + weight(t), 0);
    658         let r = rng() * total, idx = 0;
    659         while (idx < topics.length - 1 && (r -= weight(topics[idx])) > 0) idx++;
    660         topic = topics.splice(idx, 1)[0];
    661       }
    662       const filter = topic === ANY_TOPIC ? null : (w) => w.t.indexOf(topic.id) >= 0;
    663       const next = pickNext(profile, now, rng, filter, used);
    664       if (!next) { if (!topics.length) break; continue; }
    665       used.push(next.word.w);
    666       queue.push({ topic, word: next.word, mode: next.mode, review: next.review, patron: patronFor(next.word.w) });
    667       if (!topics.length && queue.length && topic === ANY_TOPIC) break;    // one generic patron is enough
    668     }
    669     return queue;
    670   }
    671 
    672   // Two distractor covers for the "which book is it?" check. Prefers words
    673   // the child has met that share letters with the target (cat/cap, pin/pan).
    674   function distractors(word, profile, rng) {
    675     rng = rng || Math.random;
    676     const seen = servedWords(profile);
    677     const usable = (list) => list.filter((w) => w !== word.w && WORD_BY_NAME[w] && WORD_BY_NAME[w].art !== word.art);
    678     const shares = (w) => WORD_BY_NAME[w].w.split('').some((c) => word.w.indexOf(c) >= 0);
    679     let pool = usable(seen);
    680     if (pool.length < 2) pool = usable(availableWords(profile.introduced).map((x) => x.w));
    681     if (pool.length < 2) pool = usable(WORDS.map((x) => x.w));
    682     const near = pool.filter(shares), far = pool.filter((w) => !shares(w));
    683     const pick = [];
    684     const take = (arr) => {
    685       while (arr.length && pick.length < 2) {
    686         const w = arr.splice(Math.floor(rng() * arr.length), 1)[0];
    687         if (!pick.some((p) => WORD_BY_NAME[p].art === WORD_BY_NAME[w].art)) pick.push(w);
    688       }
    689     };
    690     take(near); take(far);
    691     return pick.map((w) => WORD_BY_NAME[w]);
    692   }
    693 
    694   // ── Library growth ─────────────────────────────────────────────────────
    695   const LIBRARY_UPGRADES = [
    696     { at: 2,  id: 'rug',       label: 'a story rug' },
    697     { at: 5,  id: 'plant',     label: 'a potted palm' },
    698     { at: 9,  id: 'lamp',      label: 'a reading lamp' },
    699     { at: 14, id: 'fishtank',  label: 'a fish tank' },
    700     { at: 20, id: 'bunting',   label: 'paper bunting' },
    701     { at: 28, id: 'cat',       label: 'a library cat' },
    702     { at: 38, id: 'globe',     label: 'a spinning globe' },
    703     { at: 50, id: 'telescope', label: 'a telescope' },
    704     { at: 65, id: 'dino',      label: 'a dinosaur skeleton' },
    705     { at: 85, id: 'rocket',    label: 'a rocket mobile' },
    706   ];
    707   function libraryUpgrades(masteredCount) { return LIBRARY_UPGRADES.filter((u) => masteredCount >= u.at); }
    708 
    709   // ── Blend timeline ─────────────────────────────────────────────────────
    710   // Three passes: each sound held long with big gaps, then shorter and
    711   // closer, then nearly touching. The whole word is never played here: the
    712   // child has to hear it fuse and say it. Stops and unstretchable sounds keep
    713   // their natural length; `hold` is how long a stretchable sound is held.
    714   const BLEND_PASSES = [
    715     { gap: 0.5,  hold: 0.8  },
    716     { gap: 0.2,  hold: 0.4  },
    717     { gap: 0.02, hold: 0.16 },
    718   ];
    719   const PASS_PAUSE = 0.6;
    720   const MIN_HOLD = 0.12;
    721 
    722   // Seconds a phoneme takes when a pass asks for `hold`.
    723   function phonemeLength(p, hold, durations) {
    724     const ph = PHONEMES[p] || {};
    725     const nat = (durations && durations[p]) || 0.25;
    726     if (!ph.stretch) return nat;
    727     return Math.min(nat, Math.max(MIN_HOLD, hold));
    728   }
    729 
    730   // units: [[phonemeId, ...] per grapheme]; durations: phonemeId -> seconds.
    731   function blendTimeline(units, durations, opts) {
    732     opts = opts || {};
    733     const passes = opts.passes || BLEND_PASSES;
    734     const ev = [];
    735     let t = opts.start || 0;
    736     passes.forEach((pass, pi) => {
    737       ev.push({ t, kind: 'pass', pass: pi });
    738       units.forEach((unit, ui) => {
    739         unit.forEach((p, k) => {
    740           const len = phonemeLength(p, pass.hold, durations);
    741           ev.push({ t, kind: 'phoneme', id: p, unit: ui, pass: pi, len });
    742           t += len + (k < unit.length - 1 ? 0.02 : 0);
    743         });
    744         if (ui < units.length - 1) t += pass.gap;
    745       });
    746       t += PASS_PAUSE;
    747     });
    748     ev.push({ t: t - PASS_PAUSE + 0.2, kind: 'done' });
    749     return ev;
    750   }
    751 
    752   return {
    753     DAY, MIN, PHONEMES, GRAPHEMES, BASE, SEQUENCE, STARTING_GRAPHEMES, TOPICS, TOPIC_BY_ID, ANY_TOPIC,
    754     WORDS, WORD_BY_NAME, PATRONS, AVATARS, UI_ART,
    755     STORE_KEY, MAX_PROFILES, INTERVALS_DAYS, MASTERED_STREAK, RETURN_MODE_UNLOCK, BOOKS_PER_DAY,
    756     LIBRARY_UPGRADES, BLEND_PASSES, PASS_PAUSE, MIN_HOLD,
    757     hashStr, patronFor, mulberry32,
    758     graphemes, graphemePhonemes, phonemeUnits, baseGrapheme, homeGrapheme, wordIsAvailable, availableWords,
    759     newProfile, loadStore, saveStore,
    760     wordRecord, isMastered, servedWords, masteredWords, shakyWords, recordOrder, recordGrapheme, recordTopic,
    761     dueWords, unseenWords, nextGrapheme, shouldIntroduce, introduceNext, pickNext, topicsWithWords, buildQueue,
    762     distractors, libraryUpgrades, phonemeLength, blendTimeline,
    763   };
    764 }));