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 }));