From 9599cc5b313d1b97519db291decd8a19507c057d Mon Sep 17 00:00:00 2001 From: Aleksandr Ilin Date: Wed, 23 Sep 2026 11:53:15 +0000 Subject: [PATCH 001/127] More games button, Legend/Master polish, CLAUDE.md, v0.3.0 plan - Main menu: More games opens prototypes/ - CPU wording: a/an before 8, 11, 18, 80s - Master dice: mastered facts rarer (weak facts ~70% of rolls) - Tidy pickPracticeFacts - CLAUDE.md for the project; docs/v0.3.0-plan.md Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 107 ++++++++++++++++++++++++++++++++++++++++++++ css/style.css | 1 + docs/v0.3.0-plan.md | 101 +++++++++++++++++++++++++++++++++++++++++ index.html | 2 + js/game.js | 14 ++++-- js/progress.js | 12 +++-- 6 files changed, 226 insertions(+), 11 deletions(-) create mode 100644 CLAUDE.md create mode 100644 docs/v0.3.0-plan.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..a28741f --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,107 @@ +# Blockout + +A multiplication-as-area game for 3rd graders, based on "Blockout" from Math for Love. Two dice are rolled, you draw an a × b rectangle on a grid and answer a × b to claim it. It runs mainly on classroom Chromebooks. + +**The game itself has no dependencies and no build step.** It's plain HTML/CSS/JS and runs straight from `index.html`. npm packages (Playwright, jsQR) are for testing only. + +## Layout + +| Path | What | +|---|---| +| `index.html` | Every screen and dialog (`.overlay > .dialog`), plus the Lucide icon sprite (`#i-*` symbols, used as ``). | +| `css/style.css` | All styles. Colors are tokens on `:root`, with dark-mode variants. | +| `js/core.js` | `BlockoutCore`: the board, placement rules, dice, `rollForBoard`, difficulties (`DIFFICULTIES`, `diceFaces`, `createPairDice`, `fitsDifficulty`). Pure. | +| `js/progress.js` | `BlockoutProgress`: wallet, `SHOP` (unlocks), achievements, fact stats, bonuses, practice picking, cheat codes, and `normalize` (save migrations). Pure. | +| `js/cosmetics.js` | Wardrobe items; registers them into `Progress.SHOP`. | +| `js/invite.js` | Invite links (`validate` whitelists every setting). | +| `js/classroom.js` | Client for the classroom server (SSE with long-poll fallback, session in sessionStorage). | +| `js/game.js` | All UI (~6000 lines, one IIFE): menu, game, practice, shop, wardrobe, stats, classroom screens, cheats. | +| `server/classroom.js` | Zero-dependency Node server: static files (only `index.html`, `css/`, `js/`, `prototypes/`), JSON API, SSE plus `/poll`. | +| `server/rooms.js` | Pure room logic: whole class, pairs, tournaments. | +| `server/tournament.js` | Tournament formats: knockout, double elimination, round robin, Swiss, king of the hill. | +| `server/moderation.js` | Username checks: `blocklist.txt`, plus OpenAI moderation only when `OPENAI_API_KEY` is set (fails open). | +| `prototypes/` | Other operations (addition, subtraction, division, fractions). Single player and practice only. Shares `prototypes/shared/`. | +| `test/*.test.js` | `node:test` unit tests for the pure modules and the server rooms. | + +Browser script load order (index.html): core → progress → cosmetics → invite → vendor/qrcode → classroom → game. Modules are UMD-style: `window.BlockoutX` in the browser, `require()` in tests. + +## Commands + +```sh +npm test # node --test test/*.test.js (must stay green) +node server/classroom.js 8080 # game and classroom server at http://localhost:8080 +node server/classroom.js 8080 --public-url https://.trycloudflare.com # behind a Cloudflare tunnel +cloudflared tunnel --url http://localhost:8080 +``` + +- **The dev server:** the user usually keeps one running on 8080 behind a Cloudflare quick tunnel. The tunnel URL changes, so ask for it or reuse the last one given. +- **When to restart it:** only after changing `server/*`. Client files are served `no-cache`, so a page reload picks them up. +- **Setup:** `.devcontainer/postCreateCommand.sh` installs the npm packages, Playwright Chromium with its libraries, and cloudflared. + +## Browser testing (Playwright) + +Write throwaway scripts in the session scratchpad, not the repo. Require Playwright from `/workspaces/blockout/node_modules/playwright`, and spawn a private server on a spare port (8134+) that you kill at the end. The pattern: + +```js +const { chromium } = require('/workspaces/blockout/node_modules/playwright'); +const srv = spawn('node', ['/workspaces/blockout/server/classroom.js', '8135'], { stdio: 'ignore' }); +// seed a save before the page loads (once per tab): +await page.addInitScript((pr) => { if (!sessionStorage.getItem('s')) { sessionStorage.setItem('s', 1); + localStorage.setItem('blockout.progress', JSON.stringify(pr)); } }, progress); +``` + +- **Seed the migration flags.** A seeded progress must include `boardsV2, practiceV2, placeAutoV1, gameExpertV1, practiceTimersV1, boardWinsFromHistory: true`, or the old-save migrations in `Progress.normalize` grant extra unlocks and skew the test. Add `checkIn: { last: , streak: 1 }` to skip the daily check-in popup. +- **To unlock everything,** seed `unlocks: Progress.SHOP.map(i => i.id)`, or type the IDDQD cheat (~ key). +- **Useful ids:** + - `#start-btn`, `#roll-btn`, `#unlock-yes` (the quick-unlock confirm), `#cheats-input` + - `[data-mode="single|multi|practice"]`, `[data-setting="difficulty"] [data-value=…]`, `#board-size [data-size=…]` +- **Check for errors:** collect `pageerror` events and check there are none. Screenshot and look at the result. +- **Classroom flows:** use several browser contexts (a teacher plus students). + +## Storage + +- **localStorage:** + - `blockout.progress`: wallet, unlocks, achievements, facts per player key, counters, boardWins, helpedFacts, cheats, migration flags + - `blockout.settings`, `blockout.history`, `blockout.className`, `blockout.classHost` +- **sessionStorage** (per tab): `blockout.class`, the classroom session. +- **Save changes need a migration.** Anything that changes what a save means gets a one-time flag in `Progress.normalize`, so existing players never lose what they had. + +## Game design conventions + +- **Unlocks are skill-tree chains** (`SHOP` items with `requires`): + - Only the next step shows its name and price. Later steps are blurred "skeleton" placeholders with a lock (`Progress.isHidden`, `setLockBadge`). + - Reveal gates: `winOn` (win on the previous board), `masterUpTo` / `mastery` (Times Table mastery, via `tableReady`). + - `perk` text appears in the unlock dialog and the shop. + - Buying from a menu control activates it (the `ACTIVATE` map in game.js). +- **Per-difficulty unlocks:** each difficulty has its own Expert level, timers and practice question counts (`gameExpertId`, `gameTimerId`, `practiceCountId`, `practiceTimerId`, `practiceExpertId`). +- **Difficulties:** + + | Difficulty | Dice | + |---|---| + | Easy | d6 | + | Medium | d8 | + | Hard | d12 | + | Tricky | faces 3, 4, 6, 7, 8, 9, 12 | + | Master | d12 weighted to the player's weak facts | + | Legend | 11–19 × 2–9; needs a board ≥ 11 and uses split explanations | + + Tricky, Master and Legend use pair dice and virtual dice only. Classroom stays on Easy, Medium and Hard. +- **Classroom games are unlock-free:** every lobby setting is free and cheats are off. +- **Bonuses** are in progress.js (`answerBonus`, `timerBonus`, `boardFinishBonus`, `practiceFinishBonus`). Tell the player about them in the menu hints and the unlock dialogs. +- **Achievements:** + - They show one at a time as a WoW-style banner (`celebrate`, then a queue). + - Results screens stay minimal: no points line, stickers row or achievement list. Actions go on one row of equal buttons. + - Secret achievements (`hidden: true`, group "Secrets") show as "???" until earned. +- **Cheat codes:** press ~. + - They're in `Progress.CHEAT_CODES`, with `cheatOn`/`cheatOff` storing what each changed so it can be undone. + - The menu lists active cheats by description, never by code. + - Each code gets its own secret achievement `cheat_` (a test enforces this). +- **Dialogs** have a bottom `.dialog-actions` button panel, like the Shop. +- **The audience is 8-year-olds:** use short, friendly, concrete wording ("Help me count", "Needs practice"). Target devices are Chromebooks, and it must work at phone width too. + +## Code style + +- **Match the surrounding code:** vanilla ES2020, 2-space indent, single quotes, `const` arrow helpers, short comments explaining *why*. +- **No frameworks, bundlers or runtime dependencies.** The server uses only Node built-ins. +- **Keep logic testable:** pure logic belongs in `core.js` / `progress.js` / `rooms.js` / `tournament.js` with unit tests, and `game.js` stays the UI. +- **Keep the tests green.** Add tests for new rules; run `npm test` before finishing. diff --git a/css/style.css b/css/style.css index f2e7c74..9004c76 100644 --- a/css/style.css +++ b/css/style.css @@ -678,6 +678,7 @@ button.pair-name.picked { border-style: solid; border-color: var(--accent); back 100% { transform: rotate(-8deg); } } +a.btn { color: inherit; text-decoration: none; display: inline-block; } .btn-small { padding: 0.35rem 0.8rem; font-size: 0.9rem; } /* Real dice entry */ diff --git a/docs/v0.3.0-plan.md b/docs/v0.3.0-plan.md new file mode 100644 index 0000000..068699b --- /dev/null +++ b/docs/v0.3.0-plan.md @@ -0,0 +1,101 @@ +# Blockout v0.3.0 plan + +Teacher, parent and student accounts; a Turborepo monorepo; polished prototype games. Read CLAUDE.md first and follow it. + +## Git rules (hard) +- **Branch:** work only on `v0.3.0`. Never check out, merge into, rebase or push `main`. +- **Pending changes:** commit the pending working-tree changes first. Leave `Blockout.pdf` untracked. +- **Commits and pushes:** commit after every finished step with a clear message, and push to `origin v0.3.0` after each stage. If pushing fails for permission reasons, keep committing locally and note it in the log. +- **Tests:** keep `npm test` green at every commit. + - Add unit tests for every new rule. + - Add a Playwright check (scratchpad scripts, per CLAUDE.md) for every new screen, and look at the screenshots. +- **Log:** keep `docs/v0.3.0-log.md` up to date: what's done, decisions made on the user's behalf, open questions, and how to try each feature. Where something is unclear, pick the sensible default, log it, and keep going. Never stop to ask. + +## Stage 0: Legend fix +Legend games fill the board in about 3–8 rectangles. +- Make about 1 in 3 rolls a teen × 2–9 and the rest Tricky-style facts, so games run a normal length and work from 12×12 up. +- Update the hints and tests. + +## Stage 1: accounts, on today's layout (zero dependencies, `node:sqlite`) + +### Auth +- **One module, four roles:** a single swappable auth module (client and server) with roles guest, student, teacher and parent. +- **No real registration.** For now a "dev login" picks or creates demo users. +- **Ready for Keycloak:** design it so Keycloak (OIDC, PKCE) drops in later. The server checks bearer tokens through one provider interface, and the dev provider issues HMAC-signed tokens. +- **Schools:** Keycloak will broker Google sign-in for students, restricted to `@iusd.org`. The first school is Cadence Park (IUSD, Irvine CA). Model a school with an allowed email domain. +- **Guest play always stays available, unchanged:** local progress, and join-by-code in live classes. + +### Storage +- **sqlite** behind a small store module, holding: + - users and schools + - classes and class members + - progress + - assignments + - parent links + - scheduled tournaments +- **Progress sync:** signed-in students' progress syncs to the server. On first sign-in, the device's local progress merges into the account; after that the server copy wins, with local as a cache. + +### Teacher +- **Hosting:** hosting a class requires a teacher account (gate at `openHostSetup()`). +- **Classes:** teachers create classes with a class code or link. Any `@iusd.org` student with the code joins the roster. +- **Class stats page:** each kid's Times Table mastery, facts that need practice, recent activity and homework status. +- **Scheduled tournaments:** class or school tournaments with a date and time, a format and a class. The lobby opens automatically at that time. + +### Homework +- **Assignments** have a goal and a due date. Example goals: + - master the ×7 table + - finish N practice rounds on a difficulty + - win on a board size + + Completion is detected automatically from progress events. +- **Who sets it:** the teacher sets class homework. Parents can also set extra homework for their own child. +- **Who sees it:** the student sees "📚 Homework" on the main menu. Teacher and parents see its status. + +### Parent +- **Linking:** links to a child with a code from the child's account, or one given by the teacher. +- **What they can do:** see that child's stats and all their homework, and set extra homework. + +### Privacy (kids under 13 in California: FERPA, COPPA, SOPIPA) +- **Names:** store first name + last initial only, and no Google email beyond an opaque subject ID. +- **Sharing:** no third-party sharing. Skip OpenAI name moderation for signed-in students. +- **Docs:** write a short `docs/privacy.md`. + +### UI +Same style as the rest of the game: dialogs with a bottom button panel, kid-friendly wording, Chromebook and phone widths. + +## Stage 2: Turborepo monorepo (make it very clean) +- **Tooling:** npm workspaces + Turborepo, Vite, plain JavaScript ES modules, no framework. +- **Layout:** + + | Path | What | + |---|---| + | `apps/multiplication` | the main game | + | one app per prototype game | addition, subtraction, division, and the four fraction games | + | `apps/server` | classroom + accounts + sqlite | + | `packages/ui` | buttons, dialogs, tokens, icons, CSS | + | `packages/engine` | board, dice, placement and CPU, with the operation passed in | + | `packages/progress` | wallet, shop, achievements, facts, bonuses | + | `packages/classroom` | classroom client | + | `packages/auth` | auth module | + +- **game.js:** split the 6000-line file into sensible modules. +- **Scripts and serving:** root scripts `dev`, `build`, `test` and `lint`. The server serves the built apps. The main game's More games button links to the other apps. +- **Docs:** update CLAUDE.md, the devcontainer script and README for the new layout. +- **Behaviour must be identical before and after.** Rerun every unit test and a full Playwright pass of the main flows: single player, practice, Home multiplayer, whole class, pairs, tournament, shop, wardrobe, cheats and accounts. + +## Stage 3: polish the prototype games on the shared packages +Give them the main game's feature set and feel: +- wallet, shop with skill-tree unlocks, stickers and stats +- achievements (WoW banners) +- practice with Learn/Expert and timers +- difficulties and bonuses +- results screens +- invites, Home multiplayer, and classroom play where it makes sense + +They stay under More games. Keep each game's own maths explanations. + +## Done when +- All stages are committed and pushed on `v0.3.0`. +- Tests pass. +- The Playwright checks pass, with screenshots reviewed. +- `docs/v0.3.0-log.md` says how to try everything and lists every decision made on the user's behalf. diff --git a/index.html b/index.html index e704dd7..b1d502c 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,7 @@ + @@ -230,6 +231,7 @@

BLOCKOUT

+ More games diff --git a/js/game.js b/js/game.js index c0da57c..85d0276 100644 --- a/js/game.js +++ b/js/game.js @@ -1952,6 +1952,9 @@ // ---- computer: show the working step by step + // "an 8", "an 11", "an 18", but "a 7" + const article = (n) => (/^(8|11|18)$/.test(String(n)) || /^8\d$/.test(String(n)) ? 'an' : 'a'); + function addStep(html, big = false) { const li = document.createElement('li'); li.innerHTML = html; @@ -1963,7 +1966,7 @@ async function explainComputerMove(rect) { const plan = countPlan(rect); const { a, b, area } = rect; - addStep(`I drew a ${a} by ${b} rectangle. How many squares is that?`); + addStep(`I drew ${article(a)} ${a} by ${b} rectangle. How many squares is that?`); await waitInGame(cpuStep()); const split = legendSplit(rect); if (split) { @@ -2078,7 +2081,7 @@ setMsg(''); el.steps.hidden = false; const hello = currentPlayer().hello ? `${currentPlayer().hello} ` : ''; // CPU character's greeting - addStep(`${hello}I rolled a ${a} and a ${b}.`); + addStep(`${hello}I rolled ${article(a)} ${a} and ${article(b)} ${b}.`); await waitInGame(cpuStep()); if (game.placeMode === 'draw') { const where = game.board.rects.length ? 'snug against the others' : 'in a corner'; @@ -3351,12 +3354,15 @@ } // Master difficulty's dice: how often each fact comes up, from the player's - // record (like practice: "Needs practice" most, mastered hardly ever). + // record. Like practice, but mastered facts are rarer still: whoever reaches + // Master has mastered most of the table, and with practice's weights only + // about a third of the rolls would be weak facts (this makes it ~70%). + const MASTER_WEIGHTS = { ...Progress.PRACTICE_WEIGHTS, mastered: 0.1 }; function weakFactWeight(player) { const facts = playerFacts(player ? player.name : 'You'); return (a, b) => { const status = Progress.factStatus(facts[Progress.factKey(a, b)]); - let weight = Progress.PRACTICE_WEIGHTS[status]; + let weight = MASTER_WEIGHTS[status]; if ((a === 1 || b === 1) && status !== 'practice') weight /= 2; return weight; }; diff --git a/js/progress.js b/js/progress.js index 9728a02..c5fe9a6 100644 --- a/js/progress.js +++ b/js/progress.js @@ -518,13 +518,11 @@ const only = new Set(tables.filter((n) => allowed.includes(n))); const pool = []; for (const [a, b] of allPairs) { - { - if (only.size && !(set && set.bSideOnly ? only.has(b) : only.has(a) || only.has(b))) continue; - const status = factStatus(facts[factKey(a, b)]); - let weight = PRACTICE_WEIGHTS[status]; - if ((a === 1 || b === 1) && status !== 'practice') weight /= 2; // ×1 facts are easy wins - pool.push({ a, b, weight }); - } + if (only.size && !(set && set.bSideOnly ? only.has(b) : only.has(a) || only.has(b))) continue; + const status = factStatus(facts[factKey(a, b)]); + let weight = PRACTICE_WEIGHTS[status]; + if ((a === 1 || b === 1) && status !== 'practice') weight /= 2; // ×1 facts are easy wins + pool.push({ a, b, weight }); } const picked = []; while (picked.length < count && pool.length) { From 44b3f9480d1826f5db4c6618b1edcf20a3503f43 Mon Sep 17 00:00:00 2001 From: Aleksandr Ilin Date: Wed, 23 Sep 2026 11:58:08 +0000 Subject: [PATCH 002/127] Stage 0: Legend and Tricky games last as long as Hard - Legend: half teen x 2-9, half Tricky facts; needs 12x12+ - Pair dice: big rectangles rarer on small boards; near the end fall back to small facts that fit instead of ending half empty - Test: Tricky/Master/Legend game length; v0.3.0 log started Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 4 ++-- docs/v0.3.0-log.md | 36 ++++++++++++++++++++++++++++++++++++ js/core.js | 42 +++++++++++++++++++++++++++++++++++------- js/game.js | 2 +- js/invite.js | 2 +- js/progress.js | 2 +- test/core.test.js | 45 +++++++++++++++++++++++++++++++++++++++++---- 7 files changed, 117 insertions(+), 16 deletions(-) create mode 100644 docs/v0.3.0-log.md diff --git a/CLAUDE.md b/CLAUDE.md index a28741f..90a29d6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,9 +83,9 @@ await page.addInitScript((pr) => { if (!sessionStorage.getItem('s')) { sessionSt | Hard | d12 | | Tricky | faces 3, 4, 6, 7, 8, 9, 12 | | Master | d12 weighted to the player's weak facts | - | Legend | 11–19 × 2–9; needs a board ≥ 11 and uses split explanations | + | Legend | half teen × 2–9 (11–19), half Tricky facts; needs a board ≥ 12 and uses split explanations | - Tricky, Master and Legend use pair dice and virtual dice only. Classroom stays on Easy, Medium and Hard. + Tricky, Master and Legend use pair dice (`createPairDice`) and virtual dice only. Pair dice make big rectangles rarer on smaller boards (`PAIR_ROOM`), and near the end they fall back to small facts that fit, so games last about as long as on Hard (a test checks this). Classroom stays on Easy, Medium and Hard. - **Classroom games are unlock-free:** every lobby setting is free and cheats are off. - **Bonuses** are in progress.js (`answerBonus`, `timerBonus`, `boardFinishBonus`, `practiceFinishBonus`). Tell the player about them in the menu hints and the unlock dialogs. - **Achievements:** diff --git a/docs/v0.3.0-log.md b/docs/v0.3.0-log.md new file mode 100644 index 0000000..3686fd6 --- /dev/null +++ b/docs/v0.3.0-log.md @@ -0,0 +1,36 @@ +# v0.3.0 work log + +Progress on [the v0.3.0 plan](v0.3.0-plan.md). Newest entries go at the bottom of each section. + +## Status + +| Stage | State | +|---|---| +| 0: Legend fix | done | +| 1: Accounts | not started | +| 2: Turborepo | not started | +| 3: Prototype games | not started | + +## Stage 0: Legend fix + +**What changed** (`js/core.js`, `createPairDice` and `rollForBoard`): +- **The mix:** Legend deals a teen × 2–9 for half of its rolls and Tricky facts for the rest. +- **The size bias:** all pair dice (Tricky, Master, Legend) make big rectangles rarer on smaller boards, like the loaded dice on Easy, Medium and Hard do. +- **The fallback:** near the end, when none of the difficulty's facts fit the gaps left, the pair dice deal a smaller fact (up to 12 × 12) that does fit, so the board gets finished instead of both players passing. +- **Board size:** Legend now needs a 12×12 board or bigger (invites too). + +Measured, rectangles per CPU-vs-CPU game (a unit test guards the minimums): + +| Board | Hard | Tricky before → after | Legend before → after | Teen facts per Legend game | +|---|---|---|---|---| +| 12×12 | 11.7 | 4.1 → 10.0 | 2.7 → 8.7 | 1.8 | +| 16×16 | 12.8 | 6.5 → 13.6 | 3.9 → 12.7 | 2.7 | +| 20×20 | 15.1 | 9.9 → 18.8 | 5.5 → 16.9 | 3.8 | +| 24×24 | 19.1 | 13.3 → 23.2 | 7.7 → 21.5 | 5.1 | + +**Decisions made on your behalf:** +- **Teen share is 1/2, not the 1/3 in the plan.** With 1/3, the size bias and the end-of-game fill-in left only about 1 teen fact in a 12×12 game; 1/2 gives about 2 without making games shorter. It's `LEGEND_TEEN_SHARE` in core.js. +- **Tricky had the same problem** (4 rectangles on 12×12), so the size bias and fill-in apply to every pair-dice difficulty, not just Legend. +- **The fill-in facts can be easier than the difficulty** (for example 2 × 3 on Tricky). The alternative was ending games with a third of the board empty. + +**How to try it:** single player → Legend (or type IDDQD after pressing ~ to unlock everything) → 12×12 or bigger → play. The hint under the board size explains the mix. diff --git a/js/core.js b/js/core.js index effb8b1..0580d74 100644 --- a/js/core.js +++ b/js/core.js @@ -156,13 +156,35 @@ // dice differ; Master weights each fact). Easy/Medium/Hard use createDice. const usesPairDice = (difficulty) => ['tricky', 'master', 'legend'].includes(difficulty); + // Legend: about 1 roll in LEGEND_TEEN_SHARE is a teen × 2–9; the rest are + // Tricky's facts. All-teen games filled the board in 3–8 rectangles. + const LEGEND_TEEN_SHARE = 1 / 2; + const PAIR_ROOM = 1 / 6; // share of the board a "comfortable" rectangle covers + // weight(a, b): how likely a roll is (Master: weak facts more), default even. // Pairs that can't fit the board at all are left out (Legend on small boards). function createPairDice(boardSize, rng = Math.random, difficulty = 'tricky', weight = null) { - const { a: A, b: B } = diceFaces(difficulty); - const pairs = []; - for (const a of A) for (const b of B) if (a <= boardSize && b <= boardSize) pairs.push([a, b]); - const w = weight || (() => 1); + const fits = ([a, b]) => a <= boardSize && b <= boardSize; + const cross = ({ a: A, b: B }) => A.flatMap((a) => B.map((b) => [a, b])).filter(fits); + // Like the loaded dice: big rectangles are rarer on smaller boards, so a + // game lasts about as long as on Hard (big facts still come up, just less). + const roomy = boardSize * boardSize * PAIR_ROOM; + const sizeBias = (a, b) => 1 / (1 + ((a * b) / roomy) ** 2); + const given = weight || (() => 1); + let pairs = cross(diceFaces(difficulty)); + let w = (a, b) => given(a, b) * sizeBias(a, b); + if (difficulty === 'legend') { + // Each group gets its share of the rolls, with the size bias inside it + const isTeen = (a, b) => a >= 11 && b <= 9; + const teens = pairs; + const tricky = cross(diceFaces('tricky')).filter(([a, b]) => !isTeen(a, b)); // 12 × 7 is already a teen roll + pairs = [...teens, ...tricky]; + const total = (list) => list.reduce((sum, [a, b]) => sum + sizeBias(a, b), 0); + const teenShare = teens.length ? LEGEND_TEEN_SHARE : 0; + const teenTotal = total(teens) || 1; + const trickyTotal = total(tricky); + w = (a, b) => sizeBias(a, b) * (isTeen(a, b) ? teenShare / teenTotal : (1 - teenShare) / trickyTotal); + } return { sides: DIFFICULTY_SIDES[difficulty], pairs, @@ -176,8 +198,9 @@ }; } - // Can a difficulty be played on this board at all? (Legend needs 11×11 or bigger.) - const fitsDifficulty = (difficulty, boardSize) => !usesPairDice(difficulty) || createPairDice(boardSize, Math.random, difficulty).pairs.length > 0; + // Can a difficulty be played on this board? Legend needs 12×12 or bigger + // (smaller boards would never see a teen). + const fitsDifficulty = (difficulty, boardSize) => (difficulty === 'legend' ? boardSize >= 12 : !usesPairDice(difficulty) || createPairDice(boardSize, Math.random, difficulty).pairs.length > 0); // "Loaded" dice matched to the board size, so small boards aren't swallowed // by huge rectangles and big boards don't take ages to fill with 1x1s. @@ -276,7 +299,11 @@ // pair dice: near the end (or always), only pairs that fit if (!fitOnlyNow(board, sides, mode)) return bag.rollPair(); const fitting = bag.pairs.filter(([a, b]) => canFit(board, a, b)); - return fitting.length ? bag.rollPair(fitting) : bag.rollPair(); + if (fitting.length) return bag.rollPair(fitting); + // None of this difficulty's facts fit the gaps left: finish the board with + // smaller facts (up to 12 × 12) rather than ending it half empty. + const small = fittingRolls(board, 12); + return small.length ? small[Math.floor(rng() * small.length)] : bag.rollPair(); } if (!fitOnlyNow(board, sides, mode)) return [bag.roll(), bag.roll()]; const fitting = fittingRolls(board, sides); @@ -305,6 +332,7 @@ chooseComputerMove, rollDie, DIFFICULTIES, + LEGEND_TEEN_SHARE, DIFFICULTY_SIDES, TRICKY_FACES, diceFaces, diff --git a/js/game.js b/js/game.js index 85d0276..6a59772 100644 --- a/js/game.js +++ b/js/game.js @@ -1218,7 +1218,7 @@ } const bonus = Progress.boardFinishBonus(setup.size); if (settings.difficulty === 'legend' && setup.size < 19 && setup.mode !== 'practice') { - el.boardSizeHelp.textContent = `On ${setup.size}×${setup.size}, Legend rolls up to ${setup.size} × 9. Bigger boards bring bigger teens (all the way to 19 on 20×20).`; + el.boardSizeHelp.textContent = `Legend: half the rolls are teens like 14 × 7, the rest are Tricky facts. On ${setup.size}×${setup.size} the teens go up to ${setup.size}; bigger boards bring bigger teens (all the way to 19 on 20×20).`; return; } el.boardSizeHelp.textContent = bonus diff --git a/js/invite.js b/js/invite.js index 4a1c745..63fa7dd 100644 --- a/js/invite.js +++ b/js/invite.js @@ -31,7 +31,7 @@ if (!raw || typeof raw !== 'object' || raw.v !== VERSION) return null; if (raw.m !== 'single' && raw.m !== 'multi') return null; if (!Number.isInteger(raw.b) || raw.b < 6 || raw.b > 30) return null; - if (raw.s && raw.s.difficulty === 'legend' && raw.b < 11) return null; // Legend's teens need room + if (raw.s && raw.s.difficulty === 'legend' && raw.b < 12) return null; // Legend's teens need room if (!Array.isArray(raw.p)) return null; const count = raw.p.length; if (raw.m === 'single' ? count !== 1 : count < 2 || count > 4) return null; diff --git a/js/progress.js b/js/progress.js index c5fe9a6..1803b59 100644 --- a/js/progress.js +++ b/js/progress.js @@ -284,7 +284,7 @@ // After Hard: harder facts rather than bigger numbers (see Core.diceFaces) { id: 'difficulty.tricky', group: 'Difficulty', name: 'Tricky: no easy facts (no ×1, ×2, ×5, ×10, ×11)', price: 700, requires: 'difficulty.hard', mastery: { size: 12, share: 0.75 } }, { id: 'difficulty.master', group: 'Difficulty', name: 'Master: the dice go after your weakest facts', price: 900, requires: 'difficulty.tricky', mastery: { faces: Core.TRICKY_FACES, share: 0.75 } }, - { id: 'difficulty.legend', group: 'Difficulty', name: 'Legend: two-digit × one-digit (like 14 × 7)', price: 1200, requires: 'difficulty.master', mastery: { size: 12, share: 0.9 } }, + { id: 'difficulty.legend', group: 'Difficulty', name: 'Legend: two-digit × one-digit (like 14 × 7) mixed with Tricky facts', price: 1200, requires: 'difficulty.master', mastery: { size: 12, share: 0.9 } }, // Placing rectangles: Click first, then Auto (drawing is free) { id: 'placeMode', group: 'Settings', name: 'Placing: click to place', price: 40 }, { id: 'placeAuto', group: 'Settings', name: 'Placing: auto (placed for you)', price: 60, requires: 'placeMode' }, diff --git a/test/core.test.js b/test/core.test.js index 4154a54..9c89eee 100644 --- a/test/core.test.js +++ b/test/core.test.js @@ -213,13 +213,19 @@ test('difficulties after Hard: Tricky, Master and Legend dice', () => { const [a, b] = tricky.rollPair(); assert.ok(Core.TRICKY_FACES.includes(a) && Core.TRICKY_FACES.includes(b), `${a} × ${b}`); } - // Legend: a teen times 2–9, and only on a board it fits - const legend = Core.createPairDice(12, Math.random, 'legend'); - for (let i = 0; i < 200; i++) { + // Legend: teen × 2–9 for about LEGEND_TEEN_SHARE of the rolls, Tricky's facts otherwise + const legend = Core.createPairDice(20, Math.random, 'legend'); + let teens = 0; + for (let i = 0; i < 2000; i++) { const [a, b] = legend.rollPair(); - assert.ok(a >= 11 && a <= 12 && b >= 2 && b <= 9, `${a} × ${b}`); + const teen = a >= 11 && a <= 19 && b >= 2 && b <= 9; + if (teen) teens++; + else assert.ok(Core.TRICKY_FACES.includes(a) && Core.TRICKY_FACES.includes(b), `${a} × ${b}`); } + assert.ok(Math.abs(teens / 2000 - Core.LEGEND_TEEN_SHARE) < 0.05, `teen share ${teens / 2000}`); assert.equal(Core.fitsDifficulty('legend', 8), false); + assert.equal(Core.fitsDifficulty('legend', 10), false); + assert.equal(Core.fitsDifficulty('legend', 12), true); assert.equal(Core.fitsDifficulty('legend', 20), true); assert.equal(Core.fitsDifficulty('hard', 6), true); // Master: the weight picks the facts @@ -232,3 +238,34 @@ test('difficulties after Hard: Tricky, Master and Legend dice', () => { const roll = Core.rollForBoard(board, tricky, Math.random, 'always'); assert.ok(Core.TRICKY_FACES.includes(roll[0])); }); + +// Plays a CPU-vs-CPU game with the difficulty's dice; returns how many rectangles went down. +function playOut(size, difficulty) { + const board = Core.createBoard(size); + const bag = Core.usesPairDice(difficulty) ? Core.createPairDice(size, Math.random, difficulty) : Core.createDice(size, Math.random, Core.DIFFICULTY_SIDES[difficulty]); + let passes = 0; + let placed = 0; + while (passes < 2 && Core.emptyCount(board) > 0) { + const [a, b] = Core.rollForBoard(board, bag, Math.random, 'end'); + const move = Core.chooseComputerMove(board, a, b); + if (!move) { + passes++; + continue; + } + passes = 0; + Core.place(board, move.x, move.y, move.w, move.h, placed % 2); + placed++; + } + return placed; +} + +test('Tricky, Master and Legend games last about as long as Hard (not 3 rectangles)', () => { + for (const difficulty of ['tricky', 'master', 'legend']) { + let total = 0; + for (let i = 0; i < 30; i++) total += playOut(12, difficulty); + assert.ok(total / 30 >= 7, `${difficulty} on 12×12: ${total / 30} rectangles`); + total = 0; + for (let i = 0; i < 20; i++) total += playOut(24, difficulty); + assert.ok(total / 20 >= 16, `${difficulty} on 24×24: ${total / 20} rectangles`); + } +}); From 051e6891ecf9033e9c57539aa03cab3ae43cc669 Mon Sep 17 00:00:00 2001 From: Aleksandr Ilin Date: Wed, 23 Sep 2026 12:04:35 +0000 Subject: [PATCH 003/127] Accounts server: sqlite store, swappable auth, classes, homework, parents, schedules - server/store.js: node:sqlite storage (users, schools, classes, rosters, progress, homework, parent links, scheduled tournaments) - server/auth.js: dev provider (HMAC JWT) and Keycloak provider (RS256 via JWKS); identity keeps first name, last initial, role, email domain only - server/accounts.js: roles and rules; Cadence Park (@iusd.org) school - HTTP API; hosting a class needs a teacher; signed-in students join live classes under their account name without the OpenAI name check - progress.js: activity log, merge, fact summary, homework goals Co-Authored-By: Claude Opus 5.5 (1M context) --- .gitignore | 1 + js/progress.js | 157 +++++++++++++++++ server/accounts.js | 379 ++++++++++++++++++++++++++++++++++++++++++ server/auth.js | 179 ++++++++++++++++++++ server/classroom.js | 101 ++++++++++- server/store.js | 247 +++++++++++++++++++++++++++ test/accounts.test.js | 213 ++++++++++++++++++++++++ test/server.test.js | 56 +++++++ 8 files changed, 1325 insertions(+), 8 deletions(-) create mode 100644 server/accounts.js create mode 100644 server/auth.js create mode 100644 server/store.js create mode 100644 test/accounts.test.js create mode 100644 test/server.test.js diff --git a/.gitignore b/.gitignore index c2658d7..902b281 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,2 @@ node_modules/ +data/ diff --git a/js/progress.js b/js/progress.js index 1803b59..b544e2e 100644 --- a/js/progress.js +++ b/js/progress.js @@ -26,6 +26,7 @@ stickers: {}, // sticker id -> how many boardWins: {}, // board size -> wins against the CPU on it helpedFacts: {}, // '6 × 7' -> true after "Help me count" on it, until it's answered right first time + activity: [], // recent games and practice rounds (see noteActivity) }; } @@ -84,6 +85,7 @@ boardWins: saved.boardWins || {}, helpedFacts: saved.helpedFacts || {}, cheats: saved.cheats || {}, + activity: Array.isArray(saved.activity) ? saved.activity : [], seen: { wardrobe: [], stickers: [], ...(saved.seen || {}) }, // for "new" badges factsOrdered: true, }; @@ -705,6 +707,152 @@ return true; } + // ---------------------------------------------------------------- accounts + // Shared by the page and the server (server/accounts.js): an activity log, + // merging a device's progress into an account, fact summaries and homework. + + // Recent games and practice rounds, newest last (class stats, homework). + // { t, kind: 'game'|'practice'|'class', difficulty, size?, won?, count?, finished? } + const ACTIVITY_LIMIT = 200; + function noteActivity(state, entry, now = Date.now()) { + state.activity = state.activity || []; + state.activity.push({ t: now, ...entry }); + if (state.activity.length > ACTIVITY_LIMIT) state.activity.splice(0, state.activity.length - ACTIVITY_LIMIT); + } + + // Two saves of the same person (this device's and the account's) as one: the + // most of every count, every unlock, achievement and sticker, and for each + // fact whichever record has seen it more. Used once per device and account. + function mergeProgress(a, b) { + const x = normalize(a); + const y = normalize(b); + const out = normalize({ ...y, ...x }); + out.wallet = Math.max(x.wallet, y.wallet); + out.earned = Math.max(x.earned, y.earned); + out.unlocks = [...new Set([...y.unlocks, ...x.unlocks])]; + out.achievements = { ...y.achievements }; + for (const [id, t] of Object.entries(x.achievements)) out.achievements[id] = out.achievements[id] ? Math.min(out.achievements[id], t) : t; + const maxOf = (p, q) => { + const r = { ...q }; + for (const [k, v] of Object.entries(p)) r[k] = typeof v === 'number' ? Math.max(v, Number(r[k]) || 0) : r[k] ?? v; + return r; + }; + out.counters = maxOf(x.counters, y.counters); + out.stickers = maxOf(x.stickers, y.stickers); + out.boardWins = maxOf(x.boardWins, y.boardWins); + out.checkIn = (x.checkIn.last || '') >= (y.checkIn.last || '') ? x.checkIn : y.checkIn; + out.facts = {}; + for (const key of new Set([...Object.keys(x.facts), ...Object.keys(y.facts)])) { + const fx = (x.facts[key] || { facts: {} }).facts; + const fy = (y.facts[key] || { facts: {} }).facts; + const facts = {}; + for (const k of new Set([...Object.keys(fx), ...Object.keys(fy)])) { + facts[k] = ((fx[k] && fx[k].asked) || 0) >= ((fy[k] && fy[k].asked) || 0) ? fx[k] : fy[k]; + } + out.facts[key] = { ...(y.facts[key] || {}), ...(x.facts[key] || {}), facts }; + } + const seen = new Set(); + out.activity = [...(y.activity || []), ...(x.activity || [])] + .filter((e) => { + const k = JSON.stringify(e); + return !seen.has(k) && seen.add(k); + }) + .sort((p, q) => p.t - q.t) + .slice(-ACTIVITY_LIMIT); + out.cheats = {}; // cheats stay on the device that typed them + return out; + } + + // The facts of the player on this device ("You"), for account stats. + const ownFacts = (state) => ((state && state.facts && state.facts[playerKey('You')]) || { facts: {} }).facts; + + // Counts by status over 1..size × 1..size, plus the facts that need practice. + function factSummary(state, size = 12) { + const facts = ownFacts(state); + const counts = { mastered: 0, learning: 0, practice: 0, unseen: 0 }; + const needsPractice = []; + for (let a = 1; a <= size; a++) { + for (let b = 1; b <= size; b++) { + const status = factStatus(facts[factKey(a, b)]); + counts[status]++; + if (status === 'practice') needsPractice.push(factKey(a, b)); + } + } + return { ...counts, total: size * size, size, needsPractice }; + } + + // ---- homework goals + // { type: 'master', table: 7 } every 7 × 1…10 and 1…10 × 7 fact mastered + // { type: 'practice', rounds: 3, difficulty: 'medium' } finish rounds (difficulty 'any' for any) + // { type: 'win', size: 8, difficulty?: 'any' } beat the CPU on a board this big or bigger + // { type: 'games', games: 5 } finish games (any kind) + // Rounds, wins and games count from when the homework was set. + const HOMEWORK_TABLE_UPTO = 10; + const HOMEWORK_TYPES = ['master', 'practice', 'win', 'games']; + const DIFF_NAME = (d) => (d && d !== 'any' ? `${TIMER_DIFFICULTY_NAMES[d] || d} ` : ''); + + // A clean copy of a goal, or null if it isn't one. + function cleanGoal(goal) { + if (!goal || !HOMEWORK_TYPES.includes(goal.type)) return null; + const int = (v, lo, hi) => (Number.isInteger(Number(v)) && Number(v) >= lo && Number(v) <= hi ? Number(v) : null); + const diff = (d) => (d === undefined || d === 'any' ? 'any' : DIFFICULTIES.includes(d) ? d : null); + if (goal.type === 'master') { + const table = int(goal.table, 1, 12); + return table ? { type: 'master', table } : null; + } + if (goal.type === 'practice') { + const rounds = int(goal.rounds, 1, 50); + const difficulty = diff(goal.difficulty); + return rounds && difficulty ? { type: 'practice', rounds, difficulty } : null; + } + if (goal.type === 'win') { + const size = int(goal.size, 6, 30); + const difficulty = diff(goal.difficulty); + return size && difficulty ? { type: 'win', size, difficulty } : null; + } + const games = int(goal.games, 1, 50); + return games ? { type: 'games', games } : null; + } + + function describeGoal(goal) { + if (goal.type === 'master') return `Master the ×${goal.table} table`; + if (goal.type === 'practice') return `Finish ${goal.rounds} ${DIFF_NAME(goal.difficulty)}practice ${goal.rounds === 1 ? 'round' : 'rounds'}`; + if (goal.type === 'win') return `Beat the CPU on ${DIFF_NAME(goal.difficulty)}${goal.size}×${goal.size} or bigger`; + return `Finish ${goal.games} ${goal.games === 1 ? 'game' : 'games'}`; + } + + // How far along it is: { have, need, done } (from the save and when it was set). + function goalProgress(state, goal, since = 0) { + const after = ((state && state.activity) || []).filter((e) => e.t >= since); + const diffOk = (e) => goal.difficulty === 'any' || !goal.difficulty || e.difficulty === goal.difficulty; + let have = 0; + let need = 1; + if (goal.type === 'master') { + const facts = ownFacts(state); + const keys = new Set(); + for (let n = 1; n <= HOMEWORK_TABLE_UPTO; n++) keys.add(factKey(goal.table, n)).add(factKey(n, goal.table)); + need = keys.size; + have = [...keys].filter((k) => factStatus(facts[k]) === 'mastered').length; + } else if (goal.type === 'practice') { + need = goal.rounds; + have = after.filter((e) => e.kind === 'practice' && e.finished && diffOk(e)).length; + } else if (goal.type === 'win') { + have = after.some((e) => e.kind === 'game' && e.won && e.size >= goal.size && diffOk(e)) ? 1 : 0; + } else { + need = goal.games; + have = after.filter((e) => e.kind === 'game' || e.kind === 'class').length; + } + return { have: Math.min(have, need), need, done: have >= need }; + } + + // Full status for a list: 'done', 'late' (past the due date) or 'open'. + // due is 'YYYY-MM-DD' (the end of that day counts); today likewise. + function homeworkStatus(state, assignment, today) { + const p = goalProgress(state, assignment.goal, assignment.createdAt || 0); + const status = p.done ? 'done' : assignment.due && today && today > assignment.due ? 'late' : 'open'; + return { ...p, status, label: describeGoal(assignment.goal) }; + } + const api = { createProgress, normalize, @@ -717,6 +865,15 @@ practiceCountId, practiceExpertId, practiceSet, + noteActivity, + mergeProgress, + factSummary, + cleanGoal, + describeGoal, + goalProgress, + homeworkStatus, + HOMEWORK_TYPES, + HOMEWORK_TABLE_UPTO, CHEAT_CODES, cheatOn, cheatOff, diff --git a/server/accounts.js b/server/accounts.js new file mode 100644 index 0000000..fa72365 --- /dev/null +++ b/server/accounts.js @@ -0,0 +1,379 @@ +// Blockout accounts: who can do what with classes, rosters, homework, parent +// links and scheduled tournaments. Pure rules over the store (server/store.js) +// and an auth provider (server/auth.js); server/classroom.js wires them to HTTP. +// Every function takes the signed-in user (or null for a guest) first. +'use strict'; + +const crypto = require('crypto'); +const Progress = require('../js/progress.js'); +const Auth = require('./auth.js'); +const Tournament = require('./tournament.js'); + +class AccountError extends Error { + constructor(code, message, status = 400) { + super(message); + this.code = code; + this.status = status; + } +} + +const MAX_PROGRESS_BYTES = 512 * 1024; +const CLASS_NAME_MAX = 40; +const TITLE_MAX = 60; +// Class and parent codes: no 0/O, 1/I/L, so they're easy to read out loud +const CODE_LETTERS = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789'; + +function randomCode(length, taken) { + for (;;) { + let code = ''; + const bytes = crypto.randomBytes(length); + for (let i = 0; i < length; i++) code += CODE_LETTERS[bytes[i] % CODE_LETTERS.length]; + if (!taken(code)) return code; + } +} +const normCode = (code) => String(code || '').toUpperCase().replace(/[^A-Z0-9]/g, ''); + +// 'YYYY-MM-DD' for a time (the server's local day) +function day(t = Date.now()) { + const d = new Date(t); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; +} + +function createAccounts({ store, auth, now = () => Date.now() }) { + const need = (user, ...roles) => { + if (!user) throw new AccountError('signin', 'Please sign in first.', 401); + if (roles.length && !roles.includes(user.role)) throw new AccountError('role', `Only ${roles.map((r) => `${r}s`).join(' and ')} can do that.`, 403); + return user; + }; + + // What the page sees about a user (never the subject or the domain) + function publicUser(u) { + const school = u.schoolId ? store.school(u.schoolId) : null; + return { id: u.id, role: u.role, name: u.name, firstName: u.firstName, lastInitial: u.lastInitial, school: school ? { id: school.id, name: school.name } : null }; + } + + // ---- signing in + + async function authenticate(token) { + if (!token) return null; + const claims = await auth.verify(token); + const id = Auth.identity(claims, { schoolByDomain: store.schoolByDomain }); + return store.upsertUser(id, now()); + } + + // Dev sign-in only: make a token for a made-up person. + async function devSignIn(body = {}) { + if (auth.name !== 'dev') throw new AccountError('provider', 'Dev sign-in is switched off on this server.', 403); + let email = body.email; + // A dev teacher picks a school; use a made-up address at its domain + if (body.role === 'teacher' && body.schoolId && !email) { + const school = store.school(body.schoolId); + if (school) email = `teacher@${school.domain}`; + } + let token; + try { + token = auth.issue({ firstName: body.firstName, lastInitial: body.lastInitial, role: body.role, email }); + } catch (e) { + throw new AccountError('name', e.message); + } + const user = await authenticate(token); + return { token, user: publicUser(user) }; + } + + function config() { + return { + provider: auth.name, + ...(auth.config || {}), + schools: store.schools().map((s) => ({ id: s.id, name: s.name, domain: s.domain })), + }; + } + + // ---- progress + + function getProgress(user) { + need(user); + const saved = store.getProgress(user.id); + return saved ? { progress: saved.data, updatedAt: saved.updatedAt } : { progress: null }; + } + + function putProgress(user, data) { + need(user); + if (!data || typeof data !== 'object') throw new AccountError('progress', 'That isn’t a save.'); + const clean = Progress.normalize(data); + clean.cheats = {}; // cheats stay on the device + if (JSON.stringify(clean).length > MAX_PROGRESS_BYTES) throw new AccountError('progress', 'That save is too big.'); + store.putProgress(user.id, clean, now()); + return { ok: true, updatedAt: now() }; + } + + const progressOf = (userId) => (store.getProgress(userId) || { data: null }).data; + + // ---- a student's stats (teacher and parent views) + + function studentStats(student, { withParentCode = false } = {}) { + const state = progressOf(student.id); + const summary = Progress.factSummary(state, 12); + const homework = store.assignmentsForStudent(student.id).map((a) => homeworkRow(state, a)); + const activity = ((state && state.activity) || []).slice(-10).reverse(); + return { + id: student.id, + name: student.name, + lastSeen: student.lastSeen, + lastPlayed: activity.length ? activity[0].t : null, + summary, + homework: { done: homework.filter((h) => h.status === 'done').length, late: homework.filter((h) => h.status === 'late').length, total: homework.length }, + assignments: homework, + recent: activity, + ...(withParentCode ? { parentCode: ensureParentCode(student) } : {}), + }; + } + + function homeworkRow(state, a) { + const creator = store.user(a.createdBy); + return { + id: a.id, + goal: a.goal, + due: a.due, + createdAt: a.createdAt, + classId: a.classId, + from: creator ? (creator.role === 'teacher' ? 'teacher' : 'parent') : 'teacher', + fromName: creator ? creator.name : '', + ...Progress.homeworkStatus(state, a, day(now())), + }; + } + + // ---- classes + + function ownClass(user, classId) { + need(user, 'teacher'); + const c = store.classById(classId); + if (!c || c.teacherId !== user.id) throw new AccountError('class', 'That class isn’t one of yours.', 404); + return c; + } + + function createClass(user, body = {}) { + need(user, 'teacher'); + const name = String(body.name || '').replace(/\s+/g, ' ').trim().slice(0, CLASS_NAME_MAX); + if (!name) throw new AccountError('name', 'Give the class a name, like “Room 12”.'); + const code = randomCode(6, (c) => Boolean(store.classByCode(c))); + const c = store.createClass({ teacherId: user.id, schoolId: user.schoolId, name, code }, now()); + return classSummary(c); + } + + function classSummary(c) { + const school = c.schoolId ? store.school(c.schoolId) : null; + return { id: c.id, name: c.name, code: c.code, school: school ? school.name : null, students: store.members(c.id).length }; + } + + const listClasses = (user) => ({ classes: store.classesOfTeacher(need(user, 'teacher').id).map(classSummary) }); + + function classDetail(user, classId) { + const c = ownClass(user, classId); + const students = store.members(c.id).map((s) => studentStats(s, { withParentCode: true })); + const assignments = store.assignmentsForClass(c.id).map((a) => ({ + id: a.id, + goal: a.goal, + label: Progress.describeGoal(a.goal), + due: a.due, + createdAt: a.createdAt, + done: students.filter((s) => s.assignments.some((h) => h.id === a.id && h.status === 'done')).length, + of: students.length, + })); + return { class: classSummary(c), students, assignments }; + } + + function removeStudent(user, classId, studentId) { + const c = ownClass(user, classId); + store.removeMember(c.id, studentId); + return { ok: true }; + } + + // A student joins a class roster with its code. School classes only take + // accounts from that school's domain (e.g. @iusd.org). + function joinClass(user, body = {}) { + need(user, 'student'); + const c = store.classByCode(normCode(body.code)); + if (!c) throw new AccountError('code', 'No class has that code. Check it with your teacher.', 404); + const school = c.schoolId ? store.school(c.schoolId) : null; + if (school && user.domain !== school.domain) { + throw new AccountError('school', `This class is for ${school.name}. Sign in with your school account (@${school.domain}).`, 403); + } + store.addMember(c.id, user.id, now()); + return { class: { id: c.id, name: c.name } }; + } + + const myClasses = (user) => ({ classes: store.classesOfStudent(need(user, 'student').id).map((c) => ({ id: c.id, name: c.name })) }); + + // ---- parents + + function ensureParentCode(student) { + if (student.parentCode) return student.parentCode; + const code = randomCode(8, (c) => Boolean(store.userByParentCode(c))); + store.setParentCode(student.id, code); + student.parentCode = code; + return code; + } + + function parentCode(user) { + need(user, 'student'); + return { code: ensureParentCode(user) }; + } + + function linkChild(user, body = {}) { + need(user, 'parent'); + const child = store.userByParentCode(normCode(body.code)); + if (!child || child.role !== 'student') throw new AccountError('code', 'That code doesn’t match a student. Check it and try again.', 404); + store.linkParent(user.id, child.id, now()); + return { child: { id: child.id, name: child.name } }; + } + + function unlinkChild(user, childId) { + need(user, 'parent'); + store.unlinkParent(user.id, childId); + return { ok: true }; + } + + const children = (user) => ({ children: store.childrenOf(need(user, 'parent').id).map((c) => studentStats(c)) }); + + // Who may see a student: their linked parents, and teachers of their classes + function canSee(user, childId) { + if (user.role === 'parent') return store.isParentOf(user.id, childId); + if (user.role === 'teacher') return store.classesOfStudent(childId).some((c) => c.teacherId === user.id); + return false; + } + + function childDetail(user, childId) { + need(user, 'parent', 'teacher'); + const child = store.user(childId); + if (!child || !canSee(user, childId)) throw new AccountError('student', 'You can’t see that student.', 404); + return studentStats(child, { withParentCode: user.role === 'teacher' }); + } + + // ---- homework + + function createAssignment(user, body = {}) { + need(user, 'teacher', 'parent'); + const goal = Progress.cleanGoal(body.goal); + if (!goal) throw new AccountError('goal', 'Pick what the homework is.'); + const due = body.due && /^\d{4}-\d{2}-\d{2}$/.test(body.due) ? body.due : null; + if (user.role === 'teacher') { + if (body.classId) { + ownClass(user, body.classId); + return { assignment: store.createAssignment({ createdBy: user.id, classId: body.classId, goal, due }, now()) }; + } + if (!body.studentId || !canSee(user, body.studentId)) throw new AccountError('student', 'Pick one of your classes or students.', 404); + } else if (!body.studentId || !store.isParentOf(user.id, body.studentId)) { + throw new AccountError('student', 'Link your child’s account first.', 404); + } + return { assignment: store.createAssignment({ createdBy: user.id, studentId: body.studentId, goal, due }, now()) }; + } + + function deleteAssignment(user, id) { + need(user, 'teacher', 'parent'); + const a = store.assignment(id); + if (!a || a.createdBy !== user.id) throw new AccountError('assignment', 'Only whoever set that homework can remove it.', 404); + store.deleteAssignment(id); + return { ok: true }; + } + + function myHomework(user) { + need(user, 'student'); + const state = progressOf(user.id); + return { homework: store.assignmentsForStudent(user.id).map((a) => homeworkRow(state, a)) }; + } + + // ---- scheduled tournaments + + function createSchedule(user, body = {}) { + need(user, 'teacher'); + const scope = body.scope === 'school' ? 'school' : 'class'; + let classId = null; + if (scope === 'class') classId = ownClass(user, body.classId).id; + else if (!user.schoolId) throw new AccountError('school', 'School tournaments need a school account.'); + const startsAt = Number(body.startsAt); + if (!Number.isFinite(startsAt) || startsAt < now() - 60 * 1000) throw new AccountError('time', 'Pick a time that hasn’t passed yet.'); + const s = body.settings || {}; + const settings = { + type: 'tournament', + format: Tournament.FORMATS.includes(s.format) ? s.format : 'knockout', + size: [6, 8, 10, 12].includes(Number(s.size)) ? Number(s.size) : 8, + difficulty: ['easy', 'medium', 'hard'].includes(s.difficulty) ? s.difficulty : 'easy', + }; + const title = String(body.title || '').replace(/\s+/g, ' ').trim().slice(0, TITLE_MAX) || (scope === 'school' ? 'School tournament' : 'Class tournament'); + return { schedule: scheduleView(store.createSchedule({ teacherId: user.id, scope, classId, schoolId: user.schoolId, title, startsAt, settings }, now())) }; + } + + function scheduleView(s) { + const c = s.classId ? store.classById(s.classId) : null; + return { id: s.id, title: s.title, scope: s.scope, classId: s.classId, className: c ? c.name : null, startsAt: s.startsAt, settings: s.settings, open: Boolean(s.roomCode), roomCode: s.roomCode }; + } + + const listSchedules = (user) => ({ schedules: store.schedulesOfTeacher(need(user, 'teacher').id).map(scheduleView) }); + + function deleteSchedule(user, id) { + need(user, 'teacher'); + const s = store.schedule(id); + if (!s || s.teacherId !== user.id) throw new AccountError('schedule', 'That isn’t one of your tournaments.', 404); + store.deleteSchedule(id); + return { ok: true }; + } + + // The teacher's keys to an opened tournament's lobby + function hostSchedule(user, id) { + need(user, 'teacher'); + const s = store.schedule(id); + if (!s || s.teacherId !== user.id) throw new AccountError('schedule', 'That isn’t one of your tournaments.', 404); + if (!s.roomCode) throw new AccountError('schedule', 'That tournament hasn’t started yet.', 409); + return { code: s.roomCode, teacherKey: s.teacherKey }; + } + + // Called every few seconds: opens the lobby of every tournament that's due. + // createRoom(settings) -> { code, teacherKey } (a live room on this server). + function openDue(createRoom) { + const opened = []; + for (const s of store.dueSchedules(now())) { + const room = createRoom(s.settings); + store.markOpened(s.id, room.code, room.teacherKey, now()); + opened.push({ id: s.id, code: room.code }); + } + return opened; + } + + // Tournaments open right now for a student (their classes' and their school's) + function live(user) { + if (!user || user.role !== 'student') return { live: [] }; + return { live: store.openSchedulesForStudent(user).map((s) => ({ id: s.id, title: s.title, code: s.roomCode, startsAt: s.startsAt })) }; + } + + return { + authenticate, + devSignIn, + config, + publicUser, + me: (user) => ({ user: publicUser(need(user)) }), + getProgress, + putProgress, + createClass, + listClasses, + classDetail, + removeStudent, + joinClass, + myClasses, + parentCode, + linkChild, + unlinkChild, + children, + childDetail, + createAssignment, + deleteAssignment, + myHomework, + createSchedule, + listSchedules, + deleteSchedule, + hostSchedule, + openDue, + live, + }; +} + +module.exports = { createAccounts, AccountError, day, normCode }; diff --git a/server/auth.js b/server/auth.js new file mode 100644 index 0000000..db372e3 --- /dev/null +++ b/server/auth.js @@ -0,0 +1,179 @@ +// Blockout sign-in: one interface, swappable providers. No dependencies. +// +// Every provider turns a bearer token into the same claims shape (OIDC-style): +// { sub, given_name, family_name, email?, roles: ['student'|'teacher'|'parent'] } +// and `identity(claims)` turns claims into what we store (first name, last +// initial, role, email *domain* only). +// +// Providers: +// dev issues and checks HMAC-SHA256 signed JWTs ("dev login": pick any +// name and role). For development and the first classroom trials. +// keycloak checks RS256 JWTs from a Keycloak realm (OIDC with PKCE in the +// browser). Keycloak can broker Google sign-in, restricted to a +// school's domain (e.g. @iusd.org). Roles come from realm roles. +// +// BLOCKOUT_AUTH=keycloak KEYCLOAK_ISSUER=https://id.example.org/realms/blockout KEYCLOAK_CLIENT_ID=blockout +'use strict'; + +const crypto = require('crypto'); + +const ROLES = ['student', 'teacher', 'parent']; +const TOKEN_DAYS = 30; + +class AuthError extends Error { + constructor(message) { + super(message); + this.code = 'auth'; + } +} + +const b64url = (buf) => Buffer.from(buf).toString('base64').replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); +const fromB64url = (s) => Buffer.from(String(s).replace(/-/g, '+').replace(/_/g, '/'), 'base64'); + +function decodeJwt(token) { + const parts = String(token || '').split('.'); + if (parts.length !== 3) throw new AuthError('Not a sign-in token.'); + try { + return { header: JSON.parse(fromB64url(parts[0])), payload: JSON.parse(fromB64url(parts[1])), signed: `${parts[0]}.${parts[1]}`, signature: fromB64url(parts[2]) }; + } catch (e) { + throw new AuthError('Not a sign-in token.'); + } +} + +function checkTimes(payload, now) { + const secs = Math.floor(now / 1000); + if (typeof payload.exp === 'number' && payload.exp < secs) throw new AuthError('Your sign-in has expired. Please sign in again.'); + if (typeof payload.nbf === 'number' && payload.nbf > secs + 60) throw new AuthError('Sign-in token not valid yet.'); +} + +// ---------------------------------------------------------------- dev provider + +function devProvider({ secret, now = () => Date.now() }) { + if (!secret) throw new Error('dev auth needs a secret'); + const sign = (data) => crypto.createHmac('sha256', secret).update(data).digest(); + return { + name: 'dev', + // Issue a token for a made-up person: { firstName, lastInitial, role, email? } + issue(person) { + const role = ROLES.includes(person.role) ? person.role : 'student'; + const firstName = cleanFirstName(person.firstName); + if (!firstName) throw new AuthError('Please type a first name.'); + const lastInitial = cleanInitial(person.lastInitial); + const email = person.email ? String(person.email).trim().toLowerCase() : ''; + if (email && !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) throw new AuthError('That email doesn’t look right.'); + // The same person (name, role, email) always gets the same subject, so + // signing in again finds the same account. + const sub = `dev:${crypto.createHash('sha256').update(`${role}|${firstName.toLowerCase()}|${lastInitial}|${email}`).digest('hex').slice(0, 24)}`; + const iat = Math.floor(now() / 1000); + const payload = { iss: 'blockout-dev', sub, given_name: firstName, family_name: lastInitial, roles: [role], iat, exp: iat + TOKEN_DAYS * 86400 }; + if (email) payload.email = email; + const head = b64url(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = b64url(JSON.stringify(payload)); + return `${head}.${body}.${b64url(sign(`${head}.${body}`))}`; + }, + async verify(token) { + const t = decodeJwt(token); + if (t.header.alg !== 'HS256') throw new AuthError('Wrong kind of sign-in token.'); + const expected = sign(t.signed); + if (expected.length !== t.signature.length || !crypto.timingSafeEqual(expected, t.signature)) throw new AuthError('Sign-in token isn’t valid.'); + checkTimes(t.payload, now()); + return t.payload; + }, + }; +} + +// ---------------------------------------------------------------- keycloak provider + +// issuer: https://host/realms/NAME. fetchJson(url) is injectable for tests. +function keycloakProvider({ issuer, clientId, fetchJson = defaultFetchJson, now = () => Date.now() }) { + if (!issuer || !clientId) throw new Error('keycloak auth needs KEYCLOAK_ISSUER and KEYCLOAK_CLIENT_ID'); + let keys = null; // kid -> KeyObject + let fetchedAt = 0; + async function keyFor(kid) { + if (!keys || !keys.has(kid) || now() - fetchedAt > 3600 * 1000) { + const jwks = await fetchJson(`${issuer}/protocol/openid-connect/certs`); + keys = new Map((jwks.keys || []).filter((k) => k.kty === 'RSA').map((k) => [k.kid, crypto.createPublicKey({ key: k, format: 'jwk' })])); + fetchedAt = now(); + } + const key = keys.get(kid); + if (!key) throw new AuthError('Unknown sign-in key.'); + return key; + } + return { + name: 'keycloak', + config: { issuer, clientId }, + async verify(token) { + const t = decodeJwt(token); + if (t.header.alg !== 'RS256') throw new AuthError('Wrong kind of sign-in token.'); + const ok = crypto.verify('RSA-SHA256', Buffer.from(t.signed), await keyFor(t.header.kid), t.signature); + if (!ok) throw new AuthError('Sign-in token isn’t valid.'); + const p = t.payload; + if (p.iss !== issuer) throw new AuthError('Sign-in token is from somewhere else.'); + const aud = [].concat(p.aud || []); + if (p.azp !== clientId && !aud.includes(clientId)) throw new AuthError('Sign-in token is for another app.'); + checkTimes(p, now()); + // Keycloak puts realm roles under realm_access.roles + return { ...p, roles: p.roles || (p.realm_access && p.realm_access.roles) || [] }; + }, + }; +} + +async function defaultFetchJson(url) { + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) throw new AuthError('Can’t reach the sign-in server.'); + return res.json(); +} + +// ---------------------------------------------------------------- identity + +function cleanFirstName(name) { + const n = String(name || '') + .normalize('NFC') + .replace(/[^\p{L}\p{M}' -]/gu, '') + .trim() + .split(/\s+/)[0] || ''; + return n.slice(0, 20); +} + +function cleanInitial(s) { + const c = String(s || '').normalize('NFC').replace(/[^\p{L}]/gu, '').charAt(0); + return c ? c.toUpperCase() : ''; +} + +// Claims -> what we keep. Role: the first of our roles the token has; without +// one, a school-domain account is a student and anyone else a parent. +function identity(claims, { schoolByDomain = () => null } = {}) { + const email = String(claims.email || '').toLowerCase(); + const domain = email.includes('@') ? email.split('@').pop() : null; + const school = domain ? schoolByDomain(domain) : null; + const roles = [].concat(claims.roles || []); + const role = ROLES.find((r) => roles.includes(r)) || (school ? 'student' : 'parent'); + const firstName = cleanFirstName(claims.given_name || claims.name || claims.preferred_username) || 'Player'; + return { + sub: String(claims.sub), + role, + firstName, + lastInitial: cleanInitial(claims.family_name), + domain, + schoolId: school ? school.id : null, + }; +} + +function fromEnv(env, store) { + if (env.BLOCKOUT_AUTH === 'keycloak') return keycloakProvider({ issuer: env.KEYCLOAK_ISSUER, clientId: env.KEYCLOAK_CLIENT_ID }); + // The dev secret is kept in the database so sign-ins survive a restart + let secret = env.BLOCKOUT_AUTH_SECRET || store.getMeta('devSecret'); + if (!secret) { + secret = crypto.randomBytes(32).toString('hex'); + store.setMeta('devSecret', secret); + } + return devProvider({ secret }); +} + +// "Bearer xyz" -> "xyz" +const bearer = (req) => { + const h = req.headers.authorization || ''; + return h.startsWith('Bearer ') ? h.slice(7).trim() : null; +}; + +module.exports = { ROLES, AuthError, devProvider, keycloakProvider, identity, fromEnv, bearer, cleanFirstName, cleanInitial, b64url }; diff --git a/server/classroom.js b/server/classroom.js index 4e9a7c6..57bf089 100644 --- a/server/classroom.js +++ b/server/classroom.js @@ -18,6 +18,9 @@ const os = require('os'); const path = require('path'); const Rooms = require('./rooms.js'); const Moderation = require('./moderation.js'); +const Auth = require('./auth.js'); +const { openStore } = require('./store.js'); +const { createAccounts, AccountError } = require('./accounts.js'); const args = process.argv.slice(2); const flag = (name) => { @@ -41,6 +44,8 @@ const HEARTBEAT_MS = 25000; const POLL_HOLD_MS = 25000; // a long poll waits this long for a change before answering anyway const CLOSED_MEMORY_MS = 30 * 60 * 1000; // pollers of a closed class are told so for this long const MAX_BODY = 16 * 1024; +const MAX_PROGRESS_BODY = 600 * 1024; // a signed-in student's whole save +const SCHEDULE_TICK_MS = 10 * 1000; // how often due tournaments are opened const rooms = new Map(); // code -> room const streams = new Map(); // code -> Set of { res, role: 'teacher' | 'student', id } @@ -79,13 +84,13 @@ function send(res, status, body, type = 'application/json; charset=utf-8') { res.end(typeof body === 'string' ? body : JSON.stringify(body)); } -function readJson(req) { +function readJson(req, max = MAX_BODY) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on('data', (c) => { size += c.length; - if (size > MAX_BODY) { + if (size > max) { reject(new Rooms.RoomError('size', 'Too much data.')); req.destroy(); } else chunks.push(c); @@ -292,13 +297,82 @@ function lanUrls() { return out; } +// ---------------------------------------------------------------- accounts +// Teachers, students and parents (server/accounts.js), stored in sqlite +// (server/store.js). Sign-in is swappable (server/auth.js): dev sign-in for now, +// Keycloak later. Guests keep playing without any of this. + +const store = openStore(); +const auth = Auth.fromEnv(process.env, store); +const accounts = createAccounts({ store, auth }); + +// The signed-in user for a request, or null (a guest) +const signedIn = (req) => accounts.authenticate(Auth.bearer(req)); + +function openRoom(settings) { + const room = Rooms.createRoom(settings, { taken: new Set(rooms.keys()) }); + rooms.set(room.code, room); + return room; +} + +// [method, path pattern, handler(user, params, req)]; :names in the pattern become params +const accountRoutes = [ + ['GET', '/api/auth/config', () => accounts.config()], + ['POST', '/api/auth/dev', async (user, p, req) => accounts.devSignIn(await readJson(req))], + ['GET', '/api/me', (user) => accounts.me(user)], + ['GET', '/api/me/progress', (user) => accounts.getProgress(user)], + ['PUT', '/api/me/progress', async (user, p, req) => accounts.putProgress(user, (await readJson(req, MAX_PROGRESS_BODY)).progress)], + ['GET', '/api/me/homework', (user) => accounts.myHomework(user)], + ['GET', '/api/me/classes', (user) => accounts.myClasses(user)], + ['POST', '/api/me/classes', async (user, p, req) => accounts.joinClass(user, await readJson(req))], + ['GET', '/api/me/parent-code', (user) => accounts.parentCode(user)], + ['GET', '/api/me/live', (user) => accounts.live(user)], + ['GET', '/api/classes', (user) => accounts.listClasses(user)], + ['POST', '/api/classes', async (user, p, req) => accounts.createClass(user, await readJson(req))], + ['GET', '/api/classes/:id', (user, p) => accounts.classDetail(user, p.id)], + ['DELETE', '/api/classes/:id/students/:student', (user, p) => accounts.removeStudent(user, p.id, p.student)], + ['GET', '/api/students/:id', (user, p) => accounts.childDetail(user, p.id)], + ['POST', '/api/homework', async (user, p, req) => accounts.createAssignment(user, await readJson(req))], + ['DELETE', '/api/homework/:id', (user, p) => accounts.deleteAssignment(user, p.id)], + ['GET', '/api/children', (user) => accounts.children(user)], + ['POST', '/api/children', async (user, p, req) => accounts.linkChild(user, await readJson(req))], + ['DELETE', '/api/children/:id', (user, p) => accounts.unlinkChild(user, p.id)], + ['GET', '/api/schedules', (user) => accounts.listSchedules(user)], + ['POST', '/api/schedules', async (user, p, req) => accounts.createSchedule(user, await readJson(req))], + ['DELETE', '/api/schedules/:id', (user, p) => accounts.deleteSchedule(user, p.id)], + ['GET', '/api/schedules/:id/host', (user, p) => accounts.hostSchedule(user, p.id)], +]; + +function matchAccountRoute(method, pathname) { + const parts = pathname.split('/').filter(Boolean); + for (const [m, pattern, handler] of accountRoutes) { + if (m !== method) continue; + const want = pattern.split('/').filter(Boolean); + if (want.length !== parts.length) continue; + const params = {}; + if (want.every((w, i) => (w.startsWith(':') ? (params[w.slice(1)] = decodeURIComponent(parts[i])) : w === parts[i]))) return { handler, params }; + } + return null; +} + +// Scheduled tournaments: open each one's lobby when it's due +setInterval(() => { + try { + accounts.openDue(openRoom); + } catch (e) { + console.error(e); + } +}, SCHEDULE_TICK_MS).unref(); + const routes = { 'GET /api/health': () => ({ ok: true, lan: lanUrls() }), + // Hosting a class needs a teacher account 'POST /api/rooms': async (req) => { + const user = await signedIn(req); + if (!user || user.role !== 'teacher') throw new AccountError('teacher', 'Teachers: please sign in to host a class.', 401); const body = await readJson(req); - const room = Rooms.createRoom(body.settings, { taken: new Set(rooms.keys()) }); - rooms.set(room.code, room); + const room = openRoom(body.settings); return { code: room.code, teacherKey: room.teacherKey, lan: lanUrls() }; }, @@ -313,9 +387,15 @@ const routes = { if (body.teacher !== room.teacherKey && body.play !== room.playKey) throw new Rooms.RoomError('teacher', 'That teacher link isn’t right.'); p = Rooms.joinTeacher(room); } else { - const check = await Moderation.checkName(body.name, { apiKey: OPENAI_API_KEY }); - if (!check.ok) throw new Rooms.RoomError('name', 'Please use your real first name.'); - p = Rooms.join(room, body.name); + // Signed-in students play under their account's name (their school + // already knows it): no name check, nothing sent to OpenAI. + const user = await signedIn(req); + if (user && user.role === 'student') p = Rooms.join(room, user.firstName); + else { + const check = await Moderation.checkName(body.name, { apiKey: OPENAI_API_KEY }); + if (!check.ok) throw new Rooms.RoomError('name', 'Please use your real first name.'); + p = Rooms.join(room, body.name); + } } broadcast(room); return { id: p.id, key: p.key, name: p.name, teacher: Boolean(p.isTeacher) }; @@ -433,6 +513,8 @@ function route(req) { const url = new URL(req.url, 'http://x'); const parts = url.pathname.split('/').filter(Boolean); // api, rooms, CODE, action if (parts[0] !== 'api') return null; + const account = matchAccountRoute(req.method, url.pathname); + if (account) return { handler: async (r) => account.handler(await signedIn(r), account.params, r), url }; if (parts.length === 4 && parts[1] === 'rooms') { return { handler: routes[`${req.method} /api/rooms/:code/${parts[3]}`], code: parts[2], url }; } @@ -465,6 +547,8 @@ const server = http.createServer(async (req, res) => { if (!r.handler) return send(res, 404, { error: 'not-found', message: 'Not found.' }); send(res, 200, await r.handler(req, r.code)); } catch (e) { + if (e instanceof AccountError) return send(res, e.status, { error: e.code, message: e.message }); + if (e instanceof Auth.AuthError) return send(res, 401, { error: 'auth', message: e.message }); if (e instanceof Rooms.RoomError) { const status = e.code === 'room' || e.code === 'player' ? 404 : e.code === 'teacher' ? 403 : 409; return send(res, status, { error: e.code, message: e.message }); @@ -492,7 +576,8 @@ if (require.main === module) { console.log(`Blockout classroom server on http://localhost:${PORT}/`); for (const url of lanUrls()) console.log(url === PUBLIC_URL ? ` Students join at: ${url}` : ` Chromebooks on this network: ${url}`); console.log(` Name check: word list${OPENAI_API_KEY ? ' + OpenAI moderation' : ' (set OPENAI_API_KEY to add OpenAI moderation)'}`); + console.log(` Sign-in: ${auth.name === 'dev' ? 'dev sign-in (pick any name and role; set BLOCKOUT_AUTH=keycloak for Keycloak)' : `Keycloak at ${auth.config.issuer}`}`); }); } -module.exports = { server, rooms }; +module.exports = { server, rooms, store, accounts }; diff --git a/server/store.js b/server/store.js new file mode 100644 index 0000000..15ee61d --- /dev/null +++ b/server/store.js @@ -0,0 +1,247 @@ +// Blockout accounts storage: sqlite through Node's built-in node:sqlite (no +// dependencies). One small module so the rest of the server never writes SQL. +// +// Privacy: a user row holds a first name, a last initial, a role, the email +// *domain* (to match a school, e.g. iusd.org) and the sign-in provider's opaque +// subject id. Never the email address itself. +'use strict'; + +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const { DatabaseSync } = require('node:sqlite'); + +const SCHEMA = ` +CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); +CREATE TABLE IF NOT EXISTS schools (id TEXT PRIMARY KEY, name TEXT NOT NULL, domain TEXT UNIQUE NOT NULL); +CREATE TABLE IF NOT EXISTS users ( + id TEXT PRIMARY KEY, + sub TEXT UNIQUE NOT NULL, + role TEXT NOT NULL, + first_name TEXT NOT NULL, + last_initial TEXT NOT NULL DEFAULT '', + domain TEXT, + school_id TEXT REFERENCES schools(id), + parent_code TEXT UNIQUE, + created_at INTEGER NOT NULL, + last_seen INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS progress (user_id TEXT PRIMARY KEY REFERENCES users(id), data TEXT NOT NULL, updated_at INTEGER NOT NULL); +CREATE TABLE IF NOT EXISTS classes ( + id TEXT PRIMARY KEY, + teacher_id TEXT NOT NULL REFERENCES users(id), + school_id TEXT REFERENCES schools(id), + name TEXT NOT NULL, + code TEXT UNIQUE NOT NULL, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS class_members ( + class_id TEXT NOT NULL REFERENCES classes(id), + user_id TEXT NOT NULL REFERENCES users(id), + joined_at INTEGER NOT NULL, + PRIMARY KEY (class_id, user_id) +); +CREATE TABLE IF NOT EXISTS parent_links ( + parent_id TEXT NOT NULL REFERENCES users(id), + child_id TEXT NOT NULL REFERENCES users(id), + created_at INTEGER NOT NULL, + PRIMARY KEY (parent_id, child_id) +); +CREATE TABLE IF NOT EXISTS assignments ( + id TEXT PRIMARY KEY, + created_by TEXT NOT NULL REFERENCES users(id), + class_id TEXT REFERENCES classes(id), + student_id TEXT REFERENCES users(id), + goal TEXT NOT NULL, + due TEXT, + created_at INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS schedules ( + id TEXT PRIMARY KEY, + teacher_id TEXT NOT NULL REFERENCES users(id), + scope TEXT NOT NULL, + class_id TEXT REFERENCES classes(id), + school_id TEXT REFERENCES schools(id), + title TEXT NOT NULL, + starts_at INTEGER NOT NULL, + settings TEXT NOT NULL, + room_code TEXT, + teacher_key TEXT, + opened_at INTEGER, + created_at INTEGER NOT NULL +); +`; + +// The first school. More can be added with addSchool(). +const DEFAULT_SCHOOLS = [{ id: 'cadence-park', name: 'Cadence Park', domain: 'iusd.org' }]; + +const newId = () => crypto.randomBytes(8).toString('hex'); + +// file: a path, or ':memory:' (tests). Defaults to data/blockout.db next to the repo. +function openStore(file = process.env.BLOCKOUT_DB || path.join(__dirname, '..', 'data', 'blockout.db')) { + if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true }); + const db = new DatabaseSync(file); + db.exec('PRAGMA foreign_keys = ON;'); + db.exec(SCHEMA); + const insertSchool = db.prepare('INSERT OR IGNORE INTO schools (id, name, domain) VALUES (?, ?, ?)'); + for (const s of DEFAULT_SCHOOLS) insertSchool.run(s.id, s.name, s.domain); + + const one = (sql, ...args) => db.prepare(sql).get(...args) || null; + const all = (sql, ...args) => db.prepare(sql).all(...args); + const run = (sql, ...args) => db.prepare(sql).run(...args); + const plain = (row) => (row ? { ...row } : null); // node:sqlite rows have a null prototype + + const userFromRow = (r) => + r && { + id: r.id, + sub: r.sub, + role: r.role, + firstName: r.first_name, + lastInitial: r.last_initial, + name: r.last_initial ? `${r.first_name} ${r.last_initial}.` : r.first_name, + domain: r.domain, + schoolId: r.school_id, + parentCode: r.parent_code, + createdAt: r.created_at, + lastSeen: r.last_seen, + }; + const classFromRow = (r) => r && { id: r.id, teacherId: r.teacher_id, schoolId: r.school_id, name: r.name, code: r.code, createdAt: r.created_at }; + const assignmentFromRow = (r) => + r && { id: r.id, createdBy: r.created_by, classId: r.class_id, studentId: r.student_id, goal: JSON.parse(r.goal), due: r.due, createdAt: r.created_at }; + const scheduleFromRow = (r) => + r && { + id: r.id, + teacherId: r.teacher_id, + scope: r.scope, + classId: r.class_id, + schoolId: r.school_id, + title: r.title, + startsAt: r.starts_at, + settings: JSON.parse(r.settings), + roomCode: r.room_code, + teacherKey: r.teacher_key, + openedAt: r.opened_at, + createdAt: r.created_at, + }; + + return { + db, + close: () => db.close(), + + // ---- meta (e.g. the dev sign-in secret, so tokens survive a restart) + getMeta: (key) => (one('SELECT value FROM meta WHERE key = ?', key) || {}).value || null, + setMeta: (key, value) => run('INSERT INTO meta (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value', key, value), + + // ---- schools + schoolByDomain: (domain) => plain(one('SELECT * FROM schools WHERE domain = ?', String(domain || '').toLowerCase())), + school: (id) => plain(one('SELECT * FROM schools WHERE id = ?', id)), + schools: () => all('SELECT * FROM schools ORDER BY name').map(plain), + addSchool: (s) => run('INSERT OR IGNORE INTO schools (id, name, domain) VALUES (?, ?, ?)', s.id, s.name, s.domain.toLowerCase()), + + // ---- users + userBySub: (sub) => userFromRow(one('SELECT * FROM users WHERE sub = ?', sub)), + user: (id) => userFromRow(one('SELECT * FROM users WHERE id = ?', id)), + userByParentCode: (code) => userFromRow(one('SELECT * FROM users WHERE parent_code = ?', code)), + // Creates the user, or updates name/role/school when they sign in again. + upsertUser(u, now = Date.now()) { + const existing = one('SELECT id FROM users WHERE sub = ?', u.sub); + if (existing) { + run( + 'UPDATE users SET role = ?, first_name = ?, last_initial = ?, domain = ?, school_id = ?, last_seen = ? WHERE id = ?', + u.role, u.firstName, u.lastInitial || '', u.domain || null, u.schoolId || null, now, existing.id + ); + return userFromRow(one('SELECT * FROM users WHERE id = ?', existing.id)); + } + const id = newId(); + run( + 'INSERT INTO users (id, sub, role, first_name, last_initial, domain, school_id, created_at, last_seen) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + id, u.sub, u.role, u.firstName, u.lastInitial || '', u.domain || null, u.schoolId || null, now, now + ); + return userFromRow(one('SELECT * FROM users WHERE id = ?', id)); + }, + touchUser: (id, now = Date.now()) => run('UPDATE users SET last_seen = ? WHERE id = ?', now, id), + setParentCode: (id, code) => run('UPDATE users SET parent_code = ? WHERE id = ?', code, id), + usersWithRole: (role) => all('SELECT * FROM users WHERE role = ? ORDER BY first_name', role).map(userFromRow), + + // ---- progress (the game's save, as JSON) + getProgress(userId) { + const r = one('SELECT data, updated_at FROM progress WHERE user_id = ?', userId); + return r ? { data: JSON.parse(r.data), updatedAt: r.updated_at } : null; + }, + putProgress: (userId, data, now = Date.now()) => + run('INSERT INTO progress (user_id, data, updated_at) VALUES (?, ?, ?) ON CONFLICT(user_id) DO UPDATE SET data = excluded.data, updated_at = excluded.updated_at', userId, JSON.stringify(data), now), + + // ---- classes and rosters + createClass(c, now = Date.now()) { + const id = newId(); + run('INSERT INTO classes (id, teacher_id, school_id, name, code, created_at) VALUES (?, ?, ?, ?, ?, ?)', id, c.teacherId, c.schoolId || null, c.name, c.code, now); + return classFromRow(one('SELECT * FROM classes WHERE id = ?', id)); + }, + classById: (id) => classFromRow(one('SELECT * FROM classes WHERE id = ?', id)), + classByCode: (code) => classFromRow(one('SELECT * FROM classes WHERE code = ?', code)), + classesOfTeacher: (teacherId) => all('SELECT * FROM classes WHERE teacher_id = ? ORDER BY created_at', teacherId).map(classFromRow), + classesOfStudent: (userId) => + all('SELECT c.* FROM classes c JOIN class_members m ON m.class_id = c.id WHERE m.user_id = ? ORDER BY c.created_at', userId).map(classFromRow), + renameClass: (id, name) => run('UPDATE classes SET name = ? WHERE id = ?', name, id), + addMember: (classId, userId, now = Date.now()) => run('INSERT OR IGNORE INTO class_members (class_id, user_id, joined_at) VALUES (?, ?, ?)', classId, userId, now), + removeMember: (classId, userId) => run('DELETE FROM class_members WHERE class_id = ? AND user_id = ?', classId, userId), + members: (classId) => + all('SELECT u.* FROM users u JOIN class_members m ON m.user_id = u.id WHERE m.class_id = ? ORDER BY u.first_name, u.last_initial', classId).map(userFromRow), + isMember: (classId, userId) => Boolean(one('SELECT 1 AS x FROM class_members WHERE class_id = ? AND user_id = ?', classId, userId)), + + // ---- parents + linkParent: (parentId, childId, now = Date.now()) => run('INSERT OR IGNORE INTO parent_links (parent_id, child_id, created_at) VALUES (?, ?, ?)', parentId, childId, now), + unlinkParent: (parentId, childId) => run('DELETE FROM parent_links WHERE parent_id = ? AND child_id = ?', parentId, childId), + childrenOf: (parentId) => + all('SELECT u.* FROM users u JOIN parent_links l ON l.child_id = u.id WHERE l.parent_id = ? ORDER BY u.first_name', parentId).map(userFromRow), + isParentOf: (parentId, childId) => Boolean(one('SELECT 1 AS x FROM parent_links WHERE parent_id = ? AND child_id = ?', parentId, childId)), + + // ---- homework + createAssignment(a, now = Date.now()) { + const id = newId(); + run( + 'INSERT INTO assignments (id, created_by, class_id, student_id, goal, due, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)', + id, a.createdBy, a.classId || null, a.studentId || null, JSON.stringify(a.goal), a.due || null, now + ); + return assignmentFromRow(one('SELECT * FROM assignments WHERE id = ?', id)); + }, + assignment: (id) => assignmentFromRow(one('SELECT * FROM assignments WHERE id = ?', id)), + deleteAssignment: (id) => run('DELETE FROM assignments WHERE id = ?', id), + assignmentsForClass: (classId) => all('SELECT * FROM assignments WHERE class_id = ? ORDER BY created_at', classId).map(assignmentFromRow), + // Everything a student has: their classes' homework plus homework set just for them + assignmentsForStudent: (userId) => + all( + `SELECT a.* FROM assignments a + WHERE a.student_id = ? OR a.class_id IN (SELECT class_id FROM class_members WHERE user_id = ?) + ORDER BY a.created_at`, + userId, userId + ).map(assignmentFromRow), + + // ---- scheduled tournaments + createSchedule(s, now = Date.now()) { + const id = newId(); + run( + 'INSERT INTO schedules (id, teacher_id, scope, class_id, school_id, title, starts_at, settings, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)', + id, s.teacherId, s.scope, s.classId || null, s.schoolId || null, s.title, s.startsAt, JSON.stringify(s.settings || {}), now + ); + return scheduleFromRow(one('SELECT * FROM schedules WHERE id = ?', id)); + }, + schedule: (id) => scheduleFromRow(one('SELECT * FROM schedules WHERE id = ?', id)), + deleteSchedule: (id) => run('DELETE FROM schedules WHERE id = ?', id), + schedulesOfTeacher: (teacherId) => all('SELECT * FROM schedules WHERE teacher_id = ? ORDER BY starts_at', teacherId).map(scheduleFromRow), + dueSchedules: (now) => all('SELECT * FROM schedules WHERE opened_at IS NULL AND starts_at <= ?', now).map(scheduleFromRow), + markOpened: (id, roomCode, teacherKey, now = Date.now()) => + run('UPDATE schedules SET room_code = ?, teacher_key = ?, opened_at = ? WHERE id = ?', roomCode, teacherKey, now, id), + // Open tournaments a student can join: their classes', or their school's + openSchedulesForStudent: (user) => + all( + `SELECT * FROM schedules WHERE room_code IS NOT NULL AND ( + class_id IN (SELECT class_id FROM class_members WHERE user_id = ?) + OR (scope = 'school' AND school_id = ?) + ) ORDER BY starts_at`, + user.id, user.schoolId || '' + ).map(scheduleFromRow), + }; +} + +module.exports = { openStore, newId, DEFAULT_SCHOOLS }; diff --git a/test/accounts.test.js b/test/accounts.test.js new file mode 100644 index 0000000..962af88 --- /dev/null +++ b/test/accounts.test.js @@ -0,0 +1,213 @@ +const test = require('node:test'); +const assert = require('node:assert'); +const crypto = require('crypto'); +const { openStore } = require('../server/store.js'); +const Auth = require('../server/auth.js'); +const { createAccounts } = require('../server/accounts.js'); +const P = require('../js/progress.js'); + +function setup() { + let clock = Date.UTC(2026, 8, 23, 17, 0, 0); + const store = openStore(':memory:'); + const auth = Auth.devProvider({ secret: 'test-secret', now: () => clock }); + const accounts = createAccounts({ store, auth, now: () => clock }); + const signIn = async (person) => { + const { token } = await accounts.devSignIn(person); + return accounts.authenticate(token); + }; + return { store, auth, accounts, signIn, tick: (ms) => (clock += ms), now: () => clock }; +} + +test('dev sign-in: tokens check out, the same person gets the same account, bad tokens fail', async () => { + const { accounts, auth, signIn } = setup(); + const a = await signIn({ firstName: 'Maya', lastInitial: 'k', role: 'student', email: 'maya.k@iusd.org' }); + const b = await signIn({ firstName: 'Maya', lastInitial: 'K', role: 'student', email: 'maya.k@iusd.org' }); + assert.equal(a.id, b.id); + assert.equal(a.name, 'Maya K.'); + assert.equal(a.schoolId, 'cadence-park'); // @iusd.org is Cadence Park + assert.equal(a.domain, 'iusd.org'); // the domain, never the address + const token = auth.issue({ firstName: 'Sam', role: 'parent' }); + const forged = token.slice(0, -4) + (token.endsWith('AAAA') ? 'BBBB' : 'AAAA'); + await assert.rejects(accounts.authenticate(forged), /isn’t valid/); + await assert.rejects(accounts.devSignIn({ firstName: ' ', role: 'student' }), /first name/); +}); + +test('privacy: only first name, last initial, role and email domain are stored', async () => { + const { store, signIn } = setup(); + await signIn({ firstName: 'Olivia Grace', lastInitial: 'Martinez', role: 'student', email: 'olivia.martinez@iusd.org' }); + const rows = store.db.prepare('SELECT * FROM users').all(); + const text = JSON.stringify(rows); + assert.ok(!text.includes('olivia.martinez'), 'no email address'); + assert.ok(!text.includes('Martinez'), 'no last name'); + assert.equal(rows[0].first_name, 'Olivia'); + assert.equal(rows[0].last_initial, 'M'); +}); + +test('keycloak provider: checks RS256 signature, issuer and client; roles from realm_access', async () => { + const { publicKey, privateKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'k1', use: 'sig', alg: 'RS256' }; + const issuer = 'https://id.example.org/realms/blockout'; + const kc = Auth.keycloakProvider({ issuer, clientId: 'blockout', fetchJson: async () => ({ keys: [jwk] }), now: () => 1_800_000_000_000 }); + const make = (payload, key = privateKey) => { + const head = Auth.b64url(JSON.stringify({ alg: 'RS256', kid: 'k1', typ: 'JWT' })); + const body = Auth.b64url(JSON.stringify(payload)); + return `${head}.${body}.${Auth.b64url(crypto.sign('RSA-SHA256', Buffer.from(`${head}.${body}`), key))}`; + }; + const good = { iss: issuer, azp: 'blockout', sub: 'abc', given_name: 'Ana', family_name: 'Lopez', email: 'ana@iusd.org', realm_access: { roles: ['teacher'] }, exp: 1_900_000_000 }; + const claims = await kc.verify(make(good)); + assert.deepEqual(claims.roles, ['teacher']); + await assert.rejects(kc.verify(make({ ...good, iss: 'https://evil.example.org' })), /somewhere else/); + await assert.rejects(kc.verify(make({ ...good, azp: 'other' })), /another app/); + await assert.rejects(kc.verify(make({ ...good, exp: 1_000 })), /expired/); + const other = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }).privateKey; + await assert.rejects(kc.verify(make(good, other)), /isn’t valid/); + // Google-brokered student without a role: school domain -> student + const id = Auth.identity({ sub: 'g1', given_name: 'Leo', email: 'leo@iusd.org' }, { schoolByDomain: (d) => (d === 'iusd.org' ? { id: 'cadence-park' } : null) }); + assert.deepEqual([id.role, id.schoolId, id.domain], ['student', 'cadence-park', 'iusd.org']); +}); + +test('classes: teacher creates, any @iusd.org student joins with the code, others cannot', async () => { + const { accounts, signIn } = setup(); + const teacher = await signIn({ firstName: 'Ms', lastInitial: 'R', role: 'teacher', schoolId: 'cadence-park' }); + assert.equal(teacher.schoolId, 'cadence-park'); + const c = accounts.createClass(teacher, { name: 'Room 12' }); + assert.match(c.code, /^[A-Z2-9]{6}$/); + const kid = await signIn({ firstName: 'Maya', lastInitial: 'K', role: 'student', email: 'maya@iusd.org' }); + const outsider = await signIn({ firstName: 'Bo', role: 'student', email: 'bo@gmail.com' }); + assert.equal(accounts.joinClass(kid, { code: c.code.toLowerCase() }).class.name, 'Room 12'); + assert.throws(() => accounts.joinClass(outsider, { code: c.code }), /@iusd\.org/); + assert.throws(() => accounts.joinClass(kid, { code: 'NOPE22' }), /No class/); + assert.throws(() => accounts.createClass(kid, { name: 'x' }), /Only teachers/); + const other = await signIn({ firstName: 'Mr', lastInitial: 'T', role: 'teacher', schoolId: 'cadence-park' }); + assert.throws(() => accounts.classDetail(other, c.id), /isn’t one of yours/); + const detail = accounts.classDetail(teacher, c.id); + assert.equal(detail.students.length, 1); + assert.equal(detail.students[0].name, 'Maya K.'); + assert.match(detail.students[0].parentCode, /^[A-Z2-9]{8}$/); +}); + +test('progress sync: saves round-trip, cheats stay on the device, stats come from the save', async () => { + const { accounts, signIn } = setup(); + const kid = await signIn({ firstName: 'Maya', role: 'student', email: 'maya@iusd.org' }); + assert.equal(accounts.getProgress(kid).progress, null); + const s = P.createProgress(); + s.wallet = 50; + P.cheatOn(s, 'fivemoreminutesmom'); + s.facts.you = { name: 'You', facts: { '6 × 7': { asked: 4, firstTry: 1, wrong: 3, timeouts: 0, said: [], times: [] } } }; + accounts.putProgress(kid, s); + const back = accounts.getProgress(kid).progress; + assert.equal(back.wallet, 50); + assert.deepEqual(back.cheats, {}); + assert.deepEqual(P.factSummary(back).needsPractice, ['6 × 7']); +}); + +test('merging a device into an account keeps the most of everything', () => { + const device = P.createProgress(); + device.wallet = 30; + device.unlocks = ['practice']; + device.facts.you = { name: 'You', facts: { '3 × 4': { asked: 5, firstTry: 5, wrong: 0, timeouts: 0, said: [], times: [] } } }; + P.noteActivity(device, { kind: 'game', won: true, size: 6, difficulty: 'easy' }, 1000); + const account = P.createProgress(); + account.wallet = 80; + account.unlocks = ['board8']; + account.achievements = { first_game: 500 }; + account.facts.you = { name: 'You', facts: { '3 × 4': { asked: 2, firstTry: 1, wrong: 1, timeouts: 0, said: [], times: [] }, '5 × 5': { asked: 1, firstTry: 1, wrong: 0, timeouts: 0, said: [], times: [] } } }; + P.noteActivity(account, { kind: 'practice', finished: true, difficulty: 'easy' }, 2000); + const m = P.mergeProgress(device, account); + assert.equal(m.wallet, 80); + assert.deepEqual(m.unlocks.sort(), ['board8', 'practice']); + assert.equal(m.achievements.first_game, 500); + assert.equal(m.facts.you.facts['3 × 4'].asked, 5); + assert.ok(m.facts.you.facts['5 × 5']); + assert.deepEqual(m.activity.map((e) => e.kind), ['game', 'practice']); +}); + +test('homework: teacher sets class homework, parent sets extra; progress detected from the save', async () => { + const { accounts, signIn, tick, now } = setup(); + const teacher = await signIn({ firstName: 'Ms', lastInitial: 'R', role: 'teacher', schoolId: 'cadence-park' }); + const c = accounts.createClass(teacher, { name: 'Room 12' }); + const kid = await signIn({ firstName: 'Maya', role: 'student', email: 'maya@iusd.org' }); + accounts.joinClass(kid, { code: c.code }); + const parent = await signIn({ firstName: 'Ana', role: 'parent', email: 'ana@gmail.com' }); + assert.throws(() => accounts.createAssignment(parent, { studentId: kid.id, goal: { type: 'games', games: 1 } }), /Link your child/); + const code = accounts.parentCode(kid).code; + assert.equal(accounts.linkChild(parent, { code: code.toLowerCase() }).child.name, 'Maya'); + accounts.createAssignment(teacher, { classId: c.id, goal: { type: 'practice', rounds: 2, difficulty: 'easy' }, due: '2026-09-25' }); + accounts.createAssignment(parent, { studentId: kid.id, goal: { type: 'master', table: 7 } }); + assert.throws(() => accounts.createAssignment(teacher, { classId: c.id, goal: { type: 'fly' } }), /Pick what/); + tick(1000); + const s = P.createProgress(); + P.noteActivity(s, { kind: 'practice', finished: true, difficulty: 'easy' }, now()); + accounts.putProgress(kid, s); + let hw = accounts.myHomework(kid).homework; + assert.equal(hw.length, 2); + const practice = hw.find((h) => h.goal.type === 'practice'); + assert.deepEqual([practice.have, practice.need, practice.status, practice.from], [1, 2, 'open', 'teacher']); + assert.equal(hw.find((h) => h.goal.type === 'master').from, 'parent'); + // the parent sees everything, the teacher sees class progress + const seen = accounts.childDetail(parent, kid.id); + assert.equal(seen.assignments.length, 2); + const stranger = await signIn({ firstName: 'Zed', role: 'parent' }); + assert.throws(() => accounts.childDetail(stranger, kid.id), /can’t see/); + const detail = accounts.classDetail(teacher, c.id); + assert.deepEqual([detail.assignments.length, detail.assignments[0].done, detail.assignments[0].of], [1, 0, 1]); + // only the setter can remove homework + const extra = hw.find((h) => h.from === 'parent'); + assert.throws(() => accounts.deleteAssignment(teacher, extra.id), /Only whoever/); + accounts.deleteAssignment(parent, extra.id); + hw = accounts.myHomework(kid).homework; + assert.equal(hw.length, 1); +}); + +test('homework goals: master, practice by difficulty, win by board, games; late after due', () => { + const s = P.createProgress(); + s.facts.you = { name: 'You', facts: {} }; + for (let n = 1; n <= 10; n++) { + s.facts.you.facts[P.factKey(7, n)] = { asked: 3, firstTry: 3, wrong: 0, timeouts: 0, said: [], times: [] }; + s.facts.you.facts[P.factKey(n, 7)] = { asked: 3, firstTry: 3, wrong: 0, timeouts: 0, said: [], times: [] }; + } + assert.equal(P.goalProgress(s, { type: 'master', table: 7 }).done, true); + assert.deepEqual(P.goalProgress(s, { type: 'master', table: 8 }), { have: 2, need: 19, done: false }); // 8 × 7 and 7 × 8 + P.noteActivity(s, { kind: 'practice', finished: true, difficulty: 'medium' }, 100); + P.noteActivity(s, { kind: 'practice', finished: false, difficulty: 'medium' }, 200); + P.noteActivity(s, { kind: 'game', won: true, size: 8, difficulty: 'easy' }, 300); + assert.equal(P.goalProgress(s, { type: 'practice', rounds: 1, difficulty: 'medium' }).done, true); + assert.equal(P.goalProgress(s, { type: 'practice', rounds: 1, difficulty: 'hard' }).done, false); + assert.equal(P.goalProgress(s, { type: 'practice', rounds: 1, difficulty: 'medium' }, 150).done, false); // set after it + assert.equal(P.goalProgress(s, { type: 'win', size: 8, difficulty: 'any' }).done, true); + assert.equal(P.goalProgress(s, { type: 'win', size: 10, difficulty: 'any' }).done, false); + assert.equal(P.goalProgress(s, { type: 'games', games: 2 }).have, 1); + const late = P.homeworkStatus(s, { goal: { type: 'games', games: 2 }, due: '2026-09-20', createdAt: 0 }, '2026-09-23'); + assert.equal(late.status, 'late'); + assert.equal(late.label, 'Finish 2 games'); + assert.equal(P.cleanGoal({ type: 'win', size: 99 }), null); +}); + +test('scheduled tournaments open on time; students of the class (or school) see them', async () => { + const { accounts, signIn, tick, now } = setup(); + const teacher = await signIn({ firstName: 'Ms', lastInitial: 'R', role: 'teacher', schoolId: 'cadence-park' }); + const c = accounts.createClass(teacher, { name: 'Room 12' }); + const kid = await signIn({ firstName: 'Maya', role: 'student', email: 'maya@iusd.org' }); + const other = await signIn({ firstName: 'Leo', role: 'student', email: 'leo@iusd.org' }); + accounts.joinClass(kid, { code: c.code }); + assert.throws(() => accounts.createSchedule(teacher, { classId: c.id, startsAt: now() - 3600e3 }), /hasn’t passed/); + const { schedule } = accounts.createSchedule(teacher, { classId: c.id, startsAt: now() + 60e3, settings: { format: 'swiss', size: 8 } }); + accounts.createSchedule(teacher, { scope: 'school', title: 'Cadence Park Cup', startsAt: now() + 120e3 }); + let made = 0; + const createRoom = (settings) => { + assert.equal(settings.type, 'tournament'); + made++; + return { code: `ROOM${made}`, teacherKey: `key${made}` }; + }; + assert.deepEqual(accounts.openDue(createRoom), []); + assert.throws(() => accounts.hostSchedule(teacher, schedule.id), /hasn’t started/); + tick(61e3); + assert.equal(accounts.openDue(createRoom).length, 1); + assert.deepEqual(accounts.hostSchedule(teacher, schedule.id), { code: 'ROOM1', teacherKey: 'key1' }); + assert.deepEqual(accounts.live(kid).live.map((l) => l.code), ['ROOM1']); + assert.deepEqual(accounts.live(other).live, []); // not in the class + tick(60e3); + accounts.openDue(createRoom); + assert.deepEqual(accounts.live(other).live.map((l) => l.title), ['Cadence Park Cup']); // the whole school + assert.equal(accounts.openDue(createRoom).length, 0); // each opens once +}); diff --git a/test/server.test.js b/test/server.test.js new file mode 100644 index 0000000..3a0e1cf --- /dev/null +++ b/test/server.test.js @@ -0,0 +1,56 @@ +// The HTTP API end to end: a real server on a spare port with an in-memory database. +process.env.BLOCKOUT_DB = ':memory:'; +process.env.BLOCKOUT_AUTH_SECRET = 'test-secret'; +const test = require('node:test'); +const assert = require('node:assert'); +const { server } = require('../server/classroom.js'); + +let base; +test.before(() => new Promise((resolve) => server.listen(0, () => ((base = `http://localhost:${server.address().port}`), resolve())))); +test.after(() => server.close()); + +async function call(method, path, body, token) { + const res = await fetch(base + path, { + method, + headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, + body: body ? JSON.stringify(body) : undefined, + }); + return { status: res.status, data: await res.json() }; +} +const signIn = async (person) => (await call('POST', '/api/auth/dev', person)).data.token; + +test('HTTP: sign in, class roster, hosting needs a teacher, signed-in students join under their account name', async () => { + const config = await call('GET', '/api/auth/config'); + assert.equal(config.data.provider, 'dev'); + assert.deepEqual(config.data.schools.map((s) => s.name), ['Cadence Park']); + + const teacher = await signIn({ firstName: 'Ms', lastInitial: 'R', role: 'teacher', schoolId: 'cadence-park' }); + const kid = await signIn({ firstName: 'Maya', lastInitial: 'K', role: 'student', email: 'maya@iusd.org' }); + assert.equal((await call('GET', '/api/me', null, kid)).data.user.name, 'Maya K.'); + assert.equal((await call('GET', '/api/me')).status, 401); + assert.equal((await call('GET', '/api/me', null, 'nonsense')).status, 401); + + const c = (await call('POST', '/api/classes', { name: 'Room 12' }, teacher)).data; + assert.equal((await call('POST', '/api/me/classes', { code: c.code }, kid)).status, 200); + assert.equal((await call('POST', '/api/classes', { name: 'Nope' }, kid)).status, 403); + const detail = (await call('GET', `/api/classes/${c.id}`, null, teacher)).data; + assert.deepEqual(detail.students.map((s) => s.name), ['Maya K.']); + + // guests and students can't host; teachers can + assert.equal((await call('POST', '/api/rooms', { settings: {} })).status, 401); + assert.equal((await call('POST', '/api/rooms', { settings: {} }, kid)).status, 401); + const room = (await call('POST', '/api/rooms', { settings: {} }, teacher)).data; + assert.match(room.code, /^[A-Z]+$/); + // a signed-in student joins as their account's first name, whatever they type + const joined = (await call('POST', `/api/rooms/${room.code}/join`, { name: 'Whatever' }, kid)).data; + assert.equal(joined.name, 'Maya'); + // guests still join with a name + assert.equal((await call('POST', `/api/rooms/${room.code}/join`, { name: 'Leo' })).data.name, 'Leo'); + + // progress sync + const P = require('../js/progress.js'); + const save = P.createProgress(); + save.wallet = 42; + assert.equal((await call('PUT', '/api/me/progress', { progress: save }, kid)).status, 200); + assert.equal((await call('GET', '/api/me/progress', null, kid)).data.progress.wallet, 42); +}); From d4abf94e214a562f1c12beb2a1cb385f15525cb2 Mon Sep 17 00:00:00 2001 From: Aleksandr Ilin Date: Wed, 23 Sep 2026 20:22:09 +0000 Subject: [PATCH 004/127] feat(accounts): sign in, homework, My classes and My kids in the game - js/auth.js: dev sign-in, Keycloak PKCE ready, per-account save, first-use merge of the device's guest save, sync - account screens: sign-in dialog (guests always welcome), student homework and classes, teacher roster/stats/homework/tournaments, parent view with extra homework, live tournament banner - hosting a class needs a teacher; activity log for stats and homework - docs/privacy.md Co-Authored-By: Claude Opus 5.5 (1M context) --- CLAUDE.md | 13 +- css/style.css | 96 +++++ docs/privacy.md | 43 ++ docs/v0.3.0-log.md | 64 ++- index.html | 12 +- js/accounts.js | 894 ++++++++++++++++++++++++++++++++++++++++++ js/auth.js | 214 ++++++++++ js/classroom.js | 4 +- js/game.js | 54 ++- js/progress.js | 15 +- server/accounts.js | 8 +- test/accounts.test.js | 4 + 12 files changed, 1405 insertions(+), 16 deletions(-) create mode 100644 docs/privacy.md create mode 100644 js/accounts.js create mode 100644 js/auth.js diff --git a/CLAUDE.md b/CLAUDE.md index 90a29d6..46983a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,9 +21,15 @@ A multiplication-as-area game for 3rd graders, based on "Blockout" from Math for | `server/tournament.js` | Tournament formats: knockout, double elimination, round robin, Swiss, king of the hill. | | `server/moderation.js` | Username checks: `blocklist.txt`, plus OpenAI moderation only when `OPENAI_API_KEY` is set (fails open). | | `prototypes/` | Other operations (addition, subtraction, division, fractions). Single player and practice only. Shares `prototypes/shared/`. | +| `js/auth.js` | `BlockoutAuth`: sign-in (dev or Keycloak PKCE), the bearer token, per-account progress key and sync with the server. | +| `js/accounts.js` | Account screens: sign in, student homework, teacher My classes, parent My kids, live tournament banner. Uses `window.BlockoutGame` (a small API game.js exposes). | +| `server/store.js` | sqlite (`node:sqlite`) storage for accounts; `data/blockout.db` or `BLOCKOUT_DB` (`:memory:` in tests). | +| `server/auth.js` | Sign-in providers behind one interface: dev (HMAC JWT) and Keycloak (RS256 via JWKS); `identity()` keeps only first name, last initial, role, email domain. | +| `server/accounts.js` | Account rules: classes and rosters, homework, parent links, scheduled tournaments. | +| `docs/` | `privacy.md`, and the v0.3.0 plan and log. | | `test/*.test.js` | `node:test` unit tests for the pure modules and the server rooms. | -Browser script load order (index.html): core → progress → cosmetics → invite → vendor/qrcode → classroom → game. Modules are UMD-style: `window.BlockoutX` in the browser, `require()` in tests. +Browser script load order (index.html): core → progress → auth → cosmetics → invite → vendor/qrcode → classroom → game → accounts. Modules are UMD-style: `window.BlockoutX` in the browser, `require()` in tests. ## Commands @@ -56,11 +62,13 @@ await page.addInitScript((pr) => { if (!sessionStorage.getItem('s')) { sessionSt - `#start-btn`, `#roll-btn`, `#unlock-yes` (the quick-unlock confirm), `#cheats-input` - `[data-mode="single|multi|practice"]`, `[data-setting="difficulty"] [data-value=…]`, `#board-size [data-size=…]` - **Check for errors:** collect `pageerror` events and check there are none. Screenshot and look at the result. -- **Classroom flows:** use several browser contexts (a teacher plus students). +- **Classroom flows:** use several browser contexts (a teacher plus students). Hosting needs a teacher sign-in: POST `/api/auth/dev` with `{ role: 'teacher', firstName, schoolId: 'cadence-park' }` and store `{ token, user }` in `localStorage['blockout.auth']`, or use the sign-in dialog. +- **Accounts:** start the test server with `BLOCKOUT_DB=:memory:` so runs don't share data. ## Storage - **localStorage:** + - `blockout.auth`: `{ token, user }` when signed in; `blockout.progress.` is that account's save (cache), `blockout.mergedInto` the accounts this device's guest save went into - `blockout.progress`: wallet, unlocks, achievements, facts per player key, counters, boardWins, helpedFacts, cheats, migration flags - `blockout.settings`, `blockout.history`, `blockout.className`, `blockout.classHost` - **sessionStorage** (per tab): `blockout.class`, the classroom session. @@ -97,6 +105,7 @@ await page.addInitScript((pr) => { if (!sessionStorage.getItem('s')) { sessionSt - The menu lists active cheats by description, never by code. - Each code gets its own secret achievement `cheat_` (a test enforces this). - **Dialogs** have a bottom `.dialog-actions` button panel, like the Shop. +- **Accounts are optional:** guests must always be able to play everything except hosting a class. Store as little about children as possible (see docs/privacy.md). - **The audience is 8-year-olds:** use short, friendly, concrete wording ("Help me count", "Needs practice"). Target devices are Chromebooks, and it must work at phone width too. ## Code style diff --git a/css/style.css b/css/style.css index 9004c76..6a190ef 100644 --- a/css/style.css +++ b/css/style.css @@ -1435,3 +1435,99 @@ a.btn { color: inherit; text-decoration: none; display: inline-block; } border-radius: 0 0 var(--radius) var(--radius); z-index: 1; } + +/* ---------- Accounts: sign in, homework, classes, kids (js/accounts.js) ---------- */ +.top-row { display: flex; justify-content: center; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; } +.top-row .wallet { margin-bottom: 0; } +.account-chip { + font: inherit; + font-weight: 800; + padding: 4px 12px; + border: 2px solid var(--line); + border-radius: 999px; + background: var(--surface); + color: var(--ink); + cursor: pointer; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.account-chip:hover { border-color: var(--accent); } +.live-banner { margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; } +.live-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px; border: 2px solid var(--gold-line); background: var(--gold-bg); border-radius: 12px; font-weight: 800; text-align: left; } +.acct-menu-btn { position: relative; } + +.acct-dialog { text-align: left; max-height: calc(100vh - 32px); overflow-y: auto; } +.acct-dialog h2 { text-align: center; margin-bottom: 0.8rem; } +.acct-dialog.wide-dialog { max-width: 860px; } +.acct-dialog.account-dialog { max-width: 460px; } +.acct-dialog :is(input[type="text"], input[type="email"], input[type="date"], input[type="datetime-local"], select) { + font: inherit; + padding: 0.5rem 0.65rem; + border: 2px solid var(--line); + border-radius: 10px; + background: var(--surface); + color: var(--ink); + min-width: 0; +} +.acct-dialog :is(input, select):focus { outline: none; border-color: var(--accent); } +.acct-form { display: flex; flex-direction: column; gap: 12px; } +.acct-names { display: grid; grid-template-columns: 1fr 110px; gap: 10px; } +.acct-initial { text-transform: uppercase; } +.acct-note { margin: 0 0 10px; padding: 8px 12px; border-radius: 10px; background: var(--gold-bg); border: 2px solid var(--gold-line); font-weight: 800; } +.acct-privacy { margin: 0; } +.acct-card { text-align: center; margin-bottom: 8px; } +.acct-big { font-size: 1.4rem; font-weight: 900; } +.acct-sub { color: var(--muted); font-weight: 700; } +.acct-small { display: block; font-size: 0.8rem; color: var(--muted); font-weight: 700; } +.acct-section { margin-top: 16px; } +.acct-section h4, .acct-hw-form h4 { margin: 0 0 8px; } +.acct-list { display: flex; flex-direction: column; gap: 8px; } +.acct-item { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 12px; border: 2px solid var(--line); border-radius: 10px; background: var(--surface); } +.acct-item strong { flex: 1 1 auto; } +.acct-item.live { border-color: var(--gold-line); background: var(--gold-bg); } +.acct-inline { display: flex; gap: 8px; margin-top: 8px; } +.acct-inline input { flex: 1 1 auto; } +.acct-code-input { text-transform: uppercase; letter-spacing: 0.08em; font-weight: 800; } +.acct-code { font-size: 1.6rem; font-weight: 900; letter-spacing: 0.12em; font-family: ui-monospace, "Courier New", monospace; } +.acct-mono { font-family: ui-monospace, "Courier New", monospace; font-weight: 700; } +.acct-join { text-align: center; padding: 12px; border: 2px dashed var(--line); border-radius: 12px; margin: 12px 0; } +.acct-join .btn { margin-top: 6px; } +.acct-tabs { display: flex; gap: 6px; flex-wrap: wrap; } +.acct-mastery { display: flex; height: 10px; border-radius: 5px; overflow: hidden; background: var(--soft-2); min-width: 90px; } +.acct-mastery .seg.is-mastered, .acct-count.is-mastered { background: var(--good); color: #fff; } +.acct-mastery .seg.is-learning, .acct-count.is-learning { background: var(--learning); color: var(--learning-ink); } +.acct-mastery .seg.is-practice, .acct-count.is-practice, .acct-chip.is-practice { background: var(--danger); color: #fff; } +.acct-mastery .seg.is-unseen, .acct-count.is-unseen { background: var(--soft-2); color: var(--ink); } +.acct-counts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.acct-count, .acct-chip { padding: 2px 8px; border-radius: 999px; font-size: 0.8rem; font-weight: 800; } +.acct-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.acct-hw { padding: 10px 12px; border: 2px solid var(--line); border-radius: 12px; background: var(--surface); display: flex; flex-direction: column; gap: 6px; } +.acct-hw.done { border-color: var(--good); } +.acct-hw.late { border-color: var(--danger); } +.acct-hw-top, .acct-hw-meta { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; } +.acct-hw-state { font-weight: 800; font-size: 0.85rem; color: var(--muted); } +.acct-hw.late .acct-hw-state { color: var(--bad); } +.acct-hw.done .acct-hw-state { color: var(--good-ink); } +.acct-hw-meta { font-size: 0.8rem; color: var(--muted); font-weight: 700; } +.acct-hw-form { margin-top: 12px; padding: 12px; border: 2px dashed var(--line); border-radius: 12px; } +.acct-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin-bottom: 10px; } +.acct-grid > .field:first-child { grid-column: span 2; } +.acct-grid select, .acct-grid input { width: 100%; } +.acct-table-wrap { overflow-x: auto; } +.acct-roster td { vertical-align: middle; } +.acct-roster td:first-child { white-space: nowrap; } +.acct-link { font: inherit; font-weight: 800; background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer; text-decoration: underline; } +.acct-remove { font: inherit; margin-left: 8px; border: 0; background: none; color: var(--muted); cursor: pointer; font-weight: 900; } +.acct-remove:hover { color: var(--bad); } +.acct-activity { display: flex; justify-content: space-between; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--line); font-size: 0.9rem; } +.acct-when { color: var(--muted); white-space: nowrap; } +.acct-kids { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } +.acct-kid { font: inherit; text-align: left; display: flex; flex-direction: column; gap: 6px; padding: 12px; border: 2px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--ink); cursor: pointer; } +.acct-kid:hover { border-color: var(--accent); } +.acct-detail .fact-grid-wrap { max-width: 420px; margin: 8px auto; } +@media (max-width: 560px) { + .acct-dialog { --pad-x: 14px; --pad-y: 18px; } + .acct-names { grid-template-columns: 1fr 80px; } +} diff --git a/docs/privacy.md b/docs/privacy.md new file mode 100644 index 0000000..a38ab73 --- /dev/null +++ b/docs/privacy.md @@ -0,0 +1,43 @@ +# Blockout and children's privacy + +Blockout is used by 3rd graders, mostly under 13, starting at Cadence Park (Irvine Unified School District, California). That brings in FERPA (student education records), COPPA (children under 13 online) and California's SOPIPA (student data held by K–12 services). This page says what we keep and why. + +## Guests keep everything on their device +- Playing as a guest (the default) stores nothing on our server. Progress, stats and settings stay in the browser's local storage. +- A live class game keeps only the first name typed in, in memory, and forgets it when the class ends. + +## Signed-in accounts +When a student, teacher or parent signs in, the server (`server/store.js`) keeps: + +| What | Why | +|---|---| +| First name and **last initial** only | To show names to the teacher and parents ("Maya K.") | +| Role (student, teacher or parent) | To decide who can see what | +| The **email domain** only, e.g. `iusd.org` | To match a school; the address itself is never stored | +| An opaque ID from the sign-in provider | To recognise the same account next time | +| The game save: points, unlocks, which multiplication facts are mastered, recent games | Progress, class stats and homework | +| Class memberships, homework, parent links, scheduled tournaments | The features themselves | + +We don't collect: full names, email addresses, birthdays, photos, location, contacts, device IDs or anything for advertising. + +## Who sees a student's data +- **The student.** +- **Teachers of a class the student joined**, through the roster and student pages. +- **Parents the student linked,** by giving them the code on the student's account page, or a code from the teacher. + +Nobody else sees it: not other students, other parents or other teachers. + +## No third parties +- There's no advertising and no analytics, and no student data is sold or shared. +- Signed-in students' names are not checked with OpenAI. +- The optional OpenAI name check (only when the server has `OPENAI_API_KEY`) only ever sees the first name a *guest* types to join a live class game. +- Sign-in will go through the school's own identity provider: Keycloak, brokering Google sign-in for `@iusd.org`. Only that provider sees email addresses. + +## Deleting data +- A student's data is deleted on request from the school or a parent. For now that's a manual database task; a teacher-facing delete button is on the to-do list. +- Guests can clear their data from Stats → Reset stats, or by clearing the browser's site data. + +## Security +- Tokens are signed: HMAC in dev sign-in, RS256 from Keycloak. +- The database is a local sqlite file (`data/`, not in git). +- Deployments must use HTTPS. diff --git a/docs/v0.3.0-log.md b/docs/v0.3.0-log.md index 3686fd6..2ad706d 100644 --- a/docs/v0.3.0-log.md +++ b/docs/v0.3.0-log.md @@ -7,7 +7,7 @@ Progress on [the v0.3.0 plan](v0.3.0-plan.md). Newest entries go at the bottom o | Stage | State | |---|---| | 0: Legend fix | done | -| 1: Accounts | not started | +| 1: Accounts | done | | 2: Turborepo | not started | | 3: Prototype games | not started | @@ -34,3 +34,65 @@ Measured, rectangles per CPU-vs-CPU game (a unit test guards the minimums): - **The fill-in facts can be easier than the difficulty** (for example 2 × 3 on Tricky). The alternative was ending games with a third of the board empty. **How to try it:** single player → Legend (or type IDDQD after pressing ~ to unlock everything) → 12×12 or bigger → play. The hint under the board size explains the mix. + +## Stage 1: accounts + +**What's new:** +- **Server:** + - `server/store.js` keeps everything in sqlite (`node:sqlite`, no npm packages), in `data/blockout.db` (`BLOCKOUT_DB` changes the path). + - `server/auth.js` has the swappable sign-in providers: + - dev: HMAC-signed tokens + - Keycloak: RS256 tokens checked against the realm's published keys; the browser uses PKCE + - `server/accounts.js` has the rules. The HTTP routes are in `server/classroom.js`. +- **Shared logic** (`js/progress.js`): an activity log, merging a device's save into an account, fact summaries and homework goals. +- **Browser:** + - `js/auth.js`: sign-in, the token, and progress sync. + - `js/accounts.js`: the new screens. + - The account chip at the top of the menu. + - A sign-in dialog, with play as a guest always offered. + - The student's 📚 Homework and account page (classes, join by code, the code for a grown-up). + - The teacher's 🏫 My classes (roster, stats, homework, tournaments). + - The parent's 👪 My kids (link a child, their stats and homework, set extra homework). + - A banner on the menu when a tournament is open. +- **Guests play exactly as before.** The only changes: hosting a class now needs a teacher sign-in, and there's a small "👤 Sign in" chip. +- **Privacy:** see [privacy.md](privacy.md). + +**How to try it** (`node server/classroom.js 8080`, open http://localhost:8080): +1. **Teacher:** + - Tap 👤 Sign in, choose Teacher, type a name, and pick Cadence Park. + - Open 🏫 My classes and make a class: you get a 6-letter code and a join link. + - Add class homework, and plan a tournament a few minutes ahead. +2. **Student** (another browser or a private window): + - Open the join link, `…/#roster=CODE`, and sign in as a Student. The email is filled in as `name@iusd.org`. You join the class. + - 📚 Homework shows what's set. Play; it ticks itself off. + - Tap your name at the top for your classes and the code for a grown-up. +3. **Parent** (a third browser): sign in as a Parent, open 👪 My kids, type the child's code, open the child, and add homework. +4. **The tournament:** when it's due, the lobby opens by itself (the server checks every 10 s). + - The teacher gets "Open the lobby" in My classes. + - Students of that class, or of the whole school for a school tournament, see "🏆 … is open! Join" on the menu. +5. **Tests:** `npm test` (accounts, the HTTP API end to end, and the Keycloak token checks with a generated key). +6. **Keycloak later:** `BLOCKOUT_AUTH=keycloak KEYCLOAK_ISSUER=https://…/realms/NAME KEYCLOAK_CLIENT_ID=blockout node server/classroom.js`. + +**Decisions made on your behalf:** +- **Hosting:** hosting any live class now needs a teacher sign-in, and the server enforces it. Students joining a live game still need no account. +- **Signed-in students in live games** play under their account's first name. No name check is needed, and nothing goes to OpenAI. +- **Roles from Keycloak:** a token's realm role `student`, `teacher` or `parent` decides the role. With no role, a school-domain account is a student and anyone else a parent. +- **Dev sign-in:** + - Teachers pick a school; that gives them a made-up `teacher@iusd.org`, used only for its domain. + - The same name, role and email always reach the same account. + - The token secret is kept in the database, so sign-ins survive restarts. +- **Separate saves:** a signed-in player's save is kept under its own key in the browser, so the guest save on a shared Chromebook isn't touched. +- **First use on a device:** the device's guest save is merged into the account once: the most points, every unlock and achievement, and for each fact the record that has seen it most. After that the server copy wins, and the device keeps a cache. +- **Cheats stay on the device.** The server stores saves with cheats and what they unlocked removed, and IDDQD is switched back on locally after syncing. +- **"Master the ×N table" homework** means every N × 1…10 and 1…10 × N fact is Mastered (19 facts). +- **Homework goals:** master a table; finish N practice rounds (any difficulty or one); beat the CPU on a board this big or bigger; finish N games. Rounds, wins and games count from when the homework was set. +- **Due dates:** late means past the due date's end, by the server's day. +- **Class and parent codes:** class codes are 6 characters and parent codes 8, with no 0/O/1/I/L so they're easy to read out. +- **Codes don't expire yet.** Parent codes stay the same until they're reset (not built yet). +- **Scheduled tournaments** use Easy, Medium or Hard and boards up to 12×12, like the lobby. The teacher needs to open the lobby from My classes to run it, just as with any tournament. +- **Keycloak token refresh isn't built.** When the access token expires, the player is signed out and signs in again. + +**Open questions:** +- Deleting a student's data from the teacher's screen (only a manual database task for now). +- Should parent codes expire, or be resettable? +- Keycloak isn't tested against a real server yet (only with a generated key in the unit tests). diff --git a/index.html b/index.html index b1d502c..01278ea 100644 --- a/index.html +++ b/index.html @@ -23,6 +23,7 @@ + @@ -68,9 +69,13 @@
-
🪙 0 points
+
+
🪙 0 points
+ +

BLOCKOUT

Roll two dice, draw a rectangle, claim the most squares.

+
+ + @@ -940,10 +948,12 @@

+ + diff --git a/js/accounts.js b/js/accounts.js new file mode 100644 index 0000000..14fc456 --- /dev/null +++ b/js/accounts.js @@ -0,0 +1,894 @@ +// Blockout account screens: signing in (dev sign-in for now), a student's +// homework and classes, a teacher's classes (roster, stats, homework, +// scheduled tournaments) and a parent's view of their children. +// Talks to server/accounts.js through js/auth.js; borrows a few helpers from +// game.js (window.BlockoutGame). Guests never see any of it except "Sign in". +// Loaded as a plain script after game.js (window.BlockoutAccounts). +(function () { + 'use strict'; + + const Auth = window.BlockoutAuth; + const Progress = window.BlockoutProgress; + const Game = window.BlockoutGame; + const { make, icon, toast } = Game; + const $ = (id) => document.getElementById(id); + + const ROLE = { + student: { emoji: '🎒', label: 'Student' }, + teacher: { emoji: '🍎', label: 'Teacher' }, + parent: { emoji: '👪', label: 'Parent' }, + }; + const DIFFICULTY_NAMES = Progress.DIFFICULTY_NAMES; + const FORMAT_NAMES = { knockout: 'Knockout', double: 'Double elimination', roundrobin: 'Round robin', swiss: 'Swiss', koth: 'King of the hill' }; + const LIVE_POLL_MS = 20 * 1000; + const SCHEDULE_POLL_MS = 15 * 1000; + + const el = { + accountBtn: $('account-btn'), + homeworkBtn: $('homework-btn'), + classesBtn: $('classes-btn'), + kidsBtn: $('kids-btn'), + liveBanner: $('live-banner'), + }; + + // ---------------------------------------------------------------- small helpers + + function button(text, cls = 'btn', onClick = null) { + const b = make('button', cls, text); + b.type = 'button'; + if (onClick) b.addEventListener('click', onClick); + return b; + } + + function field(label, input) { + const f = make('label', 'field'); + f.append(make('span', null, label), input); + return f; + } + + function input(attrs = {}) { + const i = document.createElement('input'); + for (const [k, v] of Object.entries(attrs)) i.setAttribute(k, v); + return i; + } + + function select(options, value) { + const s = document.createElement('select'); + for (const [v, text] of options) { + const o = document.createElement('option'); + o.value = v; + o.textContent = text; + s.append(o); + } + if (value !== undefined) s.value = String(value); + return s; + } + + // A row of choice buttons (the game's .segmented look); returns { el, value() } + function segmented(options, value, onChange) { + const wrap = make('div', 'segmented'); + let current = value; + for (const [v, text] of options) { + const b = button(text); + b.dataset.value = v; + b.classList.toggle('selected', v === value); + b.addEventListener('click', () => { + current = v; + for (const x of wrap.children) x.classList.toggle('selected', x === b); + if (onChange) onChange(v); + }); + wrap.append(b); + } + return { el: wrap, value: () => current }; + } + + const errorLine = () => { + const p = make('p', 'invite-error'); + p.setAttribute('role', 'alert'); + p.hidden = true; + return p; + }; + const showError = (p, err) => { + p.textContent = err.message || String(err); + p.hidden = false; + }; + + const dayString = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + const niceDay = (s) => new Date(`${s}T12:00:00`).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); + const niceTime = (t) => new Date(t).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + function ago(t) { + if (!t) return 'not yet'; + const mins = Math.round((Date.now() - t) / 60000); + if (mins < 2) return 'just now'; + if (mins < 60) return `${mins} min ago`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours} h ago`; + const days = Math.round(hours / 24); + return days === 1 ? 'yesterday' : `${days} days ago`; + } + + // Our own dialogs, built like the game's: .overlay > .dialog with a bottom + // .dialog-actions panel. Escape or the backdrop closes them. + function dialog(id, title, cls = '') { + let overlay = $(id); + if (!overlay) { + overlay = make('div', 'overlay'); + overlay.id = id; + overlay.hidden = true; + const box = make('div', `dialog acct-dialog ${cls}`); + box.setAttribute('role', 'dialog'); + box.setAttribute('aria-modal', 'true'); + box.setAttribute('aria-labelledby', `${id}-title`); + const h = make('h2', null, title); + h.id = `${id}-title`; + const body = make('div', 'acct-body'); + const actions = make('div', 'dialog-actions'); + box.append(h, body, actions); + overlay.append(box); + document.body.append(overlay); + overlay.addEventListener('click', (e) => e.target === overlay && close()); + overlay.addEventListener('keydown', (e) => e.key === 'Escape' && close()); + } + const d = { + overlay, + title: overlay.querySelector('h2'), + body: overlay.querySelector('.acct-body'), + actions: overlay.querySelector('.dialog-actions'), + onClose: null, + }; + function close() { + overlay.hidden = true; + if (overlay._onClose) overlay._onClose(); + } + d.open = () => (overlay.hidden = false); + d.close = close; + d.setOnClose = (fn) => (overlay._onClose = fn); + d.clear = (heading = title) => { + d.title.textContent = heading; + d.body.replaceChildren(); + d.actions.replaceChildren(); + }; + d.done = (text = 'Done') => { + const b = button(text, 'btn btn-primary', close); + d.actions.append(b); + return b; + }; + return d; + } + + // ---------------------------------------------------------------- the menu + + function renderMenu() { + const served = Auth.served(); + el.accountBtn.hidden = !served; + const u = Auth.user(); + el.accountBtn.replaceChildren(u ? `${ROLE[u.role].emoji} ${u.name}` : '👤 Sign in'); + el.accountBtn.title = u ? `${ROLE[u.role].label} account: tap for yours` : 'Sign in (you can always play as a guest)'; + const role = Auth.role(); + el.homeworkBtn.hidden = role !== 'student'; + el.classesBtn.hidden = role !== 'teacher'; + el.kidsBtn.hidden = role !== 'parent'; + } + + function setBadge(btn, count) { + let badge = btn.querySelector('.badge'); + if (!count) return badge && badge.remove(); + if (!badge) { + badge = make('span', 'badge'); + badge.setAttribute('aria-hidden', 'true'); + btn.append(badge); + } + badge.textContent = String(count); + } + + // ---------------------------------------------------------------- sign in + + const account = dialog('account', 'Sign in', 'account-dialog'); + + async function openSignIn({ role = 'student', note = '' } = {}) { + account.clear('Sign in'); + let config; + try { + config = await Auth.config(); + } catch (err) { + account.body.append(make('p', 'setting-help', err.message)); + account.done('OK'); + return account.open(); + } + if (note) account.body.append(make('p', 'acct-note', note)); + if (config.provider === 'keycloak') { + account.body.append(make('p', 'setting-help', 'Sign in with your school or family account.')); + account.actions.append(button('Play as a guest', 'btn', account.close), button('Sign in', 'btn btn-primary', () => Auth.signInKeycloak())); + return account.open(); + } + // Dev sign-in: pick who you are + account.body.append( + make('p', 'setting-help', 'Practice sign-in: pick who you are. (Signing in with a real school account comes later.) You can always play as a guest instead.') + ); + const who = segmented( + [ + ['student', '🎒 Student'], + ['teacher', '🍎 Teacher'], + ['parent', '👪 Parent'], + ], + role, + () => showFields() + ); + const first = input({ type: 'text', maxlength: '20', autocomplete: 'off', placeholder: 'e.g. Maya' }); + const initial = input({ type: 'text', maxlength: '1', autocomplete: 'off', placeholder: 'K', class: 'acct-initial' }); + const email = input({ type: 'email', autocomplete: 'off', placeholder: 'maya@iusd.org' }); + const school = select([...config.schools.map((s) => [s.id, `${s.name} (@${s.domain})`]), ['', 'No school']]); + const emailField = field('School email', email); + const schoolField = field('School', school); + const names = make('div', 'acct-names'); + names.append(field('First name', first), field('Last initial', initial)); + const error = errorLine(); + const form = make('form', 'acct-form'); + form.append(who.el, names, emailField, schoolField, make('p', 'setting-help acct-privacy', 'We only keep your first name and last initial.'), error); + let emailTouched = false; + email.addEventListener('input', () => (emailTouched = true)); + first.addEventListener('input', () => { + if (!emailTouched && config.schools[0]) email.value = first.value.trim() ? `${first.value.trim().toLowerCase()}@${config.schools[0].domain}` : ''; + }); + function showFields() { + emailField.hidden = who.value() !== 'student'; + schoolField.hidden = who.value() !== 'teacher'; + } + showFields(); + const submit = async (e) => { + if (e) e.preventDefault(); + error.hidden = true; + go.disabled = true; + try { + const r = who.value(); + await Auth.signInDev({ + role: r, + firstName: first.value, + lastInitial: initial.value, + email: r === 'student' ? email.value : r === 'parent' ? '' : undefined, + schoolId: r === 'teacher' ? school.value : undefined, + }); + location.reload(); // the page picks up the account's save + } catch (err) { + showError(error, err); + go.disabled = false; + } + }; + form.addEventListener('submit', submit); + account.body.append(form); + const go = button('Sign in', 'btn btn-primary', submit); + account.actions.append(button('Play as a guest', 'btn', account.close), go); + account.open(); + first.focus(); + } + + async function openAccount() { + const u = Auth.user(); + if (!u) return openSignIn(); + account.clear('Your account'); + const card = make('div', 'acct-card'); + card.append(make('div', 'acct-big', `${ROLE[u.role].emoji} ${u.name}`), make('div', 'acct-sub', `${ROLE[u.role].label}${u.school ? ` · ${u.school.name}` : ''}`)); + account.body.append(card); + if (u.role === 'student') await studentAccount(); + if (u.role === 'teacher') account.body.append(make('p', 'setting-help', 'Your classes, homework and tournaments are under 🏫 My classes on the main menu.')); + if (u.role === 'parent') account.body.append(make('p', 'setting-help', 'Follow your children under 👪 My kids on the main menu.')); + account.actions.append(button('Sign out', 'btn', () => Auth.signOut())); + account.done(); + account.open(); + } + + // A student's classes, joining one with a code, and the code for a grown-up + async function studentAccount() { + const classes = make('div', 'acct-section'); + classes.append(make('h3', 'stats-heading', 'Your classes')); + const list = make('div', 'acct-list'); + classes.append(list); + const code = input({ type: 'text', maxlength: '8', autocomplete: 'off', placeholder: 'Class code', class: 'acct-code-input' }); + const error = errorLine(); + const row = make('form', 'acct-inline'); + const join = button('Join', 'btn btn-small btn-primary'); + join.type = 'submit'; + row.append(code, join); + row.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + try { + const r = await Auth.api('POST', '/api/me/classes', { code: code.value }); + toast('🏫 Joined!', r.class.name); + code.value = ''; + await fill(); + } catch (err) { + showError(error, err); + } + }); + classes.append(row, error); + const parent = make('div', 'acct-section'); + account.body.append(classes, parent); + async function fill() { + list.replaceChildren(); + try { + const { classes: mine } = await Auth.api('GET', '/api/me/classes'); + if (!mine.length) list.append(make('p', 'setting-help', 'Not in a class yet. Type the code your teacher gives you.')); + for (const c of mine) list.append(make('div', 'acct-item', `🏫 ${c.name}`)); + } catch (err) { + list.append(make('p', 'setting-help', err.message)); + } + } + await fill(); + try { + const { code: pc } = await Auth.api('GET', '/api/me/parent-code'); + parent.append(make('h3', 'stats-heading', 'For a grown-up'), make('p', 'setting-help', 'Give this code to your mom, dad or another grown-up so they can see how you’re doing and add homework.'), make('div', 'acct-code', pc.replace(/(.{4})/, '$1 '))); + } catch (_) { + // not fatal + } + } + + function askTeacherSignIn() { + openSignIn({ role: 'teacher', note: '🍎 Teachers: sign in to host a class. Students join with the code (no sign-in needed).' }); + } + + el.accountBtn.addEventListener('click', openAccount); + + // ---------------------------------------------------------------- stats of one student + + const STATUS_ORDER = [ + ['mastered', 'Mastered'], + ['learning', 'Learning'], + ['practice', 'Needs practice'], + ['unseen', 'Not seen yet'], + ]; + + // A thin bar: mastered / learning / needs practice / not seen, out of 144 + function masteryBar(summary) { + const bar = make('div', 'acct-mastery'); + bar.title = STATUS_ORDER.map(([k, label]) => `${label}: ${summary[k]}`).join(' · '); + for (const [k] of STATUS_ORDER) { + const seg = make('span', `seg is-${k}`); + seg.style.width = `${(summary[k] / summary.total) * 100}%`; + bar.append(seg); + } + return bar; + } + + function activityText(e) { + const diff = e.difficulty ? `${DIFFICULTY_NAMES[e.difficulty] || e.difficulty} ` : ''; + if (e.kind === 'practice') return `📝 ${diff}practice, ${e.count} questions${e.expert ? ' (Expert)' : ''}`; + if (e.kind === 'class') return e.type === 'tournament' ? `🏆 Class tournament game${e.won ? ' (won)' : ''}` : e.type === 'pairs' ? `🤝 Class pairs game${e.won ? ' (won)' : ''}` : `🏫 Class game${e.rank ? ` (${e.rank}${['st', 'nd', 'rd'][e.rank - 1] || 'th'})` : ''}`; + return `🎲 ${diff}${e.size}×${e.size} ${e.mode === 'multi' ? 'game at home' : e.won ? 'game, beat the CPU' : 'game vs the CPU'}`; + } + + function homeworkList(items, { onRemove = null, mine = () => false } = {}) { + const list = make('div', 'acct-list'); + if (!items.length) list.append(make('p', 'setting-help', 'No homework.')); + for (const h of items) { + const row = make('div', `acct-hw ${h.status}`); + const top = make('div', 'acct-hw-top'); + top.append(make('strong', null, h.label || Progress.describeGoal(h.goal))); + const state = h.status === 'done' ? '✅ Done' : h.status === 'late' ? `⏰ Was due ${niceDay(h.due)}` : h.due ? `Due ${niceDay(h.due)}` : 'Any time'; + top.append(make('span', 'acct-hw-state', state)); + row.append(top); + const bar = make('div', 'practice-bar'); + const fill = make('span'); + fill.style.width = `${(h.have / h.need) * 100}%`; + bar.append(fill); + const meta = make('div', 'acct-hw-meta'); + meta.append(make('span', null, `${h.have} of ${h.need}`), make('span', null, h.from === 'parent' ? `from ${h.fromName || 'a grown-up'} 👪` : `from ${h.fromName || 'your teacher'} 🍎`)); + if (onRemove && mine(h)) meta.append(button('Remove', 'btn btn-small', () => onRemove(h))); + row.append(bar, meta); + list.append(row); + } + return list; + } + + // What a teacher or parent sees for one student + function studentDetail(s, { onAddHomework, onRemoveHomework, mine }) { + const box = make('div', 'acct-detail'); + const head = make('div', 'acct-card'); + head.append(make('div', 'acct-sub', `Last played ${ago(s.lastPlayed)}`)); // (the dialog title is the name) + box.append(head); + // Times Table + const tt = make('div', 'acct-section'); + tt.append(make('h3', 'stats-heading', 'Times Table')); + const counts = make('div', 'acct-counts'); + for (const [k, label] of STATUS_ORDER) counts.append(make('span', `acct-count is-${k}`, `${s.summary[k]} ${label.toLowerCase()}`)); + tt.append(counts, masteryBar(s.summary)); + if (s.facts) { + tt.append(Game.renderFactLegend()); + tt.append(Game.renderFactGrid(null, s.facts, 12)); + } + if (s.summary.needsPractice.length) { + tt.append(make('p', 'setting-help', 'Needs practice:')); + const chips = make('div', 'acct-chips'); + for (const f of s.summary.needsPractice) chips.append(make('span', 'acct-chip is-practice', f)); + tt.append(chips); + } + box.append(tt); + // Homework + const hw = make('div', 'acct-section'); + hw.append(make('h3', 'stats-heading', 'Homework'), homeworkList(s.assignments, { onRemove: onRemoveHomework, mine })); + if (onAddHomework) hw.append(homeworkForm(onAddHomework)); + box.append(hw); + // Recent activity + const act = make('div', 'acct-section'); + act.append(make('h3', 'stats-heading', 'Recently')); + if (!s.recent.length) act.append(make('p', 'setting-help', 'Nothing played yet.')); + for (const e of s.recent) { + const row = make('div', 'acct-activity'); + row.append(make('span', null, activityText(e)), make('span', 'acct-when', ago(e.t))); + act.append(row); + } + box.append(act); + return box; + } + + // Set homework: the goal, its details and a due date. onSubmit({ goal, due }) + function homeworkForm(onSubmit) { + const form = make('form', 'acct-hw-form'); + form.append(make('h4', null, 'Add homework')); + const type = select([ + ['master', 'Master a times table'], + ['practice', 'Finish practice rounds'], + ['win', 'Beat the CPU on a board'], + ['games', 'Play games'], + ]); + const table = select(Array.from({ length: 12 }, (_, i) => [i + 1, `×${i + 1}`]), 7); + const rounds = select([1, 2, 3, 4, 5, 6, 8, 10].map((n) => [n, String(n)]), 3); + const games = select([1, 2, 3, 4, 5, 6, 8, 10].map((n) => [n, String(n)]), 3); + const size = select([6, 8, 10, 12, 16, 20, 24].map((n) => [n, `${n}×${n}`]), 8); + const difficulty = select([['any', 'Any difficulty'], ...Progress.DIFFICULTIES.map((d) => [d, DIFFICULTY_NAMES[d]])], 'any'); + const week = new Date(Date.now() + 7 * 86400000); + const due = input({ type: 'date', value: dayString(week), min: dayString(new Date()) }); + const f = { + table: field('Table', table), + rounds: field('Rounds', rounds), + games: field('Games', games), + size: field('Board', size), + difficulty: field('Difficulty', difficulty), + }; + const grid = make('div', 'acct-grid'); + grid.append(field('Homework', type), f.table, f.rounds, f.games, f.size, f.difficulty, field('Due', due)); + const show = () => { + const t = type.value; + f.table.hidden = t !== 'master'; + f.rounds.hidden = t !== 'practice'; + f.games.hidden = t !== 'games'; + f.size.hidden = t !== 'win'; + f.difficulty.hidden = t !== 'practice' && t !== 'win'; + }; + type.addEventListener('change', show); + show(); + const error = errorLine(); + const go = button('Set homework', 'btn btn-small btn-primary'); + go.type = 'submit'; + form.append(grid, error, go); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + const t = type.value; + const goal = + t === 'master' + ? { type: t, table: Number(table.value) } + : t === 'practice' + ? { type: t, rounds: Number(rounds.value), difficulty: difficulty.value } + : t === 'win' + ? { type: t, size: Number(size.value), difficulty: difficulty.value } + : { type: t, games: Number(games.value) }; + go.disabled = true; + try { + await onSubmit({ goal, due: due.value || null }); + toast('📚 Homework set', Progress.describeGoal(goal)); + } catch (err) { + showError(error, err); + } finally { + go.disabled = false; + } + }); + return form; + } + + // ---------------------------------------------------------------- student: homework + + const homework = dialog('homework-dialog', '📚 Homework', 'wide-dialog'); + + async function openHomework() { + homework.clear(); + homework.done(); + homework.open(); + try { + const { homework: items } = await Auth.api('GET', '/api/me/homework'); + const open = items.filter((h) => h.status !== 'done'); + homework.body.append(make('p', 'setting-help', open.length ? 'Play and practice as usual: homework ticks itself off.' : items.length ? 'All done. Great work! 🎉' : 'No homework right now.')); + const order = { late: 0, open: 1, done: 2 }; + homework.body.append(homeworkList([...items].sort((a, b) => order[a.status] - order[b.status]))); + } catch (err) { + homework.body.append(make('p', 'setting-help', err.message)); + } + } + + async function refreshHomeworkBadge() { + if (Auth.role() !== 'student') return; + try { + const { homework: items } = await Auth.api('GET', '/api/me/homework'); + setBadge(el.homeworkBtn, items.filter((h) => h.status !== 'done').length); + } catch (_) { + // offline: no badge + } + } + + el.homeworkBtn.addEventListener('click', openHomework); + + // ---------------------------------------------------------------- teacher: classes + + const classes = dialog('classes-dialog', '🏫 My classes', 'wide-dialog'); + let currentClass = null; + let schedulePoll = null; + classes.setOnClose(() => clearInterval(schedulePoll)); + + async function openClasses() { + classes.open(); + await renderClasses(); + clearInterval(schedulePoll); + schedulePoll = setInterval(() => !classes.overlay.hidden && renderSchedules(), SCHEDULE_POLL_MS); + } + + async function renderClasses() { + classes.clear('🏫 My classes'); + classes.actions.append(button('Host a live game', 'btn', () => (classes.close(), Game.openHostSetup()))); + classes.done(); + let list; + try { + list = (await Auth.api('GET', '/api/classes')).classes; + } catch (err) { + classes.body.append(make('p', 'setting-help', err.message)); + return; + } + if (list.length && !list.some((c) => c.id === currentClass)) currentClass = list[0].id; + // class tabs + a new class + const tabs = make('div', 'acct-tabs'); + for (const c of list) { + const t = button(`${c.name} (${c.students})`, 'btn btn-small'); + t.classList.toggle('btn-primary', c.id === currentClass); + t.addEventListener('click', () => ((currentClass = c.id), renderClasses())); + tabs.append(t); + } + const name = input({ type: 'text', maxlength: '40', placeholder: list.length ? 'New class name' : 'Class name, like “Room 12”' }); + const add = make('form', 'acct-inline'); + const addBtn = button('+ New class', 'btn btn-small'); + addBtn.type = 'submit'; + add.append(name, addBtn); + const error = errorLine(); + add.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + const c = await Auth.api('POST', '/api/classes', { name: name.value }); + currentClass = c.id; + await renderClasses(); + } catch (err) { + showError(error, err); + } + }); + classes.body.append(tabs, add, error); + if (!list.length) { + classes.body.append(make('p', 'setting-help', 'Make a class, then give your students its code. Any student with a school account (@iusd.org) can join with it.')); + name.focus(); + return; + } + const slot = make('div', 'acct-class'); + classes.body.append(slot); + await renderClass(slot); + } + + async function renderClass(slot) { + let d; + try { + d = await Auth.api('GET', `/api/classes/${currentClass}`); + } catch (err) { + slot.append(make('p', 'setting-help', err.message)); + return; + } + const c = d.class; + // join code and link + const join = make('div', 'acct-join'); + const link = `${location.origin}${location.pathname}#roster=${c.code}`; + join.append(make('div', 'acct-sub', 'Students join with'), make('div', 'acct-code', c.code)); + const copy = button('Copy join link', 'btn btn-small', async () => { + try { + await navigator.clipboard.writeText(link); + copy.textContent = 'Copied!'; + } catch (_) { + prompt('Copy this link:', link); + } + }); + join.append(copy, make('p', 'setting-help', `Students sign in with their school account${c.school ? ` (${c.school})` : ''}, then type the code or open the link.`)); + slot.append(join); + + // roster + const roster = make('div', 'acct-section'); + roster.append(make('h3', 'stats-heading', `Students · ${d.students.length}`)); + if (!d.students.length) roster.append(make('p', 'setting-help', 'Nobody has joined yet.')); + else { + const table = make('table', 'stats-table acct-roster'); + const head = make('tr'); + for (const h of ['Name', 'Times Table', 'Needs practice', 'Last played', 'Homework', 'Parent code']) head.append(make('th', null, h)); + const thead = make('thead'); + thead.append(head); + const tbody = make('tbody'); + for (const s of d.students) { + const tr = make('tr'); + const nameCell = make('td'); + nameCell.append(button(s.name, 'acct-link', () => openStudent(s.id, classes, renderClasses))); + const tt = make('td'); + tt.append(masteryBar(s.summary), make('span', 'acct-small', `${s.summary.mastered} of ${s.summary.total} mastered`)); + const hw = s.homework.total ? `${s.homework.done} of ${s.homework.total}${s.homework.late ? ` · ${s.homework.late} late` : ''}` : '—'; + tr.append(nameCell, tt, make('td', null, s.summary.needsPractice.length ? s.summary.needsPractice.slice(0, 4).join(', ') + (s.summary.needsPractice.length > 4 ? '…' : '') : '—'), make('td', null, ago(s.lastPlayed)), make('td', null, hw), make('td', 'acct-mono', s.parentCode || '')); + const remove = button('×', 'acct-remove', async () => { + if (!confirm(`Take ${s.name} out of ${c.name}?`)) return; + await Auth.api('DELETE', `/api/classes/${c.id}/students/${s.id}`); + renderClasses(); + }); + remove.title = `Take ${s.name} out of the class`; + tr.lastChild.append(remove); + tbody.append(tr); + } + table.append(thead, tbody); + const wrap = make('div', 'acct-table-wrap'); + wrap.append(table); + roster.append(wrap); + } + slot.append(roster); + + // homework + const hw = make('div', 'acct-section'); + hw.append(make('h3', 'stats-heading', 'Class homework')); + if (!d.assignments.length) hw.append(make('p', 'setting-help', 'No homework set.')); + for (const a of d.assignments) { + const row = make('div', 'acct-item'); + row.append(make('strong', null, a.label), make('span', 'acct-small', `${a.due ? `due ${niceDay(a.due)} · ` : ''}${a.done} of ${a.of} done`)); + row.append( + button('Remove', 'btn btn-small', async () => { + if (!confirm(`Remove “${a.label}” for the whole class?`)) return; + await Auth.api('DELETE', `/api/homework/${a.id}`); + renderClasses(); + }) + ); + hw.append(row); + } + hw.append(homeworkForm(async ({ goal, due }) => { + await Auth.api('POST', '/api/homework', { classId: c.id, goal, due }); + renderClasses(); + })); + slot.append(hw); + + // scheduled tournaments + const tour = make('div', 'acct-section'); + tour.id = 'acct-schedules'; + slot.append(tour); + await renderSchedules(); + } + + async function renderSchedules() { + const box = $('acct-schedules'); + if (!box) return; + let list; + try { + list = (await Auth.api('GET', '/api/schedules')).schedules; + } catch (err) { + return; + } + box.replaceChildren(make('h3', 'stats-heading', 'Tournaments')); + const mine = list.filter((s) => s.scope === 'school' || s.classId === currentClass); + if (!mine.length) box.append(make('p', 'setting-help', 'Plan a tournament and its lobby opens by itself at that time.')); + for (const s of mine) { + const row = make('div', `acct-item${s.open ? ' live' : ''}`); + row.append( + make('strong', null, s.title), + make('span', 'acct-small', `${niceTime(s.startsAt)} · ${FORMAT_NAMES[s.settings.format]} · ${s.settings.size}×${s.settings.size} · ${s.scope === 'school' ? 'whole school' : s.className}`) + ); + if (s.open) { + row.append( + button('Open the lobby', 'btn btn-small btn-primary', async () => { + const h = await Auth.api('GET', `/api/schedules/${s.id}/host`); + classes.close(); + Game.startHost({ code: h.code, teacherKey: h.teacherKey }); + }) + ); + } + row.append( + button('Remove', 'btn btn-small', async () => { + if (!confirm(`Remove “${s.title}”?`)) return; + await Auth.api('DELETE', `/api/schedules/${s.id}`); + renderSchedules(); + }) + ); + box.append(row); + } + box.append(scheduleForm()); + } + + function scheduleForm() { + const form = make('form', 'acct-hw-form'); + form.append(make('h4', null, 'Plan a tournament')); + const soon = new Date(Date.now() + 24 * 3600 * 1000); + soon.setMinutes(0, 0, 0); + const local = (d) => `${dayString(d)}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + const title = input({ type: 'text', maxlength: '60', placeholder: 'e.g. Friday Cup' }); + const when = input({ type: 'datetime-local', value: local(soon) }); + const format = select(Object.entries(FORMAT_NAMES), 'knockout'); + const size = select([6, 8, 10, 12].map((n) => [n, `${n}×${n}`]), 8); + const difficulty = select(['easy', 'medium', 'hard'].map((d) => [d, DIFFICULTY_NAMES[d]]), 'easy'); + const scope = select([ + ['class', 'This class'], + ['school', 'Whole school'], + ]); + const grid = make('div', 'acct-grid'); + grid.append(field('Name', title), field('When', when), field('Format', format), field('Board', size), field('Difficulty', difficulty), field('Who', scope)); + const error = errorLine(); + const go = button('Plan it', 'btn btn-small btn-primary'); + go.type = 'submit'; + form.append(grid, error, go); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + try { + await Auth.api('POST', '/api/schedules', { + classId: currentClass, + scope: scope.value, + title: title.value, + startsAt: new Date(when.value).getTime(), + settings: { format: format.value, size: Number(size.value), difficulty: difficulty.value }, + }); + toast('🏆 Tournament planned', niceTime(new Date(when.value).getTime())); + renderSchedules(); + } catch (err) { + showError(error, err); + } + }); + return form; + } + + el.classesBtn.addEventListener('click', openClasses); + + // One student's page (teacher or parent), inside dialog d; back() returns + async function openStudent(id, d, back) { + d.clear('Student'); + d.actions.append(button('← Back', 'btn', back)); + d.done(); + let s; + try { + s = await Auth.api('GET', `/api/students/${id}`); + } catch (err) { + d.body.append(make('p', 'setting-help', err.message)); + return; + } + d.title.textContent = s.name; + const me = Auth.user(); + d.body.append( + studentDetail(s, { + mine: (h) => h.fromName === me.name && h.from === (me.role === 'teacher' ? 'teacher' : 'parent') && (me.role === 'parent' || !h.classId), + onAddHomework: async ({ goal, due }) => { + await Auth.api('POST', '/api/homework', { studentId: s.id, goal, due }); + openStudent(id, d, back); + }, + onRemoveHomework: async (h) => { + if (!confirm(`Remove “${h.label}”?`)) return; + await Auth.api('DELETE', `/api/homework/${h.id}`); + openStudent(id, d, back); + }, + }) + ); + } + + // ---------------------------------------------------------------- parent: my kids + + const kids = dialog('kids-dialog', '👪 My kids', 'wide-dialog'); + + async function openKids() { + kids.open(); + await renderKids(); + } + + async function renderKids() { + kids.clear('👪 My kids'); + kids.done(); + let list; + try { + list = (await Auth.api('GET', '/api/children')).children; + } catch (err) { + kids.body.append(make('p', 'setting-help', err.message)); + return; + } + if (!list.length) kids.body.append(make('p', 'setting-help', 'Link your child’s account with the code on their Blockout account page (tap their name at the top of the menu), or ask their teacher for it.')); + const cards = make('div', 'acct-kids'); + for (const c of list) { + const card = button('', 'acct-kid', () => openStudent(c.id, kids, renderKids)); + const hw = c.homework.total ? `📚 ${c.homework.done} of ${c.homework.total} homework done${c.homework.late ? ` · ${c.homework.late} late` : ''}` : '📚 No homework'; + card.append(make('strong', null, `🎒 ${c.name}`), masteryBar(c.summary), make('span', 'acct-small', `${c.summary.mastered} of ${c.summary.total} facts mastered · played ${ago(c.lastPlayed)}`), make('span', 'acct-small', hw)); + cards.append(card); + } + kids.body.append(cards); + const form = make('form', 'acct-inline'); + const code = input({ type: 'text', maxlength: '9', autocomplete: 'off', placeholder: 'Child’s code', class: 'acct-code-input' }); + const go = button('Link', 'btn btn-small btn-primary'); + go.type = 'submit'; + form.append(code, go); + const error = errorLine(); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + try { + const r = await Auth.api('POST', '/api/children', { code: code.value }); + toast('👪 Linked', r.child.name); + renderKids(); + } catch (err) { + showError(error, err); + } + }); + const section = make('div', 'acct-section'); + section.append(make('h3', 'stats-heading', 'Link a child'), form, error); + kids.body.append(section); + } + + el.kidsBtn.addEventListener('click', openKids); + + // ---------------------------------------------------------------- live tournaments (students) + + async function pollLive() { + if (Auth.role() !== 'student' || !Game.menuShowing()) return; + try { + const { live } = await Auth.api('GET', '/api/me/live'); + el.liveBanner.replaceChildren(); + el.liveBanner.hidden = !live.length; + for (const l of live) { + const row = make('div', 'live-row'); + row.append(make('span', null, `🏆 ${l.title} is open!`), button('Join', 'btn btn-small btn-primary', () => Game.joinClass(l.code))); + el.liveBanner.append(row); + } + } catch (_) { + // offline + } + } + + // ---------------------------------------------------------------- start up + + // #roster=CODE: a teacher's link to join their class roster + async function rosterLink() { + const m = /^#roster=([A-Za-z0-9]+)$/.exec(location.hash); + if (!m) return; + const code = m[1].toUpperCase(); + if (Auth.role() !== 'student') return openSignIn({ role: 'student', note: `🏫 To join class ${code}, sign in with your school account.` }); + history.replaceState(null, '', location.pathname); + try { + const r = await Auth.api('POST', '/api/me/classes', { code }); + toast('🏫 You joined', r.class.name); + } catch (err) { + toast('🏫 Couldn’t join', err.message); + } + } + + async function start() { + renderMenu(); + if (!Auth.served()) return; + try { + if (await Auth.finishKeycloak()) location.reload(); + } catch (err) { + toast('Sign-in', err.message); + } + if (Auth.signedIn()) { + try { + const next = await Auth.syncProgress(Game.progress()); + if (next) Game.replaceProgress(next); + } catch (_) { + // offline: keep playing on the local copy; it syncs with the next save + } + renderMenu(); // (a stale sign-in may have been dropped) + } + await rosterLink(); + refreshHomeworkBadge(); + pollLive(); + setInterval(pollLive, LIVE_POLL_MS); + setInterval(refreshHomeworkBadge, 60 * 1000); + } + + window.BlockoutAccounts = { askTeacherSignIn, openSignIn, openAccount, refreshHomeworkBadge }; + start(); +})(); diff --git a/js/auth.js b/js/auth.js new file mode 100644 index 0000000..89dcf4e --- /dev/null +++ b/js/auth.js @@ -0,0 +1,214 @@ +// Blockout sign-in, in the browser: who's playing, their token, and keeping a +// signed-in player's progress in step with the server (server/accounts.js). +// Guests never need any of this: they play exactly as before. +// +// Providers (the server says which, GET /api/auth/config): +// dev a "dev sign-in" form: pick a name and a role (for now) +// keycloak OpenID Connect with PKCE against a Keycloak realm (later; Keycloak +// can broker Google sign-in for @iusd.org students) +// +// Progress: a signed-in player's save lives under its own localStorage key +// (blockout.progress.), so the guest save on a shared Chromebook is +// untouched. The first time an account is used on a device, that device's guest +// progress is merged into the account; after that the server copy wins and the +// local one is just a cache. +// Loaded as a plain script (window.BlockoutAuth), after progress.js. +(function (root) { + 'use strict'; + + const Progress = root.BlockoutProgress; + const AUTH_KEY = 'blockout.auth'; // { token, user, refresh?, expires? } + const GUEST_KEY = 'blockout.progress'; + const MERGED_KEY = 'blockout.mergedInto'; // user ids this device's guest save went into + const PUSH_DELAY_MS = 1500; + const PKCE_KEY = 'blockout.pkce'; + + const read = (key, fallback = null) => { + try { + const v = JSON.parse(localStorage.getItem(key)); + return v === null ? fallback : v; + } catch (_) { + return fallback; + } + }; + const write = (key, value) => { + try { + if (value === null) localStorage.removeItem(key); + else localStorage.setItem(key, JSON.stringify(value)); + } catch (_) { + // private window etc.: signing in just won't stick + } + }; + + let session = read(AUTH_KEY); // { token, user } + const served = () => location.protocol === 'http:' || location.protocol === 'https:'; + + class AuthError extends Error { + constructor(code, message, status) { + super(message); + this.code = code; + this.status = status; + } + } + + // A JSON request with the signed-in user's token. + async function api(method, path, body) { + let res; + try { + res = await fetch(path, { + method, + headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(session ? { Authorization: `Bearer ${session.token}` } : {}) }, + body: body ? JSON.stringify(body) : undefined, + keepalive: method !== 'GET' && JSON.stringify(body || {}).length < 60000, + }); + } catch (e) { + throw new AuthError('offline', 'Can’t reach the server.'); + } + let data = null; + try { + data = await res.json(); + } catch (e) { + throw new AuthError('offline', 'The server isn’t running here.'); + } + if (res.status === 401 && session && data.error === 'auth') { + // the token expired or the server's key changed: back to guest + signOut(false); + } + if (!res.ok) throw new AuthError(data.error || 'server', data.message || 'Something went wrong.', res.status); + return data; + } + + let configCache = null; + async function config() { + if (!configCache) configCache = await api('GET', '/api/auth/config'); + return configCache; + } + + function saveSession(s) { + session = s; + write(AUTH_KEY, s); + } + + // ---- dev provider + async function signInDev(person) { + const { token, user } = await api('POST', '/api/auth/dev', person); + saveSession({ token, user }); + return user; + } + + // ---- keycloak provider (OIDC authorization code + PKCE) + const b64url = (bytes) => btoa(String.fromCharCode(...new Uint8Array(bytes))).replace(/=+$/, '').replace(/\+/g, '-').replace(/\//g, '_'); + async function signInKeycloak() { + const c = await config(); + const verifier = b64url(crypto.getRandomValues(new Uint8Array(32))); + const challenge = b64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier))); + const state = b64url(crypto.getRandomValues(new Uint8Array(16))); + sessionStorage.setItem(PKCE_KEY, JSON.stringify({ verifier, state, back: location.hash })); + const url = new URL(`${c.issuer}/protocol/openid-connect/auth`); + url.search = new URLSearchParams({ + client_id: c.clientId, + redirect_uri: location.origin + location.pathname, + response_type: 'code', + scope: 'openid profile email', + code_challenge: challenge, + code_challenge_method: 'S256', + state, + }); + location.assign(url.toString()); + } + + // Back from Keycloak with ?code=…&state=…: swap the code for tokens. + async function finishKeycloak() { + const q = new URLSearchParams(location.search); + if (!q.has('code') || !q.has('state')) return false; + const saved = JSON.parse(sessionStorage.getItem(PKCE_KEY) || 'null'); + sessionStorage.removeItem(PKCE_KEY); + history.replaceState(null, '', location.pathname + ((saved && saved.back) || '')); + if (!saved || saved.state !== q.get('state')) return false; + const c = await config(); + const res = await fetch(`${c.issuer}/protocol/openid-connect/token`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: new URLSearchParams({ grant_type: 'authorization_code', client_id: c.clientId, code: q.get('code'), redirect_uri: location.origin + location.pathname, code_verifier: saved.verifier }), + }); + if (!res.ok) throw new AuthError('auth', 'Signing in didn’t work. Please try again.'); + const t = await res.json(); + saveSession({ token: t.access_token, refresh: t.refresh_token, user: null }); + const { user } = await api('GET', '/api/me'); + saveSession({ ...session, user }); + return true; + } + + function signOut(reload = true) { + flushProgress(); + saveSession(null); + if (reload) location.reload(); + } + + // ---- progress + const user = () => (session && session.user) || null; + const progressKey = () => (user() ? `${GUEST_KEY}.${user().id}` : GUEST_KEY); + + let pushTimer = null; + let pushState = null; + // Called after every local save: sends it to the server shortly after. + function queueProgress(state) { + if (!user()) return; + pushState = state; + clearTimeout(pushTimer); + pushTimer = setTimeout(flushProgress, PUSH_DELAY_MS); + } + function flushProgress() { + clearTimeout(pushTimer); + if (!pushState || !user()) return; + const state = pushState; + pushState = null; + api('PUT', '/api/me/progress', { progress: state }).catch(() => { + pushState = pushState || state; // try again with the next save + }); + } + root.addEventListener('pagehide', flushProgress); + + // After signing in (and on every page load while signed in): bring the + // account's save and this device together. Returns the save to use, or null + // when the local cache is already right. + async function syncProgress(localState) { + if (!user()) return null; + const { progress: remote } = await api('GET', '/api/me/progress'); + const merged = read(MERGED_KEY, []); + const firstTimeHere = !merged.includes(user().id); + let result; + if (firstTimeHere) { + // this device's guest progress goes into the account, once + const guest = read(GUEST_KEY); + result = [remote, guest, localState].filter(Boolean).reduce((a, b) => Progress.mergeProgress(b, a)); + write(MERGED_KEY, [...merged, user().id]); + } else { + result = remote ? Progress.normalize(remote) : null; + } + if (!result) return null; + // Cheats belong to the device: switch this device's back on + for (const code of Object.keys((localState && localState.cheats) || {})) Progress.cheatOn(result, code); + if (firstTimeHere || !remote) await api('PUT', '/api/me/progress', { progress: Progress.withoutCheats(result) }); + return result; + } + + root.BlockoutAuth = { + served, + api, + config, + user, + signedIn: () => Boolean(user()), + token: () => (session ? session.token : null), + role: () => (user() ? user().role : 'guest'), + signInDev, + signInKeycloak, + finishKeycloak, + signOut, + progressKey, + queueProgress, + flushProgress, + syncProgress, + AuthError, + }; +})(typeof window !== 'undefined' ? window : globalThis); diff --git a/js/classroom.js b/js/classroom.js index 2babd79..6293e6e 100644 --- a/js/classroom.js +++ b/js/classroom.js @@ -22,9 +22,11 @@ async function request(method, path, body) { let res; try { + // Signed in (js/auth.js): the server knows who's hosting or joining + const token = root.BlockoutAuth && root.BlockoutAuth.token(); res = await fetch(path, { method, - headers: body ? { 'Content-Type': 'application/json' } : {}, + headers: { ...(body ? { 'Content-Type': 'application/json' } : {}), ...(token ? { Authorization: `Bearer ${token}` } : {}) }, body: body ? JSON.stringify(body) : undefined, }); } catch (e) { diff --git a/js/game.js b/js/game.js index 6a59772..1cf308f 100644 --- a/js/game.js +++ b/js/game.js @@ -5,6 +5,7 @@ const Progress = window.BlockoutProgress; const Invite = window.BlockoutInvite; const Cosmetics = window.BlockoutCosmetics; + const Auth = window.BlockoutAuth; // sign-in (js/auth.js); guests play without it const seededRandom = Invite.seededRandom; const DEFAULT_NAMES = ['Player 1', 'Player 2', 'Player 3', 'Player 4']; @@ -42,7 +43,8 @@ // Board sizes other than the small one are bought in the shop. // Only the smallest board is free; every other size is bought in the shop. const BOARD_UNLOCK = { 6: null, 8: 'board8', 10: 'board10', 12: 'board12', 16: 'board16', 20: 'board20', 24: 'board24', custom: 'boardCustom' }; - const PROGRESS_KEY = 'blockout.progress'; + // A signed-in player's save is kept apart from the guest one (see js/auth.js) + const PROGRESS_KEY = Auth ? Auth.progressKey() : 'blockout.progress'; const PASS_DELAY = 1600; const AUTO_PLACE_MS = 900; // how long an auto-placed rectangle is previewed const AUTO_ROLL_MS = 700; // pause before auto roll, so "Your turn" can be read first @@ -320,6 +322,20 @@ } catch (_) { // not fatal: progress just won't persist } + if (Auth) Auth.queueProgress(progress); // signed in: send it to the account too + renderWallet(); + } + + // Swap in another save (the account's, after signing in) and redraw what shows it. + function replaceProgress(next) { + for (const key of Object.keys(progress)) delete progress[key]; + Object.assign(progress, Progress.normalize(next)); + try { + localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress)); + } catch (_) { + // not fatal + } + refreshUnlocks(); renderWallet(); } @@ -2192,6 +2208,8 @@ toast(`🏁 Finished ${an} ${game.size}×${game.size} game!`, `+${boardBonus} bonus points`); } const humanWon = winners.length === 1 && !winners[0].cpu; + // for class stats and homework ("beat the CPU on 8×8", "finish 5 games") + Progress.noteActivity(progress, { kind: 'game', mode: game.mode, difficulty: game.difficulty, size: game.size, won: game.mode === 'single' && humanWon }); if (humanWon && game.mode === 'single') { Progress.recordBoardWin(progress, game.size); // reveals the next board syncBoardLocks(); @@ -2333,10 +2351,11 @@ return Progress.playerKey(name) === 'you'; } - function renderFactGrid(name, facts) { + // size: a fixed size (a teacher or parent looking at a student), else what this player reached + function renderFactGrid(name, facts, size = null) { const seenMax = Math.max(0, ...Object.keys(facts).flatMap((k) => k.split('×').map((n) => Number(n.trim())))); const unlocked = unlockedTableSize(); - const max = Math.max(6, seenMax > 8 ? 12 : seenMax > 6 ? 8 : 6, unlocked); + const max = size || Math.max(6, seenMax > 8 ? 12 : seenMax > 6 ? 8 : 6, unlocked); const wrap = make('div', 'fact-grid-wrap'); if (name) wrap.append(make('div', 'practice-name', name)); // no label for the default "You" const grid = make('div', 'fact-grid'); @@ -3479,7 +3498,7 @@ const queue = Progress.pickPracticeFacts(facts, Progress.practiceSet(settings.difficulty), count, Math.random, tables); const before = {}; for (const [a, b] of queue) before[Progress.factKey(a, b)] = Progress.factStatus(facts[Progress.factKey(a, b)]); - practice = { name, max, count, tables, expert, timer, startedAt: Date.now(), times: [], queue, index: 0, requeued: new Set(), before, firstTry: 0, answered: 0, streak: 0, bestStreak: 0, points: 0, earned: [], current: null }; + practice = { name, max, count, tables, expert, timer, difficulty: settings.difficulty, startedAt: Date.now(), times: [], queue, index: 0, requeued: new Set(), before, firstTry: 0, answered: 0, streak: 0, bestStreak: 0, points: 0, earned: [], current: null }; el.startScreen.hidden = true; el.gameScreen.hidden = true; el.practiceDone.hidden = true; @@ -3710,6 +3729,7 @@ toast(`🏁 Finished all ${pr.count} questions!`, `+${finish} bonus points${pr.expert ? ` (×${EXPERT_BONUS} Expert)` : ''}`); } recordPracticeRound(pr); + Progress.noteActivity(progress, { kind: 'practice', difficulty: pr.difficulty, count: pr.count, expert: pr.expert, finished: true, firstTry: pr.firstTry }); pr.earned.push(...celebrate(Progress.awardAchievements(progress, { event: 'practice' }))); saveProgress(); @@ -4089,7 +4109,10 @@ // fromLink: opened from a join link (QR code or a link posted in the class chat) async function openClassJoin(code = '', fromLink = false) { el.classCodeInput.value = code || ''; - el.classNameInput.value = lastClassName(); + // Signed-in students play under their account's first name + const account = Auth && Auth.role() === 'student' ? Auth.user() : null; + el.classNameInput.value = account ? account.firstName : lastClassName(); + el.classNameInput.readOnly = Boolean(account); el.classJoinError.hidden = true; el.classJoinForm.hidden = false; el.classWait.hidden = true; @@ -4526,6 +4549,7 @@ Progress.addPoints(progress, me.score); progress.counters.pairGames = (progress.counters.pairGames || 0) + 1; if (won) progress.counters.pairWins = (progress.counters.pairWins || 0) + 1; + Progress.noteActivity(progress, { kind: 'class', type: view.tournament ? 'tournament' : 'pairs', won }); const p = game.players[c.me]; const asked = p.answered + p.stats.timeouts; const earned = Progress.awardAchievements(progress, { @@ -4693,6 +4717,7 @@ el.rewards.innerHTML = ''; Progress.addPoints(progress, you.score); progress.counters.classGames = (progress.counters.classGames || 0) + 1; + Progress.noteActivity(progress, { kind: 'class', type: 'class', rank: you.rank }); const asked = you.answered + you.timeouts; const earned = Progress.awardAchievements(progress, { event: 'class', @@ -4771,6 +4796,8 @@ // Creating a class. Everything here (and more) can be changed in the lobby too. function openHostSetup() { + // Hosting a class needs a teacher account (the server checks too) + if (Auth && Auth.served() && Auth.role() !== 'teacher') return window.BlockoutAccounts.askTeacherSignIn(); renderHostType(); el.hostError.hidden = true; el.hostSetup.hidden = false; @@ -6274,6 +6301,23 @@ } } + // For the account screens (js/accounts.js): what they need from the game. + window.BlockoutGame = { + progress: () => progress, + replaceProgress, + saveProgress, + openHostSetup, + startHost: (h) => connectHost(h), + joinClass: (code) => openClassJoin(code, true), + renderFactGrid, + renderFactLegend, + make, + icon, + toast, + celebrate, + menuShowing: () => !el.startScreen.hidden, + }; + // Darken (negative amount) or lighten a #rrggbb colour. function shade(hex, amount) { const n = parseInt(hex.slice(1), 16); diff --git a/js/progress.js b/js/progress.js index b544e2e..e824e62 100644 --- a/js/progress.js +++ b/js/progress.js @@ -723,9 +723,18 @@ // Two saves of the same person (this device's and the account's) as one: the // most of every count, every unlock, achievement and sticker, and for each // fact whichever record has seen it more. Used once per device and account. + // A copy with every cheat's effects taken back (what an account stores: + // cheats belong to the device that typed them). + function withoutCheats(state) { + const s = normalize(JSON.parse(JSON.stringify(state))); + for (const code of activeCheats(s)) cheatOff(s, code); + s.cheats = {}; + return s; + } + function mergeProgress(a, b) { - const x = normalize(a); - const y = normalize(b); + const x = withoutCheats(a); + const y = withoutCheats(b); const out = normalize({ ...y, ...x }); out.wallet = Math.max(x.wallet, y.wallet); out.earned = Math.max(x.earned, y.earned); @@ -759,7 +768,6 @@ }) .sort((p, q) => p.t - q.t) .slice(-ACTIVITY_LIMIT); - out.cheats = {}; // cheats stay on the device that typed them return out; } @@ -867,6 +875,7 @@ practiceSet, noteActivity, mergeProgress, + withoutCheats, factSummary, cleanGoal, describeGoal, diff --git a/server/accounts.js b/server/accounts.js index fa72365..bb8a2a5 100644 --- a/server/accounts.js +++ b/server/accounts.js @@ -99,8 +99,7 @@ function createAccounts({ store, auth, now = () => Date.now() }) { function putProgress(user, data) { need(user); if (!data || typeof data !== 'object') throw new AccountError('progress', 'That isn’t a save.'); - const clean = Progress.normalize(data); - clean.cheats = {}; // cheats stay on the device + const clean = Progress.withoutCheats(data); // cheats (and what they unlocked) stay on the device if (JSON.stringify(clean).length > MAX_PROGRESS_BYTES) throw new AccountError('progress', 'That save is too big.'); store.putProgress(user.id, clean, now()); return { ok: true, updatedAt: now() }; @@ -246,7 +245,10 @@ function createAccounts({ store, auth, now = () => Date.now() }) { need(user, 'parent', 'teacher'); const child = store.user(childId); if (!child || !canSee(user, childId)) throw new AccountError('student', 'You can’t see that student.', 404); - return studentStats(child, { withParentCode: user.role === 'teacher' }); + // the detail view also draws the whole Times Table grid + const state = progressOf(child.id); + const facts = ((state && state.facts && state.facts.you) || { facts: {} }).facts; + return { ...studentStats(child, { withParentCode: user.role === 'teacher' }), facts }; } // ---- homework diff --git a/test/accounts.test.js b/test/accounts.test.js index 962af88..8819877 100644 --- a/test/accounts.test.js +++ b/test/accounts.test.js @@ -98,6 +98,10 @@ test('progress sync: saves round-trip, cheats stay on the device, stats come fro const back = accounts.getProgress(kid).progress; assert.equal(back.wallet, 50); assert.deepEqual(back.cheats, {}); + // IDDQD's free unlocks never reach the account + P.cheatOn(s, 'iddqd'); + accounts.putProgress(kid, s); + assert.deepEqual(accounts.getProgress(kid).progress.unlocks, []); assert.deepEqual(P.factSummary(back).needsPractice, ['6 × 7']); }); From 2668072337b160050b9a66507f5bc4111f96372a Mon Sep 17 00:00:00 2001 From: Aleksandr Ilin Date: Wed, 23 Sep 2026 20:22:09 +0000 Subject: [PATCH 005/127] build: move to a Turborepo of Vite apps and shared packages - apps: multiplication, seven other games, more-games hub, server (ESM) - packages: engine, progress, ui, game-kit, classroom, auth - game.js split into section modules with an ordered run() phase (pixel-identical before and after on 11 screens) - homework only offers what the child has unlocked; Play starts it - RFC 9457 problem details and security headers on the server - CI (lint, test, build, strict docs build; pinned actions), Dependabot - MkDocs Material docs site with screenshots, ADRs, CHANGELOG Co-Authored-By: Claude Opus 5.5 (1M context) --- .devcontainer/devcontainer.json | 4 +- .devcontainer/postCreateCommand.sh | 28 +- .github/dependabot.yml | 14 + .github/workflows/ci.yml | 48 + .gitignore | 2 + CHANGELOG.md | 46 + CLAUDE.md | 92 +- README.md | 54 + apps/addition/index.html | 12 + apps/addition/package.json | 16 + apps/addition/src/main.js | 5 + apps/addition/vite.config.js | 9 + apps/division/index.html | 12 + apps/division/package.json | 16 + apps/division/src/main.js | 5 + apps/division/vite.config.js | 9 + apps/fraction-add/index.html | 12 + apps/fraction-add/package.json | 16 + apps/fraction-add/src/main.js | 5 + apps/fraction-add/vite.config.js | 9 + apps/fraction-divide/index.html | 12 + apps/fraction-divide/package.json | 16 + apps/fraction-divide/src/main.js | 5 + apps/fraction-divide/vite.config.js | 9 + apps/fraction-multiply/index.html | 12 + apps/fraction-multiply/package.json | 16 + apps/fraction-multiply/src/main.js | 5 + apps/fraction-multiply/vite.config.js | 9 + apps/fraction-subtract/index.html | 12 + apps/fraction-subtract/package.json | 16 + apps/fraction-subtract/src/main.js | 5 + apps/fraction-subtract/vite.config.js | 9 + apps/more-games/index.html | 19 + apps/more-games/package.json | 17 + apps/more-games/src/main.js | 18 + apps/more-games/vite.config.js | 9 + index.html => apps/multiplication/index.html | 72 +- apps/multiplication/package.json | 21 + apps/multiplication/src/accounts.js | 939 +++ apps/multiplication/src/css/accounts.css | 96 + apps/multiplication/src/css/classroom.css | 145 + apps/multiplication/src/game/achievements.js | 96 + apps/multiplication/src/game/api.js | 14 + apps/multiplication/src/game/base.js | 320 + apps/multiplication/src/game/cheats.js | 116 + apps/multiplication/src/game/checkin.js | 22 + apps/multiplication/src/game/classroom.js | 1408 ++++ apps/multiplication/src/game/index.js | 31 + apps/multiplication/src/game/input.js | 270 + apps/multiplication/src/game/invites.js | 274 + apps/multiplication/src/game/play.js | 1114 +++ apps/multiplication/src/game/practice.js | 549 ++ apps/multiplication/src/game/render.js | 544 ++ apps/multiplication/src/game/settings.js | 35 + apps/multiplication/src/game/shop.js | 424 ++ apps/multiplication/src/game/stats.js | 392 + apps/multiplication/src/game/wallet.js | 406 ++ apps/multiplication/src/game/wardrobe.js | 707 ++ apps/multiplication/src/invite.js | 109 + apps/multiplication/src/main.js | 6 + apps/multiplication/src/style.css | 16 + .../multiplication/test}/invite.test.js | 6 +- apps/multiplication/vite.config.js | 14 + apps/server/package.json | 17 + {server => apps/server/src}/accounts.js | 18 +- {server => apps/server/src}/auth.js | 6 +- {server => apps/server/src}/blocklist.txt | 0 .../classroom.js => apps/server/src/index.js | 97 +- {server => apps/server/src}/moderation.js | 10 +- {server => apps/server/src}/rooms.js | 16 +- {server => apps/server/src}/store.js | 16 +- {server => apps/server/src}/tournament.js | 4 +- {test => apps/server/test}/accounts.test.js | 14 +- {test => apps/server/test}/moderation.test.js | 6 +- .../server/test/rooms.test.js | 8 +- {test => apps/server/test}/server.test.js | 20 +- {test => apps/server/test}/tournament.test.js | 6 +- apps/subtraction/index.html | 12 + apps/subtraction/package.json | 16 + apps/subtraction/src/main.js | 5 + apps/subtraction/vite.config.js | 9 + css/style.css | 1533 ---- .../screenshots/classroom-host-setup.png | Bin 0 -> 90795 bytes docs/assets/screenshots/classroom-join.png | Bin 0 -> 64202 bytes docs/assets/screenshots/classroom-lobby.png | Bin 0 -> 85620 bytes docs/assets/screenshots/classroom-student.png | Bin 0 -> 31366 bytes docs/assets/screenshots/classroom-teacher.png | Bin 0 -> 49235 bytes docs/assets/screenshots/fraction-game.png | Bin 0 -> 35059 bytes docs/assets/screenshots/main-menu.png | Bin 0 -> 76533 bytes docs/assets/screenshots/more-games.png | Bin 0 -> 162592 bytes docs/assets/screenshots/parent-my-kids.png | Bin 0 -> 23164 bytes docs/assets/screenshots/practice-menu.png | Bin 0 -> 115776 bytes docs/assets/screenshots/practice.png | Bin 0 -> 32171 bytes docs/assets/screenshots/results.png | Bin 0 -> 92017 bytes docs/assets/screenshots/shop.png | Bin 0 -> 83080 bytes docs/assets/screenshots/sign-in.png | Bin 0 -> 33561 bytes docs/assets/screenshots/single-player.png | Bin 0 -> 30353 bytes docs/assets/screenshots/stats.png | Bin 0 -> 73399 bytes docs/assets/screenshots/student-homework.png | Bin 0 -> 33579 bytes docs/assets/screenshots/student-menu.png | Bin 0 -> 98516 bytes .../assets/screenshots/teacher-my-classes.png | Bin 0 -> 112882 bytes docs/assets/screenshots/teacher-student.png | Bin 0 -> 60086 bytes docs/assets/screenshots/wardrobe.png | Bin 0 -> 69812 bytes docs/concepts/architecture.md | 55 + docs/concepts/security.md | 29 + docs/decisions/0001-turborepo-monorepo.md | 27 + docs/decisions/0002-swappable-sign-in.md | 26 + .../0003-sqlite-with-node-builtin.md | 25 + .../0004-store-minimum-child-data.md | 25 + docs/decisions/0005-game-modules-run-phase.md | 26 + docs/decisions/index.md | 13 + docs/guides/back-up-the-database.md | 14 + docs/guides/parents.md | 15 + docs/guides/playing.md | 74 + docs/guides/set-up-keycloak.md | 21 + docs/guides/share-through-a-tunnel.md | 17 + docs/guides/teachers.md | 63 + docs/home/index.md | 60 + docs/home/quickstart.md | 54 + docs/privacy.md | 2 + docs/project/changelog.md | 3 + docs/project/ci.md | 18 + docs/project/installing.md | 39 + docs/project/testing.md | 23 + docs/reference/CONFIGURATION.md | 29 + docs/reference/HTTP-API.md | 89 + docs/requirements.txt | 2 + docs/v0.3.0-log.md | 75 +- eslint.config.js | 25 + js/accounts.js | 894 --- js/auth.js | 214 - js/classroom.js | 187 - js/core.js | 353 - js/cosmetics.js | 299 - js/game.js | 6330 ----------------- js/invite.js | 106 - js/progress.js | 930 --- js/vendor/qrcode.js | 2297 ------ mkdocs.yml | 106 + package-lock.json | 2117 +++++- package.json | 27 +- packages/auth/package.json | 16 + packages/auth/src/client.js | 211 + packages/classroom/package.json | 16 + packages/classroom/src/client.js | 187 + packages/engine/package.json | 17 + packages/engine/src/core.js | 347 + packages/engine/src/ops/boards.js | 208 + packages/engine/src/ops/fraction.js | 76 + packages/engine/src/ops/variants.js | 623 ++ {test => packages/engine/test}/core.test.js | 6 +- .../engine/test/ops.test.js | 10 +- packages/game-kit/package.json | 18 + .../game-kit/src/games.css | 2 +- .../game-kit/src/index.js | 38 +- packages/game-kit/src/style.css | 16 + packages/progress/package.json | 18 + packages/progress/src/cosmetics.js | 292 + packages/progress/src/progress.js | 951 +++ .../progress/test}/cosmetics.test.js | 8 +- .../progress/test}/progress.test.js | 18 +- packages/ui/package.json | 16 + packages/ui/src/css/base.css | 28 + packages/ui/src/css/dice-skins.css | 209 + packages/ui/src/css/game.css | 52 + packages/ui/src/css/menu.css | 150 + packages/ui/src/css/panels.css | 33 + packages/ui/src/css/play.css | 66 + packages/ui/src/css/players.css | 89 + packages/ui/src/css/results.css | 164 + packages/ui/src/css/rewards.css | 208 + packages/ui/src/css/small-screens.css | 15 + packages/ui/src/css/tokens.css | 170 + packages/ui/src/css/wardrobe.css | 98 + packages/ui/src/icons.svg | 54 + packages/ui/src/index.js | 22 + packages/ui/src/vite-plugin.js | 25 + prototypes/addition.html | 24 - prototypes/division.html | 24 - prototypes/fraction-add.html | 24 - prototypes/fraction-divide.html | 24 - prototypes/fraction-multiply.html | 24 - prototypes/fraction-subtract.html | 24 - prototypes/index.html | 47 - prototypes/shared/boards.js | 213 - prototypes/shared/fraction.js | 66 - prototypes/shared/variants.js | 621 -- prototypes/subtraction.html | 24 - turbo.json | 20 + 189 files changed, 16367 insertions(+), 14516 deletions(-) create mode 100644 .github/dependabot.yml create mode 100644 .github/workflows/ci.yml create mode 100644 CHANGELOG.md create mode 100644 apps/addition/index.html create mode 100644 apps/addition/package.json create mode 100644 apps/addition/src/main.js create mode 100644 apps/addition/vite.config.js create mode 100644 apps/division/index.html create mode 100644 apps/division/package.json create mode 100644 apps/division/src/main.js create mode 100644 apps/division/vite.config.js create mode 100644 apps/fraction-add/index.html create mode 100644 apps/fraction-add/package.json create mode 100644 apps/fraction-add/src/main.js create mode 100644 apps/fraction-add/vite.config.js create mode 100644 apps/fraction-divide/index.html create mode 100644 apps/fraction-divide/package.json create mode 100644 apps/fraction-divide/src/main.js create mode 100644 apps/fraction-divide/vite.config.js create mode 100644 apps/fraction-multiply/index.html create mode 100644 apps/fraction-multiply/package.json create mode 100644 apps/fraction-multiply/src/main.js create mode 100644 apps/fraction-multiply/vite.config.js create mode 100644 apps/fraction-subtract/index.html create mode 100644 apps/fraction-subtract/package.json create mode 100644 apps/fraction-subtract/src/main.js create mode 100644 apps/fraction-subtract/vite.config.js create mode 100644 apps/more-games/index.html create mode 100644 apps/more-games/package.json create mode 100644 apps/more-games/src/main.js create mode 100644 apps/more-games/vite.config.js rename index.html => apps/multiplication/index.html (81%) create mode 100644 apps/multiplication/package.json create mode 100644 apps/multiplication/src/accounts.js create mode 100644 apps/multiplication/src/css/accounts.css create mode 100644 apps/multiplication/src/css/classroom.css create mode 100644 apps/multiplication/src/game/achievements.js create mode 100644 apps/multiplication/src/game/api.js create mode 100644 apps/multiplication/src/game/base.js create mode 100644 apps/multiplication/src/game/cheats.js create mode 100644 apps/multiplication/src/game/checkin.js create mode 100644 apps/multiplication/src/game/classroom.js create mode 100644 apps/multiplication/src/game/index.js create mode 100644 apps/multiplication/src/game/input.js create mode 100644 apps/multiplication/src/game/invites.js create mode 100644 apps/multiplication/src/game/play.js create mode 100644 apps/multiplication/src/game/practice.js create mode 100644 apps/multiplication/src/game/render.js create mode 100644 apps/multiplication/src/game/settings.js create mode 100644 apps/multiplication/src/game/shop.js create mode 100644 apps/multiplication/src/game/stats.js create mode 100644 apps/multiplication/src/game/wallet.js create mode 100644 apps/multiplication/src/game/wardrobe.js create mode 100644 apps/multiplication/src/invite.js create mode 100644 apps/multiplication/src/main.js create mode 100644 apps/multiplication/src/style.css rename {test => apps/multiplication/test}/invite.test.js (95%) create mode 100644 apps/multiplication/vite.config.js create mode 100644 apps/server/package.json rename {server => apps/server/src}/accounts.js (96%) rename {server => apps/server/src}/auth.js (97%) rename {server => apps/server/src}/blocklist.txt (100%) rename server/classroom.js => apps/server/src/index.js (85%) rename {server => apps/server/src}/moderation.js (94%) rename {server => apps/server/src}/rooms.js (99%) rename {server => apps/server/src}/store.js (97%) rename {server => apps/server/src}/tournament.js (99%) rename {test => apps/server/test}/accounts.test.js (97%) rename {test => apps/server/test}/moderation.test.js (95%) rename test/classroom.test.js => apps/server/test/rooms.test.js (99%) rename {test => apps/server/test}/server.test.js (77%) rename {test => apps/server/test}/tournament.test.js (97%) create mode 100644 apps/subtraction/index.html create mode 100644 apps/subtraction/package.json create mode 100644 apps/subtraction/src/main.js create mode 100644 apps/subtraction/vite.config.js delete mode 100644 css/style.css create mode 100644 docs/assets/screenshots/classroom-host-setup.png create mode 100644 docs/assets/screenshots/classroom-join.png create mode 100644 docs/assets/screenshots/classroom-lobby.png create mode 100644 docs/assets/screenshots/classroom-student.png create mode 100644 docs/assets/screenshots/classroom-teacher.png create mode 100644 docs/assets/screenshots/fraction-game.png create mode 100644 docs/assets/screenshots/main-menu.png create mode 100644 docs/assets/screenshots/more-games.png create mode 100644 docs/assets/screenshots/parent-my-kids.png create mode 100644 docs/assets/screenshots/practice-menu.png create mode 100644 docs/assets/screenshots/practice.png create mode 100644 docs/assets/screenshots/results.png create mode 100644 docs/assets/screenshots/shop.png create mode 100644 docs/assets/screenshots/sign-in.png create mode 100644 docs/assets/screenshots/single-player.png create mode 100644 docs/assets/screenshots/stats.png create mode 100644 docs/assets/screenshots/student-homework.png create mode 100644 docs/assets/screenshots/student-menu.png create mode 100644 docs/assets/screenshots/teacher-my-classes.png create mode 100644 docs/assets/screenshots/teacher-student.png create mode 100644 docs/assets/screenshots/wardrobe.png create mode 100644 docs/concepts/architecture.md create mode 100644 docs/concepts/security.md create mode 100644 docs/decisions/0001-turborepo-monorepo.md create mode 100644 docs/decisions/0002-swappable-sign-in.md create mode 100644 docs/decisions/0003-sqlite-with-node-builtin.md create mode 100644 docs/decisions/0004-store-minimum-child-data.md create mode 100644 docs/decisions/0005-game-modules-run-phase.md create mode 100644 docs/decisions/index.md create mode 100644 docs/guides/back-up-the-database.md create mode 100644 docs/guides/parents.md create mode 100644 docs/guides/playing.md create mode 100644 docs/guides/set-up-keycloak.md create mode 100644 docs/guides/share-through-a-tunnel.md create mode 100644 docs/guides/teachers.md create mode 100644 docs/home/index.md create mode 100644 docs/home/quickstart.md create mode 100644 docs/project/changelog.md create mode 100644 docs/project/ci.md create mode 100644 docs/project/installing.md create mode 100644 docs/project/testing.md create mode 100644 docs/reference/CONFIGURATION.md create mode 100644 docs/reference/HTTP-API.md create mode 100644 docs/requirements.txt create mode 100644 eslint.config.js delete mode 100644 js/accounts.js delete mode 100644 js/auth.js delete mode 100644 js/classroom.js delete mode 100644 js/core.js delete mode 100644 js/cosmetics.js delete mode 100644 js/game.js delete mode 100644 js/invite.js delete mode 100644 js/progress.js delete mode 100644 js/vendor/qrcode.js create mode 100644 mkdocs.yml create mode 100644 packages/auth/package.json create mode 100644 packages/auth/src/client.js create mode 100644 packages/classroom/package.json create mode 100644 packages/classroom/src/client.js create mode 100644 packages/engine/package.json create mode 100644 packages/engine/src/core.js create mode 100644 packages/engine/src/ops/boards.js create mode 100644 packages/engine/src/ops/fraction.js create mode 100644 packages/engine/src/ops/variants.js rename {test => packages/engine/test}/core.test.js (98%) rename test/prototypes.test.js => packages/engine/test/ops.test.js (95%) create mode 100644 packages/game-kit/package.json rename prototypes/shared/proto.css => packages/game-kit/src/games.css (98%) rename prototypes/shared/engine.js => packages/game-kit/src/index.js (95%) create mode 100644 packages/game-kit/src/style.css create mode 100644 packages/progress/package.json create mode 100644 packages/progress/src/cosmetics.js create mode 100644 packages/progress/src/progress.js rename {test => packages/progress/test}/cosmetics.test.js (96%) rename {test => packages/progress/test}/progress.test.js (96%) create mode 100644 packages/ui/package.json create mode 100644 packages/ui/src/css/base.css create mode 100644 packages/ui/src/css/dice-skins.css create mode 100644 packages/ui/src/css/game.css create mode 100644 packages/ui/src/css/menu.css create mode 100644 packages/ui/src/css/panels.css create mode 100644 packages/ui/src/css/play.css create mode 100644 packages/ui/src/css/players.css create mode 100644 packages/ui/src/css/results.css create mode 100644 packages/ui/src/css/rewards.css create mode 100644 packages/ui/src/css/small-screens.css create mode 100644 packages/ui/src/css/tokens.css create mode 100644 packages/ui/src/css/wardrobe.css create mode 100644 packages/ui/src/icons.svg create mode 100644 packages/ui/src/index.js create mode 100644 packages/ui/src/vite-plugin.js delete mode 100644 prototypes/addition.html delete mode 100644 prototypes/division.html delete mode 100644 prototypes/fraction-add.html delete mode 100644 prototypes/fraction-divide.html delete mode 100644 prototypes/fraction-multiply.html delete mode 100644 prototypes/fraction-subtract.html delete mode 100644 prototypes/index.html delete mode 100644 prototypes/shared/boards.js delete mode 100644 prototypes/shared/fraction.js delete mode 100644 prototypes/shared/variants.js delete mode 100644 prototypes/subtraction.html create mode 100644 turbo.json diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json index 1c64cf2..64e4e22 100644 --- a/.devcontainer/devcontainer.json +++ b/.devcontainer/devcontainer.json @@ -9,8 +9,8 @@ // "features": {}, // Use 'forwardPorts' to make a list of ports inside the container available locally. - // The classroom server (npm run classroom) - "forwardPorts": [8080], + // The Blockout server (npm start) and the Vite dev server (npm run dev) + "forwardPorts": [8080, 5173], // Use 'postCreateCommand' to run commands after the container is created. // Playwright + Chromium for the browser tests, and cloudflared for tunnels diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index a0e6eb9..6a6c797 100755 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -1,11 +1,12 @@ #!/usr/bin/env bash # Sets up the dev container with everything used to build and test Blockout: -# - npm dev dependencies from package.json: Playwright (browser tests) and jsQR -# (reading the QR codes the game draws) +# - npm packages from package.json: Turborepo, Vite and ESLint, plus Playwright +# (browser tests) and jsQR (reading the QR codes the game draws) # - Chromium for Playwright, plus the Linux libraries it needs # - cloudflared, for sharing the classroom server through a Cloudflare tunnel -# The game itself has no dependencies: it runs from index.html, and the classroom -# server is plain Node (node server/classroom.js). +# - python3-venv, to build the docs site (see docs/project/installing.md) +# Blockout is a Turborepo (npm workspaces): apps/* and packages/*, built with Vite. +# The server (apps/server) uses Node's built-ins only, including node:sqlite. # # Safe to run again: each step skips what's already there. set -euo pipefail @@ -20,13 +21,20 @@ if [ "$(id -u)" -ne 0 ]; then if command -v sudo >/dev/null 2>&1; then SUDO='sudo'; else echo "Needs root or sudo for system packages." >&2; exit 1; fi fi -say "npm packages (Playwright, jsQR)" +say "npm packages (Turborepo, Vite, ESLint, Playwright, jsQR)" if [ -f package-lock.json ]; then npm ci; else npm install; fi say "Chromium for Playwright, with its system libraries" # --with-deps installs the Debian packages Chromium needs (fonts, NSS, …) npx --yes playwright install --with-deps chromium +say "Python venv (for the docs site: MkDocs Material)" +if python3 -c 'import ensurepip' >/dev/null 2>&1; then + echo "already there" +else + $SUDO apt-get install -y -qq python3-venv +fi + say "cloudflared (Cloudflare tunnels)" if command -v cloudflared >/dev/null 2>&1; then echo "already installed: $(cloudflared --version | head -n 1)" @@ -42,14 +50,16 @@ fi say "Checking" node --version npx playwright --version -node --test test/*.test.js 2>&1 | grep -E '^ℹ (tests|pass|fail)' || true +npm test 2>&1 | grep -E 'ℹ (pass|fail)|Tasks:' || true cat <<'EOF' Ready. Handy commands: - npm test unit tests - npm run classroom classroom server on http://localhost:8080 + npm test unit tests (every app and package) + npm run lint ESLint + npm run dev Vite dev servers + the API server (game at http://localhost:5173) + npm start build everything and serve it on http://localhost:8080 cloudflared tunnel --url http://localhost:8080 share it; then restart the server with - npm run classroom -- --public-url https://.trycloudflare.com + npm start -- --public-url https://.trycloudflare.com EOF diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..677b88a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +# Keeps the pinned action SHAs (with their version comments) and npm packages current. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + - package-ecosystem: npm + directory: / + schedule: + interval: weekly + groups: + tooling: + patterns: ['*'] diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..fd64537 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +# Lint, test and build every app and package, and build the docs site, on each +# push and pull request. +name: ci + +on: + pull_request: + push: + branches: [main, 'v*'] + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + check: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 + with: + node-version: 24 + cache: npm + - run: npm ci + - run: npm run lint + - run: npm test + - run: npm run build + + docs: + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + - uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + cache: pip + cache-dependency-path: docs/requirements.txt + - run: pip install -r docs/requirements.txt + # --strict: a broken link or a page missing from the nav fails the build + - run: mkdocs build --strict diff --git a/.gitignore b/.gitignore index 902b281..07b621b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ data/ +dist/ +.turbo/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3cc4e10 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,46 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +Work towards 0.3.0 on the `v0.3.0` branch. Details and decisions: [the v0.3.0 work log](https://github.com/alexanderilyin/blockout/blob/v0.3.0/docs/v0.3.0-log.md). + +### Added +- Accounts for students, teachers and parents, signed in with a practice "dev sign-in" for now and ready for Keycloak (and school Google accounts) later. Guests can still play everything except hosting a class. +- Teachers: classes that students join with a code, a class stats page, homework and scheduled class or school tournaments whose lobby opens by itself. +- Parents: link a child with a code, see their Times Table and homework, and set extra homework. +- Homework only offers what the child can already play, and "Play ›" starts it straight away. +- A signed-in student's progress follows them to any device; the first time on a device, that device's guest progress is added to the account. +- Tricky, Master and Legend difficulties after Hard, and the IDDQD and FIVEMOREMINUTESMOM cheat codes, each with a secret achievement. +- More games: addition, subtraction, division and four fraction games, reachable from the main menu. + +### Changed +- Hosting a class now needs a teacher sign-in. +- Legend deals teens (like 14 × 7) for half of its rolls and Tricky facts for the rest; Tricky, Master and Legend games now last about as long as Hard ones. +- The project is now a Turborepo of Vite apps and shared packages. `npm start` builds and serves everything; the other games moved from `prototypes/*.html` to `//`. + +### Security +- Only a student's first name, last initial, role and email domain are stored; see [Privacy](https://github.com/alexanderilyin/blockout/blob/v0.3.0/docs/privacy.md). + +## [0.2.0] - 2026-09-23 + +### Added +- Classroom multiplayer: a whole class on the same rolls, pairs on a shared board, and tournaments (knockout, double elimination, round robin, Swiss, king of the hill), run from the teacher's screen with a join code, link and QR code. +- Home multiplayer for 2–4 players on one screen. +- Shop with skill-tree unlocks, Wardrobe, stickers, achievements shown one at a time, stats and the Times Table. +- Practice mode with Learn and Expert levels, timers and finishing bonuses. +- Username checks for classroom names. + +## [0.1.0] - 2026-09-23 + +### Added +- The first playable Blockout: roll two dice, draw the rectangle, answer the multiplication, against the CPU. + + +[Unreleased]: https://github.com/alexanderilyin/blockout/compare/4921120...v0.3.0 +[0.2.0]: https://github.com/alexanderilyin/blockout/compare/382b38c...4921120 +[0.1.0]: https://github.com/alexanderilyin/blockout/tree/382b38c diff --git a/CLAUDE.md b/CLAUDE.md index 46983a7..6b48761 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,55 +2,61 @@ A multiplication-as-area game for 3rd graders, based on "Blockout" from Math for Love. Two dice are rolled, you draw an a × b rectangle on a grid and answer a × b to claim it. It runs mainly on classroom Chromebooks. -**The game itself has no dependencies and no build step.** It's plain HTML/CSS/JS and runs straight from `index.html`. npm packages (Playwright, jsQR) are for testing only. +**A Turborepo of plain JavaScript** (ES modules, no framework), built with Vite. npm workspaces: `apps/*` and `packages/*`. The only runtime dependency is `qrcode-generator`. The server uses Node built-ins only, including `node:sqlite`. ## Layout | Path | What | |---|---| -| `index.html` | Every screen and dialog (`.overlay > .dialog`), plus the Lucide icon sprite (`#i-*` symbols, used as ``). | -| `css/style.css` | All styles. Colors are tokens on `:root`, with dark-mode variants. | -| `js/core.js` | `BlockoutCore`: the board, placement rules, dice, `rollForBoard`, difficulties (`DIFFICULTIES`, `diceFaces`, `createPairDice`, `fitsDifficulty`). Pure. | -| `js/progress.js` | `BlockoutProgress`: wallet, `SHOP` (unlocks), achievements, fact stats, bonuses, practice picking, cheat codes, and `normalize` (save migrations). Pure. | -| `js/cosmetics.js` | Wardrobe items; registers them into `Progress.SHOP`. | -| `js/invite.js` | Invite links (`validate` whitelists every setting). | -| `js/classroom.js` | Client for the classroom server (SSE with long-poll fallback, session in sessionStorage). | -| `js/game.js` | All UI (~6000 lines, one IIFE): menu, game, practice, shop, wardrobe, stats, classroom screens, cheats. | -| `server/classroom.js` | Zero-dependency Node server: static files (only `index.html`, `css/`, `js/`, `prototypes/`), JSON API, SSE plus `/poll`. | -| `server/rooms.js` | Pure room logic: whole class, pairs, tournaments. | -| `server/tournament.js` | Tournament formats: knockout, double elimination, round robin, Swiss, king of the hill. | -| `server/moderation.js` | Username checks: `blocklist.txt`, plus OpenAI moderation only when `OPENAI_API_KEY` is set (fails open). | -| `prototypes/` | Other operations (addition, subtraction, division, fractions). Single player and practice only. Shares `prototypes/shared/`. | -| `js/auth.js` | `BlockoutAuth`: sign-in (dev or Keycloak PKCE), the bearer token, per-account progress key and sync with the server. | -| `js/accounts.js` | Account screens: sign in, student homework, teacher My classes, parent My kids, live tournament banner. Uses `window.BlockoutGame` (a small API game.js exposes). | -| `server/store.js` | sqlite (`node:sqlite`) storage for accounts; `data/blockout.db` or `BLOCKOUT_DB` (`:memory:` in tests). | -| `server/auth.js` | Sign-in providers behind one interface: dev (HMAC JWT) and Keycloak (RS256 via JWKS); `identity()` keeps only first name, last initial, role, email domain. | -| `server/accounts.js` | Account rules: classes and rosters, homework, parent links, scheduled tournaments. | -| `docs/` | `privacy.md`, and the v0.3.0 plan and log. | -| `test/*.test.js` | `node:test` unit tests for the pure modules and the server rooms. | - -Browser script load order (index.html): core → progress → auth → cosmetics → invite → vendor/qrcode → classroom → game → accounts. Modules are UMD-style: `window.BlockoutX` in the browser, `require()` in tests. +| `apps/multiplication` | The main game, at `/`. `index.html` has every screen and dialog (`.overlay > .dialog`). `src/main.js` loads the styles, `src/game/index.js`, then `src/accounts.js`. | +| `apps/multiplication/src/game/*.js` | The game UI, one module per area: `base` (constants, `el`), `wallet`, `settings`, `wardrobe`, `play` (game state and turns), `stats`, `shop`, `achievements`, `practice`, `invites`, `classroom`, `cheats`, `checkin`, `input`, `render`. See "How the game modules load" below. | +| `apps/multiplication/src/accounts.js` | Account screens: sign in, student homework (Play › starts the game), teacher My classes, parent My kids, live tournament banner. Imports what it needs from `src/game/api.js`. | +| `apps/multiplication/src/invite.js` | Invite links (`validate` whitelists every setting). | +| `apps/` (7), `apps/more-games` | The other games (`/addition/`, …), each a tiny app running `@blockout/game-kit`, plus the More games hub (`/more-games/`). | +| `apps/server/src/index.js` | Node server: the built apps (`apps//dist`; multiplication at `/`), JSON API, SSE plus `/poll` for classrooms, accounts routes. | +| `apps/server/src/rooms.js`, `tournament.js` | Pure room logic: whole class, pairs, tournaments (knockout, double elimination, round robin, Swiss, king of the hill). | +| `apps/server/src/moderation.js` | Username checks: `blocklist.txt`, plus OpenAI moderation only when `OPENAI_API_KEY` is set (fails open). | +| `apps/server/src/store.js`, `auth.js`, `accounts.js` | Accounts: sqlite storage (`data/blockout.db` at the repo root, or `BLOCKOUT_DB`; `:memory:` in tests); sign-in providers behind one interface (dev HMAC JWT, Keycloak RS256 via JWKS; `identity()` keeps only first name, last initial, role, email domain); the rules (classes, rosters, homework, parent links, scheduled tournaments). | +| `packages/engine` | `@blockout/engine`: board, placement, dice, `rollForBoard`, difficulties (`DIFFICULTIES`, `diceFaces`, `createPairDice`, `fitsDifficulty`), CPU. `@blockout/engine/{fraction,boards,variants}`: the other operations' rules. Pure. | +| `packages/progress` | `@blockout/progress`: wallet, `SHOP` (unlocks), achievements, fact stats, bonuses, practice picking, homework goals and options, cheat codes, `normalize` (save migrations). `@blockout/progress/cosmetics`: the Wardrobe (registers its items into `SHOP`). Pure. | +| `packages/ui` | `@blockout/ui`: CSS in `src/css/*` (tokens, base, menu, game, dice skins, results, panels, rewards, wardrobe, players), the Lucide icon sprite (`icons.svg`), DOM helpers, and `@blockout/ui/vite` (`blockoutPage()`: adds the theme script and the sprite to every page). | +| `packages/game-kit` | `@blockout/game-kit`: `startGame(variantId)`, the shared page engine of the other games, and their `style.css`. | +| `packages/classroom`, `packages/auth` | Browser clients: classroom server (SSE with long-poll fallback, session in sessionStorage); sign-in (dev or Keycloak PKCE), bearer token, per-account progress key and sync. | +| `docs/` | The MkDocs Material site (`mkdocs.yml`): `home/`, `guides/`, `concepts/`, `reference/`, `project/`, `decisions/` (ADRs), `privacy.md`, screenshots in `assets/screenshots/`. The v0.3.0 plan and log are kept out of the nav. | +| `.github/` | CI (`workflows/ci.yml`: lint, test, build, strict docs build; actions pinned to commit SHAs) and Dependabot. | + +Tests live next to their code (`packages/*/test`, `apps/*/test`, `node:test`). + +### How the game modules load +Every module in `src/game/` only declares things when it's imported. What used to run at load (event listeners, the initial render, `const el = {…}`) is in each module's `export function run()`, and `src/game/index.js` calls every `run()` in a fixed order. Keep it that way: +- **No load-time work outside `run()`,** except declarations that run nothing. +- **Cross-module writes go through a setter:** `setGame`, `setGameId`, `setPractice` and `setWardrobeFilter`; the imported binding is read-only. +- **Imports between modules are circular.** That's fine as long as nothing is used before `run()`. ## Commands ```sh -npm test # node --test test/*.test.js (must stay green) -node server/classroom.js 8080 # game and classroom server at http://localhost:8080 -node server/classroom.js 8080 --public-url https://.trycloudflare.com # behind a Cloudflare tunnel +npm test # every package's tests through Turborepo (must stay green) +npm run lint # ESLint (eslint.config.js) +npm run build # Vite builds every app into apps/*/dist +npm start # build, then serve everything on http://localhost:8080 +npm start -- --public-url https://.trycloudflare.com # behind a Cloudflare tunnel +npm run dev # Vite dev servers (multiplication on :5173, proxying /api) + the server with --watch cloudflared tunnel --url http://localhost:8080 ``` - **The dev server:** the user usually keeps one running on 8080 behind a Cloudflare quick tunnel. The tunnel URL changes, so ask for it or reuse the last one given. -- **When to restart it:** only after changing `server/*`. Client files are served `no-cache`, so a page reload picks them up. +- **After client changes:** rebuild (`npm run build`). Built assets are hashed and `index.html` is served `no-cache`, so a page reload picks them up. +- **After server changes:** restart the server. - **Setup:** `.devcontainer/postCreateCommand.sh` installs the npm packages, Playwright Chromium with its libraries, and cloudflared. ## Browser testing (Playwright) -Write throwaway scripts in the session scratchpad, not the repo. Require Playwright from `/workspaces/blockout/node_modules/playwright`, and spawn a private server on a spare port (8134+) that you kill at the end. The pattern: +Write throwaway scripts in the session scratchpad, not the repo. Build first (`npm run build`), require Playwright from `/workspaces/blockout/node_modules/playwright`, and spawn a private server on a spare port (8134+) that you kill at the end. The pattern: ```js const { chromium } = require('/workspaces/blockout/node_modules/playwright'); -const srv = spawn('node', ['/workspaces/blockout/server/classroom.js', '8135'], { stdio: 'ignore' }); +const srv = spawn('node', ['/workspaces/blockout/apps/server/src/index.js', '8135'], { stdio: 'ignore', env: { ...process.env, BLOCKOUT_DB: ':memory:' } }); // seed a save before the page loads (once per tab): await page.addInitScript((pr) => { if (!sessionStorage.getItem('s')) { sessionStorage.setItem('s', 1); localStorage.setItem('blockout.progress', JSON.stringify(pr)); } }, progress); @@ -64,6 +70,7 @@ await page.addInitScript((pr) => { if (!sessionStorage.getItem('s')) { sessionSt - **Check for errors:** collect `pageerror` events and check there are none. Screenshot and look at the result. - **Classroom flows:** use several browser contexts (a teacher plus students). Hosting needs a teacher sign-in: POST `/api/auth/dev` with `{ role: 'teacher', firstName, schoolId: 'cadence-park' }` and store `{ token, user }` in `localStorage['blockout.auth']`, or use the sign-in dialog. - **Accounts:** start the test server with `BLOCKOUT_DB=:memory:` so runs don't share data. +- **Other games:** they're at `//`. `window.PROTO_FAST = true` (an init script) makes the CPU quick, and `window.ProtoGame` exposes `state()`, `piece()` and `boards`. ## Storage @@ -80,7 +87,7 @@ await page.addInitScript((pr) => { if (!sessionStorage.getItem('s')) { sessionSt - Only the next step shows its name and price. Later steps are blurred "skeleton" placeholders with a lock (`Progress.isHidden`, `setLockBadge`). - Reveal gates: `winOn` (win on the previous board), `masterUpTo` / `mastery` (Times Table mastery, via `tableReady`). - `perk` text appears in the unlock dialog and the shop. - - Buying from a menu control activates it (the `ACTIVATE` map in game.js). + - Buying from a menu control activates it (the `ACTIVATE` map in `src/game/shop.js`). - **Per-difficulty unlocks:** each difficulty has its own Expert level, timers and practice question counts (`gameExpertId`, `gameTimerId`, `practiceCountId`, `practiceTimerId`, `practiceExpertId`). - **Difficulties:** @@ -95,7 +102,7 @@ await page.addInitScript((pr) => { if (!sessionStorage.getItem('s')) { sessionSt Tricky, Master and Legend use pair dice (`createPairDice`) and virtual dice only. Pair dice make big rectangles rarer on smaller boards (`PAIR_ROOM`), and near the end they fall back to small facts that fit, so games last about as long as on Hard (a test checks this). Classroom stays on Easy, Medium and Hard. - **Classroom games are unlock-free:** every lobby setting is free and cheats are off. -- **Bonuses** are in progress.js (`answerBonus`, `timerBonus`, `boardFinishBonus`, `practiceFinishBonus`). Tell the player about them in the menu hints and the unlock dialogs. +- **Bonuses** are in `@blockout/progress` (`answerBonus`, `timerBonus`, `boardFinishBonus`, `practiceFinishBonus`). Tell the player about them in the menu hints and the unlock dialogs. - **Achievements:** - They show one at a time as a WoW-style banner (`celebrate`, then a queue). - Results screens stay minimal: no points line, stickers row or achievement list. Actions go on one row of equal buttons. @@ -106,11 +113,26 @@ await page.addInitScript((pr) => { if (!sessionStorage.getItem('s')) { sessionSt - Each code gets its own secret achievement `cheat_` (a test enforces this). - **Dialogs** have a bottom `.dialog-actions` button panel, like the Shop. - **Accounts are optional:** guests must always be able to play everything except hosting a class. Store as little about children as possible (see docs/privacy.md). +- **Homework** only asks for what the child can already play (`Progress.availableOptions`, `sharedOptions` for a class). Tapping it sets up the menu and starts the game. - **The audience is 8-year-olds:** use short, friendly, concrete wording ("Help me count", "Needs practice"). Target devices are Chromebooks, and it must work at phone width too. +## Docs, changelog and commits + +- **Docs** follow the site's structure: + - guides for doing things (with screenshots) + - `concepts/` for why things are the way they are + - `reference/` for facts to look up (`CONFIGURATION.md`, `HTTP-API.md`) + + Every page starts with a ``. Check with `mkdocs build --strict` (Python venv with `docs/requirements.txt`). +- **Screenshots** in `docs/assets/screenshots/` come from Playwright runs of the real game on a fresh server (1280 px wide). Retake them when a pictured screen changes. +- **CHANGELOG.md** follows Keep a Changelog: user-facing changes under `[Unreleased]`, by category. The version follows SemVer; it's 0.x, so a minor bump for new features. +- **Decisions:** a hard-to-reverse decision gets an ADR in `docs/decisions/`. Never edit an accepted ADR; supersede it with a new one. +- **Commits** follow Conventional Commits (`feat(accounts): …`, `fix(server): …`, `docs: …`, `refactor: …`, `build: …`, `ci: …`). +- **Server errors** are RFC 9457 problem details (`sendProblem`), keeping the `error` and `message` members the clients read. + ## Code style -- **Match the surrounding code:** vanilla ES2020, 2-space indent, single quotes, `const` arrow helpers, short comments explaining *why*. -- **No frameworks, bundlers or runtime dependencies.** The server uses only Node built-ins. -- **Keep logic testable:** pure logic belongs in `core.js` / `progress.js` / `rooms.js` / `tournament.js` with unit tests, and `game.js` stays the UI. -- **Keep the tests green.** Add tests for new rules; run `npm test` before finishing. +- **Match the surrounding code:** modern JavaScript ES modules, 2-space indent, single quotes, `const` arrow helpers, short comments explaining *why*. +- **No frameworks.** Keep runtime dependencies to a minimum; the server uses only Node built-ins. +- **Keep logic testable:** pure logic belongs in `packages/engine`, `packages/progress` or `apps/server/src/{rooms,tournament,accounts}.js`, with unit tests. The game modules stay the UI. +- **Keep the tests and lint green.** Add tests for new rules, and run `npm test && npm run lint` before finishing. diff --git a/README.md b/README.md index fb85542..d3ff056 100644 --- a/README.md +++ b/README.md @@ -1 +1,55 @@ # Blockout + +A multiplication game for 3rd graders, based on "Blockout" from [Math for Love](https://mathforlove.com/). Roll two dice, draw that rectangle on the grid, say how many squares it is, and claim the most of the board. It runs in the browser, mostly on classroom Chromebooks. + +It has: +- **Single player** against the CPU, **Practice**, and **Home** multiplayer on one screen. +- **Classroom:** whole class on the same rolls, pairs, and tournaments. +- **Optional accounts:** teachers see class stats and set homework; parents follow their child. Guests can always play. +- **More games** for other operations: addition, subtraction, division and fractions. + +## Getting started + +```sh +npm install +npm start # build everything and serve it on http://localhost:8080 +npm run dev # Vite dev server (http://localhost:5173) + the API server with reload +npm test # unit tests for every app and package +npm run lint # ESLint +``` + +- **Node:** needs Node 22.12 or newer. The server uses the built-in `node:sqlite`. +- **Sharing through a tunnel:** run `cloudflared tunnel --url http://localhost:8080`, then `npm start -- --public-url https://<name>.trycloudflare.com` so the class join links and QR codes use the tunnel's address. + +## Layout + +A [Turborepo](https://turbo.build/) of plain JavaScript (ES modules, no framework), built with [Vite](https://vite.dev/). + +| Path | What | +|---|---| +| `apps/multiplication` | The main game, served at `/` | +| `apps/addition`, `apps/subtraction`, `apps/division`, `apps/fraction-*` | The other games, at `/<name>/` | +| `apps/more-games` | The More games page (`/more-games/`) | +| `apps/server` | Serves the built games; runs classrooms (live updates over SSE, with long polling as a fallback) and accounts (sqlite) | +| `packages/engine` | Board, dice, placement and CPU; the other operations' rules | +| `packages/progress` | Wallet, shop (skill-tree unlocks), achievements, fact stats, bonuses, homework goals, Wardrobe | +| `packages/ui` | Shared look: colour tokens, CSS, icon sprite, a Vite plugin for every page | +| `packages/game-kit` | The shared page engine for the other games | +| `packages/classroom` | Browser client for the classroom server | +| `packages/auth` | Browser sign-in (dev sign-in now; Keycloak with PKCE later) and progress sync | + +## Documentation + +The docs site is in `docs/` ([MkDocs Material](https://squidfunk.github.io/mkdocs-material/)); build it with `mkdocs serve` (see [Installing](docs/project/installing.md#the-docs-site)). Good places to start: +- [Welcome](docs/home/index.md) and [Quickstart](docs/home/quickstart.md) +- the guides for [teachers](docs/guides/teachers.md) and [parents](docs/guides/parents.md) +- [Architecture](docs/concepts/architecture.md), [Privacy](docs/privacy.md) and the [decision log](docs/decisions/index.md) +- [CHANGELOG.md](CHANGELOG.md) + +`CLAUDE.md` has the conventions for working on the code. + +## Accounts and sign-in + +- **For now, dev sign-in:** pick a name and a role (student, teacher or parent). +- **Keycloak later:** set `BLOCKOUT_AUTH=keycloak`, `KEYCLOAK_ISSUER` and `KEYCLOAK_CLIENT_ID`. It can bring in Google sign-in, limited to a school's domain (the first school is Cadence Park, `@iusd.org`). +- **Stored:** first name, last initial, role and email domain. See [Privacy](docs/privacy.md). diff --git a/apps/addition/index.html b/apps/addition/index.html new file mode 100644 index 0000000..7f3225c --- /dev/null +++ b/apps/addition/index.html @@ -0,0 +1,12 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <title>Addition · Blockout + + + + + + diff --git a/apps/addition/package.json b/apps/addition/package.json new file mode 100644 index 0000000..d790bee --- /dev/null +++ b/apps/addition/package.json @@ -0,0 +1,16 @@ +{ + "name": "@blockout/addition", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "Blockout: addition, one of the games under More games (built on @blockout/game-kit).", + "dependencies": { + "@blockout/game-kit": "*", + "@blockout/ui": "*" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint src" + } +} diff --git a/apps/addition/src/main.js b/apps/addition/src/main.js new file mode 100644 index 0000000..f542c45 --- /dev/null +++ b/apps/addition/src/main.js @@ -0,0 +1,5 @@ +// Addition: the shared game kit with this game's rules. +import '@blockout/game-kit/style.css'; +import { startGame } from '@blockout/game-kit'; + +startGame('addition'); diff --git a/apps/addition/vite.config.js b/apps/addition/vite.config.js new file mode 100644 index 0000000..bf96621 --- /dev/null +++ b/apps/addition/vite.config.js @@ -0,0 +1,9 @@ +// Served at /addition/ by apps/server. +import { defineConfig } from 'vite'; +import { blockoutPage } from '@blockout/ui/vite'; + +export default defineConfig({ + base: '/addition/', + plugins: [blockoutPage()], + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' }, +}); diff --git a/apps/division/index.html b/apps/division/index.html new file mode 100644 index 0000000..9a57b55 --- /dev/null +++ b/apps/division/index.html @@ -0,0 +1,12 @@ + + + + + + Division · Blockout + + + + + + diff --git a/apps/division/package.json b/apps/division/package.json new file mode 100644 index 0000000..987b3c5 --- /dev/null +++ b/apps/division/package.json @@ -0,0 +1,16 @@ +{ + "name": "@blockout/division", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "Blockout: division, one of the games under More games (built on @blockout/game-kit).", + "dependencies": { + "@blockout/game-kit": "*", + "@blockout/ui": "*" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint src" + } +} diff --git a/apps/division/src/main.js b/apps/division/src/main.js new file mode 100644 index 0000000..aaae8d1 --- /dev/null +++ b/apps/division/src/main.js @@ -0,0 +1,5 @@ +// Division: the shared game kit with this game's rules. +import '@blockout/game-kit/style.css'; +import { startGame } from '@blockout/game-kit'; + +startGame('division'); diff --git a/apps/division/vite.config.js b/apps/division/vite.config.js new file mode 100644 index 0000000..942a749 --- /dev/null +++ b/apps/division/vite.config.js @@ -0,0 +1,9 @@ +// Served at /division/ by apps/server. +import { defineConfig } from 'vite'; +import { blockoutPage } from '@blockout/ui/vite'; + +export default defineConfig({ + base: '/division/', + plugins: [blockoutPage()], + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' }, +}); diff --git a/apps/fraction-add/index.html b/apps/fraction-add/index.html new file mode 100644 index 0000000..2d369e8 --- /dev/null +++ b/apps/fraction-add/index.html @@ -0,0 +1,12 @@ + + + + + + Adding fractions · Blockout + + + + + + diff --git a/apps/fraction-add/package.json b/apps/fraction-add/package.json new file mode 100644 index 0000000..883b7e9 --- /dev/null +++ b/apps/fraction-add/package.json @@ -0,0 +1,16 @@ +{ + "name": "@blockout/fraction-add", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "Blockout: adding fractions, one of the games under More games (built on @blockout/game-kit).", + "dependencies": { + "@blockout/game-kit": "*", + "@blockout/ui": "*" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint src" + } +} diff --git a/apps/fraction-add/src/main.js b/apps/fraction-add/src/main.js new file mode 100644 index 0000000..7c1af3c --- /dev/null +++ b/apps/fraction-add/src/main.js @@ -0,0 +1,5 @@ +// Adding fractions: the shared game kit with this game's rules. +import '@blockout/game-kit/style.css'; +import { startGame } from '@blockout/game-kit'; + +startGame('fraction-add'); diff --git a/apps/fraction-add/vite.config.js b/apps/fraction-add/vite.config.js new file mode 100644 index 0000000..40bc8dd --- /dev/null +++ b/apps/fraction-add/vite.config.js @@ -0,0 +1,9 @@ +// Served at /fraction-add/ by apps/server. +import { defineConfig } from 'vite'; +import { blockoutPage } from '@blockout/ui/vite'; + +export default defineConfig({ + base: '/fraction-add/', + plugins: [blockoutPage()], + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' }, +}); diff --git a/apps/fraction-divide/index.html b/apps/fraction-divide/index.html new file mode 100644 index 0000000..39e9ef4 --- /dev/null +++ b/apps/fraction-divide/index.html @@ -0,0 +1,12 @@ + + + + + + Dividing fractions · Blockout + + + + + + diff --git a/apps/fraction-divide/package.json b/apps/fraction-divide/package.json new file mode 100644 index 0000000..28ee4f8 --- /dev/null +++ b/apps/fraction-divide/package.json @@ -0,0 +1,16 @@ +{ + "name": "@blockout/fraction-divide", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "Blockout: dividing fractions, one of the games under More games (built on @blockout/game-kit).", + "dependencies": { + "@blockout/game-kit": "*", + "@blockout/ui": "*" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint src" + } +} diff --git a/apps/fraction-divide/src/main.js b/apps/fraction-divide/src/main.js new file mode 100644 index 0000000..c88cd5d --- /dev/null +++ b/apps/fraction-divide/src/main.js @@ -0,0 +1,5 @@ +// Dividing fractions: the shared game kit with this game's rules. +import '@blockout/game-kit/style.css'; +import { startGame } from '@blockout/game-kit'; + +startGame('fraction-divide'); diff --git a/apps/fraction-divide/vite.config.js b/apps/fraction-divide/vite.config.js new file mode 100644 index 0000000..c43b501 --- /dev/null +++ b/apps/fraction-divide/vite.config.js @@ -0,0 +1,9 @@ +// Served at /fraction-divide/ by apps/server. +import { defineConfig } from 'vite'; +import { blockoutPage } from '@blockout/ui/vite'; + +export default defineConfig({ + base: '/fraction-divide/', + plugins: [blockoutPage()], + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' }, +}); diff --git a/apps/fraction-multiply/index.html b/apps/fraction-multiply/index.html new file mode 100644 index 0000000..0eb6dc7 --- /dev/null +++ b/apps/fraction-multiply/index.html @@ -0,0 +1,12 @@ + + + + + + Multiplying fractions · Blockout + + + + + + diff --git a/apps/fraction-multiply/package.json b/apps/fraction-multiply/package.json new file mode 100644 index 0000000..3645e03 --- /dev/null +++ b/apps/fraction-multiply/package.json @@ -0,0 +1,16 @@ +{ + "name": "@blockout/fraction-multiply", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "Blockout: multiplying fractions, one of the games under More games (built on @blockout/game-kit).", + "dependencies": { + "@blockout/game-kit": "*", + "@blockout/ui": "*" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint src" + } +} diff --git a/apps/fraction-multiply/src/main.js b/apps/fraction-multiply/src/main.js new file mode 100644 index 0000000..2abe265 --- /dev/null +++ b/apps/fraction-multiply/src/main.js @@ -0,0 +1,5 @@ +// Multiplying fractions: the shared game kit with this game's rules. +import '@blockout/game-kit/style.css'; +import { startGame } from '@blockout/game-kit'; + +startGame('fraction-multiply'); diff --git a/apps/fraction-multiply/vite.config.js b/apps/fraction-multiply/vite.config.js new file mode 100644 index 0000000..b7ba744 --- /dev/null +++ b/apps/fraction-multiply/vite.config.js @@ -0,0 +1,9 @@ +// Served at /fraction-multiply/ by apps/server. +import { defineConfig } from 'vite'; +import { blockoutPage } from '@blockout/ui/vite'; + +export default defineConfig({ + base: '/fraction-multiply/', + plugins: [blockoutPage()], + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' }, +}); diff --git a/apps/fraction-subtract/index.html b/apps/fraction-subtract/index.html new file mode 100644 index 0000000..6b830b6 --- /dev/null +++ b/apps/fraction-subtract/index.html @@ -0,0 +1,12 @@ + + + + + + Subtracting fractions · Blockout + + + + + + diff --git a/apps/fraction-subtract/package.json b/apps/fraction-subtract/package.json new file mode 100644 index 0000000..41e1e9a --- /dev/null +++ b/apps/fraction-subtract/package.json @@ -0,0 +1,16 @@ +{ + "name": "@blockout/fraction-subtract", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "Blockout: subtracting fractions, one of the games under More games (built on @blockout/game-kit).", + "dependencies": { + "@blockout/game-kit": "*", + "@blockout/ui": "*" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint src" + } +} diff --git a/apps/fraction-subtract/src/main.js b/apps/fraction-subtract/src/main.js new file mode 100644 index 0000000..3d6052f --- /dev/null +++ b/apps/fraction-subtract/src/main.js @@ -0,0 +1,5 @@ +// Subtracting fractions: the shared game kit with this game's rules. +import '@blockout/game-kit/style.css'; +import { startGame } from '@blockout/game-kit'; + +startGame('fraction-subtract'); diff --git a/apps/fraction-subtract/vite.config.js b/apps/fraction-subtract/vite.config.js new file mode 100644 index 0000000..84182c4 --- /dev/null +++ b/apps/fraction-subtract/vite.config.js @@ -0,0 +1,9 @@ +// Served at /fraction-subtract/ by apps/server. +import { defineConfig } from 'vite'; +import { blockoutPage } from '@blockout/ui/vite'; + +export default defineConfig({ + base: '/fraction-subtract/', + plugins: [blockoutPage()], + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' }, +}); diff --git a/apps/more-games/index.html b/apps/more-games/index.html new file mode 100644 index 0000000..0e9ed05 --- /dev/null +++ b/apps/more-games/index.html @@ -0,0 +1,19 @@ + + + + + + More games · Blockout + + +
+
+ ← Blockout (multiplication) +

BLOCKOUT

+

More games: the same roll, place and answer loop, built for other operations. Each one has Single player (you vs the CPU) and Practice (Learn or Expert). There’s no shop, stats or saving yet.

+
+
+
+ + + diff --git a/apps/more-games/package.json b/apps/more-games/package.json new file mode 100644 index 0000000..c207a7a --- /dev/null +++ b/apps/more-games/package.json @@ -0,0 +1,17 @@ +{ + "name": "@blockout/more-games", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "Blockout: the More games page, a list of the games for other operations.", + "dependencies": { + "@blockout/engine": "*", + "@blockout/game-kit": "*", + "@blockout/ui": "*" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint src" + } +} diff --git a/apps/more-games/src/main.js b/apps/more-games/src/main.js new file mode 100644 index 0000000..6bb8e29 --- /dev/null +++ b/apps/more-games/src/main.js @@ -0,0 +1,18 @@ +// More games: a card for each game built on @blockout/game-kit. +import '@blockout/game-kit/style.css'; +import { VARIANTS } from '@blockout/engine/variants'; + +const esc = (t) => String(t).replace(/[&<>"]/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]); +const BOARD_KIND = { grid: 'Grid board', bars: 'Fraction bars' }; + +document.getElementById('hub-grid').innerHTML = VARIANTS.map((v) => { + const kind = BOARD_KIND[v.newBoard('easy').kind]; + return `
+

${esc(v.op)} ${esc(v.name)}

+

${esc(v.title)} · ${esc(v.grade)}

+

${esc(v.tagline)}

+

${esc(v.concept)}

+ ${kind}${v.answer === 'frac' ? ' · fraction answers' : ''} + Play › +
`; +}).join(''); diff --git a/apps/more-games/vite.config.js b/apps/more-games/vite.config.js new file mode 100644 index 0000000..75cc444 --- /dev/null +++ b/apps/more-games/vite.config.js @@ -0,0 +1,9 @@ +// Served at /more-games/ by apps/server. +import { defineConfig } from 'vite'; +import { blockoutPage } from '@blockout/ui/vite'; + +export default defineConfig({ + base: '/more-games/', + plugins: [blockoutPage()], + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' }, +}); diff --git a/index.html b/apps/multiplication/index.html similarity index 81% rename from index.html rename to apps/multiplication/index.html index 01278ea..14fcb0c 100644 --- a/index.html +++ b/apps/multiplication/index.html @@ -4,68 +4,8 @@ BLOCKOUT - - - -
@@ -239,7 +179,7 @@

BLOCKOUT

- More games + More games
@@ -946,14 +886,6 @@

- - - - - - - - - + diff --git a/apps/multiplication/package.json b/apps/multiplication/package.json new file mode 100644 index 0000000..9c03735 --- /dev/null +++ b/apps/multiplication/package.json @@ -0,0 +1,21 @@ +{ + "name": "@blockout/multiplication", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "Blockout: the multiplication game (single player, practice, Home multiplayer, classroom, accounts).", + "dependencies": { + "@blockout/engine": "*", + "@blockout/progress": "*", + "@blockout/ui": "*", + "@blockout/classroom": "*", + "@blockout/auth": "*", + "qrcode-generator": "^2.0.4" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "test": "node --test", + "lint": "eslint src test" + } +} diff --git a/apps/multiplication/src/accounts.js b/apps/multiplication/src/accounts.js new file mode 100644 index 0000000..940674e --- /dev/null +++ b/apps/multiplication/src/accounts.js @@ -0,0 +1,939 @@ +// Account screens: signing in (dev sign-in for now), a student's homework and +// classes, a teacher's classes (roster, stats, homework, scheduled tournaments) +// and a parent's view of their children. Talks to apps/server through +// @blockout/auth and borrows a few helpers from the game (./game/api.js). +// Guests never see any of it except "Sign in". + +import * as Auth from '@blockout/auth'; +import * as Game from './game/api.js'; +import * as Progress from '@blockout/progress'; + +let el, account, homework, classes, kids; + +const { make, toast } = Game; + +const $ = (id) => document.getElementById(id); + +const ROLE = { + student: { emoji: '🎒', label: 'Student' }, + teacher: { emoji: '🍎', label: 'Teacher' }, + parent: { emoji: '👪', label: 'Parent' }, +}; + +const DIFFICULTY_NAMES = Progress.DIFFICULTY_NAMES; + +const FORMAT_NAMES = { knockout: 'Knockout', double: 'Double elimination', roundrobin: 'Round robin', swiss: 'Swiss', koth: 'King of the hill' }; + +const LIVE_POLL_MS = 20 * 1000; + +const SCHEDULE_POLL_MS = 15 * 1000; + +function button(text, cls = 'btn', onClick = null) { + const b = make('button', cls, text); + b.type = 'button'; + if (onClick) b.addEventListener('click', onClick); + return b; +} + +function field(label, input) { + const f = make('label', 'field'); + f.append(make('span', null, label), input); + return f; +} + +function input(attrs = {}) { + const i = document.createElement('input'); + for (const [k, v] of Object.entries(attrs)) i.setAttribute(k, v); + return i; +} + +function select(options, value) { + const s = document.createElement('select'); + for (const [v, text] of options) { + const o = document.createElement('option'); + o.value = v; + o.textContent = text; + s.append(o); + } + if (value !== undefined) s.value = String(value); + return s; +} + +// A row of choice buttons (the game's .segmented look); returns { el, value() } +function segmented(options, value, onChange) { + const wrap = make('div', 'segmented'); + let current = value; + for (const [v, text] of options) { + const b = button(text); + b.dataset.value = v; + b.classList.toggle('selected', v === value); + b.addEventListener('click', () => { + current = v; + for (const x of wrap.children) x.classList.toggle('selected', x === b); + if (onChange) onChange(v); + }); + wrap.append(b); + } + return { el: wrap, value: () => current }; +} + +const errorLine = () => { + const p = make('p', 'invite-error'); + p.setAttribute('role', 'alert'); + p.hidden = true; + return p; +}; + +const showError = (p, err) => { + p.textContent = err.message || String(err); + p.hidden = false; +}; + +const dayString = (d) => `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + +const niceDay = (s) => new Date(`${s}T12:00:00`).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); + +const niceTime = (t) => new Date(t).toLocaleString(undefined, { weekday: 'short', month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + +function ago(t) { + if (!t) return 'not yet'; + const mins = Math.round((Date.now() - t) / 60000); + if (mins < 2) return 'just now'; + if (mins < 60) return `${mins} min ago`; + const hours = Math.round(mins / 60); + if (hours < 24) return `${hours} h ago`; + const days = Math.round(hours / 24); + return days === 1 ? 'yesterday' : `${days} days ago`; +} + +// Our own dialogs, built like the game's: .overlay > .dialog with a bottom +// .dialog-actions panel. Escape or the backdrop closes them. +function dialog(id, title, cls = '') { + let overlay = $(id); + if (!overlay) { + overlay = make('div', 'overlay'); + overlay.id = id; + overlay.hidden = true; + const box = make('div', `dialog acct-dialog ${cls}`); + box.setAttribute('role', 'dialog'); + box.setAttribute('aria-modal', 'true'); + box.setAttribute('aria-labelledby', `${id}-title`); + const h = make('h2', null, title); + h.id = `${id}-title`; + const body = make('div', 'acct-body'); + const actions = make('div', 'dialog-actions'); + box.append(h, body, actions); + overlay.append(box); + document.body.append(overlay); + overlay.addEventListener('click', (e) => e.target === overlay && close()); + overlay.addEventListener('keydown', (e) => e.key === 'Escape' && close()); + } + const d = { + overlay, + title: overlay.querySelector('h2'), + body: overlay.querySelector('.acct-body'), + actions: overlay.querySelector('.dialog-actions'), + onClose: null, + }; + function close() { + overlay.hidden = true; + if (overlay._onClose) overlay._onClose(); + } + d.open = () => (overlay.hidden = false); + d.close = close; + d.setOnClose = (fn) => (overlay._onClose = fn); + d.clear = (heading = title) => { + d.title.textContent = heading; + d.body.replaceChildren(); + d.actions.replaceChildren(); + }; + d.done = (text = 'Done') => { + const b = button(text, 'btn btn-primary', close); + d.actions.append(b); + return b; + }; + return d; +} + +function renderMenu() { + const served = Auth.served(); + el.accountBtn.hidden = !served; + const u = Auth.user(); + el.accountBtn.replaceChildren(u ? `${ROLE[u.role].emoji} ${u.name}` : '👤 Sign in'); + el.accountBtn.title = u ? `${ROLE[u.role].label} account: tap for yours` : 'Sign in (you can always play as a guest)'; + const role = Auth.role(); + el.homeworkBtn.hidden = role !== 'student'; + el.classesBtn.hidden = role !== 'teacher'; + el.kidsBtn.hidden = role !== 'parent'; +} + +function setBadge(btn, count) { + let badge = btn.querySelector('.badge'); + if (!count) return badge && badge.remove(); + if (!badge) { + badge = make('span', 'badge'); + badge.setAttribute('aria-hidden', 'true'); + btn.append(badge); + } + badge.textContent = String(count); +} + +async function openSignIn({ role = 'student', note = '' } = {}) { + account.clear('Sign in'); + let config; + try { + config = await Auth.config(); + } catch (err) { + account.body.append(make('p', 'setting-help', err.message)); + account.done('OK'); + return account.open(); + } + if (note) account.body.append(make('p', 'acct-note', note)); + if (config.provider === 'keycloak') { + account.body.append(make('p', 'setting-help', 'Sign in with your school or family account.')); + account.actions.append(button('Play as a guest', 'btn', account.close), button('Sign in', 'btn btn-primary', () => Auth.signInKeycloak())); + return account.open(); + } + // Dev sign-in: pick who you are + account.body.append( + make('p', 'setting-help', 'Practice sign-in: pick who you are. (Signing in with a real school account comes later.) You can always play as a guest instead.') + ); + const who = segmented( + [ + ['student', '🎒 Student'], + ['teacher', '🍎 Teacher'], + ['parent', '👪 Parent'], + ], + role, + () => showFields() + ); + const first = input({ type: 'text', maxlength: '20', autocomplete: 'off', placeholder: 'e.g. Maya' }); + const initial = input({ type: 'text', maxlength: '1', autocomplete: 'off', placeholder: 'K', class: 'acct-initial' }); + const email = input({ type: 'email', autocomplete: 'off', placeholder: 'maya@iusd.org' }); + const school = select([...config.schools.map((s) => [s.id, `${s.name} (@${s.domain})`]), ['', 'No school']]); + const emailField = field('School email', email); + const schoolField = field('School', school); + const names = make('div', 'acct-names'); + names.append(field('First name', first), field('Last initial', initial)); + const error = errorLine(); + const form = make('form', 'acct-form'); + form.append(who.el, names, emailField, schoolField, make('p', 'setting-help acct-privacy', 'We only keep your first name and last initial.'), error); + let emailTouched = false; + email.addEventListener('input', () => (emailTouched = true)); + first.addEventListener('input', () => { + if (!emailTouched && config.schools[0]) email.value = first.value.trim() ? `${first.value.trim().toLowerCase()}@${config.schools[0].domain}` : ''; + }); + function showFields() { + emailField.hidden = who.value() !== 'student'; + schoolField.hidden = who.value() !== 'teacher'; + } + showFields(); + const submit = async (e) => { + if (e) e.preventDefault(); + error.hidden = true; + go.disabled = true; + try { + const r = who.value(); + await Auth.signInDev({ + role: r, + firstName: first.value, + lastInitial: initial.value, + email: r === 'student' ? email.value : r === 'parent' ? '' : undefined, + schoolId: r === 'teacher' ? school.value : undefined, + }); + location.reload(); // the page picks up the account's save + } catch (err) { + showError(error, err); + go.disabled = false; + } + }; + form.addEventListener('submit', submit); + account.body.append(form); + const go = button('Sign in', 'btn btn-primary', submit); + account.actions.append(button('Play as a guest', 'btn', account.close), go); + account.open(); + first.focus(); +} + +async function openAccount() { + const u = Auth.user(); + if (!u) return openSignIn(); + account.clear('Your account'); + const card = make('div', 'acct-card'); + card.append(make('div', 'acct-big', `${ROLE[u.role].emoji} ${u.name}`), make('div', 'acct-sub', `${ROLE[u.role].label}${u.school ? ` · ${u.school.name}` : ''}`)); + account.body.append(card); + if (u.role === 'student') await studentAccount(); + if (u.role === 'teacher') account.body.append(make('p', 'setting-help', 'Your classes, homework and tournaments are under 🏫 My classes on the main menu.')); + if (u.role === 'parent') account.body.append(make('p', 'setting-help', 'Follow your children under 👪 My kids on the main menu.')); + account.actions.append(button('Sign out', 'btn', () => Auth.signOut())); + account.done(); + account.open(); +} + +// A student's classes, joining one with a code, and the code for a grown-up +async function studentAccount() { + const classes = make('div', 'acct-section'); + classes.append(make('h3', 'stats-heading', 'Your classes')); + const list = make('div', 'acct-list'); + classes.append(list); + const code = input({ type: 'text', maxlength: '8', autocomplete: 'off', placeholder: 'Class code', class: 'acct-code-input' }); + const error = errorLine(); + const row = make('form', 'acct-inline'); + const join = button('Join', 'btn btn-small btn-primary'); + join.type = 'submit'; + row.append(code, join); + row.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + try { + const r = await Auth.api('POST', '/api/me/classes', { code: code.value }); + toast('🏫 Joined!', r.class.name); + code.value = ''; + await fill(); + } catch (err) { + showError(error, err); + } + }); + classes.append(row, error); + const parent = make('div', 'acct-section'); + account.body.append(classes, parent); + async function fill() { + list.replaceChildren(); + try { + const { classes: mine } = await Auth.api('GET', '/api/me/classes'); + if (!mine.length) list.append(make('p', 'setting-help', 'Not in a class yet. Type the code your teacher gives you.')); + for (const c of mine) list.append(make('div', 'acct-item', `🏫 ${c.name}`)); + } catch (err) { + list.append(make('p', 'setting-help', err.message)); + } + } + await fill(); + try { + const { code: pc } = await Auth.api('GET', '/api/me/parent-code'); + parent.append(make('h3', 'stats-heading', 'For a grown-up'), make('p', 'setting-help', 'Give this code to your mom, dad or another grown-up so they can see how you’re doing and add homework.'), make('div', 'acct-code', pc.replace(/(.{4})/, '$1 '))); + } catch (_) { + // not fatal + } +} + +function askTeacherSignIn() { + openSignIn({ role: 'teacher', note: '🍎 Teachers: sign in to host a class. Students join with the code (no sign-in needed).' }); +} + +const STATUS_ORDER = [ + ['mastered', 'Mastered'], + ['learning', 'Learning'], + ['practice', 'Needs practice'], + ['unseen', 'Not seen yet'], +]; + +// A thin bar: mastered / learning / needs practice / not seen, out of 144 +function masteryBar(summary) { + const bar = make('div', 'acct-mastery'); + bar.title = STATUS_ORDER.map(([k, label]) => `${label}: ${summary[k]}`).join(' · '); + for (const [k] of STATUS_ORDER) { + const seg = make('span', `seg is-${k}`); + seg.style.width = `${(summary[k] / summary.total) * 100}%`; + bar.append(seg); + } + return bar; +} + +function activityText(e) { + const diff = e.difficulty ? `${DIFFICULTY_NAMES[e.difficulty] || e.difficulty} ` : ''; + if (e.kind === 'practice') return `📝 ${diff}practice, ${e.count} questions${e.expert ? ' (Expert)' : ''}`; + if (e.kind === 'class') return e.type === 'tournament' ? `🏆 Class tournament game${e.won ? ' (won)' : ''}` : e.type === 'pairs' ? `🤝 Class pairs game${e.won ? ' (won)' : ''}` : `🏫 Class game${e.rank ? ` (${e.rank}${['st', 'nd', 'rd'][e.rank - 1] || 'th'})` : ''}`; + return `🎲 ${diff}${e.size}×${e.size} ${e.mode === 'multi' ? 'game at home' : e.won ? 'game, beat the CPU' : 'game vs the CPU'}`; +} + +// onPlay(goal): the student's list, where each open homework starts its game +function homeworkList(items, { onRemove = null, mine = () => false, onPlay = null } = {}) { + const list = make('div', 'acct-list'); + if (!items.length) list.append(make('p', 'setting-help', 'No homework.')); + for (const h of items) { + const row = make('div', `acct-hw ${h.status}`); + const top = make('div', 'acct-hw-top'); + top.append(make('strong', null, h.label || Progress.describeGoal(h.goal))); + const state = h.status === 'done' ? '✅ Done' : h.status === 'late' ? `⏰ Was due ${niceDay(h.due)}` : h.due ? `Due ${niceDay(h.due)}` : 'Any time'; + top.append(make('span', 'acct-hw-state', state)); + row.append(top); + const bar = make('div', 'practice-bar'); + const fill = make('span'); + fill.style.width = `${(h.have / h.need) * 100}%`; + bar.append(fill); + const meta = make('div', 'acct-hw-meta'); + meta.append(make('span', null, `${h.have} of ${h.need}`), make('span', null, h.from === 'parent' ? `from ${h.fromName || 'a grown-up'} 👪` : `from ${h.fromName || 'your teacher'} 🍎`)); + if (onRemove && mine(h)) meta.append(button('Remove', 'btn btn-small', () => onRemove(h))); + if (onPlay && h.status !== 'done') { + row.classList.add('playable'); + const play = button('Play ›', 'btn btn-small btn-primary', () => onPlay(h.goal)); + play.setAttribute('aria-label', `Play: ${h.label || Progress.describeGoal(h.goal)}`); + meta.append(play); + } + row.append(bar, meta); + list.append(row); + } + return list; +} + +// What a teacher or parent sees for one student +function studentDetail(s, { onAddHomework, onRemoveHomework, mine }) { + const box = make('div', 'acct-detail'); + const head = make('div', 'acct-card'); + head.append(make('div', 'acct-sub', `Last played ${ago(s.lastPlayed)}`)); // (the dialog title is the name) + box.append(head); + // Times Table + const tt = make('div', 'acct-section'); + tt.append(make('h3', 'stats-heading', 'Times Table')); + const counts = make('div', 'acct-counts'); + for (const [k, label] of STATUS_ORDER) counts.append(make('span', `acct-count is-${k}`, `${s.summary[k]} ${label.toLowerCase()}`)); + tt.append(counts, masteryBar(s.summary)); + if (s.facts) { + tt.append(Game.renderFactLegend()); + tt.append(Game.renderFactGrid(null, s.facts, 12)); + } + if (s.summary.needsPractice.length) { + tt.append(make('p', 'setting-help', 'Needs practice:')); + const chips = make('div', 'acct-chips'); + for (const f of s.summary.needsPractice) chips.append(make('span', 'acct-chip is-practice', f)); + tt.append(chips); + } + box.append(tt); + // Homework + const hw = make('div', 'acct-section'); + hw.append(make('h3', 'stats-heading', 'Homework'), homeworkList(s.assignments, { onRemove: onRemoveHomework, mine })); + if (onAddHomework) hw.append(homeworkForm(onAddHomework, s.options, s.name)); + box.append(hw); + // Recent activity + const act = make('div', 'acct-section'); + act.append(make('h3', 'stats-heading', 'Recently')); + if (!s.recent.length) act.append(make('p', 'setting-help', 'Nothing played yet.')); + for (const e of s.recent) { + const row = make('div', 'acct-activity'); + row.append(make('span', null, activityText(e)), make('span', 'acct-when', ago(e.t))); + act.append(row); + } + box.append(act); + return box; +} + +// Set homework: the goal, its details and a due date. onSubmit({ goal, due }) +// Only offers what the child (or every child in the class) can already play: +// options = { practice, difficulties, boards, tables } from the server. +function homeworkForm(onSubmit, options, who) { + const form = make('form', 'acct-hw-form'); + form.append(make('h4', null, 'Add homework')); + const kinds = []; + if (options.practice && options.tables.length) kinds.push(['master', 'Master a times table']); + if (options.practice) kinds.push(['practice', 'Finish practice rounds']); + kinds.push(['win', 'Beat the CPU on a board'], ['games', 'Play games']); + const type = select(kinds); + const table = select(options.tables.map((n) => [n, `×${n}`]), options.tables.includes(7) ? 7 : options.tables[options.tables.length - 1]); + const rounds = select([1, 2, 3, 4, 5, 6, 8, 10].map((n) => [n, String(n)]), 3); + const games = select([1, 2, 3, 4, 5, 6, 8, 10].map((n) => [n, String(n)]), 3); + const size = select(options.boards.map((n) => [n, `${n}×${n}`]), options.boards.includes(8) ? 8 : options.boards[options.boards.length - 1]); + const difficulty = select([['any', 'Any difficulty'], ...options.difficulties.map((d) => [d, DIFFICULTY_NAMES[d]])], 'any'); + const week = new Date(Date.now() + 7 * 86400000); + const due = input({ type: 'date', value: dayString(week), min: dayString(new Date()) }); + const f = { + table: field('Table', table), + rounds: field('Rounds', rounds), + games: field('Games', games), + size: field('Board', size), + difficulty: field('Difficulty', difficulty), + }; + const grid = make('div', 'acct-grid'); + grid.append(field('Homework', type), f.table, f.rounds, f.games, f.size, f.difficulty, field('Due', due)); + const show = () => { + const t = type.value; + f.table.hidden = t !== 'master'; + f.rounds.hidden = t !== 'practice'; + f.games.hidden = t !== 'games'; + f.size.hidden = t !== 'win'; + f.difficulty.hidden = t !== 'practice' && t !== 'win'; + }; + type.addEventListener('change', show); + show(); + const error = errorLine(); + const go = button('Set homework', 'btn btn-small btn-primary'); + go.type = 'submit'; + const note = make('p', 'setting-help', `Only what ${who} has unlocked, so it can be played straight away. More kinds of homework show up as they unlock practice, times tables, difficulties and boards.`); + form.append(grid, note, error, go); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + const t = type.value; + const goal = + t === 'master' + ? { type: t, table: Number(table.value) } + : t === 'practice' + ? { type: t, rounds: Number(rounds.value), difficulty: difficulty.value } + : t === 'win' + ? { type: t, size: Number(size.value), difficulty: difficulty.value } + : { type: t, games: Number(games.value) }; + go.disabled = true; + try { + await onSubmit({ goal, due: due.value || null }); + toast('📚 Homework set', Progress.describeGoal(goal)); + } catch (err) { + showError(error, err); + } finally { + go.disabled = false; + } + }); + return form; +} + +async function openHomework() { + homework.clear(); + homework.done(); + homework.open(); + try { + const { homework: items } = await Auth.api('GET', '/api/me/homework'); + const open = items.filter((h) => h.status !== 'done'); + homework.body.append(make('p', 'setting-help', open.length ? 'Play and practice as usual: homework ticks itself off.' : items.length ? 'All done. Great work! 🎉' : 'No homework right now.')); + const order = { late: 0, open: 1, done: 2 }; + homework.body.append(homeworkList([...items].sort((a, b) => order[a.status] - order[b.status]), { onPlay: playHomework })); + } catch (err) { + homework.body.append(make('p', 'setting-help', err.message)); + } +} + +// Tapping a homework: set the menu up for it (mode, difficulty, board, table) and start. +// Homework only asks for what the student has unlocked, so nothing here should be locked. +function playHomework(goal) { + homework.close(); + const tap = (selector) => { + const b = document.querySelector(selector); + if (b && !b.dataset.unlock) b.click(); + return b && !b.dataset.unlock; + }; + const setDifficulty = (d) => d && d !== 'any' && tap(`[data-setting="difficulty"] [data-value="${d}"]`); + if (goal.type === 'master' || goal.type === 'practice') { + tap('[data-mode="practice"]'); + if (goal.type === 'practice') setDifficulty(goal.difficulty); + else { + // a difficulty whose practice tables include this one, then just that table + const chip = () => document.querySelector(`#practice-tables [data-table="${goal.table}"]`); + for (const d of Progress.DIFFICULTIES) { + if (chip()) break; + setDifficulty(d); + } + for (const on of document.querySelectorAll('#practice-tables [aria-pressed="true"]')) on.click(); + tap(`#practice-tables [data-table="${goal.table}"]`); + } + } else { + tap('[data-mode="single"]'); + setDifficulty(goal.difficulty); + if (goal.type === 'win') tap(`#board-size [data-size="${goal.size}"]`); + } + const start = document.getElementById('start-btn'); + if (!start.disabled) start.click(); +} + +async function refreshHomeworkBadge() { + if (Auth.role() !== 'student') return; + try { + const { homework: items } = await Auth.api('GET', '/api/me/homework'); + setBadge(el.homeworkBtn, items.filter((h) => h.status !== 'done').length); + } catch (_) { + // offline: no badge + } +} + +let currentClass = null; + +let schedulePoll = null; + +async function openClasses() { + classes.open(); + await renderClasses(); + clearInterval(schedulePoll); + schedulePoll = setInterval(() => !classes.overlay.hidden && renderSchedules(), SCHEDULE_POLL_MS); +} + +async function renderClasses() { + classes.clear('🏫 My classes'); + classes.actions.append(button('Host a live game', 'btn', () => (classes.close(), Game.openHostSetup()))); + classes.done(); + let list; + try { + list = (await Auth.api('GET', '/api/classes')).classes; + } catch (err) { + classes.body.append(make('p', 'setting-help', err.message)); + return; + } + if (list.length && !list.some((c) => c.id === currentClass)) currentClass = list[0].id; + // class tabs + a new class + const tabs = make('div', 'acct-tabs'); + for (const c of list) { + const t = button(`${c.name} (${c.students})`, 'btn btn-small'); + t.classList.toggle('btn-primary', c.id === currentClass); + t.addEventListener('click', () => ((currentClass = c.id), renderClasses())); + tabs.append(t); + } + const name = input({ type: 'text', maxlength: '40', placeholder: list.length ? 'New class name' : 'Class name, like “Room 12”' }); + const add = make('form', 'acct-inline'); + const addBtn = button('+ New class', 'btn btn-small'); + addBtn.type = 'submit'; + add.append(name, addBtn); + const error = errorLine(); + add.addEventListener('submit', async (e) => { + e.preventDefault(); + try { + const c = await Auth.api('POST', '/api/classes', { name: name.value }); + currentClass = c.id; + await renderClasses(); + } catch (err) { + showError(error, err); + } + }); + classes.body.append(tabs, add, error); + if (!list.length) { + classes.body.append(make('p', 'setting-help', 'Make a class, then give your students its code. Any student with a school account (@iusd.org) can join with it.')); + name.focus(); + return; + } + const slot = make('div', 'acct-class'); + classes.body.append(slot); + await renderClass(slot); +} + +async function renderClass(slot) { + let d; + try { + d = await Auth.api('GET', `/api/classes/${currentClass}`); + } catch (err) { + slot.append(make('p', 'setting-help', err.message)); + return; + } + const c = d.class; + // join code and link + const join = make('div', 'acct-join'); + const link = `${location.origin}${location.pathname}#roster=${c.code}`; + join.append(make('div', 'acct-sub', 'Students join with'), make('div', 'acct-code', c.code)); + const copy = button('Copy join link', 'btn btn-small', async () => { + try { + await navigator.clipboard.writeText(link); + copy.textContent = 'Copied!'; + } catch (_) { + prompt('Copy this link:', link); + } + }); + join.append(copy, make('p', 'setting-help', `Students sign in with their school account${c.school ? ` (${c.school})` : ''}, then type the code or open the link.`)); + slot.append(join); + + // roster + const roster = make('div', 'acct-section'); + roster.append(make('h3', 'stats-heading', `Students · ${d.students.length}`)); + if (!d.students.length) roster.append(make('p', 'setting-help', 'Nobody has joined yet.')); + else { + const table = make('table', 'stats-table acct-roster'); + const head = make('tr'); + for (const h of ['Name', 'Times Table', 'Needs practice', 'Last played', 'Homework', 'Parent code']) head.append(make('th', null, h)); + const thead = make('thead'); + thead.append(head); + const tbody = make('tbody'); + for (const s of d.students) { + const tr = make('tr'); + const nameCell = make('td'); + nameCell.append(button(s.name, 'acct-link', () => openStudent(s.id, classes, renderClasses))); + const tt = make('td'); + tt.append(masteryBar(s.summary), make('span', 'acct-small', `${s.summary.mastered} of ${s.summary.total} mastered`)); + const hw = s.homework.total ? `${s.homework.done} of ${s.homework.total}${s.homework.late ? ` · ${s.homework.late} late` : ''}` : '—'; + tr.append(nameCell, tt, make('td', null, s.summary.needsPractice.length ? s.summary.needsPractice.slice(0, 4).join(', ') + (s.summary.needsPractice.length > 4 ? '…' : '') : '—'), make('td', null, ago(s.lastPlayed)), make('td', null, hw), make('td', 'acct-mono', s.parentCode || '')); + const remove = button('×', 'acct-remove', async () => { + if (!confirm(`Take ${s.name} out of ${c.name}?`)) return; + await Auth.api('DELETE', `/api/classes/${c.id}/students/${s.id}`); + renderClasses(); + }); + remove.title = `Take ${s.name} out of the class`; + tr.lastChild.append(remove); + tbody.append(tr); + } + table.append(thead, tbody); + const wrap = make('div', 'acct-table-wrap'); + wrap.append(table); + roster.append(wrap); + } + slot.append(roster); + + // homework + const hw = make('div', 'acct-section'); + hw.append(make('h3', 'stats-heading', 'Class homework')); + if (!d.assignments.length) hw.append(make('p', 'setting-help', 'No homework set.')); + for (const a of d.assignments) { + const row = make('div', 'acct-item'); + row.append(make('strong', null, a.label), make('span', 'acct-small', `${a.due ? `due ${niceDay(a.due)} · ` : ''}${a.done} of ${a.of} done`)); + row.append( + button('Remove', 'btn btn-small', async () => { + if (!confirm(`Remove “${a.label}” for the whole class?`)) return; + await Auth.api('DELETE', `/api/homework/${a.id}`); + renderClasses(); + }) + ); + hw.append(row); + } + hw.append( + homeworkForm( + async ({ goal, due }) => { + await Auth.api('POST', '/api/homework', { classId: c.id, goal, due }); + renderClasses(); + }, + d.options, + d.students.length ? `every student in ${c.name}` : 'a new student' + ) + ); + slot.append(hw); + + // scheduled tournaments + const tour = make('div', 'acct-section'); + tour.id = 'acct-schedules'; + slot.append(tour); + await renderSchedules(); +} + +async function renderSchedules() { + const box = $('acct-schedules'); + if (!box) return; + let list; + try { + list = (await Auth.api('GET', '/api/schedules')).schedules; + } catch (err) { + return; + } + box.replaceChildren(make('h3', 'stats-heading', 'Tournaments')); + const mine = list.filter((s) => s.scope === 'school' || s.classId === currentClass); + if (!mine.length) box.append(make('p', 'setting-help', 'Plan a tournament and its lobby opens by itself at that time.')); + for (const s of mine) { + const row = make('div', `acct-item${s.open ? ' live' : ''}`); + row.append( + make('strong', null, s.title), + make('span', 'acct-small', `${niceTime(s.startsAt)} · ${FORMAT_NAMES[s.settings.format]} · ${s.settings.size}×${s.settings.size} · ${s.scope === 'school' ? 'whole school' : s.className}`) + ); + if (s.open) { + row.append( + button('Open the lobby', 'btn btn-small btn-primary', async () => { + const h = await Auth.api('GET', `/api/schedules/${s.id}/host`); + classes.close(); + Game.startHost({ code: h.code, teacherKey: h.teacherKey }); + }) + ); + } + row.append( + button('Remove', 'btn btn-small', async () => { + if (!confirm(`Remove “${s.title}”?`)) return; + await Auth.api('DELETE', `/api/schedules/${s.id}`); + renderSchedules(); + }) + ); + box.append(row); + } + box.append(scheduleForm()); +} + +function scheduleForm() { + const form = make('form', 'acct-hw-form'); + form.append(make('h4', null, 'Plan a tournament')); + const soon = new Date(Date.now() + 24 * 3600 * 1000); + soon.setMinutes(0, 0, 0); + const local = (d) => `${dayString(d)}T${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + const title = input({ type: 'text', maxlength: '60', placeholder: 'e.g. Friday Cup' }); + const when = input({ type: 'datetime-local', value: local(soon) }); + const format = select(Object.entries(FORMAT_NAMES), 'knockout'); + const size = select([6, 8, 10, 12].map((n) => [n, `${n}×${n}`]), 8); + const difficulty = select(['easy', 'medium', 'hard'].map((d) => [d, DIFFICULTY_NAMES[d]]), 'easy'); + const scope = select([ + ['class', 'This class'], + ['school', 'Whole school'], + ]); + const grid = make('div', 'acct-grid'); + grid.append(field('Name', title), field('When', when), field('Format', format), field('Board', size), field('Difficulty', difficulty), field('Who', scope)); + const error = errorLine(); + const go = button('Plan it', 'btn btn-small btn-primary'); + go.type = 'submit'; + form.append(grid, error, go); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + try { + await Auth.api('POST', '/api/schedules', { + classId: currentClass, + scope: scope.value, + title: title.value, + startsAt: new Date(when.value).getTime(), + settings: { format: format.value, size: Number(size.value), difficulty: difficulty.value }, + }); + toast('🏆 Tournament planned', niceTime(new Date(when.value).getTime())); + renderSchedules(); + } catch (err) { + showError(error, err); + } + }); + return form; +} + +// One student's page (teacher or parent), inside dialog d; back() returns +async function openStudent(id, d, back) { + d.clear('Student'); + d.actions.append(button('← Back', 'btn', back)); + d.done(); + let s; + try { + s = await Auth.api('GET', `/api/students/${id}`); + } catch (err) { + d.body.append(make('p', 'setting-help', err.message)); + return; + } + d.title.textContent = s.name; + const me = Auth.user(); + d.body.append( + studentDetail(s, { + mine: (h) => h.fromName === me.name && h.from === (me.role === 'teacher' ? 'teacher' : 'parent') && (me.role === 'parent' || !h.classId), + onAddHomework: async ({ goal, due }) => { + await Auth.api('POST', '/api/homework', { studentId: s.id, goal, due }); + openStudent(id, d, back); + }, + onRemoveHomework: async (h) => { + if (!confirm(`Remove “${h.label}”?`)) return; + await Auth.api('DELETE', `/api/homework/${h.id}`); + openStudent(id, d, back); + }, + }) + ); +} + +async function openKids() { + kids.open(); + await renderKids(); +} + +async function renderKids() { + kids.clear('👪 My kids'); + kids.done(); + let list; + try { + list = (await Auth.api('GET', '/api/children')).children; + } catch (err) { + kids.body.append(make('p', 'setting-help', err.message)); + return; + } + if (!list.length) kids.body.append(make('p', 'setting-help', 'Link your child’s account with the code on their Blockout account page (tap their name at the top of the menu), or ask their teacher for it.')); + const cards = make('div', 'acct-kids'); + for (const c of list) { + const card = button('', 'acct-kid', () => openStudent(c.id, kids, renderKids)); + const hw = c.homework.total ? `📚 ${c.homework.done} of ${c.homework.total} homework done${c.homework.late ? ` · ${c.homework.late} late` : ''}` : '📚 No homework'; + card.append(make('strong', null, `🎒 ${c.name}`), masteryBar(c.summary), make('span', 'acct-small', `${c.summary.mastered} of ${c.summary.total} facts mastered · played ${ago(c.lastPlayed)}`), make('span', 'acct-small', hw)); + cards.append(card); + } + kids.body.append(cards); + const form = make('form', 'acct-inline'); + const code = input({ type: 'text', maxlength: '9', autocomplete: 'off', placeholder: 'Child’s code', class: 'acct-code-input' }); + const go = button('Link', 'btn btn-small btn-primary'); + go.type = 'submit'; + form.append(code, go); + const error = errorLine(); + form.addEventListener('submit', async (e) => { + e.preventDefault(); + error.hidden = true; + try { + const r = await Auth.api('POST', '/api/children', { code: code.value }); + toast('👪 Linked', r.child.name); + renderKids(); + } catch (err) { + showError(error, err); + } + }); + const section = make('div', 'acct-section'); + section.append(make('h3', 'stats-heading', 'Link a child'), form, error); + kids.body.append(section); +} + +async function pollLive() { + if (Auth.role() !== 'student' || !Game.menuShowing()) return; + try { + const { live } = await Auth.api('GET', '/api/me/live'); + el.liveBanner.replaceChildren(); + el.liveBanner.hidden = !live.length; + for (const l of live) { + const row = make('div', 'live-row'); + row.append(make('span', null, `🏆 ${l.title} is open!`), button('Join', 'btn btn-small btn-primary', () => Game.joinClass(l.code))); + el.liveBanner.append(row); + } + } catch (_) { + // offline + } +} + +// #roster=CODE: a teacher's link to join their class roster +async function rosterLink() { + const m = /^#roster=([A-Za-z0-9]+)$/.exec(location.hash); + if (!m) return; + const code = m[1].toUpperCase(); + if (Auth.role() !== 'student') return openSignIn({ role: 'student', note: `🏫 To join class ${code}, sign in with your school account.` }); + history.replaceState(null, '', location.pathname); + try { + const r = await Auth.api('POST', '/api/me/classes', { code }); + toast('🏫 You joined', r.class.name); + } catch (err) { + toast('🏫 Couldn’t join', err.message); + } +} + +async function start() { + renderMenu(); + if (!Auth.served()) return; + try { + if (await Auth.finishKeycloak()) location.reload(); + } catch (err) { + toast('Sign-in', err.message); + } + if (Auth.signedIn()) { + try { + const next = await Auth.syncProgress(Game.progress()); + if (next) Game.replaceProgress(next); + } catch (_) { + // offline: keep playing on the local copy; it syncs with the next save + } + renderMenu(); // (a stale sign-in may have been dropped) + } + await rosterLink(); + refreshHomeworkBadge(); + pollLive(); + setInterval(pollLive, LIVE_POLL_MS); + setInterval(refreshHomeworkBadge, 60 * 1000); +} + +// What the account screens do when the page loads (main.js runs this after the game) +export function run() { + el = { + accountBtn: $('account-btn'), + homeworkBtn: $('homework-btn'), + classesBtn: $('classes-btn'), + kidsBtn: $('kids-btn'), + liveBanner: $('live-banner'), + }; + + account = dialog('account', 'Sign in', 'account-dialog'); + + el.accountBtn.addEventListener('click', openAccount); + + homework = dialog('homework-dialog', '📚 Homework', 'wide-dialog'); + + el.homeworkBtn.addEventListener('click', openHomework); + + classes = dialog('classes-dialog', '🏫 My classes', 'wide-dialog'); + + classes.setOnClose(() => clearInterval(schedulePoll)); + + el.classesBtn.addEventListener('click', openClasses); + + kids = dialog('kids-dialog', '👪 My kids', 'wide-dialog'); + + el.kidsBtn.addEventListener('click', openKids); + + + start(); +} + +export { askTeacherSignIn, openSignIn, openAccount, refreshHomeworkBadge }; diff --git a/apps/multiplication/src/css/accounts.css b/apps/multiplication/src/css/accounts.css new file mode 100644 index 0000000..be72179 --- /dev/null +++ b/apps/multiplication/src/css/accounts.css @@ -0,0 +1,96 @@ +/* ---------- Accounts: sign in, homework, classes, kids (js/accounts.js) ---------- */ +.top-row { display: flex; justify-content: center; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; } +.top-row .wallet { margin-bottom: 0; } +.account-chip { + font: inherit; + font-weight: 800; + padding: 4px 12px; + border: 2px solid var(--line); + border-radius: 999px; + background: var(--surface); + color: var(--ink); + cursor: pointer; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.account-chip:hover { border-color: var(--accent); } +.live-banner { margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; } +.live-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px; border: 2px solid var(--gold-line); background: var(--gold-bg); border-radius: 12px; font-weight: 800; text-align: left; } +.acct-menu-btn { position: relative; } + +.acct-dialog { text-align: left; max-height: calc(100vh - 32px); overflow-y: auto; } +.acct-dialog h2 { text-align: center; margin-bottom: 0.8rem; } +.acct-dialog.wide-dialog { max-width: 860px; } +.acct-dialog.account-dialog { max-width: 460px; } +.acct-dialog :is(input[type="text"], input[type="email"], input[type="date"], input[type="datetime-local"], select) { + font: inherit; + padding: 0.5rem 0.65rem; + border: 2px solid var(--line); + border-radius: 10px; + background: var(--surface); + color: var(--ink); + min-width: 0; +} +.acct-dialog :is(input, select):focus { outline: none; border-color: var(--accent); } +.acct-form { display: flex; flex-direction: column; gap: 12px; } +.acct-names { display: grid; grid-template-columns: 1fr 110px; gap: 10px; } +.acct-initial { text-transform: uppercase; } +.acct-note { margin: 0 0 10px; padding: 8px 12px; border-radius: 10px; background: var(--gold-bg); border: 2px solid var(--gold-line); font-weight: 800; } +.acct-privacy { margin: 0; } +.acct-card { text-align: center; margin-bottom: 8px; } +.acct-big { font-size: 1.4rem; font-weight: 900; } +.acct-sub { color: var(--muted); font-weight: 700; } +.acct-small { display: block; font-size: 0.8rem; color: var(--muted); font-weight: 700; } +.acct-section { margin-top: 16px; } +.acct-section h4, .acct-hw-form h4 { margin: 0 0 8px; } +.acct-list { display: flex; flex-direction: column; gap: 8px; } +.acct-item { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 12px; border: 2px solid var(--line); border-radius: 10px; background: var(--surface); } +.acct-item strong { flex: 1 1 auto; } +.acct-item.live { border-color: var(--gold-line); background: var(--gold-bg); } +.acct-inline { display: flex; gap: 8px; margin-top: 8px; } +.acct-inline input { flex: 1 1 auto; } +.acct-code-input { text-transform: uppercase; letter-spacing: 0.08em; font-weight: 800; } +.acct-code { font-size: 1.6rem; font-weight: 900; letter-spacing: 0.12em; font-family: ui-monospace, "Courier New", monospace; } +.acct-mono { font-family: ui-monospace, "Courier New", monospace; font-weight: 700; } +.acct-join { text-align: center; padding: 12px; border: 2px dashed var(--line); border-radius: 12px; margin: 12px 0; } +.acct-join .btn { margin-top: 6px; } +.acct-tabs { display: flex; gap: 6px; flex-wrap: wrap; } +.acct-mastery { display: flex; height: 10px; border-radius: 5px; overflow: hidden; background: var(--soft-2); min-width: 90px; } +.acct-mastery .seg.is-mastered, .acct-count.is-mastered { background: var(--good); color: #fff; } +.acct-mastery .seg.is-learning, .acct-count.is-learning { background: var(--learning); color: var(--learning-ink); } +.acct-mastery .seg.is-practice, .acct-count.is-practice, .acct-chip.is-practice { background: var(--danger); color: #fff; } +.acct-mastery .seg.is-unseen, .acct-count.is-unseen { background: var(--soft-2); color: var(--ink); } +.acct-counts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } +.acct-count, .acct-chip { padding: 2px 8px; border-radius: 999px; font-size: 0.8rem; font-weight: 800; } +.acct-chips { display: flex; flex-wrap: wrap; gap: 6px; } +.acct-hw { padding: 10px 12px; border: 2px solid var(--line); border-radius: 12px; background: var(--surface); display: flex; flex-direction: column; gap: 6px; } +.acct-hw.done { border-color: var(--good); } +.acct-hw.playable .acct-hw-meta .btn-primary { margin-left: auto; } +.acct-hw.late { border-color: var(--danger); } +.acct-hw-top, .acct-hw-meta { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; } +.acct-hw-state { font-weight: 800; font-size: 0.85rem; color: var(--muted); } +.acct-hw.late .acct-hw-state { color: var(--bad); } +.acct-hw.done .acct-hw-state { color: var(--good-ink); } +.acct-hw-meta { font-size: 0.8rem; color: var(--muted); font-weight: 700; } +.acct-hw-form { margin-top: 12px; padding: 12px; border: 2px dashed var(--line); border-radius: 12px; } +.acct-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin-bottom: 10px; } +.acct-grid > .field:first-child { grid-column: span 2; } +.acct-grid select, .acct-grid input { width: 100%; } +.acct-table-wrap { overflow-x: auto; } +.acct-roster td { vertical-align: middle; } +.acct-roster td:first-child { white-space: nowrap; } +.acct-link { font: inherit; font-weight: 800; background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer; text-decoration: underline; } +.acct-remove { font: inherit; margin-left: 8px; border: 0; background: none; color: var(--muted); cursor: pointer; font-weight: 900; } +.acct-remove:hover { color: var(--bad); } +.acct-activity { display: flex; justify-content: space-between; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--line); font-size: 0.9rem; } +.acct-when { color: var(--muted); white-space: nowrap; } +.acct-kids { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } +.acct-kid { font: inherit; text-align: left; display: flex; flex-direction: column; gap: 6px; padding: 12px; border: 2px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--ink); cursor: pointer; } +.acct-kid:hover { border-color: var(--accent); } +.acct-detail .fact-grid-wrap { max-width: 420px; margin: 8px auto; } +@media (max-width: 560px) { + .acct-dialog { --pad-x: 14px; --pad-y: 18px; } + .acct-names { grid-template-columns: 1fr 80px; } +} diff --git a/apps/multiplication/src/css/classroom.css b/apps/multiplication/src/css/classroom.css new file mode 100644 index 0000000..79638f4 --- /dev/null +++ b/apps/multiplication/src/css/classroom.css @@ -0,0 +1,145 @@ +/* ---------- Classroom: join / host dialogs ---------- */ +.class-dialog { max-width: 440px; text-align: left; } +.class-dialog h2 { text-align: center; margin-bottom: 0.6rem; } +.class-dialog form { display: flex; flex-direction: column; gap: 12px; } +.class-dialog > .field { display: flex; flex-direction: column; gap: 12px; margin-bottom: 12px; } +.class-dialog .dialog-actions { margin-top: 8px; } +.class-dialog .field { text-align: left; } +.code-input { font-size: 1.8rem !important; font-weight: 900; letter-spacing: 0.3em; text-transform: uppercase; text-align: center; } +.class-wait-big { font-size: 1.3rem; font-weight: 900; text-align: center; margin: 0.4rem 0; } +.class-wait-text { text-align: center; color: var(--muted); margin: 0 0 12px; } +.score-rank { margin-top: 4px; font-size: 0.85rem; font-weight: 800; color: var(--muted); } +.result-list li.result-you { outline: 3px solid var(--c); } +.result-list li.result-gap { background: none; border: 0; text-align: center; color: var(--muted); padding: 0; margin: -2px 0 4px; display: block; } + +/* ---------- Classroom: the teacher's screen ---------- */ +#class-host-screen .game-header { max-width: 1200px; } +.host-code-pill { background: var(--paper); border: 2px solid var(--edge); border-radius: 999px; padding: 4px 14px; font-weight: 800; } +.host-code-pill strong { letter-spacing: 0.12em; } +.host-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; max-width: 1200px; margin: 0 auto; align-items: start; } +.host-main, .host-side { background: var(--paper); border: 2px solid var(--edge); border-radius: var(--radius); box-shadow: 0 6px 0 var(--edge-shadow); padding: clamp(16px, 3vw, 32px); } +.host-panel { display: flex; flex-direction: column; align-items: center; gap: 16px; text-align: center; } +.host-kicker { margin: 0; font-size: 1.6rem; font-weight: 900; } +.host-join { display: flex; align-items: center; justify-content: center; gap: clamp(16px, 4vw, 40px); flex-wrap: wrap; } +.host-qr { width: min(240px, 60vw); height: auto; border-radius: 12px; border: 2px solid var(--edge); background: #fff; image-rendering: pixelated; } +.host-join-text { text-align: left; } +.host-step { margin: 0; color: var(--muted); font-weight: 800; } +.host-url { margin: 0 0 12px; font-size: clamp(1.1rem, 2.4vw, 1.7rem); font-weight: 900; word-break: break-all; } +.host-share { display: flex; gap: 6px; flex-wrap: wrap; margin: -4px 0 12px; } +.host-share .btn.copied, #host-copy-teacher.copied { background: var(--good-bg); border-color: var(--good); color: var(--good-ink); } +.host-code { margin: 0; font-size: clamp(3rem, 9vw, 5.5rem); font-weight: 900; letter-spacing: 0.15em; line-height: 1; color: var(--accent); } +.host-settings { margin: 0; color: var(--muted); font-weight: 700; } +.host-panel .btn-big { max-width: 420px; } +.host-dice { gap: 28px; margin: 0; } +.host-dice .die { width: 120px; height: 120px; padding: 16px; border-radius: 22px; box-shadow: 0 6px 0 var(--edge-shadow); } +.host-dice .pip { width: 20px; height: 20px; } +.host-dice .die-num { font-size: 3.4rem; } +.host-roll-text { margin: 0; font-size: 1.5rem; font-weight: 900; } +.host-progress { width: min(100%, 420px); display: flex; align-items: center; gap: 12px; font-weight: 800; white-space: nowrap; } +.host-progress .progress-bar { flex: 1; height: 16px; } +#host-done-fill { display: block; height: 100%; background: var(--good); transition: width 0.3s; } +.host-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } +.host-actions .btn { font-size: 1.1rem; padding: 0.7rem 1.3rem; } +.host-auto { display: flex; gap: 8px; align-items: center; font-weight: 700; color: var(--muted); cursor: pointer; } +.host-auto input { width: 18px; height: 18px; accent-color: var(--accent); } +.host-side .stats-heading { margin-top: 0; } +.host-roster { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-height: calc(100vh - 200px); overflow-y: auto; } +.host-roster li { display: flex; align-items: center; gap: 10px; background: var(--surface); border: 2px solid var(--line); border-radius: 10px; padding: 6px 10px; font-weight: 800; } +.host-roster li.offline { opacity: 0.55; } +.host-roster li.waiting { border-style: dashed; } +.host-waiting { font-size: 0.8rem; color: var(--muted); white-space: nowrap; } +.host-roster li.host-empty { display: block; border-style: dashed; color: var(--muted); font-weight: 700; } +.host-rank { min-width: 2.6em; color: var(--muted); font-variant-numeric: tabular-nums; } +.host-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.host-score { font-variant-numeric: tabular-nums; font-size: 1.1rem; } +.host-status { width: 1.4em; text-align: center; color: var(--good-ink); } +.host-remove { border: 0; background: none; font-size: 1.3rem; line-height: 1; color: var(--muted); cursor: pointer; padding: 0 4px; } +.host-remove:hover { color: var(--bad); } +.podium { list-style: none; margin: 0; padding: 0; display: flex; gap: 12px; justify-content: center; align-items: flex-end; flex-wrap: wrap; } +.podium li { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 140px; padding: 16px; border-radius: 14px; border: 2px solid var(--edge); background: var(--surface); font-weight: 900; } +.podium .podium-1 { order: 2; background: var(--gold-bg); border-color: var(--gold-line); padding-top: 28px; } +.podium .podium-2 { order: 1; } +.podium .podium-3 { order: 3; } +.podium-medal { font-size: 2.6rem; } +.podium-name { font-size: 1.3rem; } +.podium-score { color: var(--muted); } +#host-report { width: 100%; } +.host-hardest { list-style: none; margin: 0; padding: 0; width: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; } +.host-hardest li { display: flex; flex-direction: column; background: var(--bad-bg); border-radius: 10px; padding: 8px 12px; text-align: left; } +.host-hardest li.host-empty { background: var(--good-bg); grid-column: 1 / -1; font-weight: 800; } +.host-fact { font-size: 1.4rem; font-weight: 900; } +.host-missed { font-size: 0.85rem; font-weight: 700; color: var(--bad); } +/* Lobby: game settings */ +.host-game-settings { width: 100%; max-width: 760px; text-align: left; border-top: 2px solid var(--line); padding-top: 12px; } +.host-settings-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } +.host-settings-head .stats-heading { margin: 0; } +.host-game-settings > .setting-help { margin: 2px 0 10px; } +.host-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 10px 16px; } +.host-settings-grid .segmented button { padding: 0.45rem 0.3rem; font-size: 0.92rem; } +.host-settings-grid small { display: block; } + +/* Tournaments */ +.host-settings-grid .field.wide { grid-column: 1 / -1; } +.host-settings-grid [data-key="format"] { display: grid; grid-template-columns: repeat(5, 1fr); } +@media (max-width: 700px) { + .host-settings-grid [data-key="format"] { grid-template-columns: repeat(2, 1fr); } +} +.host-tournament { width: 100%; display: flex; flex-direction: column; gap: 14px; } +.bracket { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 6px; align-items: stretch; } +.bracket-round { display: flex; flex-direction: column; justify-content: space-around; gap: 10px; min-width: 150px; } +.bracket-label { font-size: 0.8rem; font-weight: 900; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; text-align: center; } +.bracket-match { border: 2px solid var(--edge); border-radius: 10px; background: var(--surface); overflow: hidden; } +.bracket-name { padding: 4px 10px; font-weight: 800; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.bracket-name + .bracket-name { border-top: 1px solid var(--line); } +.bracket-name.winner { background: var(--good-bg); color: var(--good-ink); } +.bracket-name.loser { color: var(--muted); } +.bracket-bye { font-size: 0.8rem; color: var(--muted); font-style: italic; text-align: center; } +.standings { width: 100%; border-collapse: collapse; font-weight: 700; font-variant-numeric: tabular-nums; } +.standings th, .standings td { padding: 5px 8px; text-align: left; border-bottom: 1px solid var(--line); } +.standings th { font-size: 0.8rem; color: var(--muted); } +.standings tr.out td { color: var(--muted); } +.koth-hills { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } +.koth-hill { border: 2px solid var(--gold-line); background: var(--gold-bg); border-radius: 12px; padding: 10px 12px; text-align: left; } +.koth-title { font-size: 0.8rem; font-weight: 900; color: var(--gold-ink); text-transform: uppercase; } +.koth-king { font-size: 1.4rem; font-weight: 900; } +.koth-streak { font-weight: 800; } +.koth-queue, .koth-count { font-size: 0.85rem; color: var(--muted); font-weight: 700; } +.host-roster li.knocked-out { opacity: 0.6; } +.result-tournament { margin: 0 0 14px; padding: 8px 12px; border-radius: 10px; background: var(--gold-bg); border: 2px solid var(--gold-line); font-weight: 800; text-align: center; } + +/* Pairs */ +.host-pairs { width: 100%; max-width: 760px; display: flex; flex-direction: column; gap: 12px; text-align: left; } +#host-odd small { display: block; } +.host-pairs .setting-help { margin: 0; } +.host-pair-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 6px; } +.host-pair-list li { display: flex; align-items: center; gap: 6px; background: var(--surface); border: 2px solid var(--line); border-radius: 10px; padding: 6px 8px; font-weight: 800; } +.pair-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; } +button.pair-name { border: 2px dashed var(--line); border-radius: 8px; background: var(--paper); padding: 4px 6px; font-weight: 800; cursor: pointer; } +button.pair-name:hover { border-color: var(--accent); } +button.pair-name.picked { border-style: solid; border-color: var(--accent); background: var(--accent-soft); } +.pair-name.other { color: var(--muted); font-style: italic; } +.pair-vs { font-size: 0.75rem; color: var(--muted); } +.host-teacher-device { display: flex; gap: 14px; align-items: center; background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 12px; padding: 10px; } +.host-qr.small { width: 120px; } +.host-teacher-url { margin: 2px 0; font-weight: 800; word-break: break-all; font-size: 0.85rem; } +.host-teacher-status { margin: 0; font-weight: 800; color: var(--muted); } +.host-teacher-status.ok { color: var(--good-ink); } +.host-matches { list-style: none; margin: 0; padding: 0; width: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } +.host-match { background: var(--surface); border: 2px solid var(--edge); border-radius: 12px; padding: 8px 10px; text-align: left; } +.host-match.over { border-color: var(--line); } +.match-row { display: flex; align-items: center; gap: 6px; font-weight: 800; padding: 2px 0; } +.match-row.winner { color: var(--good-ink); } +.match-turn { width: 1.4em; text-align: center; } +.match-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.match-score { font-size: 1.2rem; font-weight: 900; font-variant-numeric: tabular-nums; } +.match-status { margin-top: 4px; font-size: 0.8rem; font-weight: 700; color: var(--muted); } +.host-mygame.your-turn { animation: pulse 1.2s ease-in-out infinite; } +@keyframes pulse { + 0%, 100% { box-shadow: 0 3px 0 var(--edge-shadow), 0 0 0 0 var(--accent-ring); } + 50% { box-shadow: 0 3px 0 var(--edge-shadow), 0 0 0 8px var(--accent-ring); } +} + +@media (max-width: 820px) { + .host-layout { grid-template-columns: 1fr; } + .host-roster { max-height: none; } +} diff --git a/apps/multiplication/src/game/achievements.js b/apps/multiplication/src/game/achievements.js new file mode 100644 index 0000000..58c74c8 --- /dev/null +++ b/apps/multiplication/src/game/achievements.js @@ -0,0 +1,96 @@ +// Achievements: part of the multiplication game (split from the old js/game.js). + +import * as Progress from '@blockout/progress'; +import { el } from './base.js'; +import { progress } from './wallet.js'; +import { icon } from './wardrobe.js'; +import { make, unlockedTableSize } from './play.js'; + +function renderAchievements() { + el.achievementsBody.innerHTML = ''; + const got = Progress.ACHIEVEMENTS.filter((a) => progress.achievements[a.id]).length; + el.achievementsBody.append(make('p', 'stats-summary', `${got} of ${Progress.ACHIEVEMENTS.length} earned`)); + let list = null; + let group = null; + for (const a of Progress.ACHIEVEMENTS) { + if (a.group === 'Fact speed') continue; // shown as a grid below + if (a.group !== group) { + group = a.group; + const earnedHere = Progress.ACHIEVEMENTS.filter((x) => x.group === group && progress.achievements[x.id]).length; + const total = Progress.ACHIEVEMENTS.filter((x) => x.group === group).length; + el.achievementsBody.append(make('h3', 'stats-heading', `${group} · ${earnedHere} of ${total}`)); + if (group === 'Times tables') { + el.achievementsBody.append(make('p', 'setting-help', `📗 Learner: every fact in the line is Learning or better. 🏅 Master: every fact is Mastered. Lines go up to your table size (${unlockedTableSize()} × ${unlockedTableSize()} now).`)); + } + list = make('div', 'achievement-list'); + el.achievementsBody.append(list); + } + const when = progress.achievements[a.id]; + const row = make('div', 'achievement' + (when ? ' earned' : '')); + const badge = make('span', 'achievement-icon', when ? a.icon : undefined); + if (!when) badge.append(icon('lock')); + row.append(badge); + const text = make('div', 'achievement-text'); + const secret = a.hidden && !when; + text.append(make('strong', null, secret ? '???' : a.name), make('span', null, secret ? 'A secret achievement. Keep exploring!' : a.desc)); + row.append(text); + row.append( + make('span', 'achievement-reward', when ? new Date(when).toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) : `+${a.reward}`) + ); + list.append(row); + } + el.achievementsBody.append(renderFactSpeed()); +} + +// Fact speed: one cell per fact with the best tier earned (10, 5 or 1 seconds). +function renderFactSpeed() { + const box = make('div', 'fact-speed'); + const size = unlockedTableSize(); + const all = Progress.ACHIEVEMENTS.filter((a) => a.group === 'Fact speed'); + const earned = all.filter((a) => progress.achievements[a.id]); + box.append(make('h3', 'stats-heading', `Fact speed · ${earned.length} of ${all.length}`)); + box.append(make('p', 'setting-help', `Master a fact and answer it fast. Each fact has three tiers: under 10, 5 and 1 seconds. Showing up to ${size} × ${size}.`)); + const counts = make('div', 'fact-legend'); + for (const t of Progress.FACT_SPEED_TIERS) { + const n = earned.filter((a) => a.tier === t.secs).length; + const item = make('span', 'fact-legend-item'); + item.append(make('span', `fact-cell speed-${t.secs}`, String(t.secs)), document.createTextNode(`Under ${t.secs} s: ${n}`)); + counts.append(item); + } + box.append(counts); + const grid = make('div', 'fact-grid'); + grid.style.setProperty('--n', size + 1); + grid.append(make('span', 'fact-head', '×')); + for (let c = 1; c <= size; c++) grid.append(make('span', 'fact-head', String(c))); + for (let r = 1; r <= size; r++) { + grid.append(make('span', 'fact-head', String(r))); + for (let c = 1; c <= size; c++) { + const best = Progress.FACT_SPEED_TIERS.slice().reverse().find((t) => progress.achievements[`fact_${r}x${c}_${t.secs}`]); + const cell = make('span', `fact-cell ${best ? `speed-${best.secs}` : 'unseen'}`, best ? String(best.secs) : ''); + cell.title = best ? `${r} × ${c}: under ${best.secs} s` : `${r} × ${c}: not earned yet`; + grid.append(cell); + } + } + box.append(grid); + return box; +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.achievementsBtn.addEventListener('click', () => { + renderAchievements(); + el.achievementsDialog.hidden = false; + el.achievementsDialog.querySelector('.dialog').scrollTop = 0; + el.achievementsDone.focus(); + }); + + el.achievementsDone.addEventListener('click', () => (el.achievementsDialog.hidden = true)); + + el.achievementsDialog.addEventListener('click', (e) => { + if (e.target === el.achievementsDialog) el.achievementsDialog.hidden = true; + }); + + el.achievementsDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') el.achievementsDialog.hidden = true; + }); +} diff --git a/apps/multiplication/src/game/api.js b/apps/multiplication/src/game/api.js new file mode 100644 index 0000000..d0ff2f3 --- /dev/null +++ b/apps/multiplication/src/game/api.js @@ -0,0 +1,14 @@ +// What the account screens (../accounts.js) use from the game. +import { el } from './base.js'; +import { progress as state } from './wallet.js'; +import { connectHost, openClassJoin } from './classroom.js'; + +export { replaceProgress, saveProgress, toast, celebrate } from './wallet.js'; +export { openHostSetup } from './classroom.js'; +export { renderFactGrid, renderFactLegend, make } from './play.js'; +export { icon } from './wardrobe.js'; + +export const progress = () => state; +export const startHost = (h) => connectHost(h); +export const joinClass = (code) => openClassJoin(code, true); +export const menuShowing = () => !el.startScreen.hidden; diff --git a/apps/multiplication/src/game/base.js b/apps/multiplication/src/game/base.js new file mode 100644 index 0000000..93a552f --- /dev/null +++ b/apps/multiplication/src/game/base.js @@ -0,0 +1,320 @@ +// Shared constants, elements and helpers: part of the multiplication game (split from the old js/game.js). + +import * as Auth from '@blockout/auth'; +import * as Invite from '../invite.js'; + +let PROGRESS_KEY, el, ctx; + +const seededRandom = Invite.seededRandom; + +const DEFAULT_NAMES = ['Player 1', 'Player 2', 'Player 3', 'Player 4']; + +const PIPS = { + 1: [4], + 2: [0, 8], + 3: [0, 4, 8], + 4: [0, 2, 6, 8], + 5: [0, 2, 4, 6, 8], + 6: [0, 2, 3, 5, 6, 8], +}; + +// How long each computer action takes (ms): every explanation step, +// every square it draws and every number it counts. +const CPU_STEP_MS = { slow: 2000, normal: 1000, fast: 500 }; + +const COUNT_STEP = 850; // ms between skip-counting steps when helping a human + +const SETTINGS_KEY = 'blockout.settings'; + +const DEFAULT_SETTINGS = { + firstInCorner: false, + cpuSpeed: 'normal', + placeMode: 'draw', + diceMode: 'virtual', + cpuEnd: 'auto', // 'auto': next turn starts by itself; 'button': wait for "My turn" + showProgress: false, // "Board: X of Y filled" meter in the header + answerTime: '0', // seconds to answer the multiplication; '0' = no timer + difficulty: 'easy', // easy d6, medium d8, hard d12 + cpuSteps: 'show', // 'show': CPU explains step by step; 'instant': CPU plays straight away + autoRoll: 'manual', // 'manual': press Roll dice; 'auto': the dice roll by themselves + fitRolls: 'end', // "no room": only roll what fits 'end' (near the end), 'always' or 'never' + // Times-table grid cells: 'icon' (✓ ~ !) or 'time' (best time), per status + timesMastered: 'icon', + timesLearning: 'icon', + timesPractice: 'icon', + theme: 'auto', // 'auto' follows the device; 'light' or 'dark' forces one (free, not in the shop) +}; + +// Board sizes other than the small one are bought in the shop. +// Only the smallest board is free; every other size is bought in the shop. +const BOARD_UNLOCK = { 6: null, 8: 'board8', 10: 'board10', 12: 'board12', 16: 'board16', 20: 'board20', 24: 'board24', custom: 'boardCustom' }; + +const PASS_DELAY = 1600; + +const AUTO_PLACE_MS = 900; // how long an auto-placed rectangle is previewed + +const AUTO_ROLL_MS = 700; // pause before auto roll, so "Your turn" can be read first + +const TRY_AGAIN = ['Not quite — try again!', 'Almost! Have another go.', 'Hmm, not that one. Try again!']; + +const $ = (id) => document.getElementById(id); + +// types[i] is 'human' or 'cpu' for each multiplayer slot. +// names start empty: multiplayer players type their own ("Player N" is only a placeholder) +// multiKind: 'local' (one screen) or 'classroom' (not built yet) +// level/timer: single player's Learn / Expert and Expert's answer timer (seconds) +const setup = { mode: 'single', multiKind: 'local', level: 'learn', timer: 0, count: 2, size: 6, names: ['', '', '', ''], types: ['human', 'human', 'human', 'human'] }; + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + // A signed-in player's save is kept apart from the guest one (see js/auth.js) + PROGRESS_KEY = Auth ? Auth.progressKey() : 'blockout.progress'; + + el = { + startScreen: $('start-screen'), + gameScreen: $('game-screen'), + modeCards: document.querySelectorAll('.mode-card'), + singleSetup: $('single-setup'), + multiSetup: $('multi-setup'), + multiKind: $('multi-kind'), + localSetup: $('local-setup'), + classroomSetup: $('classroom-setup'), + classJoinOpen: $('class-join-open'), + classHostOpen: $('class-host-open'), + classOffline: $('class-offline'), + classJoin: $('class-join'), + classJoinForm: $('class-join-form'), + classCodeInput: $('class-code-input'), + classNameInput: $('class-name-input'), + classJoinError: $('class-join-error'), + classJoinBtn: $('class-join-btn'), + classJoinCancel: $('class-join-cancel'), + classWait: $('class-wait'), + classWaitName: $('class-wait-name'), + classWaitText: $('class-wait-text'), + classLeaveBtn: $('class-leave-btn'), + hostSetup: $('class-host-setup'), + hostSetupTitle: $('class-host-title'), + hostSize: $('host-size'), + hostTypeField: $('host-type-field'), + hostType: $('host-type'), + hostTypeHelp: $('host-type-help'), + hostDifficulty: $('host-difficulty'), + hostRounds: $('host-rounds'), + hostError: $('host-error'), + hostCancel: $('host-cancel'), + hostCreate: $('host-create'), + hostScreen: $('class-host-screen'), + hostCodeSmall: $('host-code-small'), + hostLobby: $('host-lobby'), + hostQr: $('host-qr'), + hostUrl: $('host-url'), + hostCode: $('host-code'), + hostSettingsGrid: $('host-settings-grid'), + hostQuick: $('host-quick'), + hostStartBtn: $('host-start-btn'), + hostLocalHelp: $('host-local-help'), + hostPlay: $('host-play'), + hostRound: $('host-round'), + hostDieA: $('host-die-a'), + hostDieB: $('host-die-b'), + hostRollText: $('host-roll-text'), + hostDoneFill: $('host-done-fill'), + hostDoneText: $('host-done-text'), + hostNextBtn: $('host-next-btn'), + hostEndBtn: $('host-end-btn'), + hostAuto: $('host-auto'), + hostEnded: $('host-ended'), + hostPodium: $('host-podium'), + hostReport: $('host-report'), + hostHardest: $('host-hardest'), + hostAgainBtn: $('host-again-btn'), + hostClose2Btn: $('host-close2-btn'), + hostCloseBtn: $('host-close-btn'), + hostRosterTitle: $('host-roster-title'), + hostRoster: $('host-roster'), + hostPairs: $('host-pairs'), + hostMatching: $('host-matching'), + hostOdd: $('host-odd'), + hostPairsHelp: $('host-pairs-help'), + hostPairList: $('host-pair-list'), + hostTeacherDevice: $('host-teacher-device'), + hostTeacherQr: $('host-teacher-qr'), + hostTeacherUrl: $('host-teacher-url'), + hostTeacherStatus: $('host-teacher-status'), + hostMyGameBtn: $('host-mygame-btn'), + hostCopyLink: $('host-copy-link'), + hostCopyMsg: $('host-copy-msg'), + hostShareBtn: $('host-share-btn'), + hostCopyTeacher: $('host-copy-teacher'), + hostMatches: $('host-matches'), + hostDice: $('host-dice'), + hostProgress: $('host-progress'), + hostAutoLabel: $('host-auto-label'), + hostMatchResults: $('host-match-results'), + hostTournament: $('host-tournament'), + hostTournamentFinal: $('host-tournament-final'), + resultTournament: $('result-tournament'), + singleName: $('single-name'), + playerCount: $('player-count'), + nameInputs: $('name-inputs'), + namesError: $('names-error'), + boardSize: $('board-size'), + gameLevel: $('game-level'), + gameLevelHelp: $('game-level-help'), + gameTimerField: $('game-timer-field'), + gameTimer: $('game-timer'), + gameTimerHelp: $('game-timer-help'), + boardSizeHelp: $('board-size-help'), + customSize: $('custom-size'), + customSizeInput: $('custom-size-input'), + customSizeEcho: $('custom-size-echo'), + setupForm: $('setup-form'), + menuBtn: $('menu-btn'), + progress: $('progress'), + progressText: $('progress-text'), + progressBar: $('progress-bar'), + boardWrap: $('board-wrap'), + canvas: $('board'), + turnPanel: $('turn-panel'), + turnLabel: $('turn-label'), + dieA: $('die-a'), + dieB: $('die-b'), + turnMsg: $('turn-msg'), + rollBtn: $('roll-btn'), + rotateBtn: $('rotate-btn'), + math: $('math'), + mathQ: $('math-q'), + answerBox: $('answer-box'), + countTrail: $('count-trail'), + mathFeedback: $('math-feedback'), + keypad: $('keypad'), + helpBtn: $('help-btn'), + steps: $('steps'), + diceEntry: $('dice-entry'), + diePicks: document.querySelectorAll('.die-pick'), + useDiceBtn: $('use-dice'), + continueBtn: $('continue-btn'), + timer: $('timer'), + timerFill: $('timer-fill'), + timerNum: $('timer-num'), + stats: $('stats'), + detailsBtn: $('details-btn'), + scores: $('scores'), + gameOver: $('game-over'), + resultTitle: $('result-title'), + resultSub: $('result-sub'), + resultList: $('result-list'), + againBtn: $('again-btn'), + toMenuBtn: $('to-menu-btn'), + settingsBtn: $('settings-btn'), + historyBtn: $('history-btn'), + historyDialog: $('history'), + historyBody: $('history-body'), + historyDone: $('history-done'), + resetHistory: $('reset-history'), + settingsDialog: $('settings'), + walletAmount: $('wallet-amount'), + wardrobeBtn: $('wardrobe-btn'), + unlockDialog: $('unlock'), + unlockTitle: $('unlock-title'), + unlockPrice: $('unlock-price'), + unlockMessage: $('unlock-message'), + unlockYes: $('unlock-yes'), + unlockNo: $('unlock-no'), + wardrobeDialog: $('wardrobe'), + wardrobeBody: $('wardrobe-body'), + wardrobeDone: $('wardrobe-done'), + stickersBtn: $('stickers-btn'), + stickersDialog: $('stickers'), + stickersBody: $('stickers-body'), + stickersTrade: $('stickers-trade'), + stickersDone: $('stickers-done'), + gameSettingsBtn: $('game-settings-btn'), + practiceSetup: $('practice-setup'), + practiceName: $('practice-name'), + practiceCount: $('practice-count'), + practiceCountHelp: $('practice-count-help'), + practiceTimerField: $('practice-timer-field'), + practiceTimer: $('practice-timer'), + practiceTimerHelp: $('practice-timer-help'), + practicePreview: $('practice-preview'), + practiceTables: $('practice-tables'), + practiceLevel: $('practice-level'), + practiceLevelHelp: $('practice-level-help'), + difficultyField: $('difficulty-field'), + boardSizeField: $('board-size-field'), + startBtn: $('start-btn'), + practiceScreen: $('practice-screen'), + practiceMenuBtn: $('practice-menu-btn'), + practiceSettingsBtn: $('practice-settings-btn'), + prCounter: $('pr-counter'), + prStreak: $('pr-streak'), + streakBadge: $('streak-badge'), + prBar: $('pr-bar'), + prArray: $('pr-array'), + prTimer: $('pr-timer'), + prTimerFill: $('pr-timer-fill'), + prTimerNum: $('pr-timer-num'), + prQ: $('pr-q'), + prAnswer: $('pr-answer'), + prTrail: $('pr-trail'), + prFeedback: $('pr-feedback'), + prKeypad: $('pr-keypad'), + prHelp: $('pr-help'), + practiceDone: $('practice-done'), + pdSub: $('pd-sub'), + pdBody: $('pd-body'), + pdDetailsBtn: $('pd-details-btn'), + pdShopBtn: $('pd-shop-btn'), + resultsShopBtn: $('results-shop-btn'), + pdAgain: $('pd-again'), + pdMenu: $('pd-menu'), + settingsNote: $('settings-note'), + toasts: $('toasts'), + shopBtn: $('shop-btn'), + shopDialog: $('shop'), + shopBody: $('shop-body'), + shopDone: $('shop-done'), + achievementsBtn: $('achievements-btn'), + achievementsDialog: $('achievements'), + achievementsBody: $('achievements-body'), + achievementsDone: $('achievements-done'), + rewards: $('rewards'), + inviteBtn: $('invite-btn'), + inviteMake: $('invite-make'), + inviteCodeInput: $('invite-code-input'), + inviteCodeOpen: $('invite-code-open'), + inviteCodeError: $('invite-code-error'), + invitePlayers: $('invite-players'), + inviteNote: $('invite-note'), + inviteBoard: $('invite-board'), + inviteCustomField: $('invite-custom-field'), + inviteCustom: $('invite-custom'), + inviteSeed: $('invite-seed'), + inviteCreate: $('invite-create'), + inviteResult: $('invite-result'), + inviteLink: $('invite-link'), + inviteCode: $('invite-code'), + inviteLocalWarning: $('invite-local-warning'), + inviteTry: $('invite-try'), + inviteQrWrap: $('invite-qr-wrap'), + inviteQr: $('invite-qr'), + inviteQrSave: $('invite-qr-save'), + inviteQrPrint: $('invite-qr-print'), + inviteDone: $('invite-done'), + inviteOpen: $('invite-open'), + inviteOpenNote: $('invite-open-note'), + inviteOpenSummary: $('invite-open-summary'), + inviteOpenNameField: $('invite-open-name-field'), + inviteOpenName: $('invite-open-name'), + inviteStart: $('invite-start'), + inviteCancel: $('invite-cancel'), + settingsDone: $('settings-done'), + cornerRuleHint: $('corner-rule-hint'), + }; + + ctx = el.canvas.getContext('2d'); +} + +export { $, AUTO_PLACE_MS, AUTO_ROLL_MS, BOARD_UNLOCK, COUNT_STEP, CPU_STEP_MS, DEFAULT_NAMES, DEFAULT_SETTINGS, PASS_DELAY, PIPS, PROGRESS_KEY, SETTINGS_KEY, TRY_AGAIN, ctx, el, seededRandom, setup }; diff --git a/apps/multiplication/src/game/cheats.js b/apps/multiplication/src/game/cheats.js new file mode 100644 index 0000000..33ae50c --- /dev/null +++ b/apps/multiplication/src/game/cheats.js @@ -0,0 +1,116 @@ +// Cheat codes: part of the multiplication game (split from the old js/game.js). + +import * as Progress from '@blockout/progress'; +import { $, el } from './base.js'; +import { celebrate, confettiFullScreen, progress, saveProgress, updateMenuBadges } from './wallet.js'; +import { saveSettings } from './settings.js'; +import { enforceLocks, renderBoardHint, renderGameLevel, renderWardrobe, syncBoardLocks, syncModeLocks, syncSettingsUI } from './wardrobe.js'; +import { game, make } from './play.js'; +import { refreshAvailableFilters, refreshOpenNudges, renderShop } from './shop.js'; +import { updatePracticePreview } from './practice.js'; + +let cheatsEl; + +const inClassGame = () => game && (game.mode === 'class' || game.mode === 'pair'); + +// Unlocks changed: bring every menu, list and lock badge up to date. +function refreshUnlocks() { + enforceLocks(); + saveSettings(); + saveProgress(); + refreshAvailableFilters(); + if (!el.shopDialog.hidden) renderShop(); + if (!el.wardrobeDialog.hidden) renderWardrobe(); + syncSettingsUI(); + syncBoardLocks(); + syncModeLocks(); + updatePracticePreview(); + renderGameLevel(); + renderBoardHint(); + refreshOpenNudges(); + updateMenuBadges(); +} + +function renderActiveCheats() { + const codes = Progress.activeCheats(progress); + cheatsEl.active.hidden = !codes.length; + cheatsEl.active.replaceChildren(); + if (!codes.length) return; + cheatsEl.active.append(make('h3', null, 'Active')); + for (const code of codes) { + const row = make('div', 'cheat-row'); + const off = make('button', 'btn btn-small', 'Turn off'); + off.type = 'button'; + off.addEventListener('click', () => { + Progress.cheatOff(progress, code); + refreshUnlocks(); + renderActiveCheats(); + cheatsEl.msg.className = 'cheats-msg'; + cheatsEl.msg.textContent = 'Cheat turned off.'; + }); + row.append(make('span', null, `✨ ${Progress.CHEAT_CODES[code].name}`), off); + cheatsEl.active.append(row); + } +} + +function openCheats() { + cheatsEl.input.value = ''; + cheatsEl.input.classList.remove('wrong'); + cheatsEl.msg.className = 'cheats-msg'; + cheatsEl.msg.textContent = inClassGame() ? 'Cheats are switched off in class games. Nice try! 😄' : ''; + renderActiveCheats(); + cheatsEl.box.hidden = false; + cheatsEl.input.focus(); +} + +const closeCheats = () => (cheatsEl.box.hidden = true); + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + // Press ~ anywhere (outside a text box) to open the cheat codes box. + + // code (lower case) -> { name, run() }. None yet. + // Codes live in Progress.CHEAT_CODES. The menu lists the ones that are on by + // what they do (never the code itself), each with a Turn off button. + cheatsEl = { box: $('cheats'), form: $('cheats-form'), input: $('cheats-input'), msg: $('cheats-msg'), close: $('cheats-close'), active: $('cheats-active') }; + + document.addEventListener('keydown', (e) => { + if (e.key !== '~' && e.key !== '`') return; + if (e.ctrlKey || e.metaKey || e.altKey || e.target.closest('input, textarea, select')) return; + if ([...document.querySelectorAll('.overlay')].some((o) => !o.hidden)) return; // not on top of another dialog + e.preventDefault(); + openCheats(); + }); + + cheatsEl.close.addEventListener('click', closeCheats); + + cheatsEl.box.addEventListener('click', (e) => e.target === cheatsEl.box && closeCheats()); + + cheatsEl.box.addEventListener('keydown', (e) => e.key === 'Escape' && closeCheats()); + + cheatsEl.form.addEventListener('submit', (e) => { + e.preventDefault(); + const code = cheatsEl.input.value.trim().toLowerCase(); + if (!code || inClassGame()) return; + cheatsEl.input.classList.remove('wrong'); + cheatsEl.input.value = ''; + if (Progress.CHEAT_CODES[code]) { + const on = Progress.cheatOn(progress, code); + if (on) { + confettiFullScreen(3); + celebrate(Progress.awardAchievements(progress, { event: 'cheat', code })); // Cheater, plus this code's secret + refreshUnlocks(); + } + renderActiveCheats(); + cheatsEl.msg.className = 'cheats-msg good'; + cheatsEl.msg.textContent = on ? `✨ ${Progress.CHEAT_CODES[code].name}` : 'That one is already on.'; + return; + } + void cheatsEl.input.offsetWidth; // restart the shake + cheatsEl.input.classList.add('wrong'); + cheatsEl.msg.className = 'cheats-msg'; + cheatsEl.msg.textContent = 'Nothing happened…'; + }); +} + +export { refreshUnlocks }; diff --git a/apps/multiplication/src/game/checkin.js b/apps/multiplication/src/game/checkin.js new file mode 100644 index 0000000..db839e9 --- /dev/null +++ b/apps/multiplication/src/game/checkin.js @@ -0,0 +1,22 @@ +// Daily check-in: part of the multiplication game (split from the old js/game.js). + +import * as Progress from '@blockout/progress'; +import { celebrate, progress, renderWallet, saveProgress, toast } from './wallet.js'; +import { checkUrlForInvite } from './invites.js'; + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + renderWallet(); + + { + const visit = Progress.checkIn(progress); + if (visit) { + const streak = visit.streak > 1 ? ` · ${visit.streak} days in a row 🔥` : ''; + toast(`☀️ Daily check-in: +${visit.bonus} points`, `Welcome back!${streak}`); + celebrate(Progress.awardAchievements(progress, { event: 'checkin', streak: visit.streak })); + saveProgress(); + } + } + + checkUrlForInvite(); +} diff --git a/apps/multiplication/src/game/classroom.js b/apps/multiplication/src/game/classroom.js new file mode 100644 index 0000000..fa78b94 --- /dev/null +++ b/apps/multiplication/src/game/classroom.js @@ -0,0 +1,1408 @@ +// Classroom: part of the multiplication game (split from the old js/game.js). + +import * as Auth from '@blockout/auth'; +import { askTeacherSignIn } from '../accounts.js'; +import * as Classroom from '@blockout/classroom'; +import * as Core from '@blockout/engine'; +import * as Progress from '@blockout/progress'; +import { AUTO_ROLL_MS, el } from './base.js'; +import { celebrate, celebrateWin, grantStickers, progress, saveProgress, toast } from './wallet.js'; +import { settings } from './settings.js'; +import { icon, syncBoardLocks } from './wardrobe.js'; +import { currentPlayer, game, gameId, humanRoll, make, renderStats, resetTurn, rollDice, setGame, setGameId, showRewards, startGame, startPlacing, statsName, stopTimer, waitInGame } from './play.js'; +import { loadHistory, saveHistory } from './stats.js'; +import { refreshNudges, tap } from './shop.js'; +import { drawQr } from './invites.js'; +import { setDetailsOpen, toMenu } from './input.js'; +import { render, renderDie, resizeBoard, setMsg } from './render.js'; + +let HOST_SETTINGS; + + +function ordinal(n) { + const tens = n % 100; + const suffix = tens >= 11 && tens <= 13 ? 'th' : { 1: 'st', 2: 'nd', 3: 'rd' }[n % 10] || 'th'; + return `${n}${suffix}`; +} + +// Can this page reach the classroom server? Shown in the Classroom menu. +async function checkClassroom() { + const reason = await Classroom.check(); + el.classOffline.hidden = !reason; + el.classJoinOpen.disabled = el.classHostOpen.disabled = Boolean(reason); + if (reason) { + el.classOffline.innerHTML = + reason === 'file' + ? 'Classroom games run through the Blockout server. On the teacher’s computer run node server/classroom.js, then open the address it shows (like http://localhost:8080).' + : 'Can’t reach the classroom server. Run node server/classroom.js and open this page from the address it prints (like http://localhost:8080), not from another web server.'; + } + return !reason; +} + +// ---- students + +let classSession = null; // { code, id, key, name } + +let classStop = null; // closes the live updates + +let classSeen = false; // got at least one update since connecting + +// The first name used in the last class on this device, to fill in next time +const CLASS_NAME_KEY = 'blockout.className'; + +function lastClassName() { + try { + return localStorage.getItem(CLASS_NAME_KEY) || ''; + } catch (e) { + return ''; + } +} + +// fromLink: opened from a join link (QR code or a link posted in the class chat) +async function openClassJoin(code = '', fromLink = false) { + el.classCodeInput.value = code || ''; + // Signed-in students play under their account's first name + const account = Auth && Auth.role() === 'student' ? Auth.user() : null; + el.classNameInput.value = account ? account.firstName : lastClassName(); + el.classNameInput.readOnly = Boolean(account); + el.classJoinError.hidden = true; + el.classJoinForm.hidden = false; + el.classWait.hidden = true; + el.classJoin.hidden = false; + // With the code and your name filled in, joining is one tap + (!code ? el.classCodeInput : el.classNameInput.value ? el.classJoinBtn : el.classNameInput).focus(); + if (fromLink && code) { + try { + await Classroom.roomInfo(code); + } catch (err) { + if (err.code !== 'room') return; + el.classJoinError.textContent = 'This class has finished. Ask your teacher for a new link.'; + el.classJoinError.hidden = false; + el.classCodeInput.value = ''; + el.classCodeInput.focus(); + } + } +} + +function connectClass(session) { + if (classStop) classStop(); + classSession = session; + classSeen = false; + Classroom.saveSession(session); + classStop = Classroom.listenStudent(session, { + view: (v) => { + classSeen = true; + onClassView(v); + }, + gone: onClassGone, + }); +} + +function leaveClass() { + if (!classSession) return; + Classroom.leave(classSession).catch(() => {}); + if (classStop) classStop(); + classStop = null; + classSession = null; + Classroom.saveSession(null); +} + +function onClassGone(reason) { + const wasIn = classSeen; + const wasHost = classSession && classSession.host; + classStop = null; + classSession = null; + Classroom.saveSession(null); + el.classJoin.hidden = true; + if (wasHost) { + // the teacher's own player on the projector page: the host screen handles the rest + if (game && game.mode === 'pair') setGame(null); + return; + } + if (game && (game.mode === 'class' || game.mode === 'pair') && game.phase !== 'over') toMenu(); + if (!wasIn) return; // an old class from last time: just forget it + if (reason === 'closed') toast('👋 Your teacher closed the class', 'Thanks for playing!'); + else if (reason === 'removed') toast('You’ve left the class', 'Join again with the code if that was a mistake.'); + else toast('⚠️ Lost the class', 'Join again with the code on the board.'); +} + +function showClassWaiting(view) { + el.classWaitName.textContent = classSession.name; + const t = view && view.tournament; + el.classWaitText.textContent = t + ? tournamentLine(t) + : classSession.teacher + ? 'You’re the teacher’s player. When there’s an odd number of students, you’ll play the odd one out here.' + : view && view.state === 'playing' + ? 'That game started without you. You’ll play in the next one!' + : view && view.state === 'ended' + ? 'That game just finished. Wait here for the next one!' + : 'Waiting for your teacher to start the game…'; + el.classJoinForm.hidden = true; + el.classWait.hidden = false; + el.classJoin.hidden = false; +} + +function onClassView(view) { + if (view.type === 'pairs' || view.type === 'tournament') return onPairView(view); + if (view.you.benched) return showClassWaiting(view); // joined after this game started + const mine = game && game.mode === 'class' && game.classroom.game === view.game; + if (view.state === 'playing') { + if (!mine) startClassGame(view); + else syncClassGame(view); + return; + } + if (view.state === 'ended' && mine) { + if (game.phase !== 'over') finishClassGame(view); + return; + } + // Lobby (or a game that finished before we got here): wait for the next one. + // Results stay on screen until the next game starts. + if (game && game.mode === 'class' && game.phase === 'over') return; + if (game && game.mode === 'class') toMenu(); + showClassWaiting(view); +} + +// The teacher's settings for everyone in the class (no shop locks); placing is +// the student's own choice unless the teacher picked one. +function classCfg(s) { + return { + answerTime: s.answerTime || 0, + placeMode: s.placeMode && s.placeMode !== 'choice' ? s.placeMode : settings.placeMode, + firstInCorner: false, + diceMode: 'virtual', + }; +} + +function startClassGame(view) { + el.classJoin.hidden = true; + for (const d of document.querySelectorAll('.overlay')) if (d !== el.gameOver) d.hidden = true; + const you = view.you; + const { size, difficulty } = view.settings; + startGame([{ name: you.name, statsName: el.singleName.value.trim() || 'You', cpu: false }], size, 'class', null, { + cfg: { ...classCfg(view.settings), difficulty, autoRoll: 'off', fitRolls: 'never' }, // the teacher rolls + classroom: { session: classSession, game: view.game, round: 0, reported: true, view }, + }); + // Rejoining mid-game: put your rectangles back and pick up your score. + const p = game.players[0]; + for (const r of you.rects) { + const rect = Core.place(game.board, r.x, r.y, r.w, r.h, 0); + Object.assign(rect, { a: r.a, b: r.b, solved: true }); + } + Object.assign(p, { squares: you.squares, bonus: you.bonus, score: you.score, streak: you.streak, answered: you.answered, firstTry: you.firstTry }); + if (you.rects.length) toast(`🏫 Back in class ${view.code}`, `You have ${you.score} points.`); + syncClassGame(view); +} + +// A new roll from the teacher. If you were still working on the last one, it's gone. +function syncClassGame(view) { + const c = game.classroom; + c.view = view; + if (view.round > c.round) { + if (!['wait', 'over'].includes(game.phase)) { + setGameId(gameId + 1); // stop whatever the last roll was still doing + if (!c.reported) { + if (game.pending) Core.removeRect(game.board, game.pending.rect); + const [a, b] = game.dice || []; + if (a) currentPlayer().log.push({ timeout: true, a, b }); + currentPlayer().streak = 0; + toast('⏭️ Next roll!', 'Your teacher moved on before you finished that one.'); + } + } + resetTurn(); + c.round = view.round; + const [a, b] = view.roll; + if (view.you.done) { + // No room for this roll (the server checks), or you'd already answered it + c.reported = true; + game.dice = [a, b]; + renderDie(el.dieA, a); + renderDie(el.dieB, b); + game.phase = 'wait'; + if (view.you.result === 'pass') { + currentPlayer().log.push({ pass: true, a, b }); + setMsg(`No room for a ${a} × ${b} anywhere. You pass this time.`); + } else setMsg('Waiting for the next roll…'); + } else { + c.reported = false; + classTurn(a, b); + } + } + render(); +} + +async function classTurn(a, b) { + setMsg(''); + await rollDice([a, b]); + await startPlacing(a, b); +} + +// Tell the server how this roll went (once per roll). +function reportClass(result) { + const c = game.classroom; + if (!c || c.reported) return; + c.reported = true; + c.doneTurn = c.round; + Classroom.sendResult(c.session, { round: c.round, ...result }).catch((err) => { + if (err.code !== 'stale' && err.code !== 'done') toast('⚠️ Couldn’t send your answer', err.message); + }); +} + +// ---- pairs: head to head on a shared board, taking turns + +function onPairView(view) { + const host = classSession.host; // the teacher playing from the projector screen + // (a tournament plays many games: each has its own match id) + const sameGame = game && game.mode === 'pair' && game.classroom.game === view.game; + const mine = sameGame && (!view.match || game.classroom.matchId === view.match.id); + if (view.type === 'tournament') tournamentUpdate(view, sameGame); + if (view.match && view.state !== 'lobby') { + if (!mine) { + if (view.match.over) return host ? updateMyGameButton() : showClassWaiting(view); // joined after it finished + startPairGame(view); + } else syncPairGame(view); + return; + } + if (sameGame && game.phase === 'over') return; // results stay up until the next game starts + if (game && game.mode === 'pair') { + if (host) setGame(null); + else toMenu(); + } + if (host) return updateMyGameButton(); + showClassWaiting(view); +} + +function startPairGame(view) { + const m = view.match; + const me = m.players.findIndex((p) => p.id === view.you.id); + if (!classSession.host) { + el.classJoin.hidden = true; + for (const d of document.querySelectorAll('.overlay')) if (d !== el.gameOver) d.hidden = true; + } + const defs = m.players.map((p, i) => ({ + name: p.name, + cpu: p.isCpu, + ...(i === me ? { statsName: el.singleName.value.trim() || 'You' } : {}), + })); + const { size, difficulty } = view.settings; + startGame(defs, size, 'pair', null, { + cfg: { ...classCfg(view.settings), difficulty, autoRoll: view.settings.autoRoll ? 'auto' : 'off' }, + classroom: { session: classSession, game: view.game, matchId: m.id, me, round: 0, reported: true, doneTurn: 0, synced: 0, passSeen: 0, view }, + }); + if (classSession.host && !hostOnGame) showHostView(); // the projector keeps showing the class + syncPairGame(view); +} + +const opponent = () => game.players[1 - game.classroom.me]; + +// Bring the shared board and the turn up to date with the server. +function syncPairGame(view) { + if (game.phase === 'over') return; // finished (and rewarded) once already + const c = game.classroom; + const m = view.match; + c.view = view; + m.players.forEach((sp, i) => Object.assign(game.players[i], { score: sp.score, squares: sp.squares, bonus: sp.bonus })); + let note = ''; + // New rectangles (yours are already on the board) + for (const r of m.rects.slice(c.synced)) { + if (game.board.rects.some((x) => x.x === r.x && x.y === r.y && x.w === r.w && x.h === r.h)) continue; + if (game.pending) { + // Can't happen in turn order, but never draw over your own rectangle + Core.removeRect(game.board, game.pending.rect); + game.pending = null; + } + const rect = Core.place(game.board, r.x, r.y, r.w, r.h, r.owner); + Object.assign(rect, { a: r.a, b: r.b, solved: true }); + game.lastRect = rect; + game.players[r.owner].log.push({ a: r.a, b: r.b, area: rect.area }); + if (r.owner !== c.me) note = `${game.players[r.owner].name} drew ${r.a} × ${r.b} = ${rect.area}.`; + } + c.synced = m.rects.length; + // Passes + if (m.lastPass && m.lastPass.turn > c.passSeen) { + c.passSeen = m.lastPass.turn; + const who = m.players.findIndex((p) => p.id === m.lastPass.by); + const [a, b] = m.lastPass.roll; + game.players[who].log.push({ pass: true, a, b }); + note = who === c.me ? `No room for a ${a} × ${b} anywhere. You pass.` : `${game.players[who].name} had no room for a ${a} × ${b}, so they pass.`; + } + game.current = m.current; + if (m.over) return finishPairGame(view); + const myTurn = m.current === c.me; + const newTurn = m.turn !== c.round; + if (newTurn) c.round = m.turn; + if (newTurn && myTurn && ['wait', 'roll'].includes(game.phase)) startPairTurn(note); + else if (!myTurn && game.phase === 'wait') { + if (m.roll) { + renderDie(el.dieA, m.roll[0]); + renderDie(el.dieB, m.roll[1]); + setMsg(`${opponent().name} rolled ${m.roll[0]} and ${m.roll[1]}…`); + } else if (newTurn || note) { + renderDie(el.dieA, null); + renderDie(el.dieB, null); + setMsg(`${note} ${opponent().name}'s turn…`.trim()); + } + } + render(); + updateMyGameButton(); +} + +function startPairTurn(note = '') { + resetTurn(); + game.classroom.reported = false; + game.phase = 'roll'; + setMsg(`${note} ${game.autoRoll ? 'Your turn! Rolling…' : 'Your turn: roll the dice!'}`.trim()); + render(); + if (classSession.host && !hostOnGame) toast('🎲 Your turn', `in your game with ${opponent().name}`); + if (game.autoRoll) waitInGame(AUTO_ROLL_MS).then(humanRoll); +} + +// After your turn (or at the start): your turn again, or wait for your partner. +function pairTurnOrWait() { + const c = game.classroom; + const m = c.view && c.view.match; + if (m && !m.over && m.current === c.me && m.turn === c.round && m.turn !== c.doneTurn) return startPairTurn(); + game.phase = 'wait'; + setMsg(m && m.current !== c.me ? `${opponent().name}'s turn…` : 'Waiting…'); + render(); +} + +async function pairRoll() { + const c = game.classroom; + game.phase = 'rolling'; + render(); + let res; + try { + res = await Classroom.roll(c.session); + } catch (err) { + if (game && game.classroom === c) { + game.phase = 'roll'; + render(); + } + if (err.code !== 'turn' && err.code !== 'stale') toast('⚠️ Couldn’t roll', err.message); + return; + } + if (!game || game.classroom !== c) return; + const [a, b] = await rollDice(res.roll); + if (res.passed) { + c.reported = true; + c.doneTurn = c.round; + game.phase = 'wait'; + setMsg(`No room for a ${a} × ${b} anywhere. You pass.`); + render(); + return; + } + await startPlacing(a, b); +} + +function finishPairGame(view) { + setGameId(gameId + 1); + stopTimer(); + const c = game.classroom; + const m = view.match; + game.phase = 'over'; + game.dice = null; + game.pending = null; + el.math.hidden = true; + setMsg(''); + render(); + updateMyGameButton(); + const me = m.players[c.me]; + const opp = m.players[1 - c.me]; + const oppName = opp.isTeacher ? 'your teacher' : opp.name; + const Opp = oppName[0].toUpperCase() + oppName.slice(1); + el.resultTitle.textContent = + m.left === opp.id + ? `${Opp} left, so you win!` + : m.winner === me.id + ? `You beat ${oppName}! 🎉` + : m.winner === null + ? 'It’s a tie!' + : `${Opp} wins this time`; + const empty = Core.emptyCount(game.board); + el.resultSub.textContent = `${me.score} – ${opp.score} · ${empty === 0 ? 'the board is full' : `${empty} squares left empty`}`; + el.resultList.innerHTML = ''; + const top = Math.max(1, me.score, opp.score); + for (const [i, p] of [...m.players.entries()].sort((x, y) => y[1].score - x[1].score)) { + const li = make('li', i === c.me ? 'result-you' : ''); + li.style.setProperty('--c', game.players[i].color); + const bar = make('span', 'result-bar'); + const fill = make('span'); + fill.style.width = `${(p.score / top) * 100}%`; + bar.append(fill); + li.append(make('span', 'result-name', p.name), make('span', 'result-pts', `${p.score} pts`), bar); + el.resultList.append(li); + } + el.rewards.innerHTML = ''; + if (c.session.teacher) el.rewards.append(make('p', 'setting-help', 'Thanks for playing! Teacher games don’t count towards stats.')); + else if (view.type === 'tournament') { + // each tournament game earns its points; the tournament's own rewards come at the end + Progress.addPoints(progress, me.score); + saveProgress(); + showRewards([]); + } else renderPairRewards(view); + if (m.tiebreak) el.resultSub.textContent += m.tiebreak === 'coin' ? ' · a tie, settled by a coin flip' : ' · a tie, won on answers right first time'; + tournamentUpdate(view, true); + if (m.winner === me.id) celebrateWin(); + renderStats(); + setDetailsOpen(false); + if (c.session.host && !hostOnGame) { + toast('🏁 Your game is over', `${me.score} – ${opp.score} against ${opp.name}`); + return; // the projector keeps showing the class; the results wait in "My game" + } + el.gameOver.hidden = false; + el.gameOver.querySelector('.dialog').scrollTop = 0; +} + +function renderPairRewards(view) { + const c = game.classroom; + const m = view.match; + const me = m.players[c.me]; + const opp = m.players[1 - c.me]; + const won = m.winner === me.id; + Progress.addPoints(progress, me.score); + progress.counters.pairGames = (progress.counters.pairGames || 0) + 1; + if (won) progress.counters.pairWins = (progress.counters.pairWins || 0) + 1; + Progress.noteActivity(progress, { kind: 'class', type: view.tournament ? 'tournament' : 'pairs', won }); + const p = game.players[c.me]; + const asked = p.answered + p.stats.timeouts; + const earned = Progress.awardAchievements(progress, { + event: 'pair', + won, + tie: m.winner === null, + vsTeacher: opp.isTeacher, + vsCpu: opp.isCpu, + pairGames: progress.counters.pairGames, + pairWins: progress.counters.pairWins || 0, + perfect: asked >= 5 && p.firstTry === asked, + }); + grantStickers(earned); // they show up behind the Stickers button + saveProgress(); + const history = loadHistory(); + history.push({ + kind: 'class', + type: 'pairs', + at: Date.now(), + duration: Date.now() - game.startedAt, + code: view.code, + size: view.settings.size, + difficulty: view.settings.difficulty, + name: me.name, + statsName: statsName(p), + opponent: opp.isTeacher ? 'Teacher' : opp.name, + result: m.winner === null ? 'tie' : won ? 'win' : 'loss', + score: me.score, + opponentScore: opp.score, + answered: p.answered, + firstTry: p.firstTry, + timeouts: p.stats.timeouts, + times: p.stats.times.map(Math.round), + missed: p.stats.missed, + }); + saveHistory(history); + showRewards(earned); + el.rewards.append(make('p', 'setting-help', 'Stay on this screen: your teacher can start another game.')); +} + +// ---- tournaments (students): where you stand between games, and the rewards at the end + +function tournamentLine(t) { + const record = `${t.wins}–${t.losses}`; + switch (t.status) { + case 'champion': + return `🏆 You won the tournament! (${record})`; + case 'finished': + return `${t.champion} won the tournament. You finished ${ordinal(t.place)} of ${t.of} (${record}).`; + case 'out': + return `You’re out of the tournament (${record}). Cheer on the others! 📣`; + case 'bye': + return 'You have a bye this round: you go straight through! 🎟️'; + case 'playing': + return 'Your game is on!'; + default: + return t.line ? `You’re number ${t.line} in line for the hill 👑 (${record})` : `Next round starting soon… (${record})`; + } +} + +let tournamentRewarded = null; // `${code}:${game}` once its rewards are given + +// Every update: keep the line under your last result up to date, and give the +// tournament's rewards once it's over. +function tournamentUpdate(view, sameGame) { + const t = view.tournament; + if (!t) return; + if (sameGame && game.phase === 'over') { + el.resultTournament.textContent = tournamentLine(t); + el.resultTournament.hidden = false; + } + if (!el.classWait.hidden && !el.classJoin.hidden) el.classWaitText.textContent = tournamentLine(t); + const key = `${view.code}:${view.game}`; + if (view.state !== 'ended' || classSession.teacher || tournamentRewarded === key) return; + tournamentRewarded = key; + progress.counters.tournaments = (progress.counters.tournaments || 0) + 1; + if (t.status === 'champion') progress.counters.tournamentWins = (progress.counters.tournamentWins || 0) + 1; + celebrate( + Progress.awardAchievements(progress, { + event: 'tournament', + place: t.place, + of: t.of, + champion: t.status === 'champion', + undefeated: t.losses === 0 && t.wins > 0, + upsets: t.upsets, + bestStreak: t.bestStreak, + tournaments: progress.counters.tournaments, + titles: progress.counters.tournamentWins || 0, + }) + ); + saveProgress(); + const history = loadHistory(); + history.push({ + kind: 'class', + type: 'tournament', + at: Date.now(), + duration: 0, + code: view.code, + size: view.settings.size, + difficulty: view.settings.difficulty, + format: view.settings.format, + name: classSession.name, + place: t.place, + of: t.of, + wins: t.wins, + losses: t.losses, + champion: t.champion, + }); + saveHistory(history); + if (t.status === 'champion') celebrateWin(); +} + +function finishClassGame(view) { + setGameId(gameId + 1); + stopTimer(); + const you = view.you; + const p = game.players[0]; + game.classroom.view = view; + game.phase = 'over'; + game.dice = null; + game.pending = null; + el.math.hidden = true; + setMsg(''); + Object.assign(p, { score: you.score, squares: you.squares, bonus: you.bonus }); + render(); + const alone = view.of < 2; + el.resultTitle.textContent = alone + ? 'Game over!' + : you.rank === 1 + ? `${you.tied ? 'Tied for top' : 'Top'} of the class! 🏆` + : `You ${you.tied ? 'tied for' : 'came'} ${ordinal(you.rank)} of ${view.of}!`; + const asked = you.answered + you.timeouts; + el.resultSub.textContent = `${you.score} points · ${you.firstTry} of ${asked} right first time`; + el.resultList.innerHTML = ''; + const rows = [...view.top]; + if (!rows.some((r) => r.name === you.name)) rows.push({ gap: true }, { name: you.name, rank: you.rank, score: you.score }); + const top = Math.max(1, ...rows.filter((r) => !r.gap).map((r) => r.score)); + for (const r of rows) { + const li = make('li', r.gap ? 'result-gap' : r.name === you.name ? 'result-you' : ''); + if (r.gap) { + li.textContent = '⋯'; + el.resultList.append(li); + continue; + } + li.style.setProperty('--c', r.name === you.name ? p.color : 'var(--muted)'); + const bar = make('span', 'result-bar'); + const fill = make('span'); + fill.style.width = `${(r.score / top) * 100}%`; + bar.append(fill); + li.append(make('span', 'result-name', `${ordinal(r.rank)} ${r.name}`), make('span', 'result-pts', `${r.score} pts`), bar); + el.resultList.append(li); + } + renderClassRewards(view); + refreshNudges('results'); + if (you.rank === 1 && !alone) celebrateWin(); + renderStats(); + setDetailsOpen(false); + el.gameOver.hidden = false; + el.gameOver.querySelector('.dialog').scrollTop = 0; +} + +function renderClassRewards(view) { + const you = view.you; + const p = game.players[0]; + el.rewards.innerHTML = ''; + Progress.addPoints(progress, you.score); + progress.counters.classGames = (progress.counters.classGames || 0) + 1; + Progress.noteActivity(progress, { kind: 'class', type: 'class', rank: you.rank }); + const asked = you.answered + you.timeouts; + const earned = Progress.awardAchievements(progress, { + event: 'class', + classGames: progress.counters.classGames, + rank: you.rank, + of: view.of, + perfect: asked >= 5 && you.firstTry === asked, + missed: you.missed, + rolls: view.round, + }); + grantStickers(earned); // they show up behind the Stickers button + saveProgress(); + recordClassGame(view, p); + showRewards(earned); + el.rewards.append(make('p', 'setting-help', 'Stay on this screen: your teacher can start another game.')); +} + +function recordClassGame(view, p) { + const you = view.you; + const history = loadHistory(); + history.push({ + kind: 'class', + at: Date.now(), + duration: Date.now() - game.startedAt, + code: view.code, + size: view.settings.size, + difficulty: view.settings.difficulty, + name: you.name, + statsName: statsName(p), + rank: you.rank, + of: view.of, + score: you.score, + answered: you.answered, + firstTry: you.firstTry, + timeouts: you.timeouts, + times: p.stats.times.map(Math.round), + missed: p.stats.missed, + }); + saveHistory(history); +} + +// ---- teacher + +let host = null; // { code, teacherKey, lan } + +let hostStop = null; + +let hostView = null; + +function segValue(group) { + return group.querySelector('.selected').dataset.value; +} + +function setSeg(group, value) { + for (const b of group.querySelectorAll('button')) b.classList.toggle('selected', b.dataset.value === String(value)); +} + +// Game type: the whole class on one leaderboard, or head to head in pairs. +const HOST_TYPE_HELP = { + class: 'Everyone plays the same rolls on their own board, and the whole class shares one leaderboard.', + pairs: 'Students are matched in twos and play head to head on a shared board, taking turns. With an odd number, the odd one out plays you or the CPU.', + tournament: 'Short head-to-head games in a knockout, round robin, Swiss or king-of-the-hill tournament, with the bracket on your screen. Choose the format in the lobby.', +}; + +function renderHostType() { + const type = segValue(el.hostType); + for (const b of el.hostType.querySelectorAll('button')) b.setAttribute('aria-checked', String(b.dataset.value === type)); + el.hostTypeHelp.textContent = HOST_TYPE_HELP[type]; + el.hostRounds.closest('.field').hidden = type !== 'class'; // pairs and tournament games run by turns + if (type === 'tournament' && segValue(el.hostSize) === '12') setSeg(el.hostSize, 8); // many short games: a small board +} + +// Creating a class. Everything here (and more) can be changed in the lobby too. +function openHostSetup() { + // Hosting a class needs a teacher account (the server checks too) + if (Auth && Auth.served() && Auth.role() !== 'teacher') return askTeacherSignIn(); + renderHostType(); + el.hostError.hidden = true; + el.hostSetup.hidden = false; + el.hostCreate.focus(); +} + +function connectHost(h) { + if (hostStop) hostStop(); + host = h; + hostView = null; + Classroom.saveHost(h); + el.startScreen.hidden = true; + el.gameScreen.hidden = true; + el.hostScreen.hidden = false; + el.hostCodeSmall.textContent = h.code; + hostStop = Classroom.listenTeacher(h, { view: renderHost, gone: hostGone }); +} + +function hostGone(reason) { + const had = Boolean(hostView); + if (classSession && classSession.host) { + if (classStop) classStop(); + classStop = null; + classSession = null; + Classroom.saveSession(null); + if (game && game.mode === 'pair') setGame(null); + } + hostOnGame = false; + el.gameScreen.hidden = true; + el.gameOver.hidden = true; + hostStop = null; + host = null; + hostView = null; + Classroom.saveHost(null); + el.hostScreen.hidden = true; + el.startScreen.hidden = false; + if (had && reason !== 'closed') toast('⚠️ Lost the class', 'The classroom server may have restarted.'); +} + +async function hostAction(action, extra) { + try { + await Classroom.teacher(host, action, extra); + } catch (err) { + toast('⚠️ ' + err.message); + } +} + +// The address to show on the board. localhost only works on this computer, +// so use the network address the server found instead. +function joinUrl(code) { + const local = ['localhost', '127.0.0.1', '[::1]'].includes(location.hostname); + const base = local && host.lan && host.lan.length ? host.lan[0] : `${location.origin}${location.pathname}`; + return { base, full: `${base}#class=${code}`, local: local && !(host.lan && host.lan.length) }; +} + +// ---- teacher: links to post in the class chat + +async function copyText(text) { + try { + await navigator.clipboard.writeText(text); + return true; + } catch (e) { + // Older browsers or no permission: copy from a hidden text box + const box = document.createElement('textarea'); + box.value = text; + box.setAttribute('readonly', ''); + box.style.cssText = 'position:fixed;opacity:0;top:0;left:0'; + document.body.append(box); + box.select(); + let ok = false; + try { + ok = document.execCommand('copy'); + } catch (err) {} + box.remove(); + return ok; + } +} + +async function copyWithFeedback(btn, text) { + const label = btn.innerHTML; + const ok = await copyText(text); + btn.classList.add('copied'); + btn.textContent = ok ? '✓ Copied!' : 'Couldn’t copy'; + setTimeout(() => { + btn.classList.remove('copied'); + btn.innerHTML = label; + }, 1600); +} + +const joinMessage = (code) => `Join our Blockout class! Tap this link: ${joinUrl(code).full} (class code ${code})`; + +function renderHost(view) { + const first = !hostView; + hostView = view; + const s = view.settings; + el.hostLobby.hidden = view.state !== 'lobby'; + el.hostPlay.hidden = view.state !== 'playing'; + el.hostEnded.hidden = view.state !== 'ended'; + + if (view.state === 'lobby') { + const url = joinUrl(view.code); + el.hostCode.textContent = view.code; + el.hostUrl.textContent = url.base.replace(/^https?:\/\//, '').replace(/\/$/, ''); + el.hostLocalHelp.hidden = !url.local; + if (first || el.hostQr.dataset.url !== url.full) { + drawQr(el.hostQr, url.full); + el.hostQr.dataset.url = url.full; + } + const pairs = view.type === 'pairs'; + renderHostSettings(view); + el.hostPairs.hidden = !pairs; + const n = view.players.length; + el.hostStartBtn.disabled = !n; + el.hostStartBtn.textContent = n ? `Start game (${n} ${n === 1 ? 'player' : 'players'})` : 'Waiting for players to join…'; + if (view.type === 'tournament') { + el.hostStartBtn.disabled = n < 2; + el.hostStartBtn.textContent = n >= 2 ? `Start the tournament (${n} players)` : 'A tournament needs at least 2 players…'; + } + if (pairs) renderHostPairsLobby(view); + } else if (view.state === 'playing' && view.type === 'tournament') { + const t = view.tournament; + renderHostMatches(view, el.hostMatches); + renderTournament(t, el.hostTournament); + const kothPlayed = t.hills.reduce((n, h) => n + h.played, 0); + const kothTotal = t.hills.reduce((n, h) => n + h.limit, 0); + el.hostRound.textContent = t.format === 'koth' ? `King of the hill · ${kothPlayed} of ${kothTotal} games` : `${TOURNAMENT_NAMES[t.format]} · ${t.label}`; + el.hostNextBtn.replaceChildren(icon('chevron-right'), t.format === 'koth' ? ' Next games' : ' Next round'); + el.hostNextBtn.disabled = !t.roundReady; + el.hostAuto.checked = view.autoNext; + } else if (view.state === 'playing' && view.type === 'pairs') { + renderHostMatches(view, el.hostMatches); + const done = view.matches.filter((m) => m.over).length; + el.hostRound.textContent = `Pairs · ${done} of ${view.matches.length} ${view.matches.length === 1 ? 'game' : 'games'} finished`; + } else if (view.state === 'playing') { + const [a, b] = view.roll; + const sides = Core.DIFFICULTY_SIDES[s.difficulty]; + el.hostRound.textContent = `Roll ${view.round} of ${s.rounds}`; + renderDie(el.hostDieA, a, sides); + renderDie(el.hostDieB, b, sides); + el.hostRollText.textContent = `Draw a ${a} by ${b} rectangle`; + el.hostDoneFill.style.width = `${view.here ? (view.done / view.here) * 100 : 0}%`; + el.hostDoneText.textContent = `${view.done} of ${view.here} done`; + el.hostNextBtn.replaceChildren(icon(view.round >= s.rounds ? 'flag' : 'dices'), view.round >= s.rounds ? ' Finish game' : ' Next roll'); + el.hostAuto.checked = view.autoNext; + } else { + renderHostResults(view); + } + // whole class: dice and pacing; pairs: one card per pair; tournament: the bracket too + const pairsPlaying = view.type !== 'class'; + const tourney = view.type === 'tournament'; + for (const node of [el.hostDice, el.hostRollText, el.hostProgress]) node.hidden = pairsPlaying; + el.hostNextBtn.hidden = el.hostAutoLabel.hidden = view.type === 'pairs' || (tourney && view.tournament && view.tournament.format === 'koth'); + el.hostAutoLabel.lastChild.textContent = tourney ? ' Start the next round by itself' : ' Roll again by itself when everyone’s done'; + el.hostMatches.hidden = !pairsPlaying; + el.hostTournament.hidden = !tourney || view.state !== 'playing'; + el.hostPodium.hidden = view.type === 'pairs'; + el.hostMatchResults.hidden = view.type !== 'pairs'; + el.hostTournamentFinal.hidden = !tourney || view.state !== 'ended'; + renderHostRoster(view); + updateMyGameButton(); +} + +// "Make it quick": everything that saves time +const QUICK_SETTINGS = { placeMode: 'auto', answerTime: 20, autoRoll: true, fitRolls: 'always', cpuSpeed: 'fast', matchTurns: 12 }; + +function renderHostSettings(view) { + const grid = el.hostSettingsGrid; + if (!grid.children.length) { + for (const def of HOST_SETTINGS) { + const field = make('div', 'field' + (def.wide ? ' wide' : '')); + field.append(make('span', null, def.label)); + const seg = make('div', 'segmented'); + seg.dataset.key = def.key; + for (const [value, label, detail] of def.options) { + const b = make('button', null, label); + b.type = 'button'; + b.dataset.value = JSON.stringify(value); + if (detail) b.append(' ', make('small', null, detail)); + seg.append(b); + } + field.append(seg); + grid.append(field); + } + } + HOST_SETTINGS.forEach((def, i) => { + grid.children[i].hidden = (def.only && !def.only.includes(view.type)) || (def.when && !def.when(view.settings)); + }); + for (const seg of grid.querySelectorAll('.segmented')) { + const current = JSON.stringify(view.settings[seg.dataset.key]); + for (const b of seg.querySelectorAll('button')) b.classList.toggle('selected', b.dataset.value === current); + } + const quick = Object.entries(QUICK_SETTINGS).every(([k, v]) => view.settings[k] === v || (k === 'cpuSpeed' && view.type !== 'pairs') || (['autoRoll', 'fitRolls', 'cpuSpeed', 'matchTurns'].includes(k) && view.type === 'class')); + el.hostQuick.disabled = quick; +} + +// ---- teacher: tournaments on the projector + +const TOURNAMENT_NAMES = { knockout: 'Knockout', double: 'Double knockout', roundrobin: 'Round robin', swiss: 'Swiss', koth: 'King of the hill' }; + +// Knockouts: the bracket, a column per round. Other formats: the standings +// table (and, for king of the hill, each hill's king and line). +function renderTournament(t, box) { + box.innerHTML = ''; + if (t.format === 'koth') { + const hills = make('div', 'koth-hills'); + for (const h of t.hills) { + const card = make('div', 'koth-hill'); + card.append(make('div', 'koth-title', t.hills.length > 1 ? `Hill ${h.id}` : 'The hill')); + card.append(make('div', 'koth-king', h.king ? `👑 ${h.king}` : '👑 …')); + if (h.streak > 1) card.append(make('div', 'koth-streak', `🔥 ${h.streak} wins in a row`)); + card.append(make('div', 'koth-queue', h.queue.length ? `Next: ${h.queue.join(', ')}` : '')); + card.append(make('div', 'koth-count', `${h.played} of ${h.limit} games`)); + hills.append(card); + } + box.append(hills); + } + if (t.format === 'knockout' || t.format === 'double') { + const cols = make('div', 'bracket'); + for (const r of t.rounds) { + const col = make('div', 'bracket-round'); + col.append(make('div', 'bracket-label', r.label)); + for (const m of r.matches) { + const game = make('div', 'bracket-match'); + for (const name of [m.a, m.b]) game.append(make('div', 'bracket-name' + (name === m.winner ? ' winner' : ' loser'), name)); + col.append(game); + } + for (const name of r.byes) col.append(make('div', 'bracket-bye', `${name}: bye`)); + cols.append(col); + } + box.append(cols); + } + if (t.format !== 'knockout') { + const table = make('table', 'standings'); + const head = table.createTHead().insertRow(); + for (const h of ['', 'Name', 'W', 'L', t.format === 'koth' ? 'Best streak' : 'Points']) head.append(make('th', null, h)); + const body = table.createTBody(); + for (const x of t.standings) { + const tr = body.insertRow(); + if (x.out) tr.className = 'out'; + tr.append(make('td', null, ordinal(x.place)), make('td', null, x.name), make('td', null, String(x.wins)), make('td', null, String(x.losses)), make('td', null, String(t.format === 'koth' ? x.bestStreak : x.points))); + } + box.append(table); + } +} + +function renderTournamentPodium(t) { + el.hostPodium.innerHTML = ''; + const medals = ['🥇', '🥈', '🥉']; + for (const x of t.standings.filter((s) => s.place <= 3).slice(0, 4)) { + const li = make('li', `podium-${x.place}`); + li.append(make('span', 'podium-medal', medals[x.place - 1]), make('span', 'podium-name', x.name), make('span', 'podium-score', `${x.wins}–${x.losses}`)); + el.hostPodium.append(li); + } +} + +function renderTournamentRoster(view) { + const t = view.tournament; + el.hostRosterTitle.textContent = t.over ? 'Final standings' : 'Standings'; + el.hostRoster.innerHTML = ''; + el.hostRoster.classList.add('ranked'); + const byId = new Map(view.players.map((p) => [p.id, p])); + const playingNow = new Set(view.matches.filter((m) => !m.over).flatMap((m) => m.players.map((p) => p.id))); + for (const x of t.standings) { + const p = byId.get(x.id); + const li = make('li', (p && !p.connected ? 'offline ' : '') + (x.out ? 'knocked-out' : '')); + li.append(make('span', 'host-rank', ordinal(x.place)), make('span', 'host-name', x.name), make('span', 'host-score', `${x.wins}–${x.losses}`)); + li.append(make('span', 'host-status', !p ? '👋' : t.over ? (x.place === 1 ? '🏆' : '') : !p.connected ? '📴' : playingNow.has(x.id) ? '🎲' : x.out ? '' : '⏳')); + el.hostRoster.append(li); + } + for (const p of view.waiting || []) { + const li = make('li', 'waiting'); + li.append(make('span', 'host-name', p.name), make('span', 'host-waiting', '⏳ next tournament')); + el.hostRoster.append(li); + } +} + +// ---- teacher: pairs + +const MATCHING_HELP = { + shuffle: 'New pairs are drawn when the game starts. Nobody gets last game’s partner if that can be helped.', + keep: 'Same pairs as last game. Anyone new is paired up at the end.', + arrange: 'Tap two names to swap them between pairs.', +}; + +let swapPick = null; // arranging pairs: the first name tapped + +function renderHostPairsLobby(view) { + // Only "on this screen" plays from this page; otherwise let go of the teacher's + // player here (another device or the CPU plays instead). + if (view.pairOptions.odd !== 'screen' && classSession && classSession.host) { + if (classStop) classStop(); + classStop = null; + classSession = null; + Classroom.saveSession(null); + if (game && game.mode === 'pair') setGame(null); + } + setSeg(el.hostMatching, view.pairOptions.matching); + setSeg(el.hostOdd, view.pairOptions.odd); + const n = view.players.length; + const odd = view.odd; + const who = { screen: 'you, here on this screen', device: 'you, on your other device', cpu: 'the CPU' }[view.pairOptions.odd]; + el.hostPairsHelp.textContent = `${MATCHING_HELP[view.pairOptions.matching]} ${n ? `${n} ${n === 1 ? 'student' : 'students'}: ` : ''}${odd.needed ? `the odd one out plays ${who}.` : n ? 'everyone has a partner.' : ''}`; + // Pairs preview (shuffle mode draws new ones at the start) + el.hostPairList.hidden = view.pairOptions.matching === 'shuffle' || !n; + el.hostPairList.innerHTML = ''; + const arrange = view.pairOptions.matching === 'arrange'; + if (swapPick && !view.players.some((p) => p.id === swapPick)) swapPick = null; + const nameChip = (p) => { + if (!arrange) return make('span', 'pair-name', p.name); + const b = make('button', 'pair-name' + (swapPick === p.id ? ' picked' : ''), p.name); + b.type = 'button'; + b.addEventListener('click', () => { + if (!swapPick) swapPick = p.id; + else if (swapPick === p.id) swapPick = null; + else { + hostAction('swap', { a: swapPick, b: p.id }); + swapPick = null; + } + renderHostPairsLobby(hostView); + }); + return b; + }; + for (const [a, b] of view.pairs) { + const li = make('li'); + li.append(nameChip(a), make('span', 'pair-vs', 'vs')); + li.append(b ? nameChip(b) : make('span', 'pair-name other', view.pairOptions.odd === 'cpu' ? 'CPU' : 'You')); + el.hostPairList.append(li); + } + // Playing the odd one out from another device: its link and whether it has joined + const device = view.pairOptions.odd === 'device'; + el.hostTeacherDevice.hidden = !device; + if (device) { + const url = `${joinUrl(view.code).base}#class=${view.code}&play=${view.playKey}`; + el.hostTeacherUrl.textContent = url.replace(/^https?:\/\//, ''); + if (el.hostTeacherQr.dataset.url !== url) { + drawQr(el.hostTeacherQr, url); + el.hostTeacherQr.dataset.url = url; + } + const joined = view.teacher && view.teacher.connected; + el.hostTeacherStatus.textContent = joined ? '✓ Your other device has joined.' : 'Not joined yet.'; + el.hostTeacherStatus.classList.toggle('ok', Boolean(joined)); + } + if (odd.needed && !odd.ready) { + el.hostStartBtn.disabled = true; + el.hostStartBtn.textContent = 'Waiting for your other device to join…'; + } else if (n) { + const games = Math.ceil(n / 2); + el.hostStartBtn.textContent = `Start ${games} ${games === 1 ? 'game' : 'games'} (${n} ${n === 1 ? 'student' : 'students'})`; + } +} + +// One card per pair: both names and scores, whose turn it is, and how it ended. +function renderHostMatches(view, list) { + list.innerHTML = ''; + for (const m of view.matches) { + const li = make('li', 'host-match' + (m.over ? ' over' : '')); + m.players.forEach((p, i) => { + const row = make('div', 'match-row' + (m.over && m.winner === p.id ? ' winner' : '')); + const turn = !m.over && m.current === i; + row.append(make('span', 'match-turn', turn ? '🎲' : m.over && m.winner === p.id ? '🏆' : ''), make('span', 'match-name', p.name), make('span', 'match-score', String(p.score))); + li.append(row); + }); + const left = m.left && m.players.find((p) => p.id === m.left); + const status = m.over ? (left ? `${left.name} left` : m.winner ? 'Finished' : 'Tie!') : `Board ${Math.round(m.filled * 100)}% full`; + li.append(make('div', 'match-status', status)); + list.append(li); + } +} + +// ---- teacher: playing the odd one out on this screen + +let hostOnGame = false; // showing the teacher's own game instead of the class + +function showHostView() { + hostOnGame = false; + el.gameScreen.hidden = true; + el.gameOver.hidden = true; + el.hostScreen.hidden = !host; + if (!host) el.startScreen.hidden = false; + updateMyGameButton(); +} + +function showMyGame() { + if (!game || game.mode !== 'pair') return; + hostOnGame = true; + el.hostScreen.hidden = true; + el.gameScreen.hidden = false; + resizeBoard(); + render(); + if (game.phase === 'over') { + el.gameOver.hidden = false; + el.gameOver.querySelector('.dialog').scrollTop = 0; + } +} + +function updateMyGameButton() { + const btn = el.hostMyGameBtn; + const mine = classSession && classSession.host && game && game.mode === 'pair'; + btn.hidden = !mine || !hostView || hostView.state === 'lobby'; + if (btn.hidden) return; + const opp = opponent(); + const yourTurn = game.phase !== 'over' && game.current === game.classroom.me; + btn.textContent = game.phase === 'over' ? `Your game with ${opp.name} is over: see the result` : yourTurn ? `🎲 Your turn! Play your game with ${opp.name}` : `Your game with ${opp.name}`; + btn.classList.toggle('your-turn', yourTurn); +} + +function renderHostRoster(view) { + if (view.type === 'tournament' && view.tournament) return renderTournamentRoster(view); + const n = view.players.length; + el.hostRosterTitle.textContent = view.state === 'lobby' ? `${n} joined` : view.state === 'playing' ? 'Leaderboard' : 'Final scores'; + el.hostRoster.innerHTML = ''; + el.hostRoster.classList.toggle('ranked', view.state !== 'lobby'); + if (!n) el.hostRoster.append(make('li', 'host-empty', 'Nobody yet. Names show up here as students join.')); + for (const p of view.players) { + const li = make('li', p.connected ? '' : 'offline'); + if (view.state !== 'lobby') li.append(make('span', 'host-rank', ordinal(p.rank))); + li.append(make('span', 'host-name', p.name)); + if (view.state === 'lobby') { + const remove = make('button', 'host-remove', '×'); + remove.type = 'button'; + remove.title = `Remove ${p.name}`; + remove.setAttribute('aria-label', `Remove ${p.name}`); + remove.addEventListener('click', () => confirm(`Remove ${p.name} from the class?`) && hostAction('remove', { id: p.id })); + li.append(remove); + } else { + li.append(make('span', 'host-score', `${p.score}`)); + const match = view.type === 'pairs' && view.matches.find((m) => m.players.some((x) => x.id === p.id)); + const pairStatus = match ? (match.over ? '🏁' : match.players[match.current].id === p.id ? '🎲' : '') : ''; + const status = !p.connected ? '📴' : view.state !== 'playing' ? '' : view.type === 'pairs' ? pairStatus : p.done ? '✓' : '…'; + li.append(make('span', 'host-status', status)); + li.title = !p.connected ? 'Not connected' : view.type === 'pairs' ? '' : p.done ? 'Done with this roll' : 'Still working'; + } + el.hostRoster.append(li); + } + // Joined after the game started: they play the next one + for (const p of view.waiting || []) { + const li = make('li', 'waiting' + (p.connected ? '' : ' offline')); + li.append(make('span', 'host-name', p.name), make('span', 'host-waiting', '⏳ next game')); + li.title = 'Joined after this game started'; + el.hostRoster.append(li); + } +} + +function renderHostResults(view) { + if (view.type === 'pairs') renderHostMatches(view, el.hostMatchResults); + if (view.type === 'tournament' && view.tournament) { + renderTournament(view.tournament, el.hostTournamentFinal); + renderTournamentPodium(view.tournament); + renderHostReport(view); + return; + } + el.hostPodium.innerHTML = ''; + const medals = ['🥇', '🥈', '🥉']; + for (const p of view.players.filter((x) => x.rank <= 3).slice(0, 5)) { + const li = make('li', `podium-${p.rank}`); + li.append(make('span', 'podium-medal', medals[p.rank - 1]), make('span', 'podium-name', p.name), make('span', 'podium-score', `${p.score} pts`)); + el.hostPodium.append(li); + } + renderHostReport(view); +} + +function renderHostReport(view) { + const r = view.report; + el.hostReport.innerHTML = ''; + const tile = (value, label) => { + const t = make('div', 'tile'); + t.append(make('span', 'tile-value', value), make('span', 'tile-label', label)); + return t; + }; + el.hostReport.append( + tile(r.asked ? `${Math.round((r.firstTry / r.asked) * 100)}%` : '—', 'right first time'), + tile(r.avgMs ? `${(r.avgMs / 1000).toFixed(1)} s` : '—', 'average answer'), + tile(String(r.asked), 'facts answered') + ); + el.hostHardest.innerHTML = ''; + if (!r.hardest.length) el.hostHardest.append(make('li', 'host-empty', 'Nothing missed. What a class! 🌟')); + for (const f of r.hardest) { + const li = make('li'); + li.append(make('span', 'host-fact', f.fact), make('span', 'host-missed', `missed ${f.missed} of ${f.asked}`)); + el.hostHardest.append(li); + } +} + +async function closeClass() { + if (!confirm('Close the class? Everyone goes back to their menu.')) return; + const h = host; + if (hostStop) hostStop(); + await Classroom.teacher(h, 'close').catch(() => {}); + hostGone('closed'); +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.classJoinOpen.addEventListener('click', () => openClassJoin()); + + el.classJoinCancel.addEventListener('click', () => (el.classJoin.hidden = true)); + + el.classCodeInput.addEventListener('input', () => { + el.classCodeInput.value = el.classCodeInput.value.toUpperCase().replace(/[^A-Z]/g, ''); + }); + + el.classJoinForm.addEventListener('submit', async (e) => { + e.preventDefault(); + const code = el.classCodeInput.value.trim().toUpperCase(); + const name = el.classNameInput.value.trim(); + const fail = (msg, input) => { + el.classJoinError.textContent = msg; + el.classJoinError.hidden = false; + if (input) input.focus(); + }; + if (code.length !== 4) return fail('The class code has 4 letters.', el.classCodeInput); + if (!name) return fail('Type your first name.', el.classNameInput); + el.classJoinBtn.disabled = true; + try { + const joined = await Classroom.join(code, name); + try { + localStorage.setItem(CLASS_NAME_KEY, joined.name); + } catch (e) {} + connectClass({ code, id: joined.id, key: joined.key, name: joined.name }); + showClassWaiting(null); + } catch (err) { + fail(err.message, err.code === 'room' ? el.classCodeInput : el.classNameInput); + } finally { + el.classJoinBtn.disabled = false; + } + }); + + el.classLeaveBtn.addEventListener('click', () => { + leaveClass(); + el.classJoin.hidden = true; + }); + + for (const group of [el.hostType, el.hostSize, el.hostDifficulty, el.hostRounds]) { + group.addEventListener('click', (e) => { + const b = e.target.closest('button'); + if (!b) return; + setSeg(group, b.dataset.value); + if (group === el.hostType) renderHostType(); + }); + } + + el.classHostOpen.addEventListener('click', openHostSetup); + + el.hostCancel.addEventListener('click', () => (el.hostSetup.hidden = true)); + + el.hostCreate.addEventListener('click', async () => { + const chosen = { size: Number(segValue(el.hostSize)), difficulty: segValue(el.hostDifficulty), rounds: Number(segValue(el.hostRounds)) }; + el.hostCreate.disabled = true; + try { + const room = await Classroom.createRoom({ ...chosen, type: segValue(el.hostType) }); + connectHost({ code: room.code, teacherKey: room.teacherKey, lan: room.lan }); + el.hostSetup.hidden = true; + } catch (err) { + el.hostError.textContent = err.message; + el.hostError.hidden = false; + } finally { + el.hostCreate.disabled = false; + } + }); + + el.hostCopyLink.addEventListener('click', () => hostView && copyWithFeedback(el.hostCopyLink, joinUrl(hostView.code).full)); + + el.hostCopyMsg.addEventListener('click', () => hostView && copyWithFeedback(el.hostCopyMsg, joinMessage(hostView.code))); + + el.hostShareBtn.hidden = !navigator.share; + + el.hostShareBtn.addEventListener('click', () => { + if (!hostView) return; + navigator.share({ title: 'Blockout class', text: joinMessage(hostView.code), url: joinUrl(hostView.code).full }).catch(() => {}); + }); + + el.hostCopyTeacher.addEventListener('click', () => hostView && copyWithFeedback(el.hostCopyTeacher, `${joinUrl(hostView.code).base}#class=${hostView.code}&play=${hostView.playKey}`)); + + // ---- teacher: game settings in the lobby, for the whole class and free of shop locks + + // [value, label, detail]; `only` limits a setting to one game type + HOST_SETTINGS = [ + { key: 'size', label: 'Board size', options: [6, 8, 10, 12, 16, 20, 24].map((n) => [n, `${n}×${n}`]) }, + { key: 'difficulty', label: 'Difficulty', options: [['easy', 'Easy', '6-sided dice'], ['medium', 'Medium', '8-sided dice'], ['hard', 'Hard', '12-sided dice']] }, + { + key: 'format', + label: 'Tournament format', + only: ['tournament'], + wide: true, + options: [ + ['knockout', 'Knockout', 'lose once and you’re out'], + ['double', 'Double knockout', 'out after 2 losses'], + ['roundrobin', 'Round robin', 'everyone plays everyone'], + ['swiss', 'Swiss', 'set rounds, no knockouts'], + ['koth', 'King of the hill', 'winner stays on'], + ], + }, + { key: 'swissRounds', label: 'Swiss rounds', only: ['tournament'], when: (s) => s.format === 'swiss', options: [2, 3, 4, 5].map((n) => [n, String(n)]) }, + { key: 'kothMatches', label: 'Games per hill', only: ['tournament'], when: (s) => s.format === 'koth', options: [6, 10, 15].map((n) => [n, String(n)]) }, + { key: 'matchTurns', label: 'Game length', only: ['pairs', 'tournament'], options: [[0, 'Full board'], [12, '6 turns each'], [8, '4 turns each']] }, + { key: 'rounds', label: 'Rolls in a game', only: ['class'], options: [[10, '10'], [15, '15'], [20, '20']] }, + { key: 'placeMode', label: 'Placing rectangles', options: [['choice', 'Their choice'], ['draw', 'Draw'], ['click', 'Click'], ['auto', 'Auto', 'fastest']] }, + { key: 'answerTime', label: 'Time to answer', options: [[0, 'No timer'], [30, '30 s'], [20, '20 s'], [10, '10 s']] }, + { key: 'autoRoll', label: 'Rolling', only: ['pairs', 'tournament'], options: [[false, 'Tap to roll'], [true, 'Auto roll']] }, + { key: 'fitRolls', label: 'Only rolls that fit', only: ['pairs', 'tournament'], options: [['end', 'Near the end'], ['always', 'Always'], ['never', 'Never']] }, + { key: 'cpuSpeed', label: 'CPU speed', only: ['pairs'], options: [['slow', 'Slow'], ['normal', 'Normal'], ['fast', 'Fast']] }, + ]; + + el.hostSettingsGrid.addEventListener('click', (e) => { + const b = e.target.closest('.segmented button'); + if (!b) return; + const key = b.closest('.segmented').dataset.key; + hostAction('settings', { settings: { [key]: JSON.parse(b.dataset.value) } }); + }); + + el.hostQuick.addEventListener('click', () => hostAction('settings', { settings: QUICK_SETTINGS })); + + for (const [group, key] of [ + [el.hostMatching, 'matching'], + [el.hostOdd, 'odd'], + ]) { + group.addEventListener('click', (e) => { + const b = e.target.closest('button'); + if (b) hostAction('pairOptions', { options: { [key]: b.dataset.value } }); + }); + } + + el.hostMyGameBtn.addEventListener('click', showMyGame); + + el.hostStartBtn.addEventListener('click', async () => { + const v = hostView; + if (v && v.type === 'pairs' && v.odd.needed && v.odd.how === 'screen' && !(classSession && classSession.host)) { + // The odd one out plays the teacher, here: join as a player first + try { + const me = await Classroom.joinAsTeacher(host); + connectClass({ code: host.code, id: me.id, key: me.key, name: me.name, teacher: true, host: true }); + } catch (err) { + return toast('⚠️ ' + err.message); + } + } + hostAction('start'); + }); + + el.hostNextBtn.addEventListener('click', () => hostAction('next')); + + el.hostEndBtn.addEventListener('click', () => confirm('End the game now? Scores so far are final.') && hostAction('end')); + + el.hostAuto.addEventListener('change', () => hostAction('autoNext', { on: el.hostAuto.checked })); + + el.hostAgainBtn.addEventListener('click', () => hostAction('lobby')); + + el.hostCloseBtn.addEventListener('click', closeClass); + + el.hostClose2Btn.addEventListener('click', closeClass); + + // Space rolls again on the teacher's screen + document.addEventListener('keydown', (e) => { + if (el.hostScreen.hidden || !hostView || e.target.closest('input, button, .overlay')) return; + if ((e.key === ' ' || e.key === 'Enter') && hostView.state === 'playing' && hostView.type === 'class') { + e.preventDefault(); + hostAction('next'); + } + }); + + // ---- after a reload: pick up where this browser left off + + if (Classroom.served()) { + const savedHost = Classroom.host(); + const savedSession = Classroom.session(); + const link = Classroom.linkFromHash(); + if (link) { + history.replaceState(null, '', location.pathname + location.search); + tap('[data-mode="multi"]'); + tap('#multi-kind [data-kind="classroom"]'); + if (savedSession && savedSession.code === link.code) connectClass(savedSession); + else if (link.play) { + // the teacher's other device, for playing an odd one out + Classroom.joinWithPlayKey(link.code, link.play) + .then((me) => { + connectClass({ code: link.code, id: me.id, key: me.key, name: me.name, teacher: true }); + showClassWaiting(null); + }) + .catch((err) => toast('⚠️ ' + err.message)); + } else openClassJoin(link.code, true); + } else { + if (savedHost) connectHost(savedHost); + if (savedSession && (!savedHost || savedSession.host)) connectClass(savedSession); + } + } + + // Board wins started being counted when winning a board revealed the next one: + // give credit for wins already in the game history (once). + if (!progress.boardWinsFromHistory) { + for (const g of loadHistory()) { + if (!g.kind && g.vsComputer && g.players.some((p) => !p.cpu && p.result === 'win')) Progress.recordBoardWin(progress, g.size); + } + progress.boardWinsFromHistory = true; + saveProgress(); + syncBoardLocks(); + } +} + +export { TOURNAMENT_NAMES, checkClassroom, classSession, connectHost, leaveClass, openClassJoin, openHostSetup, ordinal, pairRoll, pairTurnOrWait, reportClass, showHostView }; diff --git a/apps/multiplication/src/game/index.js b/apps/multiplication/src/game/index.js new file mode 100644 index 0000000..6df59a8 --- /dev/null +++ b/apps/multiplication/src/game/index.js @@ -0,0 +1,31 @@ +// The multiplication game: every part's load-time work, in the original order. +import { run as base } from './base.js'; +import { run as wallet } from './wallet.js'; +import { run as settings } from './settings.js'; +import { run as wardrobe } from './wardrobe.js'; +import './play.js'; +import { run as stats } from './stats.js'; +import { run as shop } from './shop.js'; +import { run as achievements } from './achievements.js'; +import { run as practice } from './practice.js'; +import { run as invites } from './invites.js'; +import { run as classroom } from './classroom.js'; +import { run as cheats } from './cheats.js'; +import { run as checkin } from './checkin.js'; +import { run as input } from './input.js'; +import { run as render } from './render.js'; + +base(); +wallet(); +settings(); +wardrobe(); +stats(); +shop(); +achievements(); +practice(); +invites(); +classroom(); +cheats(); +checkin(); +input(); +render(); diff --git a/apps/multiplication/src/game/input.js b/apps/multiplication/src/game/input.js new file mode 100644 index 0000000..95da704 --- /dev/null +++ b/apps/multiplication/src/game/input.js @@ -0,0 +1,270 @@ +// Input: part of the multiplication game (split from the old js/game.js). + +import * as Core from '@blockout/engine'; +import { el } from './base.js'; +import { icon } from './wardrobe.js'; +import { cancelContinue, clearLastDie, continueResolve, currentDims, currentPlayer, game, gameId, helpCount, humanRoll, pickDie, placeInstructions, placeRect, pressContinue, setGame, setGameId, startAnswer, startGame, stopTimer, typeDie, typeKey, useRealDice } from './play.js'; +import { classSession, leaveClass, showHostView } from './classroom.js'; +import { render, renderDie, setMsg } from './render.js'; + +function setDetailsOpen(open) { + el.stats.hidden = !open; + el.detailsBtn.replaceChildren(icon('chart-column'), open ? ' Hide details' : ' Show details'); + el.detailsBtn.setAttribute('aria-expanded', String(open)); +} + +// One button per face; 8- and 12-sided dice get numbered faces. +function buildDicePicks(sides) { + el.diePicks.forEach((row, i) => { + row.innerHTML = ''; + for (let v = 1; v <= sides; v++) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'die mini'; + btn.dataset.value = v; + row.append(btn); + renderDie(btn, v, sides); + btn.setAttribute('aria-label', `Die ${i + 1}: ${v}`); + } + }); +} + +function rotate() { + if (!game || game.phase !== 'place' || game.placeMode !== 'click' || Core.mustUseCorner(game.board)) return; + game.rotated = !game.rotated; + render(); +} + +// Cell under the pointer; null when outside the board unless clamp is set. +function cellFromEvent(e, clamp = false) { + const rect = el.canvas.getBoundingClientRect(); + const cell = rect.width / game.size; + let c = Math.floor((e.clientX - rect.left) / cell); + let r = Math.floor((e.clientY - rect.top) / cell); + if (clamp) { + c = Math.max(0, Math.min(c, game.size - 1)); + r = Math.max(0, Math.min(r, game.size - 1)); + } else if (c < 0 || r < 0 || c >= game.size || r >= game.size) { + return null; + } + return { c, r }; +} + +// Top-left corner of the rectangle being previewed, centred on the pointer +// and clamped inside the board. +function previewOrigin(hover, w, h) { + if (Core.mustUseCorner(game.board)) return { x: 0, y: 0 }; + const clamp = (v, max) => Math.max(0, Math.min(v, max)); + return { + x: clamp(hover.c - Math.floor((w - 1) / 2), game.size - w), + y: clamp(hover.r - Math.floor((h - 1) / 2), game.size - h), + }; +} + +function humanPreview() { + if (!game || game.phase !== 'place' || game.placeMode !== 'click' || !game.hover) return null; + const [w, h] = currentDims(); + const { x, y } = previewOrigin(game.hover, w, h); + return { x, y, w, h, valid: Core.isValidPlacement(game.board, x, y, w, h) }; +} + +// ---- draw mode: drag out the rectangle corner to corner + +function dragRect() { + const { c0, r0, c1, r1 } = game.drag; + return { x: Math.min(c0, c1), y: Math.min(r0, r1), w: Math.abs(c1 - c0) + 1, h: Math.abs(r1 - r0) + 1 }; +} + +// How the rectangle being drawn compares to the roll. +function judgeDrawn({ x, y, w, h }) { + const [a, b] = game.dice; + const rightSize = (w === a && h === b) || (w === b && h === a); + const fits = Core.fits(game.board, x, y, w, h); + const corner = !Core.mustUseCorner(game.board) || (x === 0 && y === 0); + return { rightSize, fits, corner, ok: rightSize && fits && corner }; +} + +function finishDrag() { + const r = dragRect(); + game.drag = null; + const [a, b] = game.dice; + const verdict = judgeDrawn(r); + if (verdict.ok) { + startAnswer(placeRect(r.x, r.y, r.w, r.h)); + return; + } + let msg; + if (!verdict.rightSize) { + msg = `That's ${r.w} by ${r.h}, but you rolled ${a} and ${b}. Try again!`; + } else if (!verdict.fits) { + msg = 'Right size, but it bumps into another rectangle. Try a different spot!'; + } else { + msg = 'Right size! But the first rectangle has to start in the top-left corner.'; + } + setMsg(msg); + el.turnMsg.classList.add('oops'); + render(); +} + +function toMenu() { + cancelContinue(); + stopTimer(); + setGameId(gameId + 1); + setGame(null); + el.gameOver.hidden = true; + el.gameScreen.hidden = true; + el.startScreen.hidden = false; +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.rollBtn.addEventListener('click', humanRoll); + + el.useDiceBtn.addEventListener('click', useRealDice); + + el.continueBtn.addEventListener('click', pressContinue); + + el.detailsBtn.addEventListener('click', () => setDetailsOpen(el.stats.hidden)); + + el.diePicks.forEach((row, i) => { + row.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (btn) pickDie(i, Number(btn.dataset.value)); + }); + }); + + el.rotateBtn.addEventListener('click', rotate); + + el.helpBtn.addEventListener('click', helpCount); + + el.keypad.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (btn) typeKey(btn.dataset.key); + }); + + document.addEventListener('keydown', (e) => { + if (!game || el.gameScreen.hidden || !el.gameOver.hidden) return; + if (!el.settingsDialog.hidden || !el.shopDialog.hidden) return; // a dialog is on top of the game + if (e.target.closest('.overlay, input')) return; // e.g. Esc that just closed a dialog + if (continueResolve && (e.key === 'Enter' || e.key === ' ')) { + e.preventDefault(); + pressContinue(); + } else if (game.phase === 'answer') { + if (/^[0-9]$/.test(e.key)) typeKey(e.key); + else if (e.key === 'Backspace') typeKey('back'); + else if (e.key === 'Escape') typeKey('clear'); + else if (e.key === 'Enter') typeKey('check'); + else return; + e.preventDefault(); + } else if (game.phase === 'roll' && game.diceMode === 'real' && !currentPlayer().cpu) { + if (/^[1-9]$/.test(e.key) && Number(e.key) <= game.sides) typeDie(Number(e.key)); + else if (e.key === 'Backspace') clearLastDie(); + else if (e.key === 'Enter') useRealDice(); + else return; + e.preventDefault(); + } else if ((e.key === ' ' || e.key === 'Enter') && game.phase === 'roll') { + e.preventDefault(); + humanRoll(); + } else if (e.key === 'r' || e.key === 'R') { + rotate(); + } + }); + + el.canvas.addEventListener('pointerdown', (e) => { + if (!game || game.phase !== 'place' || game.placeMode !== 'draw') return; + const cell = cellFromEvent(e); + if (!cell) return; + e.preventDefault(); + el.canvas.setPointerCapture(e.pointerId); + game.drag = { c0: cell.c, r0: cell.r, c1: cell.c, r1: cell.r }; + setMsg(placeInstructions()); + render(); + }); + + el.canvas.addEventListener('pointerup', (e) => { + if (!game || !game.drag || game.phase !== 'place') return; + const cell = cellFromEvent(e, true); + game.drag.c1 = cell.c; + game.drag.r1 = cell.r; + finishDrag(); + }); + + el.canvas.addEventListener('pointercancel', () => { + if (!game || !game.drag) return; + game.drag = null; + render(); + }); + + el.canvas.addEventListener('pointermove', (e) => { + if (!game || game.phase !== 'place') return; + if (game.drag) { + const cell = cellFromEvent(e, true); + if (cell.c === game.drag.c1 && cell.r === game.drag.r1) return; + game.drag.c1 = cell.c; + game.drag.r1 = cell.r; + render(); + return; + } + if (e.pointerType === 'touch') return; + game.hover = cellFromEvent(e); + render(); + }); + + el.canvas.addEventListener('pointerleave', (e) => { + if (!game || e.pointerType === 'touch') return; + game.hover = null; + render(); + }); + + // ---- click mode + // Mouse: click places. Touch: first tap previews, tapping inside the preview places. + el.canvas.addEventListener('pointerdown', (e) => { + if (!game || game.phase !== 'place' || game.placeMode !== 'click') return; + const cell = cellFromEvent(e); + if (!cell) return; + if (e.pointerType === 'touch') { + const prev = humanPreview(); + const inside = + prev && cell.c >= prev.x && cell.c < prev.x + prev.w && cell.r >= prev.y && cell.r < prev.y + prev.h; + if (!inside || !prev.valid) { + game.hover = cell; + render(); + return; + } + } else { + game.hover = cell; + } + const p = humanPreview(); + if (p && p.valid) startAnswer(placeRect(p.x, p.y, p.w, p.h)); + else render(); + }); + + el.menuBtn.addEventListener('click', () => { + if (classSession && classSession.host) return showHostView(); // teacher: back to the class + const inClass = game && (game.mode === 'class' || game.mode === 'pair'); + const question = inClass ? 'Leave the class game? Your score so far won’t count.' : 'Leave this game and go back to the menu?'; + if (game && game.phase !== 'over' && !confirm(question)) return; + if (inClass) leaveClass(); + toMenu(); + }); + + el.toMenuBtn.addEventListener('click', () => { + if (classSession && classSession.host) { + el.gameOver.hidden = true; + return showHostView(); + } + if (game && (game.mode === 'class' || game.mode === 'pair')) leaveClass(); + toMenu(); + }); + + el.againBtn.addEventListener('click', () => { + startGame( + game.players.map(({ name, cpu }) => ({ name, cpu })), + game.size, + game.mode, + game.invite + ); + }); +} + +export { buildDicePicks, dragRect, humanPreview, judgeDrawn, setDetailsOpen, toMenu }; diff --git a/apps/multiplication/src/game/invites.js b/apps/multiplication/src/game/invites.js new file mode 100644 index 0000000..2b44a3a --- /dev/null +++ b/apps/multiplication/src/game/invites.js @@ -0,0 +1,274 @@ +// Invites: part of the multiplication game (split from the old js/game.js). + +import qrcode from 'qrcode-generator'; +import * as Invite from '../invite.js'; +import { $, DEFAULT_NAMES, DEFAULT_SETTINGS, el, setup } from './base.js'; +import { toast } from './wallet.js'; +import { clampCustom, saveNameInputs } from './wardrobe.js'; +import { make, startGame } from './play.js'; + +// Settings an invite can set from the "Make an invite" form (select id = invite-). +const INVITE_FORM_SETTINGS = ['difficulty', 'placeMode', 'answerTime', 'diceMode', 'autoRoll', 'fitRolls', 'cpuSteps']; + +const INVITE_LABELS = { + difficulty: { + easy: 'Easy (6-sided dice)', + medium: 'Medium (8-sided dice)', + hard: 'Hard (12-sided dice)', + tricky: 'Tricky (no easy facts)', + master: 'Master (your weakest facts)', + legend: 'Legend (teens × 2–9)', + }, + placeMode: { draw: 'You draw each rectangle', click: 'Click to place rectangles', auto: 'Rectangles are placed for you' }, + diceMode: { virtual: null, real: 'Roll real dice and tap what you got' }, + cpuSteps: { show: null, instant: 'CPU plays instantly' }, + autoRoll: { manual: null, auto: 'Dice roll by themselves' }, + fitRolls: { end: null, always: 'Every roll fits on the board', never: 'Original rules: a roll may not fit, and you pass' }, +}; + +// Players from the main-menu setup, in invite form. +function invitePlayersFromSetup() { + if (setup.mode === 'single') return [{ n: el.singleName.value.trim(), c: false }]; + saveNameInputs(); + return setup.names.slice(0, setup.count).map((name, i) => ({ n: setup.types[i] === 'cpu' ? '' : name.trim(), c: setup.types[i] === 'cpu' })); +} + +function invitePlayerNames(inv) { + let cpu = 0; + return inv.p.map((p, i) => (p.c ? `CPU ${++cpu}` : p.n || DEFAULT_NAMES[i])); +} + +function describeInvite(inv) { + const s = { ...DEFAULT_SETTINGS, ...inv.s }; + const lines = [ + inv.m === 'single' ? 'You vs the CPU' : `Players: ${invitePlayerNames(inv).join(', ')}`, + `Board: ${inv.b}×${inv.b}`, + INVITE_LABELS.difficulty[s.difficulty], + INVITE_LABELS.placeMode[s.placeMode], + ]; + if (s.answerTime !== '0') lines.push(`${s.answerTime} seconds to answer each question`); + for (const key of ['diceMode', 'autoRoll', 'fitRolls', 'cpuSteps']) if (INVITE_LABELS[key][s[key]]) lines.push(INVITE_LABELS[key][s[key]]); + if (inv.r !== undefined) lines.push('Same dice rolls as everyone else with this invite'); + return lines; +} + +function openInviteMaker() { + const names = invitePlayersFromSetup(); + el.invitePlayers.textContent = + setup.mode === 'single' + ? `Single player: ${names[0].n || 'You'} vs the CPU` + : `Multiplayer: ${invitePlayerNames({ p: names }).join(', ')}`; + el.inviteCodeError.hidden = true; + el.inviteResult.hidden = true; + el.inviteMake.hidden = false; + el.inviteMake.querySelector('.dialog').scrollTop = 0; + el.inviteCodeInput.focus(); +} + +function closeInviteMaker() { + el.inviteMake.hidden = true; +} + +function buildInvite() { + const board = el.inviteBoard.value === 'custom' ? clampCustom(el.inviteCustom.value) : Number(el.inviteBoard.value); + const s = {}; + for (const key of INVITE_FORM_SETTINGS) { + const value = $(`invite-${key}`).value; + if (value !== String(DEFAULT_SETTINGS[key])) s[key] = value; // only what differs, to keep links short + } + const inv = { v: Invite.VERSION, m: setup.mode, b: board, p: invitePlayersFromSetup(), s }; + if (el.inviteNote.value.trim()) inv.t = el.inviteNote.value.trim(); + if (el.inviteSeed.checked) inv.r = Invite.newSeed(); + return Invite.validate(inv); +} + +let madeInvite = null; + +// Black-on-white QR code, 8px per module with the standard 4-module quiet zone. +function drawQr(canvas, text) { + const qr = qrcode(0, 'M'); + qr.addData(text); + qr.make(); + const modules = qr.getModuleCount(); + const cell = 8; + const quiet = 4; + const size = (modules + quiet * 2) * cell; + canvas.width = canvas.height = size; + const g = canvas.getContext('2d'); + g.fillStyle = '#ffffff'; + g.fillRect(0, 0, size, size); + g.fillStyle = '#000000'; + for (let r = 0; r < modules; r++) { + for (let c = 0; c < modules; c++) { + if (qr.isDark(r, c)) g.fillRect((c + quiet) * cell, (r + quiet) * cell, cell, cell); + } + } +} + +// ---- a child opens an invite + +let openedInvite = null; + +function showInvite(inv) { + openedInvite = inv; + el.inviteOpenNote.hidden = !inv.t; + el.inviteOpenNote.textContent = inv.t ? `“${inv.t}”` : ''; + el.inviteOpenSummary.innerHTML = ''; + for (const line of describeInvite(inv)) el.inviteOpenSummary.append(make('li', null, line)); + el.inviteOpenNameField.hidden = inv.m !== 'single'; + el.inviteOpenName.value = inv.m === 'single' ? inv.p[0].n || el.singleName.value.trim() || '' : ''; + for (const d of document.querySelectorAll('.overlay')) if (d !== el.inviteOpen) d.hidden = true; + el.inviteOpen.hidden = false; + el.inviteStart.focus(); +} + +function clearInviteFromUrl() { + if (location.hash.includes('invite=')) history.replaceState(null, '', location.pathname + location.search); +} + +// Links look like index.html#invite=CODE +function checkUrlForInvite() { + const token = Invite.tokenFrom(location.hash); + if (!token) return; + const inv = Invite.decode(token); + if (inv) showInvite(inv); + else { + clearInviteFromUrl(); + toast('⚠️ That invite link is broken', 'Ask for a new one, or paste the code in 🔗 Invite.'); + } +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.inviteCreate.addEventListener('click', () => { + if (setup.mode === 'multi' && invitePlayersFromSetup().some((p) => !p.c && !p.n)) { + toast('✏️ Players need names', 'Type every player’s name on the main menu first.'); + return; + } + madeInvite = buildInvite(); + if (!madeInvite) return; + const token = Invite.encode(madeInvite); + el.inviteCode.value = token; + el.inviteLink.value = `${location.href.split('#')[0]}#invite=${token}`; + const local = location.protocol === 'file:'; + el.inviteLocalWarning.hidden = !local; + el.inviteQrWrap.hidden = local; // a file:// link can't be opened from another device + if (!local) drawQr(el.inviteQr, el.inviteLink.value); + el.inviteResult.hidden = false; + el.inviteResult.scrollIntoView({ block: 'nearest', behavior: 'smooth' }); + }); + + el.inviteQrSave.addEventListener('click', () => { + const a = document.createElement('a'); + a.href = el.inviteQr.toDataURL('image/png'); + a.download = 'blockout-invite.png'; + a.click(); + }); + + // A printable hand-out: QR code, the note and what the game will be. + el.inviteQrPrint.addEventListener('click', () => { + if (!madeInvite) return; + const win = window.open('', '_blank'); + if (!win) return toast('⚠️ Could not open the print page', 'Allow pop-ups for this page, or use Save QR image.'); + const doc = win.document; + doc.title = 'Blockout invite'; + const style = doc.createElement('style'); + style.textContent = + 'body{font-family:system-ui,sans-serif;text-align:center;padding:32px;color:#2b2a33}' + + 'img{width:320px;height:320px;image-rendering:pixelated}' + + '.note{font-size:1.4rem;font-weight:800;margin:16px auto;max-width:480px}' + + 'ul{display:inline-block;text-align:left;line-height:1.6}'; + doc.head.append(style); + const add = (tag, text, cls) => { + const node = doc.createElement(tag); + if (text) node.textContent = text; + if (cls) node.className = cls; + doc.body.append(node); + return node; + }; + add('h1', 'Blockout'); + if (madeInvite.t) add('p', `“${madeInvite.t}”`, 'note'); + const img = add('img'); + img.src = el.inviteQr.toDataURL('image/png'); + img.alt = 'QR code for the Blockout invite'; + add('p', 'Scan with a phone or tablet camera to start the game.'); + const list = add('ul'); + for (const line of describeInvite(madeInvite)) { + const li = doc.createElement('li'); + li.textContent = line; + list.append(li); + } + img.onload = () => win.print(); + }); + + el.inviteBoard.addEventListener('change', () => { + el.inviteCustomField.hidden = el.inviteBoard.value !== 'custom'; + }); + + el.inviteMake.addEventListener('click', async (e) => { + if (e.target === el.inviteMake) return closeInviteMaker(); + const copy = e.target.closest('[data-copy]'); + if (!copy) return; + const field = $(copy.dataset.copy); + try { + await navigator.clipboard.writeText(field.value); + } catch (_) { + field.select(); + document.execCommand('copy'); // older browsers / file:// pages + } + copy.textContent = 'Copied!'; + setTimeout(() => (copy.textContent = 'Copy'), 1500); + }); + + el.inviteCodeOpen.addEventListener('click', () => { + const token = Invite.tokenFrom(el.inviteCodeInput.value); + const inv = token && Invite.decode(token); + el.inviteCodeError.hidden = Boolean(inv); + if (!inv) return; + closeInviteMaker(); + showInvite(inv); + }); + + el.inviteCodeInput.addEventListener('keydown', (e) => { + if (e.key === 'Enter') el.inviteCodeOpen.click(); + }); + + el.inviteTry.addEventListener('click', () => { + if (!madeInvite) return; + closeInviteMaker(); + showInvite(madeInvite); + }); + + el.inviteBtn.addEventListener('click', openInviteMaker); + + el.inviteDone.addEventListener('click', closeInviteMaker); + + el.inviteMake.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeInviteMaker(); + }); + + el.inviteStart.addEventListener('click', () => { + const inv = openedInvite; + if (!inv) return; + el.inviteOpen.hidden = true; + clearInviteFromUrl(); + const names = invitePlayerNames(inv); + const players = + inv.m === 'single' + ? [ + { name: el.inviteOpenName.value.trim() || 'You', cpu: false }, + { name: 'CPU', cpu: true }, + ] + : inv.p.map((p, i) => ({ name: names[i], cpu: p.c })); + startGame(players, inv.b, inv.m, inv); + }); + + el.inviteCancel.addEventListener('click', () => { + el.inviteOpen.hidden = true; + clearInviteFromUrl(); + }); + + window.addEventListener('hashchange', checkUrlForInvite); +} + +export { checkUrlForInvite, drawQr }; diff --git a/apps/multiplication/src/game/play.js b/apps/multiplication/src/game/play.js new file mode 100644 index 0000000..b05ebd5 --- /dev/null +++ b/apps/multiplication/src/game/play.js @@ -0,0 +1,1114 @@ +// Game state: part of the multiplication game (split from the old js/game.js). + +import * as Core from '@blockout/engine'; +import * as Cosmetics from '@blockout/progress/cosmetics'; +import * as Progress from '@blockout/progress'; +import { AUTO_PLACE_MS, AUTO_ROLL_MS, COUNT_STEP, PASS_DELAY, TRY_AGAIN, el, seededRandom, setup } from './base.js'; +import { BONUS_TOASTS, capitalize, celebrate, celebrateWin, grantStickers, progress, queueAchievements, saveProgress, toast } from './wallet.js'; +import { cpuStep, settings } from './settings.js'; +import { GAME_EXPERT_BONUS, applySettingsForTurn, icon, look, syncBoardLocks } from './wardrobe.js'; +import { recordGame } from './stats.js'; +import { refreshNudges } from './shop.js'; +import { playerFacts, setPractice, stopPracticeTimer, timerText, weakFactWeight } from './practice.js'; +import { pairRoll, pairTurnOrWait, reportClass } from './classroom.js'; +import { buildDicePicks, setDetailsOpen } from './input.js'; +import { PLACE_FX_MS, animateBoard, render, renderDie, resizeBoard, setMsg } from './render.js'; + +let game = null; + +let gameId = 0; // bumped on every new game so pending timers from an old game do nothing + +// Wardrobe on players: the CPU character's name and avatar; in single player, +// your avatar and title. "CPU 2" in multiplayer becomes e.g. "Owl 2". +function wardrobeLook(p, mode) { + if (p.cpu) { + const ch = look('cpu'); + const name = p.name === 'CPU' ? ch.cpuName : p.name.replace(/^CPU\b/, ch.short); + return { name, avatar: ch.avatar, hello: ch.hello }; + } + if (mode === 'multi') return {}; + if (mode === 'pair' && !p.statsName) return {}; // your partner isn't wearing your Wardrobe + const avatar = look('avatar'); + const t = Cosmetics.title(settings.cosmetics.title); + return { avatar: avatar.id === 'none' ? '' : avatar.preview, title: t.id === 'none' ? '' : t.name }; +} + +// invite: a decoded invite, whose settings and dice seed apply to this game only. +// extra: { cfg, classroom } for a class game: fixed settings, dice from the teacher. +function startGame(playerDefs, size, mode, invite = null, extra = null) { + cancelContinue(); + stopTimer(); + stopPracticeTimer(); + setPractice(null); + el.practiceScreen.hidden = true; + el.practiceDone.hidden = true; + gameId++; + const cfg = { ...settings, ...(invite ? invite.s : {}), ...(extra ? extra.cfg : {}) }; + const rng = invite && invite.r !== undefined ? seededRandom(invite.r) : Math.random; + game = { + invite, + fixed: Boolean(extra), // settings don't change mid-game + classroom: extra ? extra.classroom : null, // { session, game, round, reported, view } + rng, + cpuSpeed: cfg.cpuSpeed, + cpuEnd: cfg.cpuEnd, + showProgress: cfg.showProgress, + id: gameId, + size, + mode, // 'single' (you vs the CPU), 'multi' (one screen) or 'class' (dice from the teacher) + board: Core.createBoard(size, { firstInCorner: cfg.firstInCorner }), + difficulty: cfg.difficulty, + sides: Core.DIFFICULTY_SIDES[cfg.difficulty] || 6, + cpuInstant: cfg.cpuSteps === 'instant', + autoRoll: cfg.autoRoll === 'auto', + fitMode: cfg.fitRolls, + players: playerDefs.map((p, i) => ({ + ...p, + ...wardrobeLook(p, mode), + color: look('colors').colors[i], + score: 0, // squares + bonus + squares: 0, + bonus: 0, + streak: 0, // first-try answers in a row + bestStreak: 0, + log: [], + answered: 0, + firstTry: 0, + stats: { placed: 0, biggest: null, wrong: 0, helped: 0, timeouts: 0, passes: 0, times: [], missed: [] }, + })), + answerTime: Number(cfg.answerTime) || 0, // seconds, 0 = untimed + startedAt: Date.now(), + turns: 0, + current: 0, + passes: 0, + dice: null, + rotated: false, + phase: 'roll', // roll | rolling | place | answer | cpu | over + hover: null, // { c, r } cell under pointer + cpuPreview: null, + lastRect: null, + placeMode: cfg.placeMode, // 'draw': drag out the rectangle; 'click': click to drop it + // 'virtual': the app rolls; 'real': players roll real dice and enter them + // (not after Hard: those dice can't be rolled at the table) + diceMode: Core.usesPairDice(cfg.difficulty) ? 'virtual' : cfg.diceMode, + picked: [null, null], // real-dice values entered so far + drag: null, // { c0, r0, c1, r1 } while a human is drawing + pending: null, // human rectangle waiting for its points to be worked out + counting: null, // { rect, byRows, shown, group } while skip-counting on the board + }; + // Loaded to suit board and difficulty. After Hard, the dice deal chosen + // pairs: Tricky skips the easy facts, Master leans on your weakest facts, + // Legend deals a teen × a one-digit number. + game.faces = Core.diceFaces(cfg.difficulty); + game.diceBag = Core.usesPairDice(cfg.difficulty) + ? Core.createPairDice(size, rng, cfg.difficulty, cfg.difficulty === 'master' ? weakFactWeight(playerDefs.find((p) => !p.cpu)) : null) + : Core.createDice(size, rng, game.sides); + // Single player's Level: Expert has no "Help me count", doubles bonus points, + // and can have an answer timer (its own, not the Settings one) + const levelled = mode === 'single' && !invite && !extra; + game.expert = levelled && setup.level === 'expert'; + game.levelTimer = game.expert ? setup.timer : 0; + if (levelled) game.answerTime = game.levelTimer; + buildDicePicks(game.sides); + el.resultTournament.hidden = true; + const classroomGame = mode === 'class' || mode === 'pair'; + el.againBtn.hidden = classroomGame; // the teacher starts the next class game + el.toMenuBtn.textContent = !classroomGame ? 'Main menu' : extra.classroom.session.host ? 'Back to the class' : 'Leave class'; + el.startScreen.hidden = true; + el.gameOver.hidden = true; + el.gameScreen.hidden = false; + renderDie(el.dieA, null); + renderDie(el.dieB, null); + resizeBoard(); + beginTurn(); +} + +const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + +// Resolves after ms, or never if the game has changed in the meantime. +function waitInGame(ms) { + const id = gameId; + return wait(ms).then(() => (id === gameId ? true : new Promise(() => {}))); +} + +function currentPlayer() { + return game.players[game.current]; +} + +function currentDims() { + if (!game.dice) return null; + const [a, b] = game.dice; + return game.rotated ? [b, a] : [a, b]; +} + +function resetTurn() { + stopTimer(); + game.dice = null; + game.rotated = false; + game.hover = null; + game.cpuPreview = null; + game.drag = null; + game.pending = null; + game.counting = null; + el.math.hidden = true; + el.steps.hidden = true; + el.steps.innerHTML = ''; + renderDie(el.dieA, null); + renderDie(el.dieB, null); +} + +function beginTurn() { + applySettingsForTurn(); + const p = currentPlayer(); + game.turns++; + resetTurn(); + if (game.mode === 'class') { + game.phase = 'wait'; + setMsg(game.classroom.round ? 'Waiting for the next roll…' : 'Get ready! Waiting for the first roll…'); + render(); + return; + } + if (game.mode === 'pair') return pairTurnOrWait(); + if (p.cpu) { + game.phase = 'cpu'; + setMsg(`${p.name} is rolling…`); + render(); + runComputerTurn(); + } else { + game.phase = 'roll'; + const first = Core.mustUseCorner(game.board); + const corner = first ? ' Your first rectangle goes in the top-left corner.' : ''; + if (game.diceMode === 'real') { + game.picked = [null, null]; + renderDicePicks(); + setMsg(`Roll your two dice, then tap the numbers you got.${corner}`); + } else if (game.autoRoll) { + setMsg(`Rolling the dice…${corner}`); + } else { + setMsg(first ? `Roll the dice!${corner}` : 'Roll the dice!'); + } + render(); + // Real dice are rolled at the table, so auto roll only applies to app dice. + if (game.autoRoll && game.diceMode !== 'real') waitInGame(AUTO_ROLL_MS).then(humanRoll); + } +} + +// forced: the class game's roll, from the teacher +async function rollDice(forced = null) { + game.phase = 'rolling'; + render(); + const rollStyle = `roll-${settings.cosmetics.roll}`; // Wardrobe: wobble, tumble, bounce, sparkle + el.dieA.classList.add('rolling', rollStyle); + el.dieB.classList.add('rolling', rollStyle); + const face = (list) => list[Math.floor(Math.random() * list.length)]; + for (let i = 0; i < 8; i++) { + renderDie(el.dieA, face(game.faces.a)); + renderDie(el.dieB, face(game.faces.b)); + await waitInGame(55); + } + el.dieA.classList.remove('rolling', rollStyle); + el.dieB.classList.remove('rolling', rollStyle); + game.dice = forced ? [...forced] : Core.rollForBoard(game.board, game.diceBag, game.rng, game.fitMode); + renderDie(el.dieA, game.dice[0]); + renderDie(el.dieB, game.dice[1]); + return game.dice; +} + +async function humanRoll() { + if (!game || game.phase !== 'roll' || currentPlayer().cpu || game.diceMode === 'real') return; + if (game.mode === 'pair') return pairRoll(); // the server rolls for a pairs game + const [a, b] = await rollDice(); + await startPlacing(a, b); +} + +// ---- real dice: the player enters what they rolled + +function pickDie(index, value) { + if (!game || game.phase !== 'roll' || game.diceMode !== 'real') return; + game.picked[index] = value; + renderDicePicks(); +} + +// Keyboard entry: a digit fills the first empty die (or the second if both are set). +function typeDie(value) { + const i = game.picked[0] === null ? 0 : 1; + pickDie(i, value); +} + +function clearLastDie() { + if (game.picked[1] !== null) pickDie(1, null); + else pickDie(0, null); +} + +async function useRealDice() { + if (!game || game.phase !== 'roll' || game.diceMode !== 'real') return; + const [a, b] = game.picked; + if (!a || !b) return; + game.dice = [a, b]; + await startPlacing(a, b); +} + +function renderDicePicks() { + el.diePicks.forEach((row, i) => { + for (const btn of row.querySelectorAll('button')) { + const on = Number(btn.dataset.value) === game.picked[i]; + btn.classList.toggle('selected', on); + btn.setAttribute('aria-pressed', String(on)); + } + }); + renderDie(el.dieA, game.picked[0]); + renderDie(el.dieB, game.picked[1]); + el.useDiceBtn.disabled = !(game.picked[0] && game.picked[1]); +} + +async function startPlacing(a, b) { + const options = Core.validPlacements(game.board, a, b); + if (options.length === 0) { + await pass(a, b); + return; + } + if (game.placeMode === 'auto') { + await autoPlace(a, b); + return; + } + // Start in an orientation that actually fits somewhere. + if (!options.some((o) => o.w === a && o.h === b)) game.rotated = true; + game.phase = 'place'; + setMsg(placeInstructions()); + render(); +} + +// Auto mode: put the rectangle in a snug spot (same strategy as the +// computer), show it briefly, then go straight to the maths. +async function autoPlace(a, b) { + game.phase = 'busy'; + const move = Core.chooseComputerMove(game.board, a, b); + setMsg(`You rolled ${a} and ${b}. Placing your ${a} by ${b} rectangle…`); + game.cpuPreview = move; + render(); + await waitInGame(AUTO_PLACE_MS); + game.cpuPreview = null; + startAnswer(placeRect(move.x, move.y, move.w, move.h)); +} + +function placeInstructions() { + const [a, b] = game.dice; + const shape = `a ${a} by ${b} rectangle`; + const corner = Core.mustUseCorner(game.board); + if (game.placeMode === 'draw') { + return corner + ? `You rolled ${a} and ${b}. Draw ${shape}: start on the top-left square and drag.` + : `You rolled ${a} and ${b}. Draw ${shape}: press on a square and drag.`; + } + return corner + ? `You rolled ${a} and ${b}. Click the board to draw ${shape} in the corner.` + : `You rolled ${a} and ${b}. Click the board to draw ${shape}.${a !== b ? ' Press R to rotate.' : ''}`; +} + +async function pass(a, b) { + const p = currentPlayer(); + game.phase = p.cpu ? 'cpu' : 'rolling'; + p.log.push({ pass: true, a, b }); + p.stats.passes++; + game.passes++; + const who = game.mode !== 'multi' && !p.cpu ? 'You pass' : `${p.name} passes`; + setMsg(`No room for a ${a} × ${b} anywhere. ${who}.`); + if (game.mode === 'class') reportClass({ kind: 'pass' }); // (pairs: the server passes for you) + render(); + if (p.cpu) await finishComputerTurn(cpuStep()); + else await waitInGame(PASS_DELAY); + endTurn(); +} + +// Draw the rectangle on the board. Its points are awarded once the maths is done. +function placeRect(x, y, w, h) { + const [a, b] = game.dice; + const rect = Core.place(game.board, x, y, w, h, game.current); + rect.a = a; + rect.b = b; + rect.solved = false; + // Wardrobe placing effect (the player who placed it chose it; CPUs use yours too) + rect.fx = settings.cosmetics.place !== 'none' ? settings.cosmetics.place : null; + rect.placedAt = performance.now(); + if (rect.fx) animateBoard(PLACE_FX_MS + 50); + game.lastRect = rect; + game.passes = 0; + return rect; +} + +function awardPoints(rect, firstTry, bonus = 0) { + const p = currentPlayer(); + rect.solved = true; + p.squares += rect.area; + p.bonus += bonus; + p.score = p.squares + p.bonus; + p.answered++; + if (firstTry) p.firstTry++; + p.stats.placed++; + if (!p.stats.biggest || rect.area > p.stats.biggest.area) p.stats.biggest = rect; + p.log.push({ a: rect.a, b: rect.b, area: rect.area, bonus, star: !p.cpu && firstTry }); +} + +// ---- human: work out the points + +function startAnswer(rect) { + game.pending = { rect, entry: '', attempts: 0, helped: false, miss: null, startedAt: performance.now(), pausedMs: 0 }; + game.phase = 'answer'; + setMsg('How many squares did you just draw?'); + el.mathQ.textContent = `${rect.a} × ${rect.b}`; + el.answerBox.textContent = ''; + el.answerBox.className = 'answer-box'; + el.countTrail.textContent = ''; + el.mathFeedback.textContent = ''; + el.mathFeedback.className = 'math-feedback'; + el.math.hidden = false; + showStreak(el.streakBadge, currentPlayer().streak); + setKeypadEnabled(true); + el.helpBtn.disabled = false; + el.helpBtn.hidden = Boolean(game.expert); // Expert: no help counting + render(); + startTimer(); + if (window.innerWidth <= 820) el.math.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); +} + +function setKeypadEnabled(on) { + el.keypad.querySelectorAll('button').forEach((b) => (b.disabled = !on)); +} + +// Can more typing still change the outcome? No once it matches, overshoots, +// or has as many digits as the answer. +// "🔥 5 in a row"; with the Wardrobe's growing flames it gets bigger at 5, 10 and 20. +function showStreak(node, n) { + node.textContent = n >= 2 ? `🔥 ${n} in a row` : ''; + const level = n >= 20 ? 3 : n >= 10 ? 2 : n >= 5 ? 1 : 0; + node.classList.toggle('flames', settings.cosmetics.streak === 'flames' && level > 0); + node.dataset.level = level; +} + +function answerDecided(entry, answer) { + return entry !== '' && (Number(entry) >= answer || entry.length >= String(answer).length); +} + +function typeKey(key) { + const pend = game && game.phase === 'answer' && game.pending; + if (!pend) return; + if (key === 'check') return checkAnswer(); + if (key === 'clear') pend.entry = ''; + else if (key === 'back') pend.entry = pend.entry.slice(0, -1); + else if (pend.entry.length < 3) pend.entry = (pend.entry === '0' ? '' : pend.entry) + key; + el.answerBox.textContent = pend.entry; + el.answerBox.className = 'answer-box'; + // Submit by itself once the answer is decided: it's right, it's bigger than + // the answer, or it has as many digits as the answer. Shorter entries wait + // for ✓, since you might not be done typing. + if (answerDecided(pend.entry, pend.rect.area)) checkAnswer(); +} + +async function checkAnswer() { + const pend = game.pending; + if (!pend || pend.entry === '') return; + const { rect } = pend; + if (Number(pend.entry) === rect.area) { + // the timer bonus goes by how much time was left (not in class games: the server scores those) + const timerLeft = timer && game.answerTime && game.mode !== 'class' && game.mode !== 'pair' ? { secs: game.answerTime, left: timer.remaining / timer.total } : null; + stopTimer(); + const p = currentPlayer(); + const ms = performance.now() - pend.startedAt - pend.pausedMs; + const firstTry = pend.attempts === 0; + p.stats.times.push(ms); + p.streak = firstTry ? p.streak + 1 : 0; + p.bestStreak = Math.max(p.bestStreak, p.streak); + const bonus = Progress.answerBonus({ firstTry, ms, streak: p.streak, timer: timerLeft }); + game.phase = 'busy'; + setKeypadEnabled(false); + el.helpBtn.disabled = true; + el.answerBox.className = 'answer-box right'; + el.mathFeedback.className = 'math-feedback good'; + const squares = `+${rect.area} ${rect.area === 1 ? 'square' : 'squares'}`; + el.mathFeedback.textContent = firstTry ? `Correct! ${squares} 🎉` : `You got it! ${squares}`; + // Each bonus pops up on its own, like an achievement. + const mult = game.expert ? GAME_EXPERT_BONUS : 1; // Expert doubles bonus points + for (const part of bonus.parts) toast(`${BONUS_TOASTS[part.label.split(' ')[0]]} ${capitalize(part.label)}`, `+${part.points * mult} bonus points${mult > 1 ? ' (×2 Expert)' : ''}`); + awardPoints(rect, firstTry, bonus.total * mult); + showStreak(el.streakBadge, p.streak); + if (game.mode === 'class' || game.mode === 'pair') reportClass({ kind: 'placed', rect: { x: rect.x, y: rect.y, w: rect.w, h: rect.h }, firstTry, ms: Math.round(ms) }); + if (countsForStats()) { + Progress.recordAnswer(progress, statsName(p), { + a: rect.a, + b: rect.b, + firstTry, + correct: true, + wrongAnswers: pend.miss ? pend.miss.answers : [], + ms, + }); + const learned = Progress.learnedAfterHelp(progress, rect.a, rect.b, firstTry); + celebrate(Progress.awardAchievements(progress, { event: 'answer', correct: true, firstTry, ms, streak: p.streak, a: rect.a, b: rect.b, facts: playerFacts(statsName(p)), size: unlockedTableSize(), learned })); + saveProgress(); + } + game.pending = null; + render(); + await waitInGame(1400); + endTurn(); + return; + } + currentPlayer().streak = 0; + pend.attempts++; + noteMiss(pend, { answer: Number(pend.entry) }); + pend.entry = ''; + currentPlayer().stats.wrong++; + el.answerBox.className = 'answer-box'; + void el.answerBox.offsetWidth; // restart the shake animation + el.answerBox.className = 'answer-box wrong'; + el.answerBox.textContent = ''; + el.mathFeedback.className = 'math-feedback try'; + el.mathFeedback.textContent = TRY_AGAIN[(pend.attempts - 1) % TRY_AGAIN.length]; + if (pend.attempts >= 2 && !pend.helped && !game.expert) { + el.mathFeedback.textContent = "Let's count them together!"; + helpCount(); + } +} + +// Legend: a teen times a one-digit number, split into tens and ones +// (14 × 7 = 10 × 7 + 4 × 7). Null for anything else. +function legendSplit(rect) { + if (!game || game.difficulty !== 'legend') return null; + const teen = Math.max(rect.a, rect.b); + const other = Math.min(rect.a, rect.b); + if (teen < 11) return null; + const ones = teen - 10; + return { teen, other, ones, tens: 10 * other, rest: ones * other }; +} + +async function helpCount() { + const pend = game.pending; + if (!pend || pend.helped || game.counting || game.expert) return; // Expert: no help + pend.helped = true; + currentPlayer().stats.helped++; + if (countsForStats()) { + Progress.noteHelp(progress, pend.rect.a, pend.rect.b); + celebrate(Progress.awardAchievements(progress, { event: 'help' })); + saveProgress(); + } + el.helpBtn.disabled = true; + const { rect } = pend; + const split = legendSplit(rect); + if (split) { + // Too many to skip-count: split it, and leave the adding to the player + el.countTrail.textContent = + `Split ${split.teen} into 10 and ${split.ones}: 10 × ${split.other} = ${split.tens}, ` + + `${split.ones} × ${split.other} = ${split.rest}. Now add them up!`; + return; + } + const plan = countPlan(rect); + const totals = []; + const helpStarted = performance.now(); // the timer is paused while counting + await skipCount(rect, plan, COUNT_STEP, (total) => { + totals.push(total); + const units = plan.steps === 1 ? plan.unit : `${plan.unit}s`; + el.countTrail.textContent = `${plan.steps} ${units} of ${plan.group}: ${totals.join(', ')}`; + }); + pend.pausedMs += performance.now() - helpStarted; + if (game.pending === pend) { + el.mathFeedback.className = 'math-feedback'; + el.mathFeedback.textContent = `So what is ${rect.a} × ${rect.b}?`; + } +} + +// A fact goes on the "to practise" list the first time it's missed in a turn. +// Record a missed fact once per turn, with every wrong answer given for it. +// Entries look like { fact: '6 × 7', answers: [48, 36], timeout: false }. +function noteMiss(pend, { answer = null, timeout = false } = {}) { + if (!pend.miss) { + pend.miss = { fact: `${pend.rect.a} × ${pend.rect.b}`, answers: [], timeout: false }; + currentPlayer().stats.missed.push(pend.miss); + } + if (answer !== null) pend.miss.answers.push(answer); + if (timeout) pend.miss.timeout = true; +} + +// ---- answer timer (paused while "Help me count" is animating) + +let timer = null; // { total, remaining, last, interval } + +function startTimer() { + stopTimer(); + el.timer.hidden = !game.answerTime; + if (!game.answerTime) return; + const id = gameId; + const total = Progress.timerLength(progress, game.answerTime) * 1000; // (the bonus still follows answerTime) + timer = { total, remaining: total, last: performance.now() }; + renderTimer(); + timer.interval = setInterval(() => { + if (!timer || id !== gameId) return stopTimer(); + const now = performance.now(); + // Paused while "Help me count" is animating or Settings is open. + if (!game.counting && el.settingsDialog.hidden) timer.remaining -= now - timer.last; + timer.last = now; + renderTimer(); + if (timer.remaining <= 0) answerTimeout(); + }, 100); +} + +function stopTimer() { + if (timer) clearInterval(timer.interval); + timer = null; +} + +function renderTimer() { + const left = Math.max(0, timer.remaining); + el.timerNum.textContent = timerText(left); + el.timerFill.style.width = `${(left / timer.total) * 100}%`; + el.timer.classList.toggle('low', left <= 5000); +} + +// Out of time: show the answer, take the rectangle back off the board, no points. +async function answerTimeout() { + stopTimer(); + const pend = game.pending; + if (!pend || game.phase !== 'answer') return; + const p = currentPlayer(); + const { rect } = pend; + game.phase = 'busy'; + game.pending = null; + setKeypadEnabled(false); + el.helpBtn.disabled = true; + noteMiss(pend, { timeout: true }); + p.stats.timeouts++; + p.streak = 0; + if (game.mode === 'class' || game.mode === 'pair') reportClass({ kind: 'timeout' }); + if (countsForStats()) { + Progress.recordAnswer(progress, statsName(p), { a: rect.a, b: rect.b, firstTry: false, correct: false, wrongAnswers: pend.miss.answers, timeout: true }); + saveProgress(); + } + p.log.push({ timeout: true, a: rect.a, b: rect.b }); + el.answerBox.textContent = rect.area; + el.answerBox.className = 'answer-box timeout'; + el.mathFeedback.className = 'math-feedback try'; + el.mathFeedback.textContent = `⏰ Time's up! ${rect.a} × ${rect.b} = ${rect.area}. No points this turn.`; + Core.removeRect(game.board, rect); + game.lastRect = null; + game.passes = 0; // the roll did fit, so this isn't a pass + render(); + await waitInGame(3000); + endTurn(); +} + +// ---- skip counting, shared by the help button and the computer's explanation + +// Count a × b as "a rows (or columns) of b", matching the order of the dice. +function countPlan(rect) { + const byRows = rect.w === rect.b; + return { byRows, group: rect.b, steps: rect.a, unit: byRows ? 'row' : 'column' }; +} + +async function skipCount(rect, plan, stepMs, onStep) { + game.counting = { rect, byRows: plan.byRows, group: plan.group, shown: 0 }; + render(); + for (let i = 1; i <= plan.steps; i++) { + await waitInGame(stepMs); + game.counting.shown = i; + onStep(i * plan.group, i); + render(); + } + await waitInGame(stepMs); +} + +// ---- computer: show the working step by step + +// "an 8", "an 11", "an 18", but "a 7" +const article = (n) => (/^(8|11|18)$/.test(String(n)) || /^8\d$/.test(String(n)) ? 'an' : 'a'); + +function addStep(html, big = false) { + const li = document.createElement('li'); + li.innerHTML = html; + if (big) li.className = 'big'; + el.steps.append(li); + return li; +} + +async function explainComputerMove(rect) { + const plan = countPlan(rect); + const { a, b, area } = rect; + addStep(`I drew ${article(a)} ${a} by ${b} rectangle. How many squares is that?`); + await waitInGame(cpuStep()); + const split = legendSplit(rect); + if (split) { + addStep(`Split ${split.teen} into 10 and ${split.ones}.`); + await waitInGame(cpuStep()); + addStep(`10 × ${split.other} = ${split.tens} and ${split.ones} × ${split.other} = ${split.rest}.`); + await waitInGame(cpuStep()); + addStep(`${split.tens} + ${split.rest} = ${area}.`); + await waitInGame(cpuStep()); + } else if (a === 1 || b === 1) { + const n = a === 1 ? b : a; + addStep(`It's just 1 ${a === 1 ? 'row' : 'column'} of ${n}, so that's ${n}.`); + await waitInGame(cpuStep()); + } else { + addStep( + `That's ${plan.steps} ${plan.unit}s of ${plan.group}. ` + + `Let's count by ${plan.group}s:` + ); + let trail = null; // created with its first number so it never shows up empty + const totals = []; + await skipCount(rect, plan, cpuStep(), (total) => { + totals.push(total); + trail = trail || addStep(''); + trail.innerHTML = totals.map((t) => `${t}`).join(', '); + }); + } + game.counting = null; + addStep(`${a} × ${b} = ${area}`, true); + awardPoints(rect, true); + render(); +} + +// ---- end of the computer's turn: carry on by itself, or wait for "My turn" + +let continueResolve = null; + +function gameEndsAfterThisTurn() { + return game.passes >= game.players.length || Core.emptyCount(game.board) === 0; +} + +async function finishComputerTurn(autoDelay) { + if (game.cpuEnd !== 'button' || gameEndsAfterThisTurn()) { + await waitInGame(autoDelay); + return; + } + const next = game.players[(game.current + 1) % game.players.length]; + el.continueBtn.replaceChildren( + game.mode === 'single' ? 'My turn ' : next.cpu ? `${next.name} ` : `${next.name}'s turn `, + icon('chevron-right') + ); + el.continueBtn.hidden = false; + el.continueBtn.focus({ preventScroll: true }); + await new Promise((resolve) => (continueResolve = resolve)); +} + +function pressContinue() { + if (!continueResolve) return; + const resolve = continueResolve; + continueResolve = null; + el.continueBtn.hidden = true; + resolve(); +} + +function cancelContinue() { + continueResolve = null; // the abandoned turn just never resumes + el.continueBtn.hidden = true; +} + +function endTurn() { + if (game.mode === 'class' || game.mode === 'pair') return beginTurn(); // the server ends these games + if (game.passes >= game.players.length || Core.emptyCount(game.board) === 0) { + finishGame(); + return; + } + game.current = (game.current + 1) % game.players.length; + beginTurn(); +} + +// Instant CPU turns: roll, place and score straight away, no explanation. +const INSTANT_CPU_MS = 250; // just long enough for the board to redraw between turns + +async function runInstantComputerTurn() { + const p = currentPlayer(); + const [a, b] = (game.dice = Core.rollForBoard(game.board, game.diceBag, game.rng, game.fitMode)); + renderDie(el.dieA, a); + renderDie(el.dieB, b); + const move = Core.chooseComputerMove(game.board, a, b); + if (move) { + const rect = placeRect(move.x, move.y, move.w, move.h); + awardPoints(rect, true); + setMsg(`${p.name} rolled ${a} and ${b}: ${a} × ${b} = ${rect.area}.`); + } else { + p.log.push({ pass: true, a, b }); + p.stats.passes++; + game.passes++; + setMsg(`${p.name} rolled ${a} and ${b}: no room, so it passes.`); + } + render(); + await waitInGame(INSTANT_CPU_MS); + endTurn(); +} + +async function runComputerTurn() { + if (game.cpuInstant) return runInstantComputerTurn(); + await waitInGame(cpuStep()); + const [a, b] = await rollDice(); + const move = Core.chooseComputerMove(game.board, a, b); + if (!move) { + await pass(a, b); + return; + } + setMsg(''); + el.steps.hidden = false; + const hello = currentPlayer().hello ? `${currentPlayer().hello} ` : ''; // CPU character's greeting + addStep(`${hello}I rolled ${article(a)} ${a} and ${article(b)} ${b}.`); + await waitInGame(cpuStep()); + if (game.placeMode === 'draw') { + const where = game.board.rects.length ? 'snug against the others' : 'in a corner'; + addStep( + `I’ll draw it ${where}: ${move.w} across ` + + `and ${move.h} down.` + ); + await drawComputerRect(move); + } else { + addStep(`I’ll put my rectangle here, ${game.board.rects.length ? 'snug against the others' : 'in a corner'}…`); + game.cpuPreview = move; + render(); + } + await waitInGame(cpuStep()); + game.cpuPreview = null; + const rect = placeRect(move.x, move.y, move.w, move.h); + render(); + await explainComputerMove(rect); + await finishComputerTurn(cpuStep()); + endTurn(); +} + +// Grow the computer's rectangle: the first row one square at a time (at +// double speed), then one whole row per step. +async function drawComputerRect(move) { + for (let w = 1; w <= move.w; w++) { + game.cpuPreview = { ...move, w, h: 1 }; + render(); + await waitInGame(cpuStep() / 2); + } + for (let h = 2; h <= move.h; h++) { + game.cpuPreview = { ...move, h }; + render(); + await waitInGame(cpuStep()); + } +} + +function finishGame() { + game.phase = 'over'; + game.dice = null; + render(); + const ranked = game.players + .map((p, i) => ({ ...p, index: i })) + .sort((p, q) => q.score - p.score); + const top = ranked[0].score; + const winners = ranked.filter((p) => p.score === top); + const isSingle = game.mode === 'single'; + if (countsForStats()) recordGame(new Set(winners.map((p) => p.index))); + + if (winners.length > 1) el.resultTitle.textContent = "It's a tie!"; + else if (isSingle) el.resultTitle.textContent = winners[0].cpu ? `${winners[0].name} wins!` : 'You win! 🎉'; + else el.resultTitle.textContent = `${winners[0].name} wins!${winners[0].cpu ? '' : ' 🎉'}`; + + const empty = Core.emptyCount(game.board); + el.resultSub.textContent = + empty === 0 ? 'The board is completely full!' : `Nobody could fit a rectangle. ${empty} squares left empty.`; + + el.resultList.innerHTML = ''; + for (const p of ranked) { + const li = document.createElement('li'); + li.style.setProperty('--c', p.color); + const name = document.createElement('span'); + name.className = 'result-name'; + name.textContent = p.avatar ? `${p.avatar} ${p.name}` : p.name; + if (p.title) name.append(make('span', 'player-title', p.title)); + const pts = document.createElement('span'); + pts.className = 'result-pts'; + pts.textContent = `${p.score} pts`; + // Bar length relative to the top score, so the gap is easy to see. + const bar = document.createElement('span'); + bar.className = 'result-bar'; + const fill = document.createElement('span'); + fill.style.width = `${top ? (p.score / top) * 100 : 0}%`; + bar.append(fill); + li.append(name, pts, bar); + el.resultList.append(li); + } + renderRewards(winners, empty); + refreshNudges('results'); + if (winners.length === 1 && !winners[0].cpu) celebrateWin(); + renderStats(); + setDetailsOpen(false); + el.gameOver.hidden = false; + el.gameOver.querySelector('.dialog').scrollTop = 0; + el.againBtn.focus(); +} + +// Human players' points go into the shared wallet, and game achievements are +// checked. Shows "+N points" and any new achievements on the end screen. +function renderRewards(winners, empty) { + el.rewards.innerHTML = ''; + const humans = game.players.filter((p) => !p.cpu); + if (!humans.length) return; + const points = humans.reduce((sum, p) => sum + p.score, 0); + Progress.addPoints(progress, points); + if (!countsForStats()) { + // local multiplayer: points only, nothing permanent + saveProgress(); + el.rewards.append(make('p', 'setting-help', 'Multiplayer games are just for fun: they don’t count towards Stats or achievements.')); + return; + } + progress.counters.games++; + // Finishing bonus for bigger boards (single player against the CPU) + const boardBonus = game.mode === 'single' ? Progress.boardFinishBonus(game.size) : 0; + if (boardBonus) { + Progress.addPoints(progress, boardBonus); + const an = [8, 11, 18].includes(game.size) ? 'an' : 'a'; // "an 8×8", "an 11×11", "an 18×18" + toast(`🏁 Finished ${an} ${game.size}×${game.size} game!`, `+${boardBonus} bonus points`); + } + const humanWon = winners.length === 1 && !winners[0].cpu; + // for class stats and homework ("beat the CPU on 8×8", "finish 5 games") + Progress.noteActivity(progress, { kind: 'game', mode: game.mode, difficulty: game.difficulty, size: game.size, won: game.mode === 'single' && humanWon }); + if (humanWon && game.mode === 'single') { + Progress.recordBoardWin(progress, game.size); // reveals the next board + syncBoardLocks(); + } + const asked = (p) => p.answered + p.stats.timeouts; + const perfect = humans.some((p) => asked(p) >= 5 && p.firstTry === asked(p)); + const noHelp = humans.some((p) => asked(p) >= 5 && p.stats.helped === 0); + const earned = Progress.awardAchievements(progress, { + event: 'game', + noHelp, + games: progress.counters.games, + wonVsCpu: game.mode === 'single' && humanWon, + humanWon, + full: empty === 0, + perfect, + difficulty: game.difficulty, + bestScore: Math.max(...humans.map((p) => p.score)), + }); + grantStickers(earned); // they show up behind the Stickers button + saveProgress(); + showRewards(earned); +} + +// End of a game: new achievements get their banners. Points and stickers go +// in quietly: the Shop / Wardrobe / Stickers buttons below show what's new. +function showRewards(earned) { + queueAchievements(earned); +} + +// Single-player and class games (and practice) count towards Stats and achievements. +function countsForStats() { + if (game && game.classroom && game.classroom.session.teacher) return false; // the teacher playing an odd one out + return game && ['single', 'class', 'pair'].includes(game.mode); +} + +// Whose times tables an answer counts towards. In a class game you play under +// your first name, but it's still you on this device. +function statsName(p) { + return p.statsName || p.name; +} + +// ---- end-of-game stats + +function make(tag, cls, text) { + const node = document.createElement(tag); + if (cls) node.className = cls; + if (text !== undefined) node.textContent = text; + return node; +} + +// Games saved before wrong answers were recorded store plain '6 × 7' strings. +function normalizeMiss(miss) { + return typeof miss === 'string' ? { fact: miss, answers: [], timeout: false } : miss; +} + +// Merge misses of the same fact: how often, which wrong answers, how many time-outs. +function groupMisses(misses, keyOf) { + const groups = new Map(); + for (const raw of misses) { + const miss = normalizeMiss(raw); + const key = keyOf(miss.fact); + const g = groups.get(key) || { fact: key, count: 0, answers: [], timeouts: 0 }; + g.count++; + g.answers.push(...miss.answers); + if (miss.timeout) g.timeouts++; + groups.set(key, g); + } + return [...groups.values()].sort((a, b) => b.count - a.count); +} + +// "6 × 7 = 42 (×2)" with "said 48, 36 · ⏰ ran out of time" underneath. +function renderPractice(name, color, groups, noneText) { + const block = make('div', 'practice'); + block.style.setProperty('--c', color); + block.append(make('div', 'practice-name', name)); + if (!groups.length) { + block.append(make('span', 'practice-none', noneText)); + return block; + } + const list = make('div', 'facts'); + for (const g of groups) { + const [a, b] = g.fact.split('×').map((n) => Number(n.trim())); + const item = make('div', 'fact'); + item.append(make('span', 'fact-eq', `${g.fact} = ${a * b}${g.count > 1 ? ` (×${g.count})` : ''}`)); + const details = []; + const said = [...new Set(g.answers)].slice(0, 5); + if (said.length) details.push(`said ${said.join(', ')}`); + if (g.timeouts) details.push(g.timeouts > 1 ? `⏰ ran out of time ×${g.timeouts}` : '⏰ ran out of time'); + if (g.rate) details.push(g.rate); + if (details.length) item.append(make('span', 'fact-said', details.join(' · '))); + list.append(item); + } + block.append(list); + return block; +} + +// ---- lifetime times-table grid + +// Biggest times table the player can reach: 6 (Easy), 8 (Medium) or 12 (Hard). +function unlockedTableSize() { + if (Progress.isUnlocked(progress, 'difficulty.hard')) return 12; + if (Progress.isUnlocked(progress, 'difficulty.medium')) return 8; + return 6; +} + +// "2.1s", or "12s" once it's 10 seconds or more, to fit in a grid cell. +function shortTime(ms) { + const secs = ms / 1000; + return secs < 10 ? `${secs.toFixed(1)}s` : `${Math.round(secs)}s`; +} + +const FACT_STATUS = { + mastered: { mark: '✓', label: 'Mastered' }, + learning: { mark: '~', label: 'Learning' }, + practice: { mark: '!', label: 'Needs practice' }, + unseen: { mark: '', label: 'Not seen yet' }, +}; + +// Which statuses show a best time instead of the icon (Settings → Times-table grid). +const TIMES_SETTING = { mastered: 'timesMastered', learning: 'timesLearning', practice: 'timesPractice' }; + +function showsTime(status) { + return TIMES_SETTING[status] && settings[TIMES_SETTING[status]] === 'time'; +} + +function renderFactLegend() { + const legend = make('div', 'fact-legend'); + for (const [status, { mark, label }] of Object.entries(FACT_STATUS)) { + const item = make('span', 'fact-legend-item'); + item.append(make('span', `fact-cell ${status}`, mark), document.createTextNode(label)); + legend.append(item); + } + return legend; +} + +// Rows and columns 1..N (6, or up to 12 once bigger dice are in play); hover a cell for details. +// Single player and practice play as "You" (personal accounts will replace this). +function isDefaultPlayer(name) { + return Progress.playerKey(name) === 'you'; +} + +// size: a fixed size (a teacher or parent looking at a student), else what this player reached +function renderFactGrid(name, facts, size = null) { + const seenMax = Math.max(0, ...Object.keys(facts).flatMap((k) => k.split('×').map((n) => Number(n.trim())))); + const unlocked = unlockedTableSize(); + const max = size || Math.max(6, seenMax > 8 ? 12 : seenMax > 6 ? 8 : 6, unlocked); + const wrap = make('div', 'fact-grid-wrap'); + if (name) wrap.append(make('div', 'practice-name', name)); // no label for the default "You" + const grid = make('div', 'fact-grid'); + grid.style.setProperty('--n', max + 1); + // Mastery badge: a gold ★ on a line's header once the whole line is mastered (both orders) + const head = (n) => { + const done = Progress.lineReached(facts, n, max, 'mastered'); + const cell = make('span', 'fact-head' + (done ? ' line-mastered' : ''), done ? `★${n}` : String(n)); + if (done) cell.title = `Every ${n} times fact is mastered!`; + return cell; + }; + grid.append(make('span', 'fact-head', '×')); + for (let c = 1; c <= max; c++) grid.append(head(c)); + for (let r = 1; r <= max; r++) { + grid.append(head(r)); + for (let c = 1; c <= max; c++) { + const f = facts[Progress.factKey(r, c)]; + const status = Progress.factStatus(f); + const best = Progress.factBestMs(f); + // Best time instead of the icon when that's switched on for this status. + const timed = showsTime(status) && best !== null; + const cell = make('span', `fact-cell ${status}${timed ? ' timed' : ''}`, timed ? shortTime(best) : FACT_STATUS[status].mark); + cell.title = f + ? `${r} × ${c} = ${r * c}: ${FACT_STATUS[status].label}. ${f.firstTry} of ${f.asked} right first time` + + (f.wrong ? `, ${f.wrong} wrong` : '') + + (f.timeouts ? `, ${f.timeouts} timed out` : '') + + (best !== null ? `, best ${(best / 1000).toFixed(1)} s` : '') + + (f.timed ? `, avg ${(f.totalMs / f.timed / 1000).toFixed(1)} s` : '') + : `${r} × ${c} = ${r * c}: not seen yet`; + grid.append(cell); + } + } + wrap.append(grid); + return wrap; +} + +function renderStats() { + const dash = '—'; + const secs = (ms) => `${(ms / 1000).toFixed(1)} s`; + const human = (fn) => (p) => (p.cpu ? dash : fn(p)); + const asked = (p) => p.answered + p.stats.timeouts; + const rows = [ + ['Points', (p) => p.score], + ['Squares', (p) => p.squares], + ['Bonus points', human((p) => p.bonus)], + ['Best streak', human((p) => p.bestStreak)], + ['Rectangles drawn', (p) => p.stats.placed], + ['Biggest rectangle', (p) => (p.stats.biggest ? `${p.stats.biggest.a} × ${p.stats.biggest.b} = ${p.stats.biggest.area}` : dash)], + ['Right on first try', human((p) => (asked(p) ? `${p.firstTry} of ${asked(p)}` : dash))], + ['Wrong answers', human((p) => p.stats.wrong)], + ['Used “Help me count”', human((p) => p.stats.helped)], + game.answerTime ? ['Ran out of time', human((p) => p.stats.timeouts)] : null, + ['Average answer time', human((p) => (p.stats.times.length ? secs(p.stats.times.reduce((s, t) => s + t, 0) / p.stats.times.length) : dash))], + ['Fastest answer', human((p) => (p.stats.times.length ? secs(Math.min(...p.stats.times)) : dash))], + ['Passes', (p) => p.stats.passes], + ].filter(Boolean); + + el.stats.innerHTML = ''; + + // Game summary + const total = game.size * game.size; + const filled = total - Core.emptyCount(game.board); + const elapsed = Math.round((Date.now() - game.startedAt) / 1000); + const time = elapsed >= 60 ? `${Math.floor(elapsed / 60)} min ${elapsed % 60} s` : `${elapsed} s`; + el.stats.append( + make('p', 'stats-summary', `⏱ ${time} · ${game.turns} turns · ${filled} of ${total} squares filled (${Math.round((filled / total) * 100)}%)`) + ); + + // Per-player table + const wrap = make('div', 'stats-table-wrap'); + const table = make('table', 'stats-table'); + const head = table.createTHead().insertRow(); + head.append(make('th')); + for (const p of game.players) { + const th = make('th'); + th.style.setProperty('--c', p.color); + th.append(make('span', 'dot'), document.createTextNode(p.name)); + head.append(th); + } + const body = table.createTBody(); + for (const [label, value] of rows) { + const tr = body.insertRow(); + tr.append(make('th', null, label)); + for (const p of game.players) tr.append(make('td', null, String(value(p)))); + } + wrap.append(table); + el.stats.append(wrap); + + // Facts to practise + const humans = game.players.filter((p) => !p.cpu); + if (humans.some((p) => asked(p) > 0)) { + el.stats.append(make('h3', 'stats-heading', 'Facts to practice')); + for (const p of humans) { + const none = asked(p) ? 'Nothing to practice. Every answer right first time! 🌟' : 'No answers this game.'; + el.stats.append(renderPractice(p.name, p.color, groupMisses(p.stats.missed, (f) => f), none)); + } + } +} + +// (game is also changed from other sections) +function setGame(value) { + return (game = value); +} + +// (gameId is also changed from other sections) +function setGameId(value) { + return (gameId = value); +} +export { FACT_STATUS, answerDecided, cancelContinue, clearLastDie, continueResolve, currentDims, currentPlayer, game, gameId, helpCount, humanRoll, isDefaultPlayer, make, pickDie, placeInstructions, placeRect, pressContinue, renderFactGrid, renderFactLegend, renderPractice, renderStats, resetTurn, rollDice, setGame, setGameId, showRewards, showStreak, startAnswer, startGame, startPlacing, statsName, stopTimer, typeDie, typeKey, unlockedTableSize, useRealDice, waitInGame }; diff --git a/apps/multiplication/src/game/practice.js b/apps/multiplication/src/game/practice.js new file mode 100644 index 0000000..578d795 --- /dev/null +++ b/apps/multiplication/src/game/practice.js @@ -0,0 +1,549 @@ +// Practice mode: part of the multiplication game (split from the old js/game.js). + +import * as Core from '@blockout/engine'; +import * as Progress from '@blockout/progress'; +import { COUNT_STEP, TRY_AGAIN, el } from './base.js'; +import { BONUS_TOASTS, capitalize, celebrate, progress, saveProgress, toast } from './wallet.js'; +import { settings } from './settings.js'; +import { icon, openSettings, selectIn, setLockBadge } from './wardrobe.js'; +import { FACT_STATUS, answerDecided, cancelContinue, gameId, make, renderFactGrid, renderFactLegend, setGame, setGameId, showStreak, stopTimer, unlockedTableSize, waitInGame } from './play.js'; +import { recordPracticeRound } from './stats.js'; +import { confirmUnlock, refreshNudges } from './shop.js'; + +let practiceTables; + +const PRACTICE_BASE_POINTS = 1; // per correct answer, before bonuses + +// For "what changed": moving right is progress (so a new fact landing in +// "needs practice" shows as a step down, not an improvement). +const STATUS_ORDER = { practice: 0, unseen: 1, learning: 2, mastered: 3 }; + +let practiceRoundSize = 5; + +// 5 comes with Practice mode; 10 and 20 are unlocked for each difficulty +const countUnlock = (count) => Progress.practiceCountId(Number(count), settings.difficulty); + +let practiceLevel = 'learn'; // 'expert' (shop unlock): no picture, no help button until 2 misses, ×2 bonus + +const EXPERT_BONUS = 2; + +const LEVEL_HELP = { + learn: 'Learn: see each fact as a rectangle and get help counting.', + expert: 'Expert: no picture and no help. First-try bonus points are doubled!', +}; + +function renderPracticeLevel() { + // Expert is unlocked for each difficulty + const expertId = Progress.practiceExpertId(settings.difficulty); + if (!Progress.isUnlocked(progress, expertId) && practiceLevel === 'expert') practiceLevel = 'learn'; + for (const btn of el.practiceLevel.querySelectorAll('button')) { + btn.classList.toggle('selected', btn.dataset.level === practiceLevel); + setLockBadge(btn, btn.dataset.level === 'expert' ? expertId : null); + } + el.practiceLevelHelp.textContent = LEVEL_HELP[practiceLevel]; + renderPracticeTimer(); +} + +// Expert practice's own timer (Off, then 30 s → 10 s → 5 s from the shop), +// unlocked separately for each difficulty +let practiceTimerSecs = 0; + +const timerUnlock = (secs) => (Number(secs) ? Progress.practiceTimerId(Number(secs), settings.difficulty) : null); + +function renderPracticeTimer() { + el.practiceTimerField.hidden = practiceLevel !== 'expert'; + if (!Progress.isUnlocked(progress, timerUnlock(practiceTimerSecs))) practiceTimerSecs = 0; + for (const btn of el.practiceTimer.querySelectorAll('button')) { + btn.classList.toggle('selected', Number(btn.dataset.secs) === practiceTimerSecs); + setLockBadge(btn, timerUnlock(btn.dataset.secs)); + } + // The timer bonus (Expert doubles it): all of it for an instant answer, none when time runs out + const top = (secs) => Progress.TIMER_BONUS_MAX[secs] * EXPERT_BONUS; + el.practiceTimerHelp.textContent = practiceTimerSecs + ? `⏱️ Answer fast for a timer bonus of up to +${top(practiceTimerSecs)} a question: the quicker, the more. If time runs out, it counts as missed.` + : `⏱️ A timer earns a bonus for quick answers: up to +${top(30)} a question with 30 s, +${top(10)} with 10 s, +${top(5)} with 5 s.`; +} + +let practice = null; + +let prTimer = null; + +function playerFacts(name) { + return (progress.facts[Progress.playerKey(name)] || { facts: {} }).facts; +} + +// Master difficulty's dice: how often each fact comes up, from the player's +// record. Like practice, but mastered facts are rarer still: whoever reaches +// Master has mastered most of the table, and with practice's weights only +// about a third of the rolls would be weak facts (this makes it ~70%). +const MASTER_WEIGHTS = { ...Progress.PRACTICE_WEIGHTS, mastered: 0.1 }; + +function weakFactWeight(player) { + const facts = playerFacts(player ? player.name : 'You'); + return (a, b) => { + const status = Progress.factStatus(facts[Progress.factKey(a, b)]); + let weight = MASTER_WEIGHTS[status]; + if ((a === 1 || b === 1) && status !== 'practice') weight /= 2; + return weight; + }; +} + +// Practice table size follows Difficulty: Easy 6, Medium 8, Hard 12 (and +// Tricky, Master and Legend, whose facts are within 12 or one teen × 2–9). +function practiceMax() { + return Math.min(Core.DIFFICULTY_SIDES[settings.difficulty] || 6, 12); +} + +// The difficulty's table chips: ×1 … ×max, Tricky's hard tables, Legend's ×2 … ×9 +const practiceChips = () => Progress.practiceSet(settings.difficulty).tables.filter((n) => n <= 12); + +// Chips for the difficulty's tables; choices it doesn't have are dropped. +function renderPracticeTables() { + const chips = practiceChips(); + for (const n of [...practiceTables]) if (!chips.includes(n) || !tableUnlocked(n)) practiceTables.delete(n); + el.practiceTables.innerHTML = ''; + for (const n of chips) { + const btn = make('button', null, `×${n}`); + btn.type = 'button'; + btn.dataset.table = n; + btn.setAttribute('aria-pressed', String(practiceTables.has(n))); + btn.setAttribute('aria-label', `${n} times table`); + setLockBadge(btn, `table${n}`); + el.practiceTables.append(btn); + } +} + +// "×3 and ×4 (11 facts): 2 need practice, 5 learning, 4 not seen yet, 0 mastered." +function tablesLabel(tables) { + const list = [...tables].sort((a, b) => a - b).map((n) => `×${n}`); + return list.length > 1 ? `${list.slice(0, -1).join(', ')} and ${list[list.length - 1]}` : list[0]; +} + +// The tables a round asks about: the ones picked, or else every unlocked one. +function practiceTableList() { + if (practiceTables.size) return [...practiceTables]; + return practiceChips().filter(tableUnlocked); +} + +function updatePracticePreview() { + if (el.practiceSetup.hidden) return; + renderPracticeLevel(); + renderPracticeCount(); + renderPracticeTables(); + const tables = practiceTableList(); + el.startBtn.disabled = !tables.length; + if (!tables.length) { + el.practicePreview.textContent = 'Unlock a times table to practice: tap ×1 to start.'; + return; + } + const facts = playerFacts(el.practiceName.value.trim() || 'You'); + const set = Progress.practiceSet(settings.difficulty); + const upTo = settings.difficulty === 'legend' ? '11–19' : `up to ${practiceMax()}`; + const counts = { practice: 0, learning: 0, unseen: 0, mastered: 0 }; + let total = 0; + for (const [a, b] of set.pairs) { + if (!(set.bSideOnly ? tables.includes(b) : tables.includes(a) || tables.includes(b))) continue; + counts[Progress.factStatus(facts[Progress.factKey(a, b)])]++; + total++; + } + const what = practiceTables.size ? `${tablesLabel(practiceTables)} ${upTo} (${total} facts)` : `All your unlocked tables (${tablesLabel(tables)}), ${upTo}`; + el.practicePreview.textContent = + `${what}: ${counts.practice} need practice, ${counts.learning} learning, ` + + `${counts.unseen} not seen yet, ${counts.mastered} mastered.`; +} + +function renderPracticeCount() { + if (!Progress.isUnlocked(progress, countUnlock(practiceRoundSize))) practiceRoundSize = 5; + for (const btn of el.practiceCount.querySelectorAll('button')) { + btn.classList.toggle('selected', Number(btn.dataset.count) === practiceRoundSize); + setLockBadge(btn, countUnlock(btn.dataset.count)); + } + // The finishing bonus for the chosen length (doubled on Expert) + const expert = practiceLevel === 'expert'; + const bonus = Progress.practiceFinishBonus(practiceRoundSize, expert); + const next = practiceRoundSize === 5 ? Progress.practiceFinishBonus(10, expert) : 0; + el.practiceCountHelp.textContent = bonus + ? `🏁 Finish all ${practiceRoundSize} for +${bonus} bonus points${expert ? ' (doubled on Expert)' : ''}.` + : `🏁 Longer rounds earn a finishing bonus: +${next} for 10 questions, +${Progress.practiceFinishBonus(20, expert)} for 20.`; +} + +// Times tables are unlocked one by one (×1, then ×2, …) +const tableUnlocked = (n) => Progress.isUnlocked(progress, `table${n}`); + +function startPractice(name, count, tables = [], expert = false, timer = 0) { + stopPracticeTimer(); + cancelContinue(); + stopTimer(); + setGameId(gameId + 1); // cancels anything still waiting from a game + setGame(null); + const max = practiceMax(); + const facts = playerFacts(name); + const queue = Progress.pickPracticeFacts(facts, Progress.practiceSet(settings.difficulty), count, Math.random, tables); + const before = {}; + for (const [a, b] of queue) before[Progress.factKey(a, b)] = Progress.factStatus(facts[Progress.factKey(a, b)]); + practice = { name, max, count, tables, expert, timer, difficulty: settings.difficulty, startedAt: Date.now(), times: [], queue, index: 0, requeued: new Set(), before, firstTry: 0, answered: 0, streak: 0, bestStreak: 0, points: 0, earned: [], current: null }; + el.startScreen.hidden = true; + el.gameScreen.hidden = true; + el.practiceDone.hidden = true; + el.practiceScreen.hidden = false; + nextPracticeQuestion(); +} + +function nextPracticeQuestion() { + stopPracticeTimer(); + if (practice.index >= practice.queue.length) return finishPractice(); + const [a, b] = practice.queue[practice.index]; + // retry: a missed fact asked again at the end; it doesn't count towards the score + practice.current = { a, b, area: a * b, retry: practice.index >= practice.count, entry: '', attempts: 0, wrong: [], helped: false, counting: false, startedAt: performance.now(), pausedMs: 0, done: false }; + const focus = + (practice.tables.length ? ` · ${practice.tables.sort((x, y) => x - y).map((n) => `×${n}`).join(' ')}` : '') + + (practice.expert ? ' · 🧠 Expert' : ''); + el.prCounter.textContent = `Question ${practice.index + 1} of ${practice.queue.length}${focus}`; + showStreak(el.prStreak, practice.streak); + el.prBar.style.width = `${(practice.index / practice.queue.length) * 100}%`; + el.prQ.textContent = `${a} × ${b}`; + el.prAnswer.textContent = ''; + el.prAnswer.className = 'answer-box'; + el.prTrail.textContent = ''; + el.prFeedback.textContent = ''; + el.prFeedback.className = 'math-feedback'; + el.prHelp.disabled = false; + el.prHelp.hidden = practice.expert; // Expert: no help button (the picture still helps after 2 misses) + el.prKeypad.querySelectorAll('button').forEach((btn) => (btn.disabled = false)); + renderPracticeArray(a, b); + el.prArray.hidden = practice.expert; // Expert: no picture to count + startPracticeTimer(); +} + +// The fact as a rectangle: a rows of b squares, with a slot for each row's running total. +function renderPracticeArray(a, b) { + el.prArray.innerHTML = ''; + el.prArray.classList.remove('counting'); + el.prArray.style.setProperty('--cols', b); + for (let r = 0; r < a; r++) { + for (let c = 0; c < b; c++) { + const cell = make('span', 'cell'); + cell.dataset.row = r; + el.prArray.append(cell); + } + const total = make('span', 'row-total', String((r + 1) * b)); + total.dataset.row = r; + el.prArray.append(total); + } +} + +function typePractice(key) { + const q = practice && practice.current; + if (!q || q.done || el.practiceScreen.hidden) return; + if (key === 'check') return checkPractice(); + if (key === 'clear') q.entry = ''; + else if (key === 'back') q.entry = q.entry.slice(0, -1); + else if (q.entry.length < 3) q.entry = (q.entry === '0' ? '' : q.entry) + key; + el.prAnswer.textContent = q.entry; + el.prAnswer.className = 'answer-box'; + if (answerDecided(q.entry, q.area)) checkPractice(); // right, too big, or all digits typed +} + +function practiceLocked(on) { + el.prKeypad.querySelectorAll('button').forEach((btn) => (btn.disabled = on)); + el.prHelp.disabled = on; +} + +// A missed fact comes back once, later in the round. +function requeue(q) { + const key = Progress.factKey(q.a, q.b); + if (practice.requeued.has(key)) return; + practice.requeued.add(key); + practice.queue.push([q.a, q.b]); +} + +async function checkPractice() { + const q = practice.current; + if (!q || q.done || q.entry === '') return; + if (Number(q.entry) === q.area) { + q.done = true; + const timerLeft = prTimer && practice.timer ? { secs: practice.timer, left: prTimer.remaining / prTimer.total } : null; + stopPracticeTimer(); + practiceLocked(true); + const ms = performance.now() - q.startedAt - q.pausedMs; + const firstTry = q.attempts === 0; + practice.answered++; + if (firstTry && !q.retry) practice.firstTry++; + practice.times.push(Math.round(ms)); + practice.streak = firstTry ? practice.streak + 1 : 0; + practice.bestStreak = Math.max(practice.bestStreak, practice.streak); + const bonus = Progress.answerBonus({ firstTry, ms, streak: practice.streak, timer: timerLeft }); + const multiplier = practice.expert ? EXPERT_BONUS : 1; + const points = PRACTICE_BASE_POINTS + bonus.total * multiplier; + practice.points += points; + Progress.addPoints(progress, points); + for (const part of bonus.parts) { + const extra = practice.expert ? ` (×${EXPERT_BONUS} Expert)` : ''; + toast(`${BONUS_TOASTS[part.label.split(' ')[0]]} ${capitalize(part.label)}`, `+${part.points * multiplier} bonus points${extra}`); + } + Progress.recordAnswer(progress, practice.name, { a: q.a, b: q.b, firstTry, correct: true, wrongAnswers: q.wrong, ms }); + const learned = Progress.learnedAfterHelp(progress, q.a, q.b, firstTry); + practice.earned.push(...celebrate(Progress.awardAchievements(progress, { event: 'answer', correct: true, firstTry, ms, streak: practice.streak, a: q.a, b: q.b, facts: playerFacts(practice.name), size: unlockedTableSize(), learned }))); + saveProgress(); + el.prAnswer.className = 'answer-box right'; + el.prFeedback.className = 'math-feedback good'; + el.prFeedback.textContent = firstTry ? `Correct! +${points} points 🎉` : `You got it! +${points} point${points === 1 ? '' : 's'}`; + showStreak(el.prStreak, practice.streak); + await waitInGame(1100); + practice.index++; + nextPracticeQuestion(); + return; + } + q.attempts++; + q.wrong.push(Number(q.entry)); + q.entry = ''; + practice.streak = 0; + showStreak(el.prStreak, 0); + requeue(q); + el.prAnswer.className = 'answer-box'; + void el.prAnswer.offsetWidth; // restart the shake + el.prAnswer.className = 'answer-box wrong'; + el.prAnswer.textContent = ''; + el.prFeedback.className = 'math-feedback try'; + el.prFeedback.textContent = TRY_AGAIN[(q.attempts - 1) % TRY_AGAIN.length]; + // Learn: count it out after two misses. Expert: no help at all. + if (q.attempts >= 2 && !q.helped && !practice.expert) { + el.prFeedback.textContent = "Let's count them together!"; + practiceHelp(); + } +} + +// Light up the rectangle one row at a time with running totals. +async function practiceHelp() { + const q = practice && practice.current; + if (!q || q.done || q.helped || practice.expert) return; // Expert gets no help + q.helped = true; + Progress.noteHelp(progress, q.a, q.b); + practice.earned.push(...celebrate(Progress.awardAchievements(progress, { event: 'help' }))); + saveProgress(); + q.counting = true; + el.prHelp.disabled = true; + el.prArray.hidden = false; // Expert hides it until now + const started = performance.now(); + el.prArray.classList.add('counting'); + const totals = []; + for (let r = 0; r < q.a; r++) { + await waitInGame(COUNT_STEP); + if (practice.current !== q) return; + el.prArray.querySelectorAll(`.cell[data-row="${r}"]`).forEach((c) => c.classList.add('counted')); + el.prArray.querySelector(`.row-total[data-row="${r}"]`).classList.add('shown'); + totals.push((r + 1) * q.b); + el.prTrail.textContent = `${q.a} ${q.a === 1 ? 'row' : 'rows'} of ${q.b}: ${totals.join(', ')}`; + } + q.counting = false; + q.pausedMs += performance.now() - started; + if (!q.done) { + el.prFeedback.className = 'math-feedback'; + el.prFeedback.textContent = `So what is ${q.a} × ${q.b}?`; + } +} + +// Answer timer from Settings; paused while counting or while Settings is open. +// Only Expert practice is timed; Learn never shows a clock. +// "7s", or "4:32" for a minute or more +function timerText(ms) { + const secs = Math.ceil(ms / 1000); + return secs < 60 ? `${secs}s` : `${Math.floor(secs / 60)}:${String(secs % 60).padStart(2, '0')}`; +} + +function startPracticeTimer() { + const secs = practice.expert ? practice.timer : 0; // Expert practice's own timer + el.prTimer.hidden = !secs; + if (!secs) return; + const q = practice.current; + const total = Progress.timerLength(progress, secs) * 1000; + prTimer = { total, remaining: total, last: performance.now() }; + const draw = () => { + const left = Math.max(0, prTimer.remaining); + el.prTimerNum.textContent = timerText(left); + el.prTimerFill.style.width = `${(left / total) * 100}%`; + el.prTimer.classList.toggle('low', left <= 5000); + }; + draw(); + prTimer.interval = setInterval(() => { + if (!practice || practice.current !== q || q.done) return stopPracticeTimer(); + const now = performance.now(); + if (!q.counting && el.settingsDialog.hidden) prTimer.remaining -= now - prTimer.last; + prTimer.last = now; + draw(); + if (prTimer.remaining <= 0) practiceTimeout(); + }, 100); +} + +function stopPracticeTimer() { + if (prTimer) clearInterval(prTimer.interval); + prTimer = null; +} + +async function practiceTimeout() { + stopPracticeTimer(); + const q = practice.current; + if (!q || q.done) return; + q.done = true; + practiceLocked(true); + practice.streak = 0; + Progress.recordAnswer(progress, practice.name, { a: q.a, b: q.b, firstTry: false, correct: false, wrongAnswers: q.wrong, timeout: true }); + saveProgress(); + requeue(q); + el.prAnswer.textContent = q.area; + el.prAnswer.className = 'answer-box timeout'; + el.prFeedback.className = 'math-feedback try'; + el.prFeedback.textContent = `⏰ Time's up! ${q.a} × ${q.b} = ${q.area}. It'll come back later.`; + await waitInGame(2200); + practice.index++; + nextPracticeQuestion(); +} + +function finishPractice() { + stopPracticeTimer(); + el.prBar.style.width = '100%'; + const pr = practice; + progress.counters.practiceRounds = (progress.counters.practiceRounds || 0) + 1; + // Longer rounds earn a finishing bonus (doubled on Expert, like the other bonuses) + const finish = Progress.practiceFinishBonus(pr.count, pr.expert); + if (finish) { + pr.points += finish; + Progress.addPoints(progress, finish); + toast(`🏁 Finished all ${pr.count} questions!`, `+${finish} bonus points${pr.expert ? ` (×${EXPERT_BONUS} Expert)` : ''}`); + } + recordPracticeRound(pr); + Progress.noteActivity(progress, { kind: 'practice', difficulty: pr.difficulty, count: pr.count, expert: pr.expert, finished: true, firstTry: pr.firstTry }); + pr.earned.push(...celebrate(Progress.awardAchievements(progress, { event: 'practice' }))); + saveProgress(); + + const retries = pr.queue.length - pr.count; // missed facts asked again + el.pdSub.textContent = + (pr.expert ? '🧠 Expert · ' : '') + + `${pr.firstTry} of ${pr.count} right first time` + + (retries ? ` · ${retries} ${retries === 1 ? 'fact' : 'facts'} retried` : '') + + (pr.bestStreak >= 3 ? ` · best streak ${pr.bestStreak} 🔥` : ''); + + // Which facts moved, e.g. "3 × 4: Needs practice → Learning" + el.pdBody.innerHTML = ''; + const facts = playerFacts(pr.name); + const changes = Object.entries(pr.before) + .map(([fact, was]) => ({ fact, was, now: Progress.factStatus(facts[fact]) })) + .filter((c) => c.was !== c.now) + .sort((x, y) => STATUS_ORDER[y.now] - STATUS_ORDER[y.was] - (STATUS_ORDER[x.now] - STATUS_ORDER[x.was])); + el.pdBody.append(make('h3', 'stats-heading', 'What changed')); + if (!changes.length) el.pdBody.append(make('p', 'practice-none', 'No changes this time. Keep practicing and your facts will move up!')); + for (const c of changes) { + const row = make('div', 'practice-change'); + const up = STATUS_ORDER[c.now] > STATUS_ORDER[c.was]; + row.append(make('span', null, c.fact), make('span', up ? 'up' : 'down', `${FACT_STATUS[c.was].label} → ${FACT_STATUS[c.now].label}`)); + el.pdBody.append(row); + } + el.pdBody.append(make('h3', 'stats-heading centered', 'Times table')); + el.pdBody.append(renderFactLegend()); + el.pdBody.append(renderFactGrid('', facts)); + + setPracticeDetailsOpen(false); + refreshNudges('pd'); + el.practiceDone.hidden = false; + el.practiceDone.querySelector('.dialog').scrollTop = 0; + el.pdAgain.focus(); +} + +function leavePractice() { + stopPracticeTimer(); + setGameId(gameId + 1); + practice = null; + el.practiceDone.hidden = true; + el.practiceScreen.hidden = true; + el.startScreen.hidden = false; + updatePracticePreview(); +} + +// "What changed" and the times table sit behind Show details, like the game results. +function setPracticeDetailsOpen(open) { + el.pdBody.hidden = !open; + el.pdDetailsBtn.replaceChildren(icon('chart-column'), open ? ' Hide details' : ' Show details'); + el.pdDetailsBtn.setAttribute('aria-expanded', String(open)); +} + +// (practice is also changed from other sections) +function setPractice(value) { + return (practice = value); +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.practiceTimer.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + practiceTimerSecs = Number(btn.dataset.secs); + renderPracticeTimer(); + }); + + el.practiceLevel.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + practiceLevel = btn.dataset.level; + renderPracticeLevel(); + renderPracticeCount(); // Expert doubles the finishing bonus + }); + + practiceTables = new Set(); + + el.practiceTables.addEventListener('click', (e) => { + const btn = e.target.closest('[data-table]'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + const n = Number(btn.dataset.table); + if (practiceTables.has(n)) practiceTables.delete(n); + else practiceTables.add(n); + updatePracticePreview(); + }); + + el.practiceName.addEventListener('input', updatePracticePreview); + + el.practiceCount.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + practiceRoundSize = Number(btn.dataset.count); + selectIn(el.practiceCount, btn); + renderPracticeCount(); + }); + + el.prKeypad.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (btn) typePractice(btn.dataset.key); + }); + + el.prHelp.addEventListener('click', practiceHelp); + + el.pdAgain.addEventListener('click', () => startPractice(practice.name, practice.count, practice.tables, practice.expert, practice.timer)); + + el.pdMenu.addEventListener('click', leavePractice); + + el.pdDetailsBtn.addEventListener('click', () => setPracticeDetailsOpen(el.pdBody.hidden)); + + el.practiceMenuBtn.addEventListener('click', () => { + if (practice && practice.index < practice.queue.length && !confirm('Stop practicing and go back to the menu?')) return; + leavePractice(); + }); + + el.practiceSettingsBtn.addEventListener('click', () => openSettings(el.practiceSettingsBtn)); + + document.addEventListener('keydown', (e) => { + if (!practice || el.practiceScreen.hidden || !el.practiceDone.hidden) return; + if (!el.settingsDialog.hidden || !el.shopDialog.hidden) return; + if (e.target.closest('.overlay, input')) return; // e.g. Esc that just closed a dialog + if (/^[0-9]$/.test(e.key)) typePractice(e.key); + else if (e.key === 'Backspace') typePractice('back'); + else if (e.key === 'Escape') typePractice('clear'); + else if (e.key === 'Enter') typePractice('check'); + else return; + e.preventDefault(); + }); +} + +export { playerFacts, practiceLevel, practiceRoundSize, practiceTableList, practiceTimerSecs, setPractice, startPractice, stopPracticeTimer, tablesLabel, timerText, updatePracticePreview, weakFactWeight }; diff --git a/apps/multiplication/src/game/render.js b/apps/multiplication/src/game/render.js new file mode 100644 index 0000000..83dc435 --- /dev/null +++ b/apps/multiplication/src/game/render.js @@ -0,0 +1,544 @@ +// Rendering: part of the multiplication game (split from the old js/game.js). + +import * as Core from '@blockout/engine'; +import { PIPS, ctx, el } from './base.js'; +import { settings } from './settings.js'; +import { look } from './wardrobe.js'; +import { currentDims, currentPlayer, game, make } from './play.js'; +import { ordinal } from './classroom.js'; +import { dragRect, humanPreview, judgeDrawn } from './input.js'; + +function setMsg(text) { + el.turnMsg.textContent = text; + el.turnMsg.classList.remove('oops'); +} + +// Pips for a normal d6; 8- and 12-sided dice show the number. +// Pips for a normal d6 (dots, or the Wardrobe's hearts/stars/…); 8- and +// 12-sided dice show the number. `skin` defaults to the Wardrobe's dice. +function renderDie(die, value, sides = game ? game.sides : 6, skin = settings.cosmetics.dice) { + die.innerHTML = ''; + for (const c of [...die.classList]) if (c.startsWith('skin-')) die.classList.remove(c); + die.classList.add(`skin-${skin}`); + die.classList.toggle('blank', !value); + die.classList.toggle('numbered', Boolean(value) && sides > 6); + die.setAttribute('aria-label', value ? `Die showing ${value}` : 'Die not rolled'); + if (!value) return; + if (sides > 6) { + const num = document.createElement('span'); + num.className = 'die-num'; + num.textContent = value; + die.append(num); + return; + } + const on = PIPS[value]; + const glyph = look('pips').glyph; // e.g. '❤️'; plain dots when undefined + for (let i = 0; i < 9; i++) { + const cell = document.createElement('span'); + if (on.includes(i)) { + cell.className = glyph ? 'pip pip-glyph' : 'pip'; + if (glyph) cell.textContent = glyph; + } + die.append(cell); + } +} + +function render() { + if (!game) return; + const p = currentPlayer(); + el.turnPanel.style.setProperty('--turn-color', p.color); + el.turnLabel.textContent = + game.phase === 'over' + ? 'Game over' + : game.mode === 'class' + ? game.classroom.round + ? `Roll ${game.classroom.round} of ${game.classroom.view.settings.rounds}` + : 'Class game' + : game.mode === 'pair' + ? game.current === game.classroom.me + ? 'Your turn' + : `${p.name}'s turn` + : game.mode === 'single' && !p.cpu + ? 'Your turn' + : `${p.name}'s turn`; + el.rollBtn.disabled = game.phase !== 'roll'; + const realDice = game.diceMode === 'real'; + el.rollBtn.hidden = p.cpu || realDice || game.mode === 'class' || !['roll', 'rolling'].includes(game.phase); + el.diceEntry.hidden = p.cpu || !realDice || game.phase !== 'roll'; + const dims = currentDims(); + el.rotateBtn.hidden = !( + game.phase === 'place' && + game.placeMode === 'click' && + dims && + dims[0] !== dims[1] && + !Core.mustUseCorner(game.board) + ); + el.progress.hidden = !game.showProgress; + if (game.showProgress) renderProgress(); + el.canvas.style.cursor = game.phase === 'place' ? 'crosshair' : 'default'; + renderScores(); + drawBoard(); +} + +// "Board: 24 of 400 filled", with a bar split into each player's colour. +function renderProgress() { + const total = game.size * game.size; + const filled = total - Core.emptyCount(game.board); + el.progressText.innerHTML = + `Board: ${filled} of ${total} filled` + + `${filled} / ${total} filled`; + el.progressBar.setAttribute('aria-valuemin', '0'); + el.progressBar.setAttribute('aria-valuemax', String(total)); + el.progressBar.setAttribute('aria-valuenow', String(filled)); + const perPlayer = game.players.map(() => 0); + for (const rect of game.board.rects) perPlayer[rect.player] += rect.area; + // Reuse the segments so their widths animate as the board fills. + if (el.progressBar.children.length !== game.players.length) { + el.progressBar.innerHTML = ''; + for (let i = 0; i < game.players.length; i++) el.progressBar.append(document.createElement('span')); + } + game.players.forEach((p, i) => { + const seg = el.progressBar.children[i]; + seg.style.width = `${(perPlayer[i] / total) * 100}%`; + seg.style.background = p.color; + }); +} + +function renderScores() { + el.scores.innerHTML = ''; + game.players.forEach((p, i) => { + const card = document.createElement('div'); + card.className = 'score-card' + (i === game.current && game.phase !== 'over' ? ' active' : ''); + card.style.setProperty('--c', p.color); + + const head = document.createElement('div'); + head.className = 'score-head'; + const name = document.createElement('span'); + name.className = 'score-name'; + name.textContent = p.avatar ? `${p.avatar} ${p.name}` : p.name; + if (p.title) { + name.append(make('span', 'player-title', p.title)); + } + const pts = document.createElement('span'); + pts.className = 'score-pts'; + pts.textContent = p.score; + head.append(name, pts); + card.append(head); + if (game.mode === 'class' && game.classroom.view && game.classroom.view.of > 1) { + const { you, of } = game.classroom.view; + card.append(make('div', 'score-rank', `🏫 ${you.tied ? 'Tied for ' : ''}${ordinal(you.rank)} of ${of} in the class`)); + } + + if (p.log.length) { + const log = document.createElement('ul'); + log.className = 'score-log'; + for (const entry of p.log) { + const li = document.createElement('li'); + if (entry.pass) { + li.className = 'pass'; + li.textContent = `${entry.a}×${entry.b} pass`; + } else if (entry.timeout) { + li.className = 'timeout'; + li.textContent = `${entry.a}×${entry.b} ⏰`; + } else { + li.textContent = `${entry.a}×${entry.b}=${entry.area}${entry.bonus ? ` +${entry.bonus}` : ''}`; + if (entry.star) li.className = 'star'; + } + log.append(li); + } + card.append(log); + } + el.scores.append(card); + }); +} + +let cssSize = 0; + +function resizeBoard() { + if (!game) return; + const wrapStyle = getComputedStyle(el.boardWrap); + const pad = parseFloat(wrapStyle.paddingLeft) + parseFloat(wrapStyle.paddingRight); + const available = el.boardWrap.clientWidth - pad; + const narrow = window.innerWidth <= 820; + // Leave room for the header, and on phones for the dice panel below the board. + const maxByHeight = window.innerHeight - (narrow ? 300 : 110); + cssSize = Math.max(200, Math.floor(Math.min(available, maxByHeight, 760))); + const dpr = window.devicePixelRatio || 1; + el.canvas.style.width = el.canvas.style.height = `${cssSize}px`; + el.canvas.width = el.canvas.height = Math.round(cssSize * dpr); + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + drawBoard(); +} + +// Board colours come from the CSS theme, so the canvas follows light/dark mode. +let pal = {}; + +function readPalette() { + const css = getComputedStyle(document.documentElement); + const v = (name) => css.getPropertyValue(name).trim(); + pal = { + bg: v('--board-bg'), + grid: v('--board-grid'), + hint: v('--board-hint'), + edge: v('--board-edge'), + fade: v('--board-fade'), + wrong: v('--board-wrong'), + bad: v('--bad'), + tagBg: v('--invert-bg'), + tagInk: v('--invert-ink'), + }; + Object.assign(pal, BOARD_THEMES[settings.cosmetics.board] || {}); // Wardrobe board +} + +// Wardrobe boards: colours plus a little decoration. Graph paper follows the theme. +const BOARD_THEMES = { + graph: null, + notebook: { bg: '#fdfdf6', grid: '#b9d3ea', edge: '#2b2a33', hint: '#8a8f99', fade: 'rgba(253, 253, 246, 0.65)', decor: 'notebook' }, + chalkboard: { bg: '#2f4f3a', grid: 'rgba(255, 255, 255, 0.16)', edge: '#1b3024', hint: '#d7e8dc', fade: 'rgba(47, 79, 58, 0.7)', wrong: '#c9d6cc', decor: 'chalk' }, + beach: { bg: '#f4e1b0', grid: 'rgba(150, 110, 50, 0.25)', edge: '#b58a4a', hint: '#8a6a3a', fade: 'rgba(244, 225, 176, 0.65)', decor: 'sand' }, + space: { bg: '#10143a', grid: 'rgba(255, 255, 255, 0.12)', edge: '#3a3f7a', hint: '#aab0e0', fade: 'rgba(16, 20, 58, 0.7)', wrong: '#aab0e0', decor: 'stars' }, + snow: { bg: '#eef6ff', grid: '#c8dcf0', edge: '#7a9cc0', hint: '#6d86a4', fade: 'rgba(238, 246, 255, 0.65)', decor: 'flakes' }, +}; + +// Repeatable pseudo-random numbers so decorations don't jump around on every redraw. +function decorRandom(seed) { + let a = seed; + return () => { + a = (a * 1103515245 + 12345) % 2147483648; + return a / 2147483648; + }; +} + +function drawBoardDecor(kind, cell) { + const r = decorRandom(7); + ctx.save(); + if (kind === 'notebook') { + ctx.strokeStyle = 'rgba(230, 110, 110, 0.7)'; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(cell * 0.5, 0); + ctx.lineTo(cell * 0.5, cssSize); + ctx.stroke(); + } else if (kind === 'stars' || kind === 'flakes' || kind === 'sand' || kind === 'chalk') { + const count = kind === 'sand' ? 260 : kind === 'chalk' ? 40 : 90; + for (let i = 0; i < count; i++) { + const x = r() * cssSize; + const y = r() * cssSize; + const size = kind === 'chalk' ? 6 + r() * 14 : 0.6 + r() * (kind === 'flakes' ? 2.2 : 1.6); + ctx.fillStyle = + kind === 'stars' ? `rgba(255, 255, 255, ${0.35 + r() * 0.6})` : + kind === 'flakes' ? 'rgba(160, 190, 225, 0.6)' : + kind === 'sand' ? 'rgba(160, 120, 60, 0.35)' : 'rgba(255, 255, 255, 0.05)'; + ctx.beginPath(); + ctx.arc(x, y, size, 0, Math.PI * 2); + ctx.fill(); + } + } + ctx.restore(); +} + +// Wardrobe rectangle patterns: light marks over your own rectangles. +const patternCache = {}; + +function rectPattern(kind) { + if (patternCache[kind]) return patternCache[kind]; + const tile = document.createElement('canvas'); + tile.width = tile.height = 16; + const g = tile.getContext('2d'); + g.strokeStyle = g.fillStyle = 'rgba(255, 255, 255, 0.35)'; + g.lineWidth = 3; + if (kind === 'stripes') { + g.beginPath(); + g.moveTo(-4, 20); + g.lineTo(20, -4); + g.moveTo(-4, 4); + g.lineTo(4, -4); + g.moveTo(12, 20); + g.lineTo(20, 12); + g.stroke(); + } else if (kind === 'dots') { + g.beginPath(); + g.arc(4, 4, 2.5, 0, Math.PI * 2); + g.arc(12, 12, 2.5, 0, Math.PI * 2); + g.fill(); + } else if (kind === 'bricks') { + g.lineWidth = 1.5; + g.strokeRect(0, 0.75, 16, 7.5); + g.beginPath(); + g.moveTo(8, 8); + g.lineTo(8, 16); + g.stroke(); + } else if (kind === 'checker') { + g.fillRect(0, 0, 8, 8); + g.fillRect(8, 8, 8, 8); + } else if (kind === 'wood') { + g.lineWidth = 1.5; + g.beginPath(); + g.moveTo(0, 4); + g.bezierCurveTo(5, 2, 11, 6, 16, 4); + g.moveTo(0, 11); + g.bezierCurveTo(5, 13, 11, 9, 16, 11); + g.stroke(); + } + return (patternCache[kind] = ctx.createPattern(tile, 'repeat')); +} + +// ---- placing effects: redraw the board for a moment after a rectangle lands +const PLACE_FX_MS = 650; + +let boardAnimUntil = 0; + +function animateBoard(ms) { + const already = boardAnimUntil > performance.now(); + boardAnimUntil = Math.max(boardAnimUntil, performance.now() + ms); + if (already) return; + const step = () => { + drawBoard(); + if (performance.now() < boardAnimUntil) requestAnimationFrame(step); + }; + requestAnimationFrame(step); +} + +const easeOutBack = (t) => 1 + 2.7 * Math.pow(t - 1, 3) + 1.7 * Math.pow(t - 1, 2); + +function drawBoard() { + if (!game || !cssSize) return; + readPalette(); + const n = game.size; + const cell = cssSize / n; + ctx.clearRect(0, 0, cssSize, cssSize); + ctx.fillStyle = pal.bg; + ctx.fillRect(0, 0, cssSize, cssSize); + if (pal.decor) drawBoardDecor(pal.decor, cell); + + // grid + ctx.strokeStyle = pal.grid; + ctx.lineWidth = 1; + ctx.beginPath(); + for (let i = 0; i <= n; i++) { + const v = Math.round(i * cell) + 0.5; + ctx.moveTo(v, 0); + ctx.lineTo(v, cssSize); + ctx.moveTo(0, v); + ctx.lineTo(cssSize, v); + } + ctx.stroke(); + + if (Core.mustUseCorner(game.board)) { + ctx.fillStyle = pal.hint; + ctx.font = `700 ${Math.max(8, cell * 0.2)}px system-ui, sans-serif`; + ctx.textAlign = 'left'; + ctx.textBaseline = 'top'; + ['Start', 'here'].forEach((t, i) => ctx.fillText(t, 4, 4 + i * cell * 0.24)); + } + + for (const rect of game.board.rects) { + drawRect(rect, game.players[rect.player].color, cell, rect === game.lastRect); + } + + const preview = game.cpuPreview ? { ...game.cpuPreview, valid: true } : humanPreview(); + if (preview) drawPreview(preview, currentPlayer().color, cell); + if (game.phase === 'place' && game.placeMode === 'draw') { + if (game.drag) drawDragging(dragRect(), currentPlayer().color, cell); + else if (game.hover) drawHoverCell(game.hover, currentPlayer().color, cell); + } + + ctx.strokeStyle = pal.edge; + ctx.lineWidth = 2; + ctx.strokeRect(1, 1, cssSize - 2, cssSize - 2); +} + +function drawRect(rect, color, cell, highlight) { + const x = rect.x * cell; + const y = rect.y * cell; + const w = rect.w * cell; + const h = rect.h * cell; + const t = rect.fx ? Math.min(1, (performance.now() - rect.placedAt) / PLACE_FX_MS) : 1; + if (rect.fx === 'pop' && t < 1) return drawPopIn(rect, color, cell, t); + const owner = game.players[rect.player]; + ctx.save(); + if (rect.fx === 'glow' && t < 1) { + ctx.shadowColor = color; + ctx.shadowBlur = 36 * (1 - t); + } + ctx.fillStyle = color; + ctx.globalAlpha = 0.82; + ctx.fillRect(x, y, w, h); + ctx.restore(); + if (!owner.cpu && settings.cosmetics.pattern !== 'none') { + ctx.fillStyle = rectPattern(settings.cosmetics.pattern); + ctx.fillRect(x, y, w, h); + } + ctx.strokeStyle = shade(color, -0.35); + ctx.lineWidth = highlight ? 3.5 : 2; + ctx.strokeRect(x + 1, y + 1, w - 2, h - 2); + if (rect.fx === 'ripple' && t < 1) { + const grow = t * cell * 0.9; + ctx.save(); + ctx.globalAlpha = 1 - t; + ctx.strokeStyle = color; + ctx.lineWidth = 3; + ctx.strokeRect(x - grow, y - grow, w + grow * 2, h + grow * 2); + ctx.restore(); + } + if (game.counting && game.counting.rect === rect) drawCounting(game.counting, color, cell); + else drawLabel(rect, x, y, w, h, cell); +} + +// Pop-in: the squares spring up one after another. +function drawPopIn(rect, color, cell, t) { + const total = rect.w * rect.h; + ctx.fillStyle = color; + for (let k = 0; k < total; k++) { + const local = Math.max(0, Math.min(1, (t - (k / total) * 0.55) / 0.45)); + if (!local) continue; + const s = easeOutBack(local); + const cx = (rect.x + (k % rect.w) + 0.5) * cell; + const cy = (rect.y + Math.floor(k / rect.w) + 0.5) * cell; + const half = (cell / 2) * s; + ctx.globalAlpha = 0.82; + ctx.fillRect(cx - half, cy - half, half * 2, half * 2); + } + ctx.globalAlpha = 1; +} + +// Skip counting: rows (or columns) already counted stay bright and show the +// running total; the rest are faded out. +function drawCounting({ rect, byRows, shown }, color, cell) { + const stripes = byRows ? rect.h : rect.w; + const group = byRows ? rect.w : rect.h; + for (let i = 0; i < stripes; i++) { + const sx = (rect.x + (byRows ? 0 : i)) * cell; + const sy = (rect.y + (byRows ? i : 0)) * cell; + const sw = (byRows ? rect.w : 1) * cell; + const sh = (byRows ? 1 : rect.h) * cell; + if (i >= shown) { + ctx.fillStyle = pal.fade; + ctx.fillRect(sx, sy, sw, sh); + continue; + } + ctx.strokeStyle = shade(color, -0.45); + ctx.lineWidth = 2; + ctx.strokeRect(sx + 2, sy + 2, sw - 4, sh - 4); + const size = Math.max(10, Math.min(cell * 0.55, 24)); + ctx.font = `900 ${size}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.lineWidth = 4; + ctx.strokeStyle = shade(color, -0.5); + ctx.fillStyle = '#ffffff'; + const text = String((i + 1) * group); + ctx.strokeText(text, sx + sw / 2, sy + sh / 2); + ctx.fillText(text, sx + sw / 2, sy + sh / 2); + } +} + +function drawLabel(rect, x, y, w, h, cell) { + const size = Math.max(9, Math.min(cell * 0.5, 22)); + ctx.font = `800 ${size}px system-ui, sans-serif`; + ctx.fillStyle = '#ffffff'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + const cx = x + w / 2; + const cy = y + h / 2; + if (!rect.solved) { + ctx.font = `900 ${Math.max(12, Math.min(cell * 0.9, 36))}px system-ui, sans-serif`; + ctx.fillText('?', cx, cy); + return; + } + const full = `${rect.a}×${rect.b}=${rect.area}`; + if (ctx.measureText(full).width <= w - 8) { + ctx.fillText(full, cx, cy); + } else if (h > w && ctx.measureText(full).width <= h - 8) { + ctx.save(); + ctx.translate(cx, cy); + ctx.rotate(-Math.PI / 2); + ctx.fillText(full, 0, 0); + ctx.restore(); + } else if (ctx.measureText(String(rect.area)).width <= w - 4) { + ctx.fillText(String(rect.area), cx, cy); + } +} + +// The rectangle a human is dragging out, labelled with its current size. +// Player colour when it matches the roll and fits, grey when the size is +// wrong, red when it overlaps. +function drawDragging(r, color, cell) { + const verdict = judgeDrawn(r); + const tint = !verdict.fits ? pal.bad : verdict.ok ? color : pal.wrong; + const x = r.x * cell; + const y = r.y * cell; + const w = r.w * cell; + const h = r.h * cell; + ctx.fillStyle = tint; + ctx.globalAlpha = verdict.ok ? 0.55 : 0.3; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = 1; + ctx.strokeStyle = shade(tint, -0.3); + ctx.lineWidth = 2.5; + ctx.strokeRect(x + 1.5, y + 1.5, w - 3, h - 3); + + const label = `${r.w} × ${r.h}${verdict.ok ? ' ✓' : ''}`; + const size = Math.max(12, Math.min(cell * 0.6, 24)); + ctx.font = `900 ${size}px system-ui, sans-serif`; + const tw = ctx.measureText(label).width + 12; + const th = size + 8; + // Tag just above the rectangle (or inside the top if there's no room). + const tx = Math.max(2, Math.min(x + w / 2 - tw / 2, cssSize - tw - 2)); + const ty = y - th - 4 >= 0 ? y - th - 4 : y + 4; + ctx.fillStyle = pal.tagBg; + ctx.beginPath(); + if (ctx.roundRect) ctx.roundRect(tx, ty, tw, th, 6); + else ctx.rect(tx, ty, tw, th); + ctx.fill(); + ctx.fillStyle = pal.tagInk; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(label, tx + tw / 2, ty + th / 2 + 1); +} + +function drawHoverCell({ c, r }, color, cell) { + ctx.strokeStyle = color; + ctx.lineWidth = 2.5; + ctx.strokeRect(c * cell + 2, r * cell + 2, cell - 4, cell - 4); +} + +function drawPreview(p, color, cell) { + const x = p.x * cell; + const y = p.y * cell; + const w = p.w * cell; + const h = p.h * cell; + ctx.fillStyle = p.valid ? color : pal.bad; + ctx.globalAlpha = p.valid ? 0.35 : 0.22; + ctx.fillRect(x, y, w, h); + ctx.globalAlpha = 1; + ctx.setLineDash([6, 4]); + ctx.strokeStyle = p.valid ? shade(color, -0.3) : pal.bad; + ctx.lineWidth = 2.5; + ctx.strokeRect(x + 1.5, y + 1.5, w - 3, h - 3); + ctx.setLineDash([]); + if (!p.valid) { + ctx.fillStyle = pal.bad; + ctx.font = `800 ${Math.max(10, Math.min(cell * 0.5, 20))}px system-ui, sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('✕', x + w / 2, y + h / 2); + } +} + +// Darken (negative amount) or lighten a #rrggbb colour. +function shade(hex, amount) { + const n = parseInt(hex.slice(1), 16); + const mix = (v) => Math.round(amount < 0 ? v * (1 + amount) : v + (255 - v) * amount); + const r = mix((n >> 16) & 255); + const g = mix((n >> 8) & 255); + const b = mix(n & 255); + return `rgb(${r}, ${g}, ${b})`; +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + window.addEventListener('resize', resizeBoard); +} + +export { PLACE_FX_MS, animateBoard, drawBoard, render, renderDie, resizeBoard, setMsg }; diff --git a/apps/multiplication/src/game/settings.js b/apps/multiplication/src/game/settings.js new file mode 100644 index 0000000..e72640f --- /dev/null +++ b/apps/multiplication/src/game/settings.js @@ -0,0 +1,35 @@ +// Settings: part of the multiplication game (split from the old js/game.js). + +import { CPU_STEP_MS, DEFAULT_SETTINGS, SETTINGS_KEY } from './base.js'; +import { game } from './play.js'; + +let settings; + +function loadSettings() { + try { + const saved = JSON.parse(localStorage.getItem(SETTINGS_KEY)); + if (saved) return { ...DEFAULT_SETTINGS, ...saved }; + } catch (_) { + // storage unavailable or corrupt: fall back to defaults + } + return { ...DEFAULT_SETTINGS }; +} + +function saveSettings() { + try { + localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings)); + } catch (_) { + // not fatal: settings just won't persist + } +} + +function cpuStep() { + return CPU_STEP_MS[(game || settings).cpuSpeed] || CPU_STEP_MS.normal; +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + settings = loadSettings(); +} + +export { cpuStep, saveSettings, settings }; diff --git a/apps/multiplication/src/game/shop.js b/apps/multiplication/src/game/shop.js new file mode 100644 index 0000000..8690fb5 --- /dev/null +++ b/apps/multiplication/src/game/shop.js @@ -0,0 +1,424 @@ +// Shop: part of the multiplication game (split from the old js/game.js). + +import * as Cosmetics from '@blockout/progress/cosmetics'; +import * as Progress from '@blockout/progress'; +import { $, el } from './base.js'; +import { celebrate, formatPoints, progress, saveProgress, toast } from './wallet.js'; +import { applyTheme, chooseCosmetic, icon, openStickers, openWardrobe, setWardrobeFilter, syncBoardLocks, syncModeLocks, syncSettingsUI, wardrobeAvailable, wardrobeFilter } from './wardrobe.js'; +import { make } from './play.js'; +import { tablesLabel, updatePracticePreview } from './practice.js'; + +let ACTIVATE; + +const BUY_REASONS = { + season: (r) => `In the shop in ${r.when}`, + points: (r) => `Need ${formatPoints(r.missing)} more`, + requires: (r) => `Unlock ${Progress.shopItem(r.requires).name.split(':')[0]} first`, + win: (r) => `Win a game on ${r.size}×${r.size} first`, + master: (r) => `Master your Times Table up to ${r.size} × ${r.size} first`, +}; + +// Opened from a results screen, the Shop / Wardrobe / Stickers show only what +// their button promised; this line says so and can switch to everything. +function filterNote(text, showAll) { + const note = make('p', 'filter-note', `${text} · `); + const btn = make('button', 'link-btn', 'Show everything'); + btn.type = 'button'; + btn.addEventListener('click', showAll); + note.append(btn); + return note; +} + +// Bottom bar of the Shop and Wardrobe: points you have, what an item would +// leave you with while it's hovered, the All / Available toggle and Done. +function renderWalletBar(prefix, onlyAvailable) { + $(`${prefix}-wallet`).textContent = formatPoints(progress.wallet); + previewCost(prefix, null); + for (const b of $(`${prefix}-view`).querySelectorAll('button')) { + const on = (b.dataset.view === 'available') === onlyAvailable; + b.classList.toggle('selected', on); + b.setAttribute('aria-pressed', on); + } +} + +function previewCost(prefix, cost) { + const after = $(`${prefix}-wallet-after`); + after.replaceChildren(); + after.className = 'wallet-after'; + if (cost == null) return; + const left = progress.wallet - cost; + if (left >= 0) { + after.append(' ', make('span', 'wallet-cost', `− ${formatPoints(cost)}`), ' = ', make('strong', 'wallet-left', formatPoints(left))); + } else { + after.classList.add('short'); + after.append(` · need ${formatPoints(-left)} more`); + } +} + +let shopFilter = null; // Set of item ids (affordable when picked), or null for everything + +let shopHighlight = null; + +function renderShop(highlight = shopHighlight) { + shopHighlight = highlight; + el.shopBody.innerHTML = ''; + renderWalletBar('shop', !!shopFilter); + if (shopFilter && !shopFilter.size) el.shopBody.append(make('p', 'filter-note', 'Nothing to unlock right now. Keep playing to earn points!')); + let group = null; + for (const item of Progress.SHOP) { + if (item.cosmetic) continue; // looks are unlocked in the Wardrobe + if (shopFilter && !shopFilter.has(item.id)) continue; + if (item.group !== group) { + group = item.group; + el.shopBody.append(make('h3', 'stats-heading', group)); + } + const row = make('div', 'shop-item' + (item.id === highlight ? ' highlight' : '')); + const hiddenStep = !Progress.isUnlocked(progress, item.id) && Progress.isHidden(progress, item.id); + if (hiddenStep) row.classList.add('skeleton-row'); + const name = make('span', 'shop-name', hiddenStep ? 'Locked' : item.name); + if (!hiddenStep && item.perk) name.append(make('span', 'shop-perk', `🎁 ${item.perk}`)); + row.append(name); + const owned = Progress.isUnlocked(progress, item.id); + if (owned) { + row.classList.add('owned'); + const owned = make('span', 'shop-owned'); + owned.append(icon('check'), ' Unlocked'); + row.append(owned); + } else { + const check = Progress.canBuy(progress, item.id); + const btn = make('button', 'btn btn-small' + (check.ok ? ' btn-primary' : ''), `🪙 ${formatPoints(item.price)}`); + btn.type = 'button'; + btn.dataset.buy = item.id; + btn.dataset.cost = item.price; + btn.disabled = !check.ok; + row.append(btn); + if (hiddenStep) { + btn.replaceChildren(icon('lock')); + btn.setAttribute('aria-label', 'Locked'); + const earn = Progress.canBuy(progress, item.id); + row.append(make('span', 'shop-why', earn.reason === 'win' ? `Win a game on ${earn.size}×${earn.size} to reveal it` : earn.reason === 'master' ? `Master your Times Table up to ${earn.size} × ${earn.size} to reveal it (${earn.mastered} of ${earn.need})` : 'Unlock the one before it first')); + } else if (!check.ok) row.append(make('span', 'shop-why', BUY_REASONS[check.reason](check))); + } + el.shopBody.append(row); + } +} + +function shopAvailable() { + return new Set(Progress.SHOP.filter((i) => !i.cosmetic && Progress.canBuy(progress, i.id).ok).map((i) => i.id)); +} + +// What was tapped to open the shop, so buying it switches that exact choice on. +let shopSource = null; // { item, el } + +// onlyAffordable: from a results screen, list just what can be bought right now +function openShop(highlight, sourceEl = null, onlyAffordable = false) { + shopSource = highlight && sourceEl ? { item: highlight, el: sourceEl } : null; + shopFilter = onlyAffordable ? shopAvailable() : null; + renderShop(highlight); + el.shopDialog.hidden = false; + const target = highlight && el.shopBody.querySelector('.shop-item.highlight'); + if (target) target.scrollIntoView({ block: 'center' }); + else el.shopDialog.querySelector('.dialog').scrollTop = 0; + el.shopDone.focus({ preventScroll: true }); +} + +function closeShop() { + el.shopDialog.hidden = true; +} + +// Buy an item and switch it on; used by the shop and the quick unlock. +function purchase(item) { + if (!Progress.buy(progress, item.id).ok) return false; + toast(`🔓 Unlocked: ${item.name}`, `-${formatPoints(item.price)} points`); + celebrate(Progress.awardAchievements(progress, { event: 'buy' })); + saveProgress(); + refreshAvailableFilters(); + if (!el.shopDialog.hidden) renderShop(item.id); + syncSettingsUI(); + syncBoardLocks(); + syncModeLocks(); + updatePracticePreview(); + refreshOpenNudges(); + activatePurchase(item); + return true; +} + +// After a purchase, "Available" lists catch up: newly revealed tiers appear, +// things you can't afford any more go, and what you've bought stays (✓ Unlocked). +function refreshAvailableFilters() { + if (shopFilter) { + const bought = [...shopFilter].filter((id) => Progress.isUnlocked(progress, id)); + shopFilter = new Set([...shopAvailable(), ...bought]); + } + if (wardrobeFilter) { + const owned = [...wardrobeFilter].filter((key) => { + const [cat, opt] = key.split(':'); + return cat === 'title' || Progress.isUnlocked(progress, Cosmetics.itemFor(cat, opt)); + }); + setWardrobeFilter(new Set([...wardrobeAvailable(), ...owned])); + } +} + +// ---- quick unlock: tapping a locked thing asks "Unlock X for 🪙 N?" (no whole shop) + +let unlockAsk = null; // { item, el } + +// What to do to reveal a locked board or difficulty. +function revealHint(r) { + if (r.reason === 'win') return `Win a game against the CPU on the ${r.size}×${r.size} board to reveal the next board.`; + const extra = r.practice ? ` and get the ${r.practice} ${r.practice === 1 ? 'fact' : 'facts'} that need practice back on track` : ''; + const which = r.faces ? `using ${tablesLabel(r.faces)}` : `up to ${r.size} × ${r.size}`; + return `Master ${r.need} of the ${r.total} facts ${which} on your Times Table${extra} to reveal the next difficulty. You have ${r.mastered} so far. Practice helps!`; +} + +function confirmUnlock(itemId, sourceEl) { + let item = Progress.shopItem(itemId); + const check = Progress.canBuy(progress, itemId); + let offer = check.ok; + let message; + if (Progress.isHidden(progress, itemId)) { + // a locked placeholder: offer the next step, without saying what this one is + item = Progress.shopItem(Progress.nextInChain(progress, itemId)); + const next = Progress.canBuy(progress, item.id); + if (next.reason === 'win' || next.reason === 'master') { + // earned, not bought: a win on the board before, or knowing the times table + unlockAsk = null; + el.unlockTitle.textContent = '🔒 Locked'; + el.unlockPrice.textContent = ''; + el.unlockMessage.textContent = revealHint(next); + el.unlockMessage.hidden = false; + el.unlockYes.hidden = true; + el.unlockNo.textContent = 'OK'; + el.unlockDialog.hidden = false; + el.unlockNo.focus(); + return; + } + offer = next.ok; + message = 'Unlock this first to see what comes next.'; + if (next.reason === 'points') message += ` You need ${formatPoints(next.missing)} more points for it.`; + sourceEl = null; + } else if (check.reason === 'requires') { + // offer the thing that has to come first + const first = Progress.shopItem(check.requires); + const firstCheck = Progress.canBuy(progress, first.id); + message = `${item.name} needs ${first.name} first.`; + item = first; + offer = firstCheck.ok; + if (!offer) message += firstCheck.reason === 'points' ? ` You need ${formatPoints(firstCheck.missing)} more points for it.` : ''; + sourceEl = null; // what gets switched on is the prerequisite + } else if (check.reason === 'points') { + message = `You need ${formatPoints(check.missing)} more points. Keep playing to earn them!`; + } else if (check.reason === 'season') { + message = `This one is in the shop in ${check.when}.`; + } + unlockAsk = offer ? { item, el: sourceEl } : null; + el.unlockTitle.textContent = offer ? `Unlock ${item.name}?` : item.name; + el.unlockPrice.textContent = `🪙 ${formatPoints(item.price)} · you have ${formatPoints(progress.wallet)}`; + // what it gives you (e.g. the bonus for finishing longer practice rounds) + const perk = offer && item.perk ? `🎁 ${item.perk}` : ''; + el.unlockMessage.textContent = [perk, message].filter(Boolean).join(' '); + el.unlockMessage.hidden = !perk && !message; + el.unlockYes.hidden = !offer; + if (offer) el.unlockYes.textContent = `Unlock for 🪙 ${formatPoints(item.price)}`; + el.unlockNo.textContent = offer ? 'Cancel' : 'OK'; + el.unlockDialog.hidden = false; + (offer ? el.unlockYes : el.unlockNo).focus(); +} + +function closeUnlock() { + el.unlockDialog.hidden = true; + unlockAsk = null; +} + +// ---- "you can afford something" button on the results screens + +// Shop items (not Wardrobe looks) you can afford right now +function affordableCount() { + return Progress.SHOP.filter((item) => !item.cosmetic && Progress.canBuy(progress, item.id).ok).length; +} + +// Things in the Wardrobe you own (or titles you've earned) but haven't looked at yet. +function wardrobeKeys() { + const keys = []; + for (const cat of Cosmetics.CATEGORIES) { + for (const opt of cat.options) { + if (opt.price && Progress.isUnlocked(progress, Cosmetics.itemFor(cat.id, opt.id))) keys.push(`${cat.id}:${opt.id}`); + } + } + for (const t of Cosmetics.TITLES) if (t.id !== 'none' && t.earned(progress)) keys.push(`title:${t.id}`); + return keys; +} + +const seenList = (kind) => (progress.seen = progress.seen || {})[kind] || (progress.seen[kind] = []); + +const newWardrobe = () => wardrobeKeys().filter((k) => !seenList('wardrobe').includes(k)).length; + +// Wardrobe looks you could buy right now (the Wardrobe's share of the shop) +const affordableLooks = () => Progress.SHOP.filter((i) => i.cosmetic && Progress.canBuy(progress, i.id).ok).length; + +const newStickers = () => Object.keys(progress.stickers || {}).filter((id) => !seenList('stickers').includes(id)).length; + +const spareStickers = () => Object.values(progress.stickers || {}).reduce((n, c) => n + Math.max(0, c - 1), 0); + +function markSeen(kind, keys) { + progress.seen[kind] = [...new Set([...seenList(kind), ...keys])]; + saveProgress(); +} + +// Shop / Wardrobe / Stickers buttons on a results screen ('results' or 'pd'), only when there's something new. +function refreshNudges(prefix) { + const shop = $(`${prefix}-shop-btn`); + const wardrobe = $(`${prefix}-wardrobe-btn`); + const stickers = $(`${prefix}-stickers-btn`); + const n = affordableCount(); + shop.hidden = n === 0; + if (n) shop.replaceChildren(icon('shopping-cart'), ` Shop · ${n} new ${n === 1 ? 'unlock' : 'unlocks'}`); + const w = newWardrobe(); + const looks = affordableLooks(); + wardrobe.hidden = !w && !looks; + const wParts = [w && `${w} new`, looks && `${looks} to unlock`].filter(Boolean); + if (!wardrobe.hidden) wardrobe.replaceChildren(icon('shirt'), ` Wardrobe · ${wParts.join(' · ')}`); + const fresh = newStickers(); + const spares = spareStickers(); + stickers.hidden = !fresh && !spares; + const parts = [fresh && `${fresh} new`, spares && `${spares} ${spares === 1 ? 'spare' : 'spares'}`].filter(Boolean); + if (!stickers.hidden) stickers.replaceChildren(icon('sticker'), ` Stickers · ${parts.join(' · ')}`); +} + +function refreshOpenNudges() { + if (!el.gameOver.hidden) refreshNudges('results'); + if (!el.practiceDone.hidden) refreshNudges('pd'); +} + +// ---- switch a purchase on straight away + +// Click a control (even inside a closed dialog) as if the player had tapped it. +function tap(selector) { + const target = document.querySelector(selector); + if (target && !target.dataset.unlock) target.click(); +} + +const setOption = (key, value) => () => tap(`[data-setting="${key}"] [data-value="${value}"]`); + +function activatePurchase(item) { + const source = shopSource; + shopSource = null; + if (item.cosmetic) chooseCosmetic(item.cosmetic[0], item.cosmetic[1]); // put it on + else if (source && source.item === item.id && source.el.isConnected) source.el.click(); // the exact thing tapped + else if (ACTIVATE[item.id]) ACTIVATE[item.id](); + applyTheme(false); // e.g. golden dice +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + for (const [prefix, selector] of [['shop', '[data-cost]'], ['wardrobe', '.wardrobe-chip[data-cost]']]) { + const dialog = $(prefix); + const show = (e) => { + const target = e.target.closest(selector); + previewCost(prefix, target ? Number(target.dataset.cost) : null); + }; + dialog.addEventListener('pointerover', show); + dialog.addEventListener('focusin', show); + dialog.addEventListener('pointerleave', () => previewCost(prefix, null)); + } + + el.shopBtn.addEventListener('click', () => openShop()); + + el.shopDone.addEventListener('click', closeShop); + + el.resultsShopBtn.addEventListener('click', () => openShop(null, null, true)); + + el.pdShopBtn.addEventListener('click', () => openShop(null, null, true)); + + for (const prefix of ['results', 'pd']) { + $(`${prefix}-wardrobe-btn`).addEventListener('click', () => openWardrobe(true)); + $(`${prefix}-stickers-btn`).addEventListener('click', () => openStickers(true)); + } + + el.shopDialog.addEventListener('click', (e) => { + if (e.target === el.shopDialog) return closeShop(); + const view = e.target.closest('#shop-view [data-view]'); + if (view) { + shopFilter = view.dataset.view === 'available' ? shopAvailable() : null; + renderShop(null); + el.shopDialog.querySelector('.dialog').scrollTop = 0; + return; + } + const btn = e.target.closest('[data-buy]'); + if (btn) purchase(Progress.shopItem(btn.dataset.buy)); + }); + + el.unlockYes.addEventListener('click', () => { + const ask = unlockAsk; + closeUnlock(); + if (!ask) return; + shopSource = ask.el ? { item: ask.item.id, el: ask.el } : null; + purchase(ask.item); + }); + + el.unlockNo.addEventListener('click', closeUnlock); + + el.unlockDialog.addEventListener('click', (e) => { + if (e.target === el.unlockDialog) closeUnlock(); + }); + + el.unlockDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeUnlock(); + }); + + // Bought straight from the shop list: switch on the obvious choice. Items with + // more than one choice and no clear favourite (CPU speed, placing) are left alone. + ACTIVATE = { + multiplayer: () => (tap('[data-mode="multi"]'), tap('#multi-kind [data-kind="local"]')), + players3: () => (tap('[data-mode="multi"]'), tap('#multi-kind [data-kind="local"]'), tap('#player-count [data-count="3"]')), + players4: () => (tap('[data-mode="multi"]'), tap('#multi-kind [data-kind="local"]'), tap('#player-count [data-count="4"]')), + practice: () => tap('[data-mode="practice"]'), + ...Object.fromEntries(Progress.DIFFICULTIES.map((d) => [Progress.practiceExpertId(d), () => (tap('[data-mode="practice"]'), tap('#practice-level [data-level="expert"]'))])), + ...Object.fromEntries( + Progress.DIFFICULTIES.flatMap((d) => [ + [Progress.gameExpertId(d), () => (tap('[data-mode="single"]'), tap('#game-level [data-level="expert"]'))], + ...[30, 10, 5].map((secs) => [Progress.gameTimerId(secs, d), () => (tap('[data-mode="single"]'), tap('#game-level [data-level="expert"]'), tap(`#game-timer [data-secs="${secs}"]`))]), + ]) + ), + ...Object.fromEntries( + Progress.DIFFICULTIES.flatMap((d) => [10, 20].map((n) => [Progress.practiceCountId(n, d), () => (tap('[data-mode="practice"]'), tap(`#practice-count [data-count="${n}"]`))])) + ), + ...Object.fromEntries( + Progress.DIFFICULTIES.flatMap((d) => + [30, 10, 5].map((secs) => [Progress.practiceTimerId(secs, d), () => (tap('[data-mode="practice"]'), tap('#practice-level [data-level="expert"]'), tap(`#practice-timer [data-secs="${secs}"]`))]) + ) + ), + ...Object.fromEntries(Array.from({ length: 12 }, (_, i) => [`table${i + 1}`, () => (tap('[data-mode="practice"]'), updatePracticePreview())])), + board8: () => tap('#board-size [data-size="8"]'), + board10: () => tap('#board-size [data-size="10"]'), + board12: () => tap('#board-size [data-size="12"]'), + board16: () => tap('#board-size [data-size="16"]'), + board24: () => tap('#board-size [data-size="24"]'), + board20: () => tap('#board-size [data-size="20"]'), + boardCustom: () => tap('#board-size [data-size="custom"]'), + 'difficulty.medium': setOption('difficulty', 'medium'), + 'difficulty.hard': setOption('difficulty', 'hard'), + 'difficulty.tricky': setOption('difficulty', 'tricky'), + 'difficulty.master': setOption('difficulty', 'master'), + 'difficulty.legend': setOption('difficulty', 'legend'), + diceMode: setOption('diceMode', 'real'), + autoRoll: setOption('autoRoll', 'auto'), + fitRolls: setOption('fitRolls', 'always'), + answerTime30: setOption('answerTime', '30'), + answerTime10: setOption('answerTime', '10'), + answerTime5: setOption('answerTime', '5'), + cpuInstant: setOption('cpuSteps', 'instant'), + cpuEnd: setOption('cpuEnd', 'button'), + showProgress: setOption('showProgress', 'true'), + firstInCorner: setOption('firstInCorner', 'true'), + timesMastered: setOption('timesMastered', 'time'), + timesLearning: setOption('timesLearning', 'time'), + timesPractice: setOption('timesPractice', 'time'), + }; + + el.shopDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeShop(); + }); +} + +export { affordableCount, affordableLooks, confirmUnlock, filterNote, markSeen, newStickers, newWardrobe, refreshAvailableFilters, refreshNudges, refreshOpenNudges, renderShop, renderWalletBar, seenList, spareStickers, tap, wardrobeKeys }; diff --git a/apps/multiplication/src/game/stats.js b/apps/multiplication/src/game/stats.js new file mode 100644 index 0000000..c1be2f0 --- /dev/null +++ b/apps/multiplication/src/game/stats.js @@ -0,0 +1,392 @@ +// All-time stats: part of the multiplication game (split from the old js/game.js). + +import * as Progress from '@blockout/progress'; +import { $, el } from './base.js'; +import { progress, saveProgress } from './wallet.js'; +import { game, isDefaultPlayer, make, renderFactGrid, renderFactLegend, renderPractice } from './play.js'; +import { playerFacts } from './practice.js'; +import { TOURNAMENT_NAMES, ordinal } from './classroom.js'; + +let howto; + +// Finished games are kept in this browser only, newest last. +const HISTORY_KEY = 'blockout.history'; + +const HISTORY_LIMIT = 200; + +function loadHistory() { + try { + const saved = JSON.parse(localStorage.getItem(HISTORY_KEY)); + return Array.isArray(saved) ? saved : []; + } catch (_) { + return []; + } +} + +function saveHistory(history) { + try { + localStorage.setItem(HISTORY_KEY, JSON.stringify(history.slice(-HISTORY_LIMIT))); + } catch (_) { + // not fatal: stats just won't persist + } +} + +function recordGame(winnerIndexes) { + const tie = winnerIndexes.size > 1; + const history = loadHistory(); + history.push({ + at: Date.now(), + size: game.size, + duration: Date.now() - game.startedAt, + turns: game.turns, + difficulty: game.difficulty, + vsComputer: game.mode === 'single', // "vs CPU" record counts single-player games only + players: game.players.map((p, i) => ({ + name: p.name, + cpu: Boolean(p.cpu), + score: p.score, + bonus: p.bonus, + result: winnerIndexes.has(i) ? (tie ? 'tie' : 'win') : 'loss', + answered: p.answered, + firstTry: p.firstTry, + timeouts: p.stats.timeouts, + times: p.stats.times.map(Math.round), + missed: p.stats.missed, + })), + }); + saveHistory(history); +} + +// Practice rounds share the history list, marked kind: 'practice'. +function recordPracticeRound(pr) { + const history = loadHistory(); + history.push({ + kind: 'practice', + at: Date.now(), + duration: Date.now() - pr.startedAt, + name: pr.name, + size: pr.max, + level: pr.expert ? 'expert' : 'learn', + tables: pr.tables, + count: pr.count, + firstTry: pr.firstTry, + times: pr.times, + points: pr.points, + }); + saveHistory(history); +} + +function formatDuration(ms) { + const mins = Math.round(ms / 60000); + if (mins < 1) return `${Math.round(ms / 1000)} s`; + if (mins < 60) return `${mins} min`; + return `${Math.floor(mins / 60)} h ${mins % 60} min`; +} + +function renderHistory() { + const history = loadHistory(); + const body = el.historyBody; + body.innerHTML = ''; + const factPlayers = Object.values(progress.facts).filter((p) => Object.keys(p.facts).length); + el.resetHistory.hidden = !history.length && !factPlayers.length; + if (!history.length && !factPlayers.length) { + body.append(make('p', 'history-empty', 'No games or practice yet. Play one and your stats will show up here!')); + return; + } + const games = history.filter((g) => !g.kind); + const rounds = history.filter((g) => g.kind === 'practice'); + const classes = history.filter((g) => g.kind === 'class' && !g.type); + const pairGames = history.filter((g) => g.kind === 'class' && g.type === 'pairs'); + const tournaments = history.filter((g) => g.kind === 'class' && g.type === 'tournament'); + if (history.length) renderGameHistory(body, games, rounds, classes, pairGames, tournaments); + + // Lifetime times tables for everyone who has answered anything, in games or practice + const names = new Map(); + for (const g of games) for (const p of g.players) if (!p.cpu) names.set(Progress.playerKey(p.name), p.name); + for (const p of factPlayers) if (!names.has(Progress.playerKey(p.name))) names.set(Progress.playerKey(p.name), p.name); + body.append(make('h3', 'stats-heading centered', 'Times Table')); + body.append(renderFactLegend()); + for (const name of names.values()) { + const facts = playerFacts(name); + const label = isDefaultPlayer(name) ? '' : name; // "You" needs no label + body.append(renderFactGrid(label, facts)); + const toPractice = Object.entries(facts) + .filter(([, f]) => f.wrong + f.timeouts > 0) + .sort(([, a], [, b]) => b.wrong + b.timeouts - (a.wrong + a.timeouts)) + .slice(0, 8) + .map(([fact, f]) => ({ fact, count: f.wrong + f.timeouts, answers: f.said, timeouts: f.timeouts, rate: `${f.firstTry} of ${f.asked} right first time` })); + const none = Object.keys(facts).length ? 'Nothing missed so far! 🌟' : 'No answers yet.'; + body.append(renderPractice(label ? `${label}: facts to practice` : 'Facts to practice', 'var(--line)', toPractice, none)); + } + + if (games.length || classes.length || pairGames.length || tournaments.length) renderRecentGames(body, [...games, ...classes, ...pairGames, ...tournaments].sort((a, b) => a.at - b.at)); +} + +// Tiles and the per-player table (finished games only). +const DIFFICULTY_NAMES = { easy: 'Easy', medium: 'Medium', hard: 'Hard' }; + +const DIFFICULTY_ORDER = { easy: 0, medium: 1, hard: 2 }; + +const avgTime = (times) => (times.length ? `${(times.reduce((x, t) => x + t, 0) / times.length / 1000).toFixed(1)} s` : '—'); + +const percent = (n, of) => (of ? `${Math.round((n / of) * 100)}%` : '—'); + +// A table whose first column is a two-line label (bold title, muted details). +function statsTable(headings, rows) { + const wrap = make('div', 'stats-table-wrap'); + const table = make('table', 'stats-table by-type'); + const head = table.createTHead().insertRow(); + for (const label of headings) head.append(make('th', null, label)); + const tbody = table.createTBody(); + for (const row of rows) { + const tr = tbody.insertRow(); + const th = make('th'); + th.append(make('span', 'row-title', row.title), make('span', 'row-sub', row.sub)); + tr.append(th); + for (const v of row.values) tr.append(make('td', null, String(v))); + } + wrap.append(table); + return wrap; +} + +// Tiles, then one row per game type (mode · board · difficulty), per practice +// type and per classroom game type. +function renderGameHistory(body, games, rounds, classes, pairGames, tournaments) { + const vs = games + .filter((g) => g.vsComputer && g.players.some((p) => !p.cpu)) + .map((g) => g.players.find((p) => !p.cpu).result); + const count = (r) => vs.filter((x) => x === r).length; + const tiles = make('div', 'tiles'); + const tile = (value, label) => { + const t = make('div', 'tile'); + t.append(make('span', 'tile-value', value), make('span', 'tile-label', label)); + return t; + }; + const played = games.length + classes.length + pairGames.length + tournaments.length; // class games are games too + tiles.append( + tile(`${played}${rounds.length ? ` + ${rounds.length}` : ''}`, rounds.length ? 'games + practice rounds' : 'games played'), + tile(formatDuration([...games, ...rounds, ...classes, ...pairGames].reduce((s, g) => s + g.duration, 0)), 'time played'), + tile(vs.length ? `${count('win')}–${count('loss')}–${count('tie')}` : '—', 'vs CPU (W–L–T)') + ); + body.append(tiles); + + if (games.length) { + const groups = new Map(); + for (const g of games) { + const mode = g.vsComputer ? 'single' : 'multi'; + const difficulty = g.difficulty || 'easy'; // older games were all 6-sided dice + const key = `${mode}|${g.size}|${difficulty}`; + const row = groups.get(key) || { mode, size: g.size, difficulty, games: 0, wins: 0, best: 0, asked: 0, firstTry: 0, times: [] }; + row.games++; + const humans = g.players.filter((p) => !p.cpu); + if (humans.some((p) => p.result === 'win')) row.wins++; + for (const p of humans) { + row.best = Math.max(row.best, p.score); + row.asked += p.answered + p.timeouts; + row.firstTry += p.firstTry; + row.times.push(...p.times); + } + groups.set(key, row); + } + const rows = [...groups.values()] + .sort((a, b) => (a.mode === b.mode ? 0 : a.mode === 'single' ? -1 : 1) || a.size - b.size || DIFFICULTY_ORDER[a.difficulty] - DIFFICULTY_ORDER[b.difficulty]) + .map((r) => ({ + title: r.mode === 'single' ? 'Single player' : 'Multiplayer', + sub: `${r.size}×${r.size} · ${DIFFICULTY_NAMES[r.difficulty]}`, + values: [r.games, r.wins, r.best, percent(r.firstTry, r.asked), avgTime(r.times)], + })); + body.append(make('h3', 'stats-heading', 'Games')); + body.append(statsTable(['', 'Games', 'Wins', 'Best score', 'Right 1st try', 'Avg answer'], rows)); + } + + if (rounds.length) { + const groups = new Map(); + for (const r of rounds) { + const key = `${r.size}|${r.level}`; + const row = groups.get(key) || { size: r.size, level: r.level, rounds: 0, best: null, asked: 0, firstTry: 0, times: [] }; + row.rounds++; + if (!row.best || r.firstTry / r.count > row.best.firstTry / row.best.count) row.best = r; + row.asked += r.count; + row.firstTry += r.firstTry; + row.times.push(...r.times); + groups.set(key, row); + } + const rows = [...groups.values()] + .sort((a, b) => a.size - b.size || (a.level === b.level ? 0 : a.level === 'learn' ? -1 : 1)) + .map((r) => ({ + title: 'Practice', + sub: `up to ×${r.size} · ${r.level === 'expert' ? 'Expert' : 'Learn'}`, + values: [r.rounds, `${r.best.firstTry}/${r.best.count}`, percent(r.firstTry, r.asked), avgTime(r.times)], + })); + body.append(make('h3', 'stats-heading', 'Practice')); + body.append(statsTable(['', 'Rounds', 'Best round', 'Right 1st try', 'Avg answer'], rows)); + } + + if (classes.length) { + const groups = new Map(); + for (const g of classes) { + const key = `${g.size}|${g.difficulty}`; + const row = groups.get(key) || { size: g.size, difficulty: g.difficulty, games: 0, bestRank: null, best: 0, asked: 0, firstTry: 0, times: [] }; + row.games++; + if (!row.bestRank || g.rank < row.bestRank.rank) row.bestRank = g; + row.best = Math.max(row.best, g.score); + row.asked += g.answered + g.timeouts; + row.firstTry += g.firstTry; + row.times.push(...g.times); + groups.set(key, row); + } + const rows = [...groups.values()] + .sort((a, b) => a.size - b.size || DIFFICULTY_ORDER[a.difficulty] - DIFFICULTY_ORDER[b.difficulty]) + .map((r) => ({ + title: 'Classroom', + sub: `${r.size}×${r.size} · ${DIFFICULTY_NAMES[r.difficulty]}`, + values: [r.games, `${ordinal(r.bestRank.rank)} of ${r.bestRank.of}`, r.best, percent(r.firstTry, r.asked), avgTime(r.times)], + })); + body.append(make('h3', 'stats-heading', 'Classroom')); + body.append(statsTable(['', 'Games', 'Best place', 'Best score', 'Right 1st try', 'Avg answer'], rows)); + } + + if (pairGames.length) { + const groups = new Map(); + for (const g of pairGames) { + const key = `${g.size}|${g.difficulty}`; + const row = groups.get(key) || { size: g.size, difficulty: g.difficulty, games: 0, wins: 0, best: 0, asked: 0, firstTry: 0, times: [] }; + row.games++; + if (g.result === 'win') row.wins++; + row.best = Math.max(row.best, g.score); + row.asked += g.answered + g.timeouts; + row.firstTry += g.firstTry; + row.times.push(...g.times); + groups.set(key, row); + } + const rows = [...groups.values()] + .sort((a, b) => a.size - b.size || DIFFICULTY_ORDER[a.difficulty] - DIFFICULTY_ORDER[b.difficulty]) + .map((r) => ({ + title: 'Pairs', + sub: `${r.size}×${r.size} · ${DIFFICULTY_NAMES[r.difficulty]}`, + values: [r.games, r.wins, r.best, percent(r.firstTry, r.asked), avgTime(r.times)], + })); + body.append(make('h3', 'stats-heading', 'Classroom: pairs')); + body.append(statsTable(['', 'Games', 'Wins', 'Best score', 'Right 1st try', 'Avg answer'], rows)); + } + + if (tournaments.length) { + const groups = new Map(); + for (const g of tournaments) { + const row = groups.get(g.format) || { format: g.format, played: 0, titles: 0, best: null, wins: 0, losses: 0 }; + row.played++; + if (g.place === 1) row.titles++; + if (!row.best || g.place < row.best.place) row.best = g; + row.wins += g.wins; + row.losses += g.losses; + groups.set(g.format, row); + } + const rows = [...groups.values()].map((r) => ({ + title: 'Tournament', + sub: TOURNAMENT_NAMES[r.format] || r.format, + values: [r.played, r.titles, `${ordinal(r.best.place)} of ${r.best.of}`, `${r.wins}–${r.losses}`], + })); + body.append(make('h3', 'stats-heading', 'Classroom: tournaments')); + body.append(statsTable(['', 'Played', 'Titles', 'Best finish', 'Games W–L'], rows)); + } +} + +function renderRecentGames(body, history) { + body.append(make('h3', 'stats-heading', 'Recent games')); + const recent = make('ol', 'recent'); + for (const g of history.slice(-10).reverse()) { + const li = make('li'); + const when = new Date(g.at).toLocaleString(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }); + if (g.kind === 'class' && g.type === 'tournament') { + li.append(make('span', 'meta', `${when} · Class ${g.code} · ${TOURNAMENT_NAMES[g.format] || 'Tournament'}`)); + li.append(document.createTextNode(`${g.name} ${g.wins}–${g.losses} · `)); + li.append(make('span', 'winner', g.place === 1 ? '🏆 Champion' : `${ordinal(g.place)} of ${g.of} · 🏆 ${g.champion}`)); + recent.append(li); + continue; + } + if (g.kind === 'class' && g.type === 'pairs') { + li.append(make('span', 'meta', `${when} · Class ${g.code} pairs · ${g.size}×${g.size}`)); + li.append(document.createTextNode(`${g.name} ${g.score} – ${g.opponent} ${g.opponentScore} · `)); + li.append(make('span', 'winner', g.result === 'win' ? `🏆 ${g.name}` : g.result === 'tie' ? 'Tie' : `🏆 ${g.opponent}`)); + recent.append(li); + continue; + } + if (g.kind === 'class') { + li.append(make('span', 'meta', `${when} · Class ${g.code} · ${g.size}×${g.size}`)); + li.append(document.createTextNode(`${g.name} ${g.score} · `)); + li.append(make('span', 'winner', g.rank === 1 && g.of > 1 ? `🏆 1st of ${g.of}` : `${ordinal(g.rank)} of ${g.of}`)); + recent.append(li); + continue; + } + li.append(make('span', 'meta', `${when} · ${g.size}×${g.size}`)); + li.append(document.createTextNode(g.players.map((p) => `${p.name} ${p.score}`).join(' – '))); + const won = g.players.filter((p) => p.result === 'win'); + li.append(document.createTextNode(' · ')); + li.append(make('span', 'winner', won.length ? `🏆 ${won[0].name}` : 'Tie')); + recent.append(li); + } + body.append(recent); +} + +function openHistory() { + renderHistory(); + el.historyDialog.hidden = false; + el.historyDialog.querySelector('.dialog').scrollTop = 0; + el.historyDone.focus(); +} + +function closeHistory() { + el.historyDialog.hidden = true; + el.historyBtn.focus(); +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + el.historyBtn.addEventListener('click', openHistory); + + // How to play popup + howto = $('howto'); + + $('howto-btn').addEventListener('click', () => { + howto.hidden = false; + howto.querySelector('.dialog').scrollTop = 0; + $('howto-done').focus(); + }); + + $('howto-done').addEventListener('click', () => { + howto.hidden = true; + $('howto-btn').focus(); + }); + + howto.addEventListener('click', (e) => { + if (e.target === howto) howto.hidden = true; + }); + + howto.addEventListener('keydown', (e) => { + if (e.key === 'Escape') howto.hidden = true; + }); + + el.historyDone.addEventListener('click', closeHistory); + + el.historyDialog.addEventListener('click', (e) => { + if (e.target === el.historyDialog) closeHistory(); + }); + + el.historyDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeHistory(); + }); + + el.resetHistory.addEventListener('click', () => { + if (!confirm('Delete all game history and times-table stats on this device? Points, unlocks and achievements are kept. This cannot be undone.')) return; + try { + localStorage.removeItem(HISTORY_KEY); + } catch (_) { + // nothing saved anyway + } + progress.facts = {}; + saveProgress(); + renderHistory(); + }); +} + +export { loadHistory, recordGame, recordPracticeRound, saveHistory }; diff --git a/apps/multiplication/src/game/wallet.js b/apps/multiplication/src/game/wallet.js new file mode 100644 index 0000000..58eb512 --- /dev/null +++ b/apps/multiplication/src/game/wallet.js @@ -0,0 +1,406 @@ +// Progress (wallet, unlocks, achievements): part of the multiplication game (split from the old js/game.js). + +import * as Auth from '@blockout/auth'; +import * as Cosmetics from '@blockout/progress/cosmetics'; +import * as Progress from '@blockout/progress'; +import { PROGRESS_KEY, el } from './base.js'; +import { settings } from './settings.js'; +import { look } from './wardrobe.js'; +import { make } from './play.js'; +import { affordableCount, affordableLooks, newStickers, newWardrobe, spareStickers } from './shop.js'; +import { refreshUnlocks } from './cheats.js'; + +let progress, glyphSprites; + +function loadProgress() { + try { + return Progress.normalize(JSON.parse(localStorage.getItem(PROGRESS_KEY))); + } catch (_) { + return Progress.createProgress(); + } +} + +function saveProgress() { + try { + localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress)); + } catch (_) { + // not fatal: progress just won't persist + } + if (Auth) Auth.queueProgress(progress); // signed in: send it to the account too + renderWallet(); +} + +// Swap in another save (the account's, after signing in) and redraw what shows it. +function replaceProgress(next) { + for (const key of Object.keys(progress)) delete progress[key]; + Object.assign(progress, Progress.normalize(next)); + try { + localStorage.setItem(PROGRESS_KEY, JSON.stringify(progress)); + } catch (_) { + // not fatal + } + refreshUnlocks(); + renderWallet(); +} + +function renderWallet() { + el.walletAmount.textContent = formatPoints(progress.wallet); + updateMenuBadges(); +} + +// iPhone-style red count badges on the main-menu Shop, Wardrobe and Stickers buttons. +function updateMenuBadges() { + if (!Cosmetics || !el.shopBtn) return; + setBadge(el.shopBtn, 'Shop', affordableCount(), 'you can unlock'); + setBadge(el.wardrobeBtn, 'Wardrobe', newWardrobe() + affordableLooks(), 'new or ready to unlock'); + setBadge(el.stickersBtn, 'Stickers', newStickers() + spareStickers(), 'new or spare'); +} + +function setBadge(btn, label, count, what) { + let badge = btn.querySelector('.badge'); + if (!count) { + if (badge) badge.remove(); + btn.removeAttribute('aria-label'); + return; + } + if (!badge) { + badge = make('span', 'badge'); + badge.setAttribute('aria-hidden', 'true'); + btn.append(badge); + } + badge.textContent = count > 99 ? '99+' : String(count); + btn.setAttribute('aria-label', `${label}, ${count} ${what}`); +} + +// 10000 → "10,000" (in the reader's locale). +function formatPoints(n) { + return Number(n).toLocaleString(); +} + +// Show newly earned achievements as toasts; returns them for the caller. +// Every big (non-minor) achievement drops one random sticker. +function grantStickers(earned) { + return earned.filter((a) => !a.minor).map(() => Cosmetics.dropSticker(progress)); +} + +function celebrate(earned) { + queueAchievements(earned); + for (const { sticker, isNew } of grantStickers(earned)) { + toast(`🎁 ${isNew ? 'New sticker' : 'Sticker'}: ${sticker.emoji}`, isNew ? `${sticker.rarity} · added to your album` : `a spare you can swap for points`); + } + // one full-screen celebration (bigger for several), not for minor ones like fact speed + const major = earned.filter((a) => !a.minor).length; + if (major) confettiFullScreen(major); + return earned; +} + +// ---- confetti: paper bits that fly, then fall and flutter. Every toast gets a +// burst from itself; achievements also get a full-screen celebration. +// Drawn on a see-through canvas that ignores clicks; skipped for reduced motion. +const CONFETTI_COLORS = ['#e4572e', '#2e86de', '#2a9d5c', '#8e5bd6', '#f2c94c', '#ff8fab']; + +const confetti = { canvas: null, ctx: null, parts: [], frame: null, last: 0 }; + +// Confetti doesn't need retina sharpness; 1× keeps the full-screen canvas small and fast. +const CONFETTI_DPR = 1; + +// Emoji pieces are bigger than paper, so half as many look just as full (and draw faster). +const pieceCount = (n) => Math.round(n * (confettiGlyphs() ? 0.5 : 1)); + +// Make sure the canvas exists and matches the window; false when motion is reduced. +function confettiReady() { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false; + if (!confetti.canvas) { + confetti.canvas = document.createElement('canvas'); + confetti.canvas.className = 'confetti'; + confetti.canvas.setAttribute('aria-hidden', 'true'); + document.body.append(confetti.canvas); + confetti.ctx = confetti.canvas.getContext('2d'); + } + const dpr = CONFETTI_DPR; + const w = Math.round(innerWidth * dpr); + const h = Math.round(innerHeight * dpr); + if (confetti.canvas.width !== w || confetti.canvas.height !== h) { + confetti.canvas.width = w; + confetti.canvas.height = h; + } + confetti.ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + return true; +} + +// Wardrobe confetti: paper, or emoji pieces (stars, hearts, snowflakes, coins…) +const CONFETTI_GLYPHS = { + stars: ['⭐', '🌟'], + hearts: ['💖', '💗', '❤️'], + snow: ['❄️'], + coins: ['🪙'], + blossom: ['🌸', '🌼'], +}; + +const pick = (list) => list[Math.floor(Math.random() * list.length)]; + +function confettiGlyphs() { + const kind = settings.cosmetics.confetti; + return kind === 'emoji' ? look('confetti').glyphs : CONFETTI_GLYPHS[kind] || null; +} + +function glyphSprite(glyph, size) { + const px = Math.round(size); + const key = `${glyph}|${px}`; + let sprite = glyphSprites.get(key); + if (!sprite) { + const dpr = CONFETTI_DPR; + const cssSize = Math.ceil(px * 2.6); + sprite = document.createElement('canvas'); + sprite.width = sprite.height = Math.ceil(cssSize * dpr); + sprite.cssSize = cssSize; + const s = sprite.getContext('2d'); + s.scale(dpr, dpr); + s.font = `${px * 2}px serif`; + s.textAlign = 'center'; + s.textBaseline = 'middle'; + s.fillText(glyph, cssSize / 2, cssSize / 2 + px * 0.1); + glyphSprites.set(key, sprite); + } + return sprite; +} + +// extra: { glyphs, gravity, size } for special effects (balloons, trophies, fireworks) +function addPiece(x, y, angleDeg, speed, maxAge = 200, extra = {}) { + const angle = (angleDeg * Math.PI) / 180; + const glyphs = extra.glyphs !== undefined ? extra.glyphs : confettiGlyphs(); + confetti.parts.push({ + x, + y, + vx: Math.cos(angle) * speed, + vy: Math.sin(angle) * speed, + size: extra.size || (glyphs ? 9 + Math.random() * 6 : 5 + Math.random() * 6), + spin: Math.random() * Math.PI, + vspin: (Math.random() - 0.5) * (glyphs ? 0.12 : 0.4), + wobble: Math.random() * Math.PI * 2, + color: extra.color || CONFETTI_COLORS[Math.floor(Math.random() * CONFETTI_COLORS.length)], + round: Math.random() < 0.3, + glyph: glyphs ? pick(glyphs) : null, + gravity: extra.gravity !== undefined ? extra.gravity : 0.28, + age: 0, + maxAge, + }); +} + +function startConfetti() { + if (!confetti.frame) { + confetti.last = performance.now(); + confetti.frame = requestAnimationFrame(stepConfetti); + } +} + +// A burst shooting up out of an element (mostly up, leaning right). +function confettiFrom(element, strength = 1) { + if (!confettiReady()) return; + const box = element.getBoundingClientRect(); + const count = pieceCount(Math.min(90 + 40 * (strength - 1), 200)); + for (let i = 0; i < count; i++) { + addPiece(box.left + Math.random() * box.width, box.top + box.height * 0.3, -90 + (Math.random() - 0.35) * 110, 7 + Math.random() * 9); + } + startConfetti(); +} + +// Winning: the Wardrobe's win celebration. +function celebrateWin() { + if (!confettiReady()) return; + const w = innerWidth; + const h = innerHeight; + const style = settings.cosmetics.win; + if (style === 'fireworks') { + for (let burst = 0; burst < 6; burst++) { + setTimeout(() => { + if (!confettiReady()) return; + const x = w * (0.15 + Math.random() * 0.7); + const y = h * (0.15 + Math.random() * 0.35); + const color = pick(CONFETTI_COLORS); + for (let i = 0; i < 70; i++) { + addPiece(x, y, (i / 70) * 360, 3 + Math.random() * 5, 90 + Math.random() * 40, { glyphs: null, gravity: 0.1, color, size: 4 }); + } + startConfetti(); + }, burst * 380); + } + } else if (style === 'balloons') { + for (let i = 0; i < 36; i++) { + addPiece(Math.random() * w, h + 20 + Math.random() * h * 0.6, -90 + (Math.random() - 0.5) * 16, 2 + Math.random() * 2.5, 520, { glyphs: ['🎈'], gravity: -0.01, size: 14 + Math.random() * 8 }); + } + startConfetti(); + } else if (style === 'trophies') { + for (let i = 0; i < 90; i++) { + addPiece(Math.random() * w, -20 - Math.random() * h * 0.9, 90 + (Math.random() - 0.5) * 20, 1 + Math.random() * 3, 420, { glyphs: ['🏆', '🪙', '⭐', '🥇'] }); + } + startConfetti(); + } else { + confettiFullScreen(3); + } +} + +// Full screen: a shower from the top across the whole width, plus a cannon +// in each bottom corner firing towards the middle. +function confettiFullScreen(strength = 1) { + if (!confettiReady()) return; + const w = innerWidth; + const h = innerHeight; + const shower = pieceCount(Math.min(Math.round((w / 6) * (0.8 + 0.3 * strength)), 420)); + for (let i = 0; i < shower; i++) { + // start above the screen at different heights so it keeps raining for a while + addPiece(Math.random() * w, -20 - Math.random() * h * 0.9, 90 + (Math.random() - 0.5) * 30, 1 + Math.random() * 3, 420); + } + const perCannon = pieceCount(Math.min(70 + 30 * (strength - 1), 150)); + const reach = Math.sqrt(h) * 0.75; // strong enough to reach the upper half on any screen + for (let i = 0; i < perCannon; i++) { + addPiece(0, h, -60 + (Math.random() - 0.5) * 30, reach * (0.7 + Math.random() * 0.5), 300); + addPiece(w, h, -120 + (Math.random() - 0.5) * 30, reach * (0.7 + Math.random() * 0.5), 300); + } + startConfetti(); +} + +function stepConfetti(now) { + const dt = Math.min(2, (now - confetti.last) / 16.7); // in 60fps frames, capped after tab switches + confetti.last = now; + const g = confetti.ctx; + g.clearRect(0, 0, innerWidth, innerHeight); + confetti.parts = confetti.parts.filter((p) => (p.y < innerHeight + 20 || p.vy < 0) && p.y > -120 && p.age < p.maxAge); + const dpr = CONFETTI_DPR; + const air = Math.pow(0.985, dt); + for (const p of confetti.parts) { + p.age += dt; + p.vy += p.gravity * dt; // gravity (balloons have a little negative gravity) + p.vx *= air; + p.vy = Math.min(p.vy, 6); // paper falls slowly + p.wobble += 0.15 * dt; + p.x += (p.vx + Math.sin(p.wobble) * 0.8) * dt; + p.y += p.vy * dt; + p.spin += p.vspin * dt; + // Position each piece with one setTransform (no save/restore per piece: much faster) + const cos = Math.cos(p.spin) * dpr; + const sin = Math.sin(p.spin) * dpr; + g.globalAlpha = Math.min(1, (p.maxAge - p.age) / 40); + if (p.glyph) { + // emoji: stamp a pre-drawn image instead of drawing the text every frame + const sprite = glyphSprite(p.glyph, p.size); + g.setTransform(cos, sin, -sin, cos, p.x * dpr, p.y * dpr); + g.drawImage(sprite, -sprite.cssSize / 2, -sprite.cssSize / 2, sprite.cssSize, sprite.cssSize); + } else if (p.round) { + g.setTransform(dpr, 0, 0, dpr, p.x * dpr, p.y * dpr); + g.fillStyle = p.color; + g.beginPath(); + g.arc(0, 0, p.size / 2.4, 0, Math.PI * 2); + g.fill(); + } else { + const flip = Math.cos(p.wobble); // flipping paper + g.setTransform(cos, sin, -sin * flip, cos * flip, p.x * dpr, p.y * dpr); + g.fillStyle = p.color; + g.fillRect(-p.size / 2, -p.size / 4, p.size, p.size / 2); + } + } + g.globalAlpha = 1; + g.setTransform(dpr, 0, 0, dpr, 0, 0); + if (confetti.parts.length) confetti.frame = requestAnimationFrame(stepConfetti); + else { + confetti.frame = null; + g.clearRect(0, 0, innerWidth, innerHeight); + } +} + +const BONUS_TOASTS = { first: '✨', speedy: '⚡', streak: '🔥', timer: '⏱️' }; + +function capitalize(text) { + return text.charAt(0).toUpperCase() + text.slice(1); +} + +// Small pop-up message in the corner that disappears by itself. +// ---- achievements: a big banner at the bottom, one at a time, with confetti + +const achievementQueue = []; + +let bannerUp = false; + +const BANNER_MS = 3200; + +const MINOR_BANNER_MS = 1800; + +// Minor ones in a row (fact speed badges) share one smaller banner. +function queueAchievements(list) { + for (const a of list) { + const last = achievementQueue[achievementQueue.length - 1]; + if (a.minor && last && last.minor) { + last.count++; + last.reward += a.reward; + continue; + } + achievementQueue.push({ icon: a.icon, name: a.name, reward: a.reward, minor: Boolean(a.minor), count: 1 }); + } + if (!bannerUp) showNextAchievement(); +} + +function showNextAchievement() { + const a = achievementQueue.shift(); + if (!a) { + bannerUp = false; + return; + } + bannerUp = true; + const box = make('div', 'achievement-banner' + (a.minor ? ' minor' : '')); + box.setAttribute('role', 'status'); + const text = make('div', 'banner-text'); + text.append( + make('span', 'banner-kicker', a.minor ? 'Badge earned' : 'Achievement earned'), + make('strong', 'banner-name', a.count > 1 ? `+${a.count} fact speed badges` : a.name), + make('span', 'banner-reward', `+${formatPoints(a.reward)} points`) + ); + box.append(make('span', 'banner-icon', a.icon), text); + document.body.append(box); + requestAnimationFrame(() => box.classList.add('show')); + setTimeout(() => confettiFrom(box), 250); + let gone = false; + const next = () => { + if (gone) return; + gone = true; + box.classList.remove('show'); + box.classList.add('leaving'); + setTimeout(() => { + box.remove(); + showNextAchievement(); + }, 350); + }; + // (clicks go straight through the banner, so it never blocks the buttons under it) + setTimeout(next, a.minor ? MINOR_BANNER_MS : BANNER_MS); +} + +function toast(title, detail = '') { + const box = document.createElement('div'); + box.className = 'toast'; + box.setAttribute('role', 'status'); + const strong = document.createElement('strong'); + strong.textContent = title; + box.append(strong); + if (detail) { + const small = document.createElement('span'); + small.textContent = detail; + box.append(small); + } + el.toasts.append(box); + // Keep at most 4 on screen: the oldest goes first. + const all = el.toasts.querySelectorAll('.toast:not(.leaving)'); + if (all.length > 4) all[0].remove(); + setTimeout(() => box.classList.add('leaving'), 3600); + setTimeout(() => box.remove(), 4000); + confettiFrom(box); // every toast gets a little burst + return box; +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + progress = loadProgress(); + + // Emoji drawn once per glyph and size into a small canvas, then stamped with + // drawImage. Drawing colour emoji as text every frame is very slow. + glyphSprites = new Map(); +} + +export { BONUS_TOASTS, capitalize, celebrate, celebrateWin, confettiFullScreen, formatPoints, grantStickers, progress, queueAchievements, renderWallet, replaceProgress, saveProgress, toast, updateMenuBadges }; diff --git a/apps/multiplication/src/game/wardrobe.js b/apps/multiplication/src/game/wardrobe.js new file mode 100644 index 0000000..3daece2 --- /dev/null +++ b/apps/multiplication/src/game/wardrobe.js @@ -0,0 +1,707 @@ +// Wardrobe (cosmetics): part of the multiplication game (split from the old js/game.js). + +import * as Core from '@blockout/engine'; +import * as Cosmetics from '@blockout/progress/cosmetics'; +import * as Progress from '@blockout/progress'; +import { BOARD_UNLOCK, DEFAULT_NAMES, DEFAULT_SETTINGS, el, setup } from './base.js'; +import { formatPoints, progress, saveProgress, toast, updateMenuBadges } from './wallet.js'; +import { saveSettings, settings } from './settings.js'; +import { game, make, startGame } from './play.js'; +import { confirmUnlock, filterNote, markSeen, refreshOpenNudges, renderWalletBar, seenList, wardrobeKeys } from './shop.js'; +import { practiceLevel, practiceRoundSize, practiceTableList, practiceTimerSecs, startPractice, updatePracticePreview } from './practice.js'; +import { checkClassroom } from './classroom.js'; +import { drawBoard, render, renderDie } from './render.js'; + +// settings.cosmetics: { dice, pips, roll, colors, pattern, board, place, confetti, +// win, streak, cpu, avatar, keypad, title }. Only owned choices are kept. +function loadCosmetics() { + const chosen = { ...(settings.cosmetics || {}) }; + if (settings.diceStyle === 'golden' && !chosen.dice) chosen.dice = 'golden'; // old Dice style setting + delete settings.diceStyle; + const clean = Cosmetics.sanitize(chosen, (id) => Progress.isUnlocked(progress, id)); + clean.title = Cosmetics.title(chosen.title).earned(progress) ? Cosmetics.title(chosen.title).id : 'none'; + settings.cosmetics = clean; +} + +const look = (categoryId) => Cosmetics.option(categoryId, settings.cosmetics[categoryId]) || Cosmetics.category(categoryId).options[0]; + +function chooseCosmetic(categoryId, optionId) { + settings.cosmetics[categoryId] = optionId; + // bought or worn while the Wardrobe is open: you've seen it, so it isn't "new" + if (!el.wardrobeDialog.hidden) markSeen('wardrobe', [`${categoryId}:${optionId}`]); + saveSettings(); + applyCosmeticsToPage(); + if (!el.wardrobeDialog.hidden) renderWardrobe(); +} + +// Keypads get a skin class; dice get theirs when drawn; the board is redrawn. +function applyCosmeticsToPage(redraw = true) { + for (const pad of document.querySelectorAll('.keypad')) { + pad.className = `keypad skin-${settings.cosmetics.keypad}`; + } + if (redraw) drawBoard(); +} + +// ---- Wardrobe dialog + +function chipPreview(cat, opt) { + const box = make('span', 'chip-preview'); + if (cat.id === 'dice') { + const die = make('span', `die mini skin-${opt.id}`); + renderDie(die, 5, 6, opt.id); + box.append(die); + } else if (cat.id === 'colors') { + for (const c of opt.colors) { + const sw = make('span', 'swatch'); + sw.style.background = c; + box.append(sw); + } + } else if (cat.id === 'pattern' || cat.id === 'board') { + box.append(make('span', `tile-preview ${cat.id}-${opt.id}`)); + } else if (cat.id === 'keypad') { + box.append(make('span', `key-preview skin-${opt.id}`, '7')); + } else if (cat.id === 'roll' || cat.id === 'place') { + box.append(make('span', `motion-preview ${cat.id}-${opt.id}`, cat.id === 'roll' ? '🎲' : '▦')); + } else if (cat.id === 'cpu') { + box.append(make('span', 'emoji-preview', opt.avatar)); + } else { + box.append(make('span', 'emoji-preview', opt.preview || '')); + } + return box; +} + +let wardrobeFilter = null; // Set of 'category:option' keys (new or unlockable when picked), or null for all + +let wardrobeNew = []; // keys that were new when the Wardrobe opened + +function wardrobeAvailable() { + const unlockable = Progress.SHOP.filter((i) => i.cosmetic && Progress.canBuy(progress, i.id).ok).map((i) => i.cosmetic.join(':')); + return new Set([...wardrobeNew, ...unlockable]); +} + +function renderWardrobe() { + const body = el.wardrobeBody; + body.innerHTML = ''; + renderWalletBar('wardrobe', !!wardrobeFilter); + const now = new Date(); + const shows = (key) => !wardrobeFilter || wardrobeFilter.has(key); + if (wardrobeFilter && !wardrobeFilter.size) body.append(make('p', 'filter-note', 'Nothing new to wear yet. Keep playing to earn points!')); + for (const cat of Cosmetics.CATEGORIES) { + if (!cat.options.some((o) => shows(`${cat.id}:${o.id}`))) continue; + body.append(make('h3', 'stats-heading', cat.name)); + const row = make('div', 'wardrobe-row'); + for (const opt of cat.options) { + if (!shows(`${cat.id}:${opt.id}`)) continue; + const item = Cosmetics.itemFor(cat.id, opt.id); + const owned = Progress.isUnlocked(progress, item); + const chip = make('button', 'wardrobe-chip' + (settings.cosmetics[cat.id] === opt.id ? ' selected' : '') + (owned ? '' : ' locked')); + chip.type = 'button'; + chip.dataset.cat = cat.id; + chip.dataset.opt = opt.id; + chip.append(chipPreview(cat, opt), make('span', 'chip-name', opt.name)); + if (!owned) { + const check = Progress.canBuy(progress, item, now); + chip.dataset.unlock = item; + const tag = make('span', 'lock-badge'); + if (Progress.isHidden(progress, item)) { + // further down this category's chain: a placeholder, like the main menu + chip.classList.add('skeleton'); + chip.setAttribute('aria-label', 'Locked: unlock the one before it first'); + tag.append(icon('lock')); + } else if (check.reason === 'season') tag.append(icon('lock'), ` ${Progress.shopItem(item).seasonLabel}`); + else { + chip.dataset.cost = opt.price; + tag.append(icon('lock'), ` ${formatPoints(opt.price)}`); + } + chip.append(tag); + } + row.append(chip); + } + body.append(row); + } + // Titles are earned, not bought + if (!Cosmetics.TITLES.some((t) => shows(`title:${t.id}`))) return; + body.append(make('h3', 'stats-heading', 'Your title')); + const row = make('div', 'wardrobe-row'); + for (const t of Cosmetics.TITLES) { + if (!shows(`title:${t.id}`)) continue; + const got = t.earned(progress); + const chip = make('button', 'wardrobe-chip title-chip' + (settings.cosmetics.title === t.id ? ' selected' : '') + (got ? '' : ' locked')); + chip.type = 'button'; + chip.dataset.cat = 'title'; + chip.dataset.opt = t.id; + chip.disabled = !got; + chip.append(make('span', 'chip-name', t.name)); + if (!got) chip.append(make('span', 'chip-need', t.need)); + row.append(chip); + } + body.append(row); +} + +// onlyNew: from a results screen, show just the looks and titles you haven't seen yet +function openWardrobe(onlyNew = false) { + loadCosmetics(); + const keys = wardrobeKeys(); + wardrobeNew = keys.filter((k) => !seenList('wardrobe').includes(k)); + wardrobeFilter = onlyNew === true ? wardrobeAvailable() : null; + renderWardrobe(); + markSeen('wardrobe', keys); + el.wardrobeDialog.hidden = false; + el.wardrobeDialog.querySelector('.dialog').scrollTop = 0; + el.wardrobeDone.focus({ preventScroll: true }); +} + +function closeWardrobe() { + el.wardrobeDialog.hidden = true; + refreshOpenNudges(); + updateMenuBadges(); +} + +// ---- Sticker album + +let stickersFilter = null; // Set of sticker ids (new or with spares when opened), or null + +function renderStickers() { + const body = el.stickersBody; + body.innerHTML = ''; + const have = progress.stickers || {}; + const got = Cosmetics.STICKERS.filter((st) => have[st.id]).length; + body.append(make('p', 'stats-summary', `${got} of ${Cosmetics.STICKERS.length} collected`)); + if (stickersFilter) { + body.append(filterNote('Showing your new stickers and spares', () => { + stickersFilter = null; + renderStickers(); + })); + } + for (const rarity of ['common', 'rare', 'epic']) { + const list = Cosmetics.STICKERS.filter((x) => x.rarity === rarity && (!stickersFilter || stickersFilter.has(x.id))); + if (!list.length) continue; + body.append(make('h3', 'stats-heading', { common: 'Common', rare: 'Rare', epic: 'Epic' }[rarity])); + const grid = make('div', 'sticker-grid'); + for (const st of list) { + const n = have[st.id] || 0; + const slot = make('div', `sticker ${rarity}${n ? '' : ' missing'}`); + slot.append(make('span', 'sticker-emoji', n ? st.emoji : '?')); + if (n > 1) slot.append(make('span', 'sticker-count', `×${n}`)); + slot.title = n ? `${st.id} (${rarity})` : 'Not found yet'; + grid.append(slot); + } + body.append(grid); + } + const value = Cosmetics.duplicateValue(progress); + el.stickersTrade.disabled = value === 0; + el.stickersTrade.textContent = value ? `Swap spares for 🪙 ${formatPoints(value)}` : 'No spares to swap'; +} + +// onlyNew: from a results screen, show just the new stickers and the ones with spares +function openStickers(onlyNew = false) { + const have = progress.stickers || {}; + stickersFilter = + onlyNew === true ? new Set(Object.keys(have).filter((id) => !seenList('stickers').includes(id) || have[id] > 1)) : null; + renderStickers(); + markSeen('stickers', Object.keys(have)); + el.stickersDialog.hidden = false; + el.stickersDone.focus(); +} + +function closeStickers() { + el.stickersDialog.hidden = true; + refreshOpenNudges(); + updateMenuBadges(); +} + +// Shop item that unlocks a setting's choice; null when it's free (the default). +function unlockFor(key, value) { + if (key === 'theme' || String(value) === String(DEFAULT_SETTINGS[key])) return null; + if (key === 'difficulty') return `difficulty.${value}`; + if (key === 'cpuSteps') return 'cpuInstant'; + if (key === 'answerTime') return `answerTime${value}`; // each length is its own unlock + if (key === 'placeMode') return value === 'auto' ? 'placeAuto' : 'placeMode'; // Click, then Auto + return key; +} + +function isAllowed(key, value) { + return Progress.isUnlocked(progress, unlockFor(key, value)); +} + +// Anything saved before it was locked (or not yet bought) falls back to the default. +function enforceLocks() { + for (const key of Object.keys(DEFAULT_SETTINGS)) { + if (!isAllowed(key, settings[key])) settings[key] = DEFAULT_SETTINGS[key]; + } +} + +// A Lucide icon from the sprite in index.html, e.g. icon('lock'). +function icon(name) { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + svg.setAttribute('class', 'icon'); + svg.setAttribute('aria-hidden', 'true'); + const use = document.createElementNS('http://www.w3.org/2000/svg', 'use'); + use.setAttribute('href', `#i-${name}`); + svg.append(use); + return svg; +} + +// Mark a button as locked with a "🔒 price" badge, or clear it. +// Locked options show their price; ones further down a chain (see SHOP +// `requires`) are a blurred placeholder with just a lock, so only the next +// step shows what it is and what it costs. +function setLockBadge(btn, itemId) { + const locked = !Progress.isUnlocked(progress, itemId); + const hidden = locked && Progress.isHidden(progress, itemId); + btn.classList.toggle('locked', locked); + btn.classList.toggle('skeleton', hidden); + btn.dataset.unlock = locked ? itemId : ''; + if (hidden) btn.setAttribute('aria-label', 'Locked: unlock the one before it first'); + else if (btn.getAttribute('aria-label') === 'Locked: unlock the one before it first') btn.removeAttribute('aria-label'); + let badge = btn.querySelector('.lock-badge'); + if (locked && !badge) { + badge = document.createElement('span'); + badge.className = 'lock-badge'; + btn.append(badge); + } + if (badge && !locked) badge.remove(); + if (badge && locked) badge.replaceChildren(icon('lock'), hidden ? '' : ` ${formatPoints(Progress.shopItem(itemId).price)}`); +} + +function syncSettingsUI() { + for (const group of document.querySelectorAll('.segmented[data-setting]')) { + const key = group.dataset.setting; + for (const btn of group.querySelectorAll('button')) { + btn.classList.toggle('selected', btn.dataset.value === String(settings[key])); + setLockBadge(btn, unlockFor(key, btn.dataset.value)); + } + } + el.cornerRuleHint.hidden = !settings.firstInCorner; +} + +// Setting buttons live in the Settings dialog and (difficulty) on the main menu. +function onSettingClick(e) { + const btn = e.target.closest('.segmented[data-setting] button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + const key = btn.parentElement.dataset.setting; + const raw = btn.dataset.value; + settings[key] = raw === 'true' ? true : raw === 'false' ? false : raw; + saveSettings(); + syncSettingsUI(); + updatePracticePreview(); + renderGameLevel(); + renderBoardHint(); +} + +function applyTheme(redraw = true) { + const root = document.documentElement; + if (settings.theme === 'light' || settings.theme === 'dark') root.dataset.theme = settings.theme; + else delete root.dataset.theme; + applyCosmeticsToPage(false); + if (redraw) drawBoard(); +} + +let settingsOpener = null; // button to return focus to + +function openSettings(opener) { + settingsOpener = opener; + const inGame = !el.gameScreen.hidden && game && game.phase !== 'over'; + el.settingsNote.hidden = !inGame; + if (inGame) { + el.settingsNote.textContent = game.invite + ? 'You’re playing an invite, so its rules stay the same until the game ends. Theme changes apply now.' + : 'Theme, CPU speed and the progress bar change now. Other changes start from the next turn. The corner rule applies to new games.'; + } + syncSettingsUI(); + el.settingsDialog.hidden = false; + el.settingsDone.focus(); +} + +// Settings changed during a game (not an invite): these are safe to switch at once. +function applySettingsNow() { + if (!game || game.invite || game.phase === 'over') return; + game.cpuSpeed = settings.cpuSpeed; + game.cpuEnd = settings.cpuEnd; + game.showProgress = settings.showProgress; + render(); +} + +// ...and these wait for the start of the next turn, so a move is never changed halfway. +function applySettingsForTurn() { + if (!game || game.invite || game.fixed) return; + applySettingsNow(); + game.placeMode = settings.placeMode; + game.diceMode = settings.diceMode; + game.autoRoll = settings.autoRoll === 'auto'; + game.cpuInstant = settings.cpuSteps === 'instant'; + // single player uses its own Expert timer; the Settings timer is for Home multiplayer + game.answerTime = game.mode === 'single' ? game.levelTimer : Number(settings.answerTime) || 0; + game.fitMode = settings.fitRolls; +} + +function selectIn(container, button) { + container.querySelectorAll('button').forEach((b) => b.classList.toggle('selected', b === button)); +} + +// Single player and Classroom are free; Local multiplayer, 3/4 players and +// Practice are shop unlocks. +const MODE_UNLOCK = { single: null, multi: null, practice: 'practice' }; + +const PLAYERS_UNLOCK = { 2: null, 3: 'players3', 4: 'players4' }; + +function syncModeLocks() { + el.modeCards.forEach((card) => setLockBadge(card, MODE_UNLOCK[card.dataset.mode])); + for (const btn of el.playerCount.querySelectorAll('button')) setLockBadge(btn, PLAYERS_UNLOCK[btn.dataset.count]); + setLockBadge(el.multiKind.querySelector('[data-kind="local"]'), 'multiplayer'); +} + +// Which parts of the setup form show for the chosen mode (and, for +// multiplayer, Local or Classroom). +function renderSetupSections() { + const classroom = setup.mode === 'multi' && setup.multiKind === 'classroom'; + el.singleSetup.hidden = setup.mode !== 'single'; + el.multiSetup.hidden = setup.mode !== 'multi'; + el.practiceSetup.hidden = setup.mode !== 'practice'; + if (setup.mode !== 'practice') renderBoardHint(); + el.localSetup.hidden = setup.multiKind !== 'local'; + el.classroomSetup.hidden = setup.multiKind !== 'classroom'; + el.boardSizeField.hidden = setup.mode === 'practice' || classroom; // practice has no board + el.difficultyField.hidden = classroom; // the teacher picks these when hosting + el.startBtn.hidden = classroom; // Join / Host instead + el.startBtn.textContent = setup.mode === 'practice' ? 'Start practice' : 'Start game'; + if (classroom) checkClassroom(); +} + +function selectMultiKind(kind) { + setup.multiKind = kind; + for (const b of el.multiKind.querySelectorAll('[data-kind]')) { + const on = b.dataset.kind === kind; + b.classList.toggle('selected', on); + b.setAttribute('aria-checked', String(on)); + } + renderSetupSections(); +} + +const CUSTOM_MIN = 6; + +const CUSTOM_MAX = 30; + +function syncBoardLocks() { + for (const btn of el.boardSize.querySelectorAll('button')) setLockBadge(btn, BOARD_UNLOCK[btn.dataset.size]); + renderBoardHint(); + renderGameLevel(); // (difficulty or purchases change it too) +} + +// ---- single player: Level (Learn / Expert) and Expert's timer, per difficulty, like Practice +const GAME_EXPERT_BONUS = 2; + +const GAME_LEVEL_HELP = { + learn: 'Learn: “Help me count” is there whenever you need it.', + expert: 'Expert: no help counting, and bonus points are doubled!', +}; + +function renderGameLevel() { + if (!el.gameLevel) return; + const d = settings.difficulty; + const expertId = Progress.gameExpertId(d); + if (!Progress.isUnlocked(progress, expertId)) setup.level = 'learn'; + for (const btn of el.gameLevel.querySelectorAll('button')) { + btn.classList.toggle('selected', btn.dataset.level === setup.level); + setLockBadge(btn, btn.dataset.level === 'expert' ? expertId : null); + } + el.gameLevelHelp.textContent = GAME_LEVEL_HELP[setup.level]; + el.gameTimerField.hidden = setup.level !== 'expert'; + const timerId = (secs) => (Number(secs) ? Progress.gameTimerId(Number(secs), d) : null); + if (!Progress.isUnlocked(progress, timerId(setup.timer))) setup.timer = 0; + for (const btn of el.gameTimer.querySelectorAll('button')) { + btn.classList.toggle('selected', Number(btn.dataset.secs) === setup.timer); + setLockBadge(btn, timerId(btn.dataset.secs)); + } + const top = (secs) => Progress.TIMER_BONUS_MAX[secs] * GAME_EXPERT_BONUS; + el.gameTimerHelp.textContent = setup.timer + ? `⏱️ Answer fast for a timer bonus of up to +${top(setup.timer)} a question: the quicker, the more. If time runs out, the rectangle comes off and scores nothing.` + : `⏱️ A timer earns a bonus for quick answers: up to +${top(30)} a question with 30 s, +${top(10)} with 10 s, +${top(5)} with 5 s.`; +} + +// Under the board sizes: the finishing bonus for the chosen size +function renderBoardHint() { + if (!el.boardSizeHelp) return; + // Legend rolls a teen (11–19), so it needs a board it fits on + const fits = Core.fitsDifficulty(settings.difficulty, setup.size); + if (setup.mode !== 'practice') el.startBtn.disabled = !fits; + el.boardSizeHelp.classList.toggle('warn', !fits); + if (!fits) { + el.boardSizeHelp.textContent = `⚠️ ${Progress.DIFFICULTY_NAMES[settings.difficulty]} rolls numbers like 14 × 7, so it needs a board of 12×12 or bigger.`; + return; + } + const bonus = Progress.boardFinishBonus(setup.size); + if (settings.difficulty === 'legend' && setup.size < 19 && setup.mode !== 'practice') { + el.boardSizeHelp.textContent = `Legend: half the rolls are teens like 14 × 7, the rest are Tricky facts. On ${setup.size}×${setup.size} the teens go up to ${setup.size}; bigger boards bring bigger teens (all the way to 19 on 20×20).`; + return; + } + el.boardSizeHelp.textContent = bonus + ? `🏁 Finish a game against the CPU on ${setup.size}×${setup.size} for +${bonus} bonus points.` + : `🏁 Bigger boards earn a finishing bonus against the CPU: +${Progress.boardFinishBonus(8)} for 8×8, up to +${Progress.boardFinishBonus(24)} for 24×24.`; +} + +function clampCustom(n) { + n = Math.round(Number(n)); + return Number.isFinite(n) ? Math.max(CUSTOM_MIN, Math.min(n, CUSTOM_MAX)) : 14; +} + +// Current custom size (clamped), mirrored next to the input. +function readCustomSize() { + const size = clampCustom(el.customSizeInput.value); + el.customSizeEcho.textContent = size; + return size; +} + +function setCustomSize(size) { + el.customSizeInput.value = clampCustom(size); + setup.size = readCustomSize(); + renderBoardHint(); +} + +function saveNameInputs() { + el.nameInputs.querySelectorAll('input').forEach((input, i) => { + if (setup.types[i] === 'human') setup.names[i] = input.value; + }); +} + +// CPU slots are numbered in order: CPU 1, CPU 2, … +function cpuName(index) { + const nth = setup.types.slice(0, index + 1).filter((t) => t === 'cpu').length; + return `CPU ${nth}`; +} + +function renderNameInputs() { + el.nameInputs.innerHTML = ''; + for (let i = 0; i < setup.count; i++) { + const isCpu = setup.types[i] === 'cpu'; + const row = document.createElement('div'); + row.className = 'name-row'; + const swatch = document.createElement('span'); + swatch.className = 'swatch'; + swatch.style.background = look('colors').colors[i]; + const input = document.createElement('input'); + input.type = 'text'; + input.maxLength = 14; + input.value = isCpu ? cpuName(i) : setup.names[i]; + input.placeholder = DEFAULT_NAMES[i]; + input.addEventListener('input', () => input.classList.remove('missing')); + input.disabled = isCpu; + input.setAttribute('aria-label', `Player ${i + 1} name`); + const kind = document.createElement('button'); + kind.type = 'button'; + kind.className = 'kind-toggle' + (isCpu ? ' cpu' : ''); + kind.dataset.index = i; + kind.textContent = isCpu ? '🤖 CPU' : '👤 Human'; + kind.setAttribute('aria-label', `Player ${i + 1}: ${isCpu ? 'CPU' : 'human'}. Click to switch.`); + row.append(swatch, input, kind); + el.nameInputs.append(row); + } +} + +// (wardrobeFilter is also changed from other sections) +function setWardrobeFilter(value) { + return (wardrobeFilter = value); +} + +// What this part does when the page loads (main.js runs every part in order) +export function run() { + loadCosmetics(); + + el.wardrobeBtn.addEventListener('click', () => openWardrobe()); + + el.wardrobeDone.addEventListener('click', closeWardrobe); + + el.wardrobeDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeWardrobe(); + }); + + el.wardrobeDialog.addEventListener('click', (e) => { + if (e.target === el.wardrobeDialog) return closeWardrobe(); + const view = e.target.closest('#wardrobe-view [data-view]'); + if (view) { + wardrobeFilter = view.dataset.view === 'available' ? wardrobeAvailable() : null; + renderWardrobe(); + el.wardrobeDialog.querySelector('.dialog').scrollTop = 0; + return; + } + const chip = e.target.closest('.wardrobe-chip'); + if (!chip || chip.disabled) return; + if (chip.dataset.unlock) return confirmUnlock(chip.dataset.unlock, chip); + chooseCosmetic(chip.dataset.cat, chip.dataset.opt); + }); + + el.stickersBtn.addEventListener('click', () => openStickers()); + + el.stickersDone.addEventListener('click', closeStickers); + + el.stickersDialog.addEventListener('click', (e) => { + if (e.target === el.stickersDialog) closeStickers(); + }); + + el.stickersDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') closeStickers(); + }); + + el.stickersTrade.addEventListener('click', () => { + const points = Cosmetics.tradeDuplicates(progress); + if (!points) return; + toast('🔁 Spares swapped', `+${formatPoints(points)} points`); + saveProgress(); + renderStickers(); + }); + + enforceLocks(); + + el.settingsDialog.addEventListener('click', (e) => { + if (e.target === el.settingsDialog) return el.settingsDone.click(); // backdrop + onSettingClick(e); + applyTheme(); + }); + + applyTheme(false); // the board doesn't exist yet during setup + + // In Auto, redraw the board when the device switches between light and dark. + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => drawBoard()); + + el.setupForm.addEventListener('click', onSettingClick); + + el.settingsBtn.addEventListener('click', () => openSettings(el.settingsBtn)); + + el.gameSettingsBtn.addEventListener('click', () => openSettings(el.gameSettingsBtn)); + + el.settingsDone.addEventListener('click', () => { + el.settingsDialog.hidden = true; + applySettingsNow(); + if (settingsOpener) settingsOpener.focus(); + }); + + el.settingsDialog.addEventListener('keydown', (e) => { + if (e.key === 'Escape') el.settingsDone.click(); + }); + + syncSettingsUI(); + + syncModeLocks(); + + el.modeCards.forEach((card) => { + card.addEventListener('click', () => { + if (card.dataset.unlock) return confirmUnlock(card.dataset.unlock, card); + setup.mode = card.dataset.mode; + // Local multiplayer is bought in the shop; until then Multiplayer opens on Classroom + if (setup.mode === 'multi' && !Progress.isUnlocked(progress, 'multiplayer')) selectMultiKind('classroom'); + el.modeCards.forEach((c) => { + c.classList.toggle('selected', c === card); + c.setAttribute('aria-checked', String(c === card)); + }); + renderSetupSections(); + updatePracticePreview(); + }); + }); + + el.multiKind.addEventListener('click', (e) => { + const btn = e.target.closest('[data-kind]'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + selectMultiKind(btn.dataset.kind); + }); + + el.playerCount.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + saveNameInputs(); + setup.count = Number(btn.dataset.count); + selectIn(el.playerCount, btn); + renderNameInputs(); + }); + + el.gameLevel.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + setup.level = btn.dataset.level; + renderGameLevel(); + }); + + el.gameTimer.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + setup.timer = Number(btn.dataset.secs); + renderGameLevel(); + }); + + syncBoardLocks(); + + el.boardSize.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + if (btn.dataset.unlock) return confirmUnlock(btn.dataset.unlock, btn); + selectIn(el.boardSize, btn); + const custom = btn.dataset.size === 'custom'; + el.customSize.hidden = !custom; + setup.size = custom ? readCustomSize() : Number(btn.dataset.size); + renderBoardHint(); + }); + + el.customSizeInput.addEventListener('input', () => { + if (el.customSizeInput.value !== '') setup.size = readCustomSize(); + renderBoardHint(); + }); + + el.customSizeInput.addEventListener('change', () => setCustomSize(readCustomSize())); + + el.customSize.addEventListener('click', (e) => { + const btn = e.target.closest('[data-step]'); + if (btn) setCustomSize(readCustomSize() + Number(btn.dataset.step)); + }); + + renderNameInputs(); + + el.nameInputs.addEventListener('click', (e) => { + const btn = e.target.closest('.kind-toggle'); + if (!btn) return; + saveNameInputs(); + const i = Number(btn.dataset.index); + setup.types[i] = setup.types[i] === 'cpu' ? 'human' : 'cpu'; + renderNameInputs(); + }); + + el.setupForm.addEventListener('submit', (e) => { + e.preventDefault(); + if (setup.mode === 'multi' && setup.multiKind === 'classroom') return; // not built yet + if (setup.mode === 'practice') { + const tables = practiceTableList(); + if (!tables.length) return; + const expert = practiceLevel === 'expert'; + startPractice(el.practiceName.value.trim() || 'You', practiceRoundSize, tables, expert, expert ? practiceTimerSecs : 0); + return; + } + saveNameInputs(); + if (!el.customSize.hidden) setCustomSize(readCustomSize()); + let players; + if (setup.mode === 'single') { + players = [ + { name: el.singleName.value.trim() || 'You', cpu: false }, + { name: 'CPU', cpu: true }, + ]; + } else { + // every human player needs a real name + const inputs = [...el.nameInputs.querySelectorAll('input')]; + const missing = inputs.filter((input, i) => setup.types[i] !== 'cpu' && !input.value.trim()); + inputs.forEach((input) => input.classList.toggle('missing', missing.includes(input))); + el.namesError.hidden = !missing.length; + if (missing.length) { + missing[0].focus(); + return; + } + players = setup.names.slice(0, setup.count).map((name, i) => + setup.types[i] === 'cpu' ? { name: cpuName(i), cpu: true } : { name: name.trim(), cpu: false } + ); + } + startGame(players, setup.size, setup.mode); + }); +} + +export { GAME_EXPERT_BONUS, applySettingsForTurn, applyTheme, chooseCosmetic, clampCustom, enforceLocks, icon, look, openSettings, openStickers, openWardrobe, renderBoardHint, renderGameLevel, renderWardrobe, saveNameInputs, selectIn, setLockBadge, setWardrobeFilter, syncBoardLocks, syncModeLocks, syncSettingsUI, wardrobeAvailable, wardrobeFilter }; diff --git a/apps/multiplication/src/invite.js b/apps/multiplication/src/invite.js new file mode 100644 index 0000000..ff14091 --- /dev/null +++ b/apps/multiplication/src/invite.js @@ -0,0 +1,109 @@ +// Blockout invites: a teacher or parent packs a game setup (players, board, +// difficulty, settings, a note and a dice seed) into a link or code. +// An ES module. + +const VERSION = 1; +const NOTE_MAX = 140; +const NAME_MAX = 14; + +// Every setting an invite may set, with its allowed values. +const SETTING_VALUES = { + difficulty: ['easy', 'medium', 'hard', 'tricky', 'master', 'legend'], + placeMode: ['draw', 'click', 'auto'], + diceMode: ['virtual', 'real'], + autoRoll: ['manual', 'auto'], + fitRolls: ['end', 'always', 'never'], + answerTime: ['0', '30', '10', '5', '20'], // 20 s is no longer offered but old invites still open + firstInCorner: [false, true], + cpuSpeed: ['slow', 'normal', 'fast'], + cpuSteps: ['show', 'instant'], + cpuEnd: ['auto', 'button'], + showProgress: [false, true], +}; + +// Invite shape (short keys keep links short): +// { v: 1, m: 'single'|'multi', b: boardSize, p: [{ n: name, c: isCpu }], +// s: { setting: value }, t: note, r: dice seed } +// Returns a clean copy, or null if anything is missing or out of range. +function validate(raw) { + if (!raw || typeof raw !== 'object' || raw.v !== VERSION) return null; + if (raw.m !== 'single' && raw.m !== 'multi') return null; + if (!Number.isInteger(raw.b) || raw.b < 6 || raw.b > 30) return null; + if (raw.s && raw.s.difficulty === 'legend' && raw.b < 12) return null; // Legend's teens need room + if (!Array.isArray(raw.p)) return null; + const count = raw.p.length; + if (raw.m === 'single' ? count !== 1 : count < 2 || count > 4) return null; + const players = []; + for (const p of raw.p) { + if (!p || typeof p !== 'object') return null; + const cpu = raw.m === 'multi' && p.c === true; + const name = typeof p.n === 'string' ? p.n.trim().slice(0, NAME_MAX) : ''; + players.push({ n: name, c: cpu }); + } + const s = {}; + for (const [key, value] of Object.entries(raw.s || {})) { + if (!SETTING_VALUES[key] || !SETTING_VALUES[key].includes(value)) return null; + s[key] = value; + } + const clean = { v: VERSION, m: raw.m, b: raw.b, p: players, s }; + if (typeof raw.t === 'string' && raw.t.trim()) clean.t = raw.t.trim().slice(0, NOTE_MAX); + if (raw.r !== undefined) { + if (!Number.isInteger(raw.r) || raw.r < 0 || raw.r > 0xffffffff) return null; + clean.r = raw.r; + } + return clean; +} + +// base64url of the UTF-8 JSON, so names and notes can use any language. +function encode(invite) { + const bytes = new TextEncoder().encode(JSON.stringify(invite)); + let binary = ''; + for (const b of bytes) binary += String.fromCharCode(b); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +function decode(token) { + try { + const b64 = token.trim().replace(/-/g, '+').replace(/_/g, '/'); + const binary = atob(b64 + '='.repeat((4 - (b64.length % 4)) % 4)); + const bytes = Uint8Array.from(binary, (ch) => ch.charCodeAt(0)); + return validate(JSON.parse(new TextDecoder().decode(bytes))); + } catch (_) { + return null; + } +} + +// Accepts a bare code, a "#invite=CODE" fragment or a full link. +function tokenFrom(text) { + const match = String(text).match(/[#&?]invite=([A-Za-z0-9_-]+)/); + if (match) return match[1]; + const bare = String(text).trim(); + return /^[A-Za-z0-9_-]+$/.test(bare) ? bare : null; +} + +// Small, fast seeded generator (mulberry32): same seed, same dice. +function seededRandom(seed) { + let a = seed >>> 0; + return function () { + a = (a + 0x6d2b79f5) >>> 0; + let t = a; + t = Math.imul(t ^ (t >>> 15), t | 1); + t ^= t + Math.imul(t ^ (t >>> 7), t | 61); + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +function newSeed() { + return Math.floor(Math.random() * 0xffffffff); +} + +export { + VERSION, + SETTING_VALUES, + validate, + encode, + decode, + tokenFrom, + seededRandom, + newSeed, +}; diff --git a/apps/multiplication/src/main.js b/apps/multiplication/src/main.js new file mode 100644 index 0000000..d95d40a --- /dev/null +++ b/apps/multiplication/src/main.js @@ -0,0 +1,6 @@ +// The multiplication game: styles, the game itself, then the account screens. +import './style.css'; +import './game/index.js'; +import { run as accounts } from './accounts.js'; + +accounts(); diff --git a/apps/multiplication/src/style.css b/apps/multiplication/src/style.css new file mode 100644 index 0000000..e9e7210 --- /dev/null +++ b/apps/multiplication/src/style.css @@ -0,0 +1,16 @@ +/* The multiplication game's styles, in their original order: shared pieces from + @blockout/ui and this game's own (./css). Order matters for the cascade. */ +@import '@blockout/ui/css/tokens.css'; +@import '@blockout/ui/css/base.css'; +@import '@blockout/ui/css/menu.css'; +@import '@blockout/ui/css/game.css'; +@import './css/classroom.css'; +@import '@blockout/ui/css/play.css'; +@import '@blockout/ui/css/dice-skins.css'; +@import '@blockout/ui/css/results.css'; +@import '@blockout/ui/css/panels.css'; +@import '@blockout/ui/css/rewards.css'; +@import '@blockout/ui/css/small-screens.css'; +@import '@blockout/ui/css/wardrobe.css'; +@import '@blockout/ui/css/players.css'; +@import './css/accounts.css'; diff --git a/test/invite.test.js b/apps/multiplication/test/invite.test.js similarity index 95% rename from test/invite.test.js rename to apps/multiplication/test/invite.test.js index 6927580..50e20ce 100644 --- a/test/invite.test.js +++ b/apps/multiplication/test/invite.test.js @@ -1,6 +1,6 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const I = require('../js/invite.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import * as I from '../src/invite.js'; const sample = { v: 1, diff --git a/apps/multiplication/vite.config.js b/apps/multiplication/vite.config.js new file mode 100644 index 0000000..36a51e0 --- /dev/null +++ b/apps/multiplication/vite.config.js @@ -0,0 +1,14 @@ +// The multiplication game is served at / (the server serves the other games at //). +import { defineConfig } from 'vite'; +import { blockoutPage } from '@blockout/ui/vite'; + +export default defineConfig({ + base: '/', + plugins: [blockoutPage()], + server: { + port: 5173, + // the API and live classroom updates come from apps/server (npm run dev starts it on 8080) + proxy: { '/api': { target: 'http://localhost:8080', changeOrigin: false } }, + }, + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' }, +}); diff --git a/apps/server/package.json b/apps/server/package.json new file mode 100644 index 0000000..781a969 --- /dev/null +++ b/apps/server/package.json @@ -0,0 +1,17 @@ +{ + "name": "@blockout/server", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "The Blockout server: serves the built games, runs classrooms (whole class, pairs, tournaments) and accounts (sqlite). Node built-ins only.", + "dependencies": { + "@blockout/engine": "*", + "@blockout/progress": "*" + }, + "scripts": { + "dev": "node --watch src/index.js 8080", + "start": "node src/index.js 8080", + "test": "node --test", + "lint": "eslint src test" + } +} diff --git a/server/accounts.js b/apps/server/src/accounts.js similarity index 96% rename from server/accounts.js rename to apps/server/src/accounts.js index bb8a2a5..30c5415 100644 --- a/server/accounts.js +++ b/apps/server/src/accounts.js @@ -2,12 +2,10 @@ // links and scheduled tournaments. Pure rules over the store (server/store.js) // and an auth provider (server/auth.js); server/classroom.js wires them to HTTP. // Every function takes the signed-in user (or null for a guest) first. -'use strict'; - -const crypto = require('crypto'); -const Progress = require('../js/progress.js'); -const Auth = require('./auth.js'); -const Tournament = require('./tournament.js'); +import crypto from 'crypto'; +import * as Progress from '@blockout/progress'; +import * as Auth from './auth.js'; +import * as Tournament from './tournament.js'; class AccountError extends Error { constructor(code, message, status = 400) { @@ -178,7 +176,9 @@ function createAccounts({ store, auth, now = () => Date.now() }) { done: students.filter((s) => s.assignments.some((h) => h.id === a.id && h.status === 'done')).length, of: students.length, })); - return { class: classSummary(c), students, assignments }; + // homework can only ask for what every student can already play + const options = Progress.sharedOptions(store.members(c.id).map((m) => Progress.availableOptions(progressOf(m.id)))); + return { class: classSummary(c), students, assignments, options }; } function removeStudent(user, classId, studentId) { @@ -248,7 +248,7 @@ function createAccounts({ store, auth, now = () => Date.now() }) { // the detail view also draws the whole Times Table grid const state = progressOf(child.id); const facts = ((state && state.facts && state.facts.you) || { facts: {} }).facts; - return { ...studentStats(child, { withParentCode: user.role === 'teacher' }), facts }; + return { ...studentStats(child, { withParentCode: user.role === 'teacher' }), facts, options: Progress.availableOptions(state) }; } // ---- homework @@ -378,4 +378,4 @@ function createAccounts({ store, auth, now = () => Date.now() }) { }; } -module.exports = { createAccounts, AccountError, day, normCode }; +export { createAccounts, AccountError, day, normCode }; diff --git a/server/auth.js b/apps/server/src/auth.js similarity index 97% rename from server/auth.js rename to apps/server/src/auth.js index db372e3..51ca615 100644 --- a/server/auth.js +++ b/apps/server/src/auth.js @@ -13,9 +13,7 @@ // school's domain (e.g. @iusd.org). Roles come from realm roles. // // BLOCKOUT_AUTH=keycloak KEYCLOAK_ISSUER=https://id.example.org/realms/blockout KEYCLOAK_CLIENT_ID=blockout -'use strict'; - -const crypto = require('crypto'); +import crypto from 'crypto'; const ROLES = ['student', 'teacher', 'parent']; const TOKEN_DAYS = 30; @@ -176,4 +174,4 @@ const bearer = (req) => { return h.startsWith('Bearer ') ? h.slice(7).trim() : null; }; -module.exports = { ROLES, AuthError, devProvider, keycloakProvider, identity, fromEnv, bearer, cleanFirstName, cleanInitial, b64url }; +export { ROLES, AuthError, devProvider, keycloakProvider, identity, fromEnv, bearer, cleanFirstName, cleanInitial, b64url }; diff --git a/server/blocklist.txt b/apps/server/src/blocklist.txt similarity index 100% rename from server/blocklist.txt rename to apps/server/src/blocklist.txt diff --git a/server/classroom.js b/apps/server/src/index.js similarity index 85% rename from server/classroom.js rename to apps/server/src/index.js index 57bf089..9497090 100644 --- a/server/classroom.js +++ b/apps/server/src/index.js @@ -1,26 +1,24 @@ #!/usr/bin/env node -// Blockout classroom server. No dependencies: Node's http module serves the +// Blockout server: the games (the apps' built files), classrooms and accounts. No dependencies: Node's http module serves the // game itself plus a small JSON API, and pushes live updates with Server-Sent Events. // Where a proxy holds event streams back (Cloudflare quick tunnels, some school // filters) pages fall back to long polling: /poll answers as soon as anything changes. // -// node server/classroom.js [port] [--public-url https://example.com] +// npm run build, then: node apps/server/src/index.js [port] [--public-url https://example.com] // // Then open http://localhost:8080 on the teacher's computer. Chromebooks on the // same network join at the address printed below (the teacher's screen shows it too). // Behind a tunnel or proxy (e.g. cloudflared tunnel --url http://localhost:8080), // pass its address as --public-url (or PUBLIC_URL=...) so the join code and QR use it. -'use strict'; - -const http = require('http'); -const fs = require('fs'); -const os = require('os'); -const path = require('path'); -const Rooms = require('./rooms.js'); -const Moderation = require('./moderation.js'); -const Auth = require('./auth.js'); -const { openStore } = require('./store.js'); -const { createAccounts, AccountError } = require('./accounts.js'); +import http from 'http'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import * as Rooms from './rooms.js'; +import * as Moderation from './moderation.js'; +import * as Auth from './auth.js'; +import { openStore } from './store.js'; +import { createAccounts, AccountError } from './accounts.js'; const args = process.argv.slice(2); const flag = (name) => { @@ -33,7 +31,10 @@ const PORT = Number(args.find((a) => /^\d+$/.test(a)) || process.env.PORT || 808 // (and only then are names sent to OpenAI). The word list always runs. const OPENAI_API_KEY = process.env.OPENAI_API_KEY || ''; const PUBLIC_URL = (flag('--public-url') || process.env.PUBLIC_URL || '').replace(/\/*$/, '/').replace(/^\/$/, ''); -const ROOT = path.resolve(__dirname, '..'); +// The built games: apps//dist. The multiplication game is at /, the +// others at // (each app is built with that base path). +const APPS = path.resolve(import.meta.dirname, '..', '..'); +const HOME_APP = 'multiplication'; const ROOM_IDLE_MS = 4 * 60 * 60 * 1000; // forget a class after 4 hours without activity const AUTO_NEXT_MS = 2500; // pause after everyone is done, so the class sees the last answers land const CPU_ROLL_MS = 1500; // pairs: the CPU's turn, slow enough to watch @@ -62,25 +63,59 @@ const TYPES = { '.svg': 'image/svg+xml', '.png': 'image/png', '.ico': 'image/x-icon', - '.pdf': 'application/pdf', + '.woff2': 'font/woff2', + '.webmanifest': 'application/manifest+json', }; -const PUBLIC = ['index.html', 'css/', 'js/', 'prototypes/']; + +// /addition/x.js -> apps/addition/dist/x.js; anything else -> the home app's dist +function staticFile(pathname) { + const parts = pathname.split('/').filter(Boolean); + const [first, ...rest] = parts; + const isApp = first && first !== HOME_APP && /^[a-z-]+$/.test(first) && fs.existsSync(path.join(APPS, first, 'dist', 'index.html')); + const dist = path.join(APPS, isApp ? first : HOME_APP, 'dist'); + let rel = (isApp ? rest : parts).join('/'); + if (rel === '' || pathname.endsWith('/')) rel = rel ? `${rel}/index.html` : 'index.html'; + const file = path.resolve(dist, rel); + return file.startsWith(dist + path.sep) ? file : null; +} function serveStatic(req, res) { - let rel = decodeURIComponent(new URL(req.url, 'http://x').pathname).replace(/^\/+/, ''); - if (rel === '' || rel.endsWith('/')) rel += 'index.html'; - const file = path.resolve(ROOT, rel); - const allowed = file.startsWith(ROOT + path.sep) && PUBLIC.some((p) => rel === p || (p.endsWith('/') && rel.startsWith(p))); - if (!allowed) return send(res, 404, 'Not found', 'text/plain'); + const pathname = decodeURIComponent(new URL(req.url, 'http://x').pathname); + // "/addition" without the slash: go to "/addition/" so relative paths work + if (/^\/[a-z-]+$/.test(pathname) && fs.existsSync(path.join(APPS, pathname.slice(1), 'dist', 'index.html'))) { + res.writeHead(301, { Location: `${pathname}/` }); + return res.end(); + } + const file = staticFile(pathname); + if (!file) return send(res, 404, 'Not found', 'text/plain'); fs.readFile(file, (err, data) => { - if (err) return send(res, 404, 'Not found', 'text/plain'); - res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': 'no-cache' }); + if (err) return send(res, 404, err.code === 'ENOENT' && !fs.existsSync(path.join(APPS, HOME_APP, 'dist')) ? 'Not built yet: run npm run build' : 'Not found', 'text/plain'); + // Vite's hashed assets never change; everything else is checked each time + const cache = /\/assets\/.+-[\w-]{8,}\.\w+$/.test(file) ? 'public, max-age=31536000, immutable' : 'no-cache'; + res.writeHead(200, { 'Content-Type': TYPES[path.extname(file)] || 'application/octet-stream', 'Cache-Control': cache, ...SECURITY_HEADERS }); res.end(data); }); } +// Sent with every response: no sniffing content types, no framing by other +// sites, no full URLs (with class codes) leaked to other sites. +const SECURITY_HEADERS = { + 'X-Content-Type-Options': 'nosniff', + 'X-Frame-Options': 'DENY', + 'Referrer-Policy': 'same-origin', +}; + +// An error as RFC 9457 problem details. `type` stays about:blank (the status +// says what kind of problem it is); `error` (a short code) and `message` (for +// the player) are extension members the games read. +const STATUS_TITLES = { 400: 'Bad Request', 401: 'Unauthorized', 403: 'Forbidden', 404: 'Not Found', 409: 'Conflict', 500: 'Internal Server Error' }; +function sendProblem(res, status, code, message) { + const body = { type: 'about:blank', title: STATUS_TITLES[status] || 'Error', status, detail: message, error: code, message }; + send(res, status, body, 'application/problem+json; charset=utf-8'); +} + function send(res, status, body, type = 'application/json; charset=utf-8') { - res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store' }); + res.writeHead(status, { 'Content-Type': type, 'Cache-Control': 'no-store', ...SECURITY_HEADERS }); res.end(typeof body === 'string' ? body : JSON.stringify(body)); } @@ -544,17 +579,17 @@ const server = http.createServer(async (req, res) => { const p = Rooms.findPlayer(room, q.get('id'), q.get('key')); return poll ? openPoll(req, res, room, 'student', p, version) : openStream(req, res, room, 'student', p); } - if (!r.handler) return send(res, 404, { error: 'not-found', message: 'Not found.' }); + if (!r.handler) return sendProblem(res, 404, 'not-found', 'Not found.'); send(res, 200, await r.handler(req, r.code)); } catch (e) { - if (e instanceof AccountError) return send(res, e.status, { error: e.code, message: e.message }); - if (e instanceof Auth.AuthError) return send(res, 401, { error: 'auth', message: e.message }); + if (e instanceof AccountError) return sendProblem(res, e.status, e.code, e.message); + if (e instanceof Auth.AuthError) return sendProblem(res, 401, 'auth', e.message); if (e instanceof Rooms.RoomError) { const status = e.code === 'room' || e.code === 'player' ? 404 : e.code === 'teacher' ? 403 : 409; - return send(res, status, { error: e.code, message: e.message }); + return sendProblem(res, status, e.code, e.message); } console.error(e); - send(res, 500, { error: 'server', message: 'Something went wrong.' }); + sendProblem(res, 500, 'server', 'Something went wrong.'); // (never the error itself: it stays in the log) } }); @@ -571,7 +606,7 @@ setInterval(() => { } }, 10 * 60 * 1000).unref(); -if (require.main === module) { +if (process.argv[1] && path.resolve(process.argv[1]) === import.meta.filename) { server.listen(PORT, () => { console.log(`Blockout classroom server on http://localhost:${PORT}/`); for (const url of lanUrls()) console.log(url === PUBLIC_URL ? ` Students join at: ${url}` : ` Chromebooks on this network: ${url}`); @@ -580,4 +615,4 @@ if (require.main === module) { }); } -module.exports = { server, rooms, store, accounts }; +export { server, rooms, store, accounts }; diff --git a/server/moderation.js b/apps/server/src/moderation.js similarity index 94% rename from server/moderation.js rename to apps/server/src/moderation.js index e921354..e8e6b73 100644 --- a/server/moderation.js +++ b/apps/server/src/moderation.js @@ -7,10 +7,8 @@ // Then (and only then) the name is sent to OpenAI. If OpenAI can't be reached // the name is allowed, so a class never gets stuck at the door. // The teacher can still remove anyone from the lobby. -'use strict'; - -const fs = require('fs'); -const path = require('path'); +import fs from 'fs'; +import path from 'path'; const OPENAI_URL = 'https://api.openai.com/v1/moderations'; const OPENAI_MODEL = 'omni-moderation-latest'; @@ -34,7 +32,7 @@ function normalize(text) { const squash = (s) => s.replace(/(.)\1+/g, '$1'); // fuuuck -> fuck -function loadList(file = path.join(__dirname, 'blocklist.txt')) { +function loadList(file = path.join(import.meta.dirname, 'blocklist.txt')) { const whole = []; const anywhere = []; for (const raw of fs.readFileSync(file, 'utf8').split('\n')) { @@ -97,4 +95,4 @@ async function checkName(name, options = {}) { return { ok: true }; } -module.exports = { normalize, loadList, listedWord, openAiFlags, checkName, OPENAI_URL, OPENAI_MODEL }; +export { normalize, loadList, listedWord, openAiFlags, checkName, OPENAI_URL, OPENAI_MODEL }; diff --git a/server/rooms.js b/apps/server/src/rooms.js similarity index 99% rename from server/rooms.js rename to apps/server/src/rooms.js index 87a3d62..bd4c3af 100644 --- a/server/rooms.js +++ b/apps/server/src/rooms.js @@ -9,12 +9,10 @@ // Pure game logic over plain objects (no networking), so it can be tested on its own. // The server keeps each student's board so it can check where rectangles go and // work out the scores itself; the leaderboard can't be faked from a browser. -'use strict'; - -const crypto = require('crypto'); -const Core = require('../js/core.js'); -const Progress = require('../js/progress.js'); -const Tournament = require('./tournament.js'); +import crypto from 'crypto'; +import * as Core from '@blockout/engine'; +import * as Progress from '@blockout/progress'; +import * as Tournament from './tournament.js'; const SIZES = [6, 8, 10, 12, 16, 20, 24]; const DIFFICULTIES = ['easy', 'medium', 'hard']; @@ -835,7 +833,9 @@ function studentView(room, player) { }; } -module.exports = { +const isHere = (p) => connected(p); + +export { SIZES, DIFFICULTIES, ROUNDS, @@ -874,7 +874,7 @@ module.exports = { backToLobby, leaderboard, report, - isHere: (p) => connected(p), + isHere, teacherView, studentView, }; diff --git a/server/store.js b/apps/server/src/store.js similarity index 97% rename from server/store.js rename to apps/server/src/store.js index 15ee61d..b5f8a00 100644 --- a/server/store.js +++ b/apps/server/src/store.js @@ -4,12 +4,10 @@ // Privacy: a user row holds a first name, a last initial, a role, the email // *domain* (to match a school, e.g. iusd.org) and the sign-in provider's opaque // subject id. Never the email address itself. -'use strict'; - -const crypto = require('crypto'); -const fs = require('fs'); -const path = require('path'); -const { DatabaseSync } = require('node:sqlite'); +import crypto from 'crypto'; +import fs from 'fs'; +import path from 'path'; +import { DatabaseSync } from 'node:sqlite'; const SCHEMA = ` CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT NOT NULL); @@ -77,8 +75,8 @@ const DEFAULT_SCHOOLS = [{ id: 'cadence-park', name: 'Cadence Park', domain: 'iu const newId = () => crypto.randomBytes(8).toString('hex'); -// file: a path, or ':memory:' (tests). Defaults to data/blockout.db next to the repo. -function openStore(file = process.env.BLOCKOUT_DB || path.join(__dirname, '..', 'data', 'blockout.db')) { +// file: a path, or ':memory:' (tests). Defaults to data/blockout.db at the repo's root. +function openStore(file = process.env.BLOCKOUT_DB || path.join(import.meta.dirname, '..', '..', '..', 'data', 'blockout.db')) { if (file !== ':memory:') fs.mkdirSync(path.dirname(file), { recursive: true }); const db = new DatabaseSync(file); db.exec('PRAGMA foreign_keys = ON;'); @@ -244,4 +242,4 @@ function openStore(file = process.env.BLOCKOUT_DB || path.join(__dirname, '..', }; } -module.exports = { openStore, newId, DEFAULT_SCHOOLS }; +export { openStore, newId, DEFAULT_SCHOOLS }; diff --git a/server/tournament.js b/apps/server/src/tournament.js similarity index 99% rename from server/tournament.js rename to apps/server/src/tournament.js index 278c301..5d0d8e9 100644 --- a/server/tournament.js +++ b/apps/server/src/tournament.js @@ -14,8 +14,6 @@ // roundrobin everyone plays everyone in groups of up to 6; group winners then play a knockout // swiss a set number of rounds; players with the same record meet, no rematches // koth king of the hill: the winner stays on, challengers queue up; longest streak wins -'use strict'; - const FORMATS = ['knockout', 'double', 'roundrobin', 'swiss', 'koth']; const GROUP_MAX = 6; const HILL_MAX = 8; @@ -380,4 +378,4 @@ function standings(s) { }); } -module.exports = { FORMATS, bracketOrder, create, nextMatches, roundDone, record, standings, stop, isOver, champion }; +export { FORMATS, bracketOrder, create, nextMatches, roundDone, record, standings, stop, isOver, champion }; diff --git a/test/accounts.test.js b/apps/server/test/accounts.test.js similarity index 97% rename from test/accounts.test.js rename to apps/server/test/accounts.test.js index 8819877..5cb13dc 100644 --- a/test/accounts.test.js +++ b/apps/server/test/accounts.test.js @@ -1,10 +1,10 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const crypto = require('crypto'); -const { openStore } = require('../server/store.js'); -const Auth = require('../server/auth.js'); -const { createAccounts } = require('../server/accounts.js'); -const P = require('../js/progress.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import crypto from 'crypto'; +import { openStore } from '../src/store.js'; +import * as Auth from '../src/auth.js'; +import { createAccounts } from '../src/accounts.js'; +import * as P from '@blockout/progress'; function setup() { let clock = Date.UTC(2026, 8, 23, 17, 0, 0); diff --git a/test/moderation.test.js b/apps/server/test/moderation.test.js similarity index 95% rename from test/moderation.test.js rename to apps/server/test/moderation.test.js index c37d039..654bb97 100644 --- a/test/moderation.test.js +++ b/apps/server/test/moderation.test.js @@ -1,6 +1,6 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const M = require('../server/moderation.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import * as M from '../src/moderation.js'; const blocked = (name) => M.listedWord(name) !== null; diff --git a/test/classroom.test.js b/apps/server/test/rooms.test.js similarity index 99% rename from test/classroom.test.js rename to apps/server/test/rooms.test.js index 70969c6..e0dd5af 100644 --- a/test/classroom.test.js +++ b/apps/server/test/rooms.test.js @@ -1,7 +1,7 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const Rooms = require('../server/rooms.js'); -const Core = require('../js/core.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import * as Rooms from '../src/rooms.js'; +import * as Core from '@blockout/engine'; // A class with players who are all "connected". function classOf(names, settings = {}) { diff --git a/test/server.test.js b/apps/server/test/server.test.js similarity index 77% rename from test/server.test.js rename to apps/server/test/server.test.js index 3a0e1cf..f2db0e3 100644 --- a/test/server.test.js +++ b/apps/server/test/server.test.js @@ -1,9 +1,11 @@ // The HTTP API end to end: a real server on a spare port with an in-memory database. process.env.BLOCKOUT_DB = ':memory:'; process.env.BLOCKOUT_AUTH_SECRET = 'test-secret'; -const test = require('node:test'); -const assert = require('node:assert'); -const { server } = require('../server/classroom.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import * as P from '@blockout/progress'; + +const { server } = await import('../src/index.js'); // (after the environment is set) let base; test.before(() => new Promise((resolve) => server.listen(0, () => ((base = `http://localhost:${server.address().port}`), resolve())))); @@ -48,9 +50,19 @@ test('HTTP: sign in, class roster, hosting needs a teacher, signed-in students j assert.equal((await call('POST', `/api/rooms/${room.code}/join`, { name: 'Leo' })).data.name, 'Leo'); // progress sync - const P = require('../js/progress.js'); const save = P.createProgress(); save.wallet = 42; assert.equal((await call('PUT', '/api/me/progress', { progress: save }, kid)).status, 200); assert.equal((await call('GET', '/api/me/progress', null, kid)).data.progress.wallet, 42); }); + +test('HTTP: errors are RFC 9457 problem details (and keep error/message for the games)', async () => { + const res = await fetch(`${base}/api/me`); + assert.equal(res.status, 401); + assert.match(res.headers.get('content-type'), /^application\/problem\+json/); + assert.equal(res.headers.get('x-content-type-options'), 'nosniff'); + const body = await res.json(); + assert.deepEqual(body, { type: 'about:blank', title: 'Unauthorized', status: 401, detail: 'Please sign in first.', error: 'signin', message: 'Please sign in first.' }); + const missing = await fetch(`${base}/api/nope`); + assert.equal((await missing.json()).status, 404); +}); diff --git a/test/tournament.test.js b/apps/server/test/tournament.test.js similarity index 97% rename from test/tournament.test.js rename to apps/server/test/tournament.test.js index 84d6ec2..ce3c097 100644 --- a/test/tournament.test.js +++ b/apps/server/test/tournament.test.js @@ -1,6 +1,6 @@ -const test = require('node:test'); -const assert = require('node:assert'); -const T = require('../server/tournament.js'); +import test from 'node:test'; +import assert from 'node:assert'; +import * as T from '../src/tournament.js'; // A seeded random number generator, so failures can be replayed. function rng(seed) { diff --git a/apps/subtraction/index.html b/apps/subtraction/index.html new file mode 100644 index 0000000..c3a3be8 --- /dev/null +++ b/apps/subtraction/index.html @@ -0,0 +1,12 @@ + + + + + + Subtraction · Blockout + + + + + + diff --git a/apps/subtraction/package.json b/apps/subtraction/package.json new file mode 100644 index 0000000..796a6da --- /dev/null +++ b/apps/subtraction/package.json @@ -0,0 +1,16 @@ +{ + "name": "@blockout/subtraction", + "version": "0.3.0", + "private": true, + "type": "module", + "description": "Blockout: subtraction, one of the games under More games (built on @blockout/game-kit).", + "dependencies": { + "@blockout/game-kit": "*", + "@blockout/ui": "*" + }, + "scripts": { + "dev": "vite", + "build": "vite build", + "lint": "eslint src" + } +} diff --git a/apps/subtraction/src/main.js b/apps/subtraction/src/main.js new file mode 100644 index 0000000..ffd0754 --- /dev/null +++ b/apps/subtraction/src/main.js @@ -0,0 +1,5 @@ +// Subtraction: the shared game kit with this game's rules. +import '@blockout/game-kit/style.css'; +import { startGame } from '@blockout/game-kit'; + +startGame('subtraction'); diff --git a/apps/subtraction/vite.config.js b/apps/subtraction/vite.config.js new file mode 100644 index 0000000..16da40b --- /dev/null +++ b/apps/subtraction/vite.config.js @@ -0,0 +1,9 @@ +// Served at /subtraction/ by apps/server. +import { defineConfig } from 'vite'; +import { blockoutPage } from '@blockout/ui/vite'; + +export default defineConfig({ + base: '/subtraction/', + plugins: [blockoutPage()], + build: { outDir: 'dist', emptyOutDir: true, target: 'es2022' }, +}); diff --git a/css/style.css b/css/style.css deleted file mode 100644 index 6a190ef..0000000 --- a/css/style.css +++ /dev/null @@ -1,1533 +0,0 @@ -/* Theme colours. Light by default; dark follows the device unless the - player picks a theme (html[data-theme="light" | "dark"]). */ -:root { - --bg: #f6f1e7; - --paper: #fffdf8; - --surface: #ffffff; - --soft: #f3eee4; - --soft-2: #efeae0; - --ink: #2b2a33; - --muted: #6d6a75; - --line: #ddd5c6; - --edge: #2b2a33; - --edge-shadow: #2b2a33; - --invert-bg: #2b2a33; - --invert-ink: #ffffff; - --accent: #e4572e; - --accent-ink: #fff; - --accent-soft: #fff1ec; - --accent-ring: #ffd9cc; - --good: #2a9d5c; - --good-ink: #1d6e41; - --good-bg: #e6f6ec; - --good-line: #9fd6b3; - --bad: #b0342a; - --bad-bg: #fbe9e7; - --danger: #d64533; - --gold-line: #e0b93c; - --gold-bg: #fff6d6; - --gold-ink: #8a5a00; - --star: #e0a800; - --learning: #f2c94c; - --learning-ink: #5c4400; - --placeholder: #c9c1b2; - --overlay: rgba(43, 42, 51, 0.45); - --dot: rgba(43, 42, 51, 0.06); - --swatch-edge: rgba(0, 0, 0, 0.15); - /* board canvas */ - --board-bg: #ffffff; - --board-grid: #d9d2c3; - --board-hint: #9a9589; - --board-edge: #2b2a33; - --board-fade: rgba(255, 255, 255, 0.62); - --board-wrong: #8a8578; - --radius: 14px; - --shadow: 0 2px 0 rgba(43, 42, 51, 0.08), 0 8px 24px rgba(43, 42, 51, 0.08); - color-scheme: light; - font-family: "Nunito", "Segoe UI", system-ui, -apple-system, sans-serif; - color: var(--ink); -} -@media (prefers-color-scheme: dark) { - :root:not([data-theme="light"]) { - --bg: #16151b; - --paper: #211f27; - --surface: #2a2731; - --soft: #322e3a; - --soft-2: #2e2a35; - --ink: #ece9f2; - --muted: #a8a3b4; - --line: #3f3a49; - --edge: #57525f; - --edge-shadow: #09080c; - --invert-bg: #ece9f2; - --invert-ink: #16151b; - --accent: #ef6a44; - --accent-ink: #fff; - --accent-soft: #3b2620; - --accent-ring: #6a3526; - --good: #34a466; - --good-ink: #86dba9; - --good-bg: #1d3528; - --good-line: #2f6b48; - --bad: #e26c5e; - --bad-bg: #3d2320; - --danger: #cf4634; - --gold-line: #a8842a; - --gold-bg: #342c1c; - --gold-ink: #f0c96a; - --star: #f2c14e; - --learning: #d6ae36; - --learning-ink: #2a2000; - --placeholder: #6e6a78; - --overlay: rgba(0, 0, 0, 0.6); - --dot: rgba(255, 255, 255, 0.05); - --swatch-edge: rgba(255, 255, 255, 0.2); - --board-bg: #1d1b22; - --board-grid: #3a3643; - --board-hint: #7d7889; - --board-edge: #57525f; - --board-fade: rgba(22, 21, 27, 0.65); - --board-wrong: #8f8a99; - --shadow: 0 2px 0 rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.35); - color-scheme: dark; - } -} -:root[data-theme="dark"] { - --bg: #16151b; - --paper: #211f27; - --surface: #2a2731; - --soft: #322e3a; - --soft-2: #2e2a35; - --ink: #ece9f2; - --muted: #a8a3b4; - --line: #3f3a49; - --edge: #57525f; - --edge-shadow: #09080c; - --invert-bg: #ece9f2; - --invert-ink: #16151b; - --accent: #ef6a44; - --accent-ink: #fff; - --accent-soft: #3b2620; - --accent-ring: #6a3526; - --good: #34a466; - --good-ink: #86dba9; - --good-bg: #1d3528; - --good-line: #2f6b48; - --bad: #e26c5e; - --bad-bg: #3d2320; - --danger: #cf4634; - --gold-line: #a8842a; - --gold-bg: #342c1c; - --gold-ink: #f0c96a; - --star: #f2c14e; - --learning: #d6ae36; - --learning-ink: #2a2000; - --placeholder: #6e6a78; - --overlay: rgba(0, 0, 0, 0.6); - --dot: rgba(255, 255, 255, 0.05); - --swatch-edge: rgba(255, 255, 255, 0.2); - --board-bg: #1d1b22; - --board-grid: #3a3643; - --board-hint: #7d7889; - --board-edge: #57525f; - --board-fade: rgba(22, 21, 27, 0.65); - --board-wrong: #8f8a99; - --shadow: 0 2px 0 rgba(0, 0, 0, 0.3), 0 8px 24px rgba(0, 0, 0, 0.35); - color-scheme: dark; -} - -* { box-sizing: border-box; } - -body { - margin: 0; - min-height: 100vh; - background: var(--bg); - background-image: radial-gradient(var(--dot) 1px, transparent 1px); - background-size: 22px 22px; -} - -[hidden] { display: none !important; } - -button { font: inherit; color: inherit; } - -/* Lucide line icons: sized to the text and drawn in its colour, so they follow the theme */ -.icon { - width: 1.15em; - height: 1.15em; - flex: none; - vertical-align: -0.2em; - fill: none; - stroke: currentColor; - stroke-width: 2.25; - stroke-linecap: round; - stroke-linejoin: round; -} -.mode-icon .icon { width: 1em; height: 1em; stroke-width: 2; color: var(--accent); } -.menu-btn .icon, .icon-btn .icon { width: 22px; height: 22px; display: block; } -.keypad .icon { width: 24px; height: 24px; stroke-width: 2.75; vertical-align: middle; } -.lock-badge .icon { width: 0.95em; height: 0.95em; stroke-width: 2.5; vertical-align: -0.15em; } -.achievement-icon .icon { width: 1.1em; height: 1.1em; color: var(--muted); } -.invite-dialog h2 .icon { color: var(--accent); } - -/* ---------- Shared ---------- */ -.logo { - margin: 0; - font-size: clamp(2.6rem, 9vw, 4rem); - font-weight: 900; - letter-spacing: -0.03em; - line-height: 1; -} -.logo span { color: var(--accent); } -.logo.small { font-size: 1.6rem; } - -.btn { - border: 2px solid var(--edge); - background: var(--paper); - border-radius: 10px; - padding: 0.55rem 1rem; - font-weight: 800; - cursor: pointer; - box-shadow: 0 3px 0 var(--edge-shadow); - transition: transform 0.08s, box-shadow 0.08s; -} -.btn:hover:not(:disabled) { transform: translateY(-1px); box-shadow: 0 4px 0 var(--edge-shadow); } -.btn:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 var(--edge-shadow); } -.btn:disabled { opacity: 0.45; cursor: not-allowed; } -.btn-primary { background: var(--accent); color: var(--accent-ink); } -.btn-big { width: 100%; padding: 0.85rem 1rem; font-size: 1.15rem; } - -.screen { padding: 16px; } - -/* ---------- Start screen ---------- */ -#start-screen { - display: flex; - justify-content: center; - align-items: flex-start; - min-height: 100vh; - padding-top: clamp(16px, 6vh, 64px); -} - -.start-card { - width: 100%; - max-width: 520px; - background: var(--paper); - border: 2px solid var(--edge); - border-radius: var(--radius); - box-shadow: 0 6px 0 var(--edge-shadow); - padding: clamp(20px, 5vw, 36px); - text-align: center; -} - -.tagline { color: var(--muted); margin: 0.6rem 0 1.5rem; } - -.mode-picker { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; margin-bottom: 1.25rem; } -@media (max-width: 480px) { - /* Single + Multiplayer side by side, Practice underneath across the full width */ - .mode-picker { grid-template-columns: 1fr 1fr; gap: 8px; } - .mode-card { padding: 12px 6px; } - .mode-card[data-mode="practice"] { - grid-column: 1 / -1; - display: grid; - grid-template-columns: auto 1fr; - grid-template-rows: auto auto; - column-gap: 12px; - justify-items: start; - text-align: left; - padding: 10px 16px; - } - .mode-card[data-mode="practice"] { grid-template-rows: auto auto auto; } - .mode-card[data-mode="practice"] .mode-icon { grid-row: 1 / -1; align-self: center; } - .mode-card[data-mode="practice"] .lock-badge { grid-column: 2; } - /* Board size: 12, 16, 20 on one row, Custom on its own row */ - /* phones: two per row (How to play · Settings / Stats · Achievements / Shop · Invite) */ - .start-card .menu-actions { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .start-card .menu-actions .btn { font-size: 0.85rem; padding-top: 0.5rem; padding-bottom: 0.5rem; } -} -@media (max-width: 340px) { - .start-card .menu-actions .btn { font-size: 0.76rem; gap: 4px; } -} - -.mode-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - padding: 16px 10px; - border: 2px solid var(--line); - border-radius: 12px; - background: var(--surface); - cursor: pointer; - transition: border-color 0.15s, background 0.15s; -} -.mode-card:hover { border-color: var(--muted); } -.mode-card.selected { border-color: var(--accent); background: var(--accent-soft); } -.mode-icon { font-size: 2rem; } -.mode-title { font-weight: 900; font-size: 1.05rem; } -.mode-sub { font-size: 0.85rem; color: var(--muted); } - -#setup-form { display: flex; flex-direction: column; gap: 1rem; text-align: left; } -.setup-section { display: flex; flex-direction: column; gap: 0.75rem; } - -.field { display: flex; flex-direction: column; gap: 6px; } -.field > span { font-weight: 800; font-size: 0.9rem; } -.field input { - font: inherit; - padding: 0.55rem 0.7rem; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); -} -.field input:focus { outline: none; border-color: var(--accent); } - -.name-inputs { display: flex; flex-direction: column; gap: 8px; } -.name-row { display: flex; align-items: center; gap: 8px; } -.name-row input { - flex: 1; - min-width: 0; - font: inherit; - padding: 0.55rem 0.7rem; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); -} -.name-row input:focus { outline: none; border-color: var(--accent); } -.name-row input.missing { border-color: var(--bad); background: var(--bad-bg); } -.name-row input:disabled { background: var(--soft); color: var(--muted); } -.kind-toggle { - flex: none; - min-width: 6.8em; - white-space: nowrap; - padding: 0.55rem 0.6rem; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); - font-weight: 800; - font-size: 0.85rem; - cursor: pointer; -} -.kind-toggle.cpu { background: var(--invert-bg); border-color: var(--invert-bg); color: var(--invert-ink); } -.swatch { width: 18px; height: 18px; border-radius: 5px; flex: none; border: 2px solid var(--swatch-edge); } - -.segmented { display: flex; border: 2px solid var(--line); border-radius: 10px; overflow: hidden; background: var(--surface); } -.segmented button { - flex: 1; - border: 0; - background: transparent; - padding: 0.55rem 0.3rem; - font-weight: 800; - cursor: pointer; -} -.segmented button + button { border-left: 2px solid var(--line); } -.segmented button.selected { background: var(--invert-bg); color: var(--invert-ink); } -.segmented small { font-weight: 600; opacity: 0.7; } -.segmented[data-setting="difficulty"] small { display: block; } - -.custom-size { display: flex; align-items: center; justify-content: center; gap: 8px; margin-top: 8px; flex-wrap: wrap; } -.custom-size .step { width: 40px; padding: 0.3rem 0; font-size: 1.2rem; line-height: 1; } -.custom-size input { - width: 4.2em; - font: inherit; - font-weight: 900; - text-align: center; - padding: 0.4rem; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); -} -.custom-size input:focus { outline: none; border-color: var(--accent); } -.custom-times { font-weight: 900; min-width: 3.2em; } -.custom-hint { flex-basis: 100%; text-align: center; font-size: 0.8rem; color: var(--muted); } - -.keys { color: var(--muted); } -kbd { - font-family: inherit; - font-size: 0.8em; - border: 1px solid var(--line); - border-bottom-width: 2px; - border-radius: 4px; - padding: 0 4px; - background: var(--surface); -} - -/* ---------- Game screen ---------- */ -.game-header { - display: grid; - grid-template-columns: 1fr auto 1fr; - align-items: center; - max-width: 1100px; - margin: 0 auto 12px; -} -.menu-btn, .icon-btn { - width: 44px; - height: 40px; - padding: 0; - font-size: 1.3rem; - line-height: 1; - flex: none; - /* centre the icon in the button box itself (iOS Safari offsets inline SVG otherwise) */ - display: inline-flex; - align-items: center; - justify-content: center; -} -.menu-btn { justify-self: start; } -.header-right { justify-self: end; display: flex; align-items: center; gap: 12px; min-width: 0; } -/* Cheat codes (~) */ -.cheats-dialog { max-width: 380px; } -.cheats-dialog h2 { margin-bottom: 0.8rem; } -.cheats-input { width: 100%; font: inherit; font-size: 1.4rem; font-weight: 900; letter-spacing: 0.08em; text-align: center; padding: 0.5rem; border: 3px solid var(--edge); border-radius: 10px; background: var(--surface); color: var(--ink); font-family: ui-monospace, "Courier New", monospace; } -.cheats-input:focus { outline: none; border-color: var(--accent); } -.cheats-input.wrong { border-color: var(--bad); animation: shake 0.35s; } -.cheats-msg { min-height: 1.3em; font-weight: 800; color: var(--muted); margin: 10px 0; } -.cheats-msg.good { color: var(--good-ink); } -.cheats-active { margin: 0 0 10px; } -.cheats-active h3 { font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); margin: 0 0 6px; } -.cheat-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 10px; border: 2px solid var(--line); border-radius: 10px; font-weight: 700; } -.cheat-row + .cheat-row { margin-top: 6px; } - -/* Board sizes: two rows of four (6 8 10 12 / 16 20 24 Custom) */ -/* Difficulty: Easy, Medium, Hard / Tricky, Master, Legend */ -#difficulty-field .segmented { display: grid; grid-template-columns: repeat(3, 1fr); } -#difficulty-field .segmented button:nth-child(3n + 1) { border-left: 0; } -#difficulty-field .segmented button:nth-child(n + 4) { border-top: 2px solid var(--line); } -.setting-help.warn { color: var(--danger, #c0392b); font-weight: 600; } -#board-size, #host-size, .host-settings-grid [data-key="size"] { display: grid; grid-template-columns: repeat(4, 1fr); } -#board-size button:nth-child(4n + 1), #host-size button:nth-child(4n + 1), .host-settings-grid [data-key="size"] button:nth-child(4n + 1) { border-left: 0; } -#board-size button:nth-child(n + 5), #host-size button:nth-child(n + 5), .host-settings-grid [data-key="size"] button:nth-child(n + 5) { border-top: 2px solid var(--line); } - -/* Multiplayer: Local / Classroom */ -#multi-kind small, #host-type small, #host-difficulty small { display: block; } -#host-type .icon { vertical-align: -0.2em; } -#multi-kind .icon { vertical-align: -0.2em; } -.classroom-actions { display: flex; flex-direction: column; gap: 8px; } -.classroom-note { margin: 0; background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 10px; padding: 10px 12px; font-size: 0.88rem; color: var(--ink); } -.classroom-note code, .classroom-note + code { font-size: 0.85em; background: var(--surface); border-radius: 4px; padding: 0 4px; } - -/* ---------- Classroom: join / host dialogs ---------- */ -.class-dialog { max-width: 440px; text-align: left; } -.class-dialog h2 { text-align: center; margin-bottom: 0.6rem; } -.class-dialog form { display: flex; flex-direction: column; gap: 12px; } -.class-dialog > .field { display: flex; flex-direction: column; gap: 12px; margin-bottom: 12px; } -.class-dialog .dialog-actions { margin-top: 8px; } -.class-dialog .field { text-align: left; } -.code-input { font-size: 1.8rem !important; font-weight: 900; letter-spacing: 0.3em; text-transform: uppercase; text-align: center; } -.class-wait-big { font-size: 1.3rem; font-weight: 900; text-align: center; margin: 0.4rem 0; } -.class-wait-text { text-align: center; color: var(--muted); margin: 0 0 12px; } -.score-rank { margin-top: 4px; font-size: 0.85rem; font-weight: 800; color: var(--muted); } -.result-list li.result-you { outline: 3px solid var(--c); } -.result-list li.result-gap { background: none; border: 0; text-align: center; color: var(--muted); padding: 0; margin: -2px 0 4px; display: block; } - -/* ---------- Classroom: the teacher's screen ---------- */ -#class-host-screen .game-header { max-width: 1200px; } -.host-code-pill { background: var(--paper); border: 2px solid var(--edge); border-radius: 999px; padding: 4px 14px; font-weight: 800; } -.host-code-pill strong { letter-spacing: 0.12em; } -.host-layout { display: grid; grid-template-columns: minmax(0, 1fr) 320px; gap: 16px; max-width: 1200px; margin: 0 auto; align-items: start; } -.host-main, .host-side { background: var(--paper); border: 2px solid var(--edge); border-radius: var(--radius); box-shadow: 0 6px 0 var(--edge-shadow); padding: clamp(16px, 3vw, 32px); } -.host-panel { display: flex; flex-direction: column; align-items: center; gap: 16px; text-align: center; } -.host-kicker { margin: 0; font-size: 1.6rem; font-weight: 900; } -.host-join { display: flex; align-items: center; justify-content: center; gap: clamp(16px, 4vw, 40px); flex-wrap: wrap; } -.host-qr { width: min(240px, 60vw); height: auto; border-radius: 12px; border: 2px solid var(--edge); background: #fff; image-rendering: pixelated; } -.host-join-text { text-align: left; } -.host-step { margin: 0; color: var(--muted); font-weight: 800; } -.host-url { margin: 0 0 12px; font-size: clamp(1.1rem, 2.4vw, 1.7rem); font-weight: 900; word-break: break-all; } -.host-share { display: flex; gap: 6px; flex-wrap: wrap; margin: -4px 0 12px; } -.host-share .btn.copied, #host-copy-teacher.copied { background: var(--good-bg); border-color: var(--good); color: var(--good-ink); } -.host-code { margin: 0; font-size: clamp(3rem, 9vw, 5.5rem); font-weight: 900; letter-spacing: 0.15em; line-height: 1; color: var(--accent); } -.host-settings { margin: 0; color: var(--muted); font-weight: 700; } -.host-panel .btn-big { max-width: 420px; } -.host-dice { gap: 28px; margin: 0; } -.host-dice .die { width: 120px; height: 120px; padding: 16px; border-radius: 22px; box-shadow: 0 6px 0 var(--edge-shadow); } -.host-dice .pip { width: 20px; height: 20px; } -.host-dice .die-num { font-size: 3.4rem; } -.host-roll-text { margin: 0; font-size: 1.5rem; font-weight: 900; } -.host-progress { width: min(100%, 420px); display: flex; align-items: center; gap: 12px; font-weight: 800; white-space: nowrap; } -.host-progress .progress-bar { flex: 1; height: 16px; } -#host-done-fill { display: block; height: 100%; background: var(--good); transition: width 0.3s; } -.host-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } -.host-actions .btn { font-size: 1.1rem; padding: 0.7rem 1.3rem; } -.host-auto { display: flex; gap: 8px; align-items: center; font-weight: 700; color: var(--muted); cursor: pointer; } -.host-auto input { width: 18px; height: 18px; accent-color: var(--accent); } -.host-side .stats-heading { margin-top: 0; } -.host-roster { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-height: calc(100vh - 200px); overflow-y: auto; } -.host-roster li { display: flex; align-items: center; gap: 10px; background: var(--surface); border: 2px solid var(--line); border-radius: 10px; padding: 6px 10px; font-weight: 800; } -.host-roster li.offline { opacity: 0.55; } -.host-roster li.waiting { border-style: dashed; } -.host-waiting { font-size: 0.8rem; color: var(--muted); white-space: nowrap; } -.host-roster li.host-empty { display: block; border-style: dashed; color: var(--muted); font-weight: 700; } -.host-rank { min-width: 2.6em; color: var(--muted); font-variant-numeric: tabular-nums; } -.host-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.host-score { font-variant-numeric: tabular-nums; font-size: 1.1rem; } -.host-status { width: 1.4em; text-align: center; color: var(--good-ink); } -.host-remove { border: 0; background: none; font-size: 1.3rem; line-height: 1; color: var(--muted); cursor: pointer; padding: 0 4px; } -.host-remove:hover { color: var(--bad); } -.podium { list-style: none; margin: 0; padding: 0; display: flex; gap: 12px; justify-content: center; align-items: flex-end; flex-wrap: wrap; } -.podium li { display: flex; flex-direction: column; align-items: center; gap: 4px; min-width: 140px; padding: 16px; border-radius: 14px; border: 2px solid var(--edge); background: var(--surface); font-weight: 900; } -.podium .podium-1 { order: 2; background: var(--gold-bg); border-color: var(--gold-line); padding-top: 28px; } -.podium .podium-2 { order: 1; } -.podium .podium-3 { order: 3; } -.podium-medal { font-size: 2.6rem; } -.podium-name { font-size: 1.3rem; } -.podium-score { color: var(--muted); } -#host-report { width: 100%; } -.host-hardest { list-style: none; margin: 0; padding: 0; width: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(170px, 1fr)); gap: 8px; } -.host-hardest li { display: flex; flex-direction: column; background: var(--bad-bg); border-radius: 10px; padding: 8px 12px; text-align: left; } -.host-hardest li.host-empty { background: var(--good-bg); grid-column: 1 / -1; font-weight: 800; } -.host-fact { font-size: 1.4rem; font-weight: 900; } -.host-missed { font-size: 0.85rem; font-weight: 700; color: var(--bad); } -/* Lobby: game settings */ -.host-game-settings { width: 100%; max-width: 760px; text-align: left; border-top: 2px solid var(--line); padding-top: 12px; } -.host-settings-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; } -.host-settings-head .stats-heading { margin: 0; } -.host-game-settings > .setting-help { margin: 2px 0 10px; } -.host-settings-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 10px 16px; } -.host-settings-grid .segmented button { padding: 0.45rem 0.3rem; font-size: 0.92rem; } -.host-settings-grid small { display: block; } - -/* Tournaments */ -.host-settings-grid .field.wide { grid-column: 1 / -1; } -.host-settings-grid [data-key="format"] { display: grid; grid-template-columns: repeat(5, 1fr); } -@media (max-width: 700px) { - .host-settings-grid [data-key="format"] { grid-template-columns: repeat(2, 1fr); } -} -.host-tournament { width: 100%; display: flex; flex-direction: column; gap: 14px; } -.bracket { display: flex; gap: 16px; overflow-x: auto; padding-bottom: 6px; align-items: stretch; } -.bracket-round { display: flex; flex-direction: column; justify-content: space-around; gap: 10px; min-width: 150px; } -.bracket-label { font-size: 0.8rem; font-weight: 900; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; text-align: center; } -.bracket-match { border: 2px solid var(--edge); border-radius: 10px; background: var(--surface); overflow: hidden; } -.bracket-name { padding: 4px 10px; font-weight: 800; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.bracket-name + .bracket-name { border-top: 1px solid var(--line); } -.bracket-name.winner { background: var(--good-bg); color: var(--good-ink); } -.bracket-name.loser { color: var(--muted); } -.bracket-bye { font-size: 0.8rem; color: var(--muted); font-style: italic; text-align: center; } -.standings { width: 100%; border-collapse: collapse; font-weight: 700; font-variant-numeric: tabular-nums; } -.standings th, .standings td { padding: 5px 8px; text-align: left; border-bottom: 1px solid var(--line); } -.standings th { font-size: 0.8rem; color: var(--muted); } -.standings tr.out td { color: var(--muted); } -.koth-hills { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } -.koth-hill { border: 2px solid var(--gold-line); background: var(--gold-bg); border-radius: 12px; padding: 10px 12px; text-align: left; } -.koth-title { font-size: 0.8rem; font-weight: 900; color: var(--gold-ink); text-transform: uppercase; } -.koth-king { font-size: 1.4rem; font-weight: 900; } -.koth-streak { font-weight: 800; } -.koth-queue, .koth-count { font-size: 0.85rem; color: var(--muted); font-weight: 700; } -.host-roster li.knocked-out { opacity: 0.6; } -.result-tournament { margin: 0 0 14px; padding: 8px 12px; border-radius: 10px; background: var(--gold-bg); border: 2px solid var(--gold-line); font-weight: 800; text-align: center; } - -/* Pairs */ -.host-pairs { width: 100%; max-width: 760px; display: flex; flex-direction: column; gap: 12px; text-align: left; } -#host-odd small { display: block; } -.host-pairs .setting-help { margin: 0; } -.host-pair-list { list-style: none; margin: 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 6px; } -.host-pair-list li { display: flex; align-items: center; gap: 6px; background: var(--surface); border: 2px solid var(--line); border-radius: 10px; padding: 6px 8px; font-weight: 800; } -.pair-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; text-align: center; } -button.pair-name { border: 2px dashed var(--line); border-radius: 8px; background: var(--paper); padding: 4px 6px; font-weight: 800; cursor: pointer; } -button.pair-name:hover { border-color: var(--accent); } -button.pair-name.picked { border-style: solid; border-color: var(--accent); background: var(--accent-soft); } -.pair-name.other { color: var(--muted); font-style: italic; } -.pair-vs { font-size: 0.75rem; color: var(--muted); } -.host-teacher-device { display: flex; gap: 14px; align-items: center; background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 12px; padding: 10px; } -.host-qr.small { width: 120px; } -.host-teacher-url { margin: 2px 0; font-weight: 800; word-break: break-all; font-size: 0.85rem; } -.host-teacher-status { margin: 0; font-weight: 800; color: var(--muted); } -.host-teacher-status.ok { color: var(--good-ink); } -.host-matches { list-style: none; margin: 0; padding: 0; width: 100%; display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } -.host-match { background: var(--surface); border: 2px solid var(--edge); border-radius: 12px; padding: 8px 10px; text-align: left; } -.host-match.over { border-color: var(--line); } -.match-row { display: flex; align-items: center; gap: 6px; font-weight: 800; padding: 2px 0; } -.match-row.winner { color: var(--good-ink); } -.match-turn { width: 1.4em; text-align: center; } -.match-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.match-score { font-size: 1.2rem; font-weight: 900; font-variant-numeric: tabular-nums; } -.match-status { margin-top: 4px; font-size: 0.8rem; font-weight: 700; color: var(--muted); } -.host-mygame.your-turn { animation: pulse 1.2s ease-in-out infinite; } -@keyframes pulse { - 0%, 100% { box-shadow: 0 3px 0 var(--edge-shadow), 0 0 0 0 var(--accent-ring); } - 50% { box-shadow: 0 3px 0 var(--edge-shadow), 0 0 0 8px var(--accent-ring); } -} - -@media (max-width: 820px) { - .host-layout { grid-template-columns: 1fr; } - .host-roster { max-height: none; } -} -.settings-note { background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 10px; padding: 8px 12px; font-size: 0.88rem; font-weight: 700; margin: 0 0 14px; } -.progress { width: min(100%, 190px); display: flex; flex-direction: column; gap: 4px; } -.progress-text { color: var(--muted); font-weight: 700; font-size: 0.85rem; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; } -.progress-text .short { display: none; } -.progress-bar { - display: flex; - height: 10px; - border: 2px solid var(--edge); - border-radius: 6px; - background: var(--surface); - overflow: hidden; -} -.progress-bar span { height: 100%; transition: width 0.4s ease-out; } - -.game-layout { - display: grid; - grid-template-columns: minmax(0, 1fr) 300px; - gap: 16px; - max-width: 1100px; - margin: 0 auto; - align-items: start; -} - -.board-wrap { - background: var(--paper); - border: 2px solid var(--edge); - border-radius: var(--radius); - box-shadow: 0 6px 0 var(--edge-shadow); - padding: 12px; - display: flex; - justify-content: center; -} -#board { display: block; touch-action: none; cursor: crosshair; } - -.side { display: flex; flex-direction: column; gap: 16px; } - -.turn-panel { - background: var(--paper); - border: 2px solid var(--edge); - border-radius: var(--radius); - padding: 16px; - text-align: center; - border-top: 8px solid var(--turn-color, var(--ink)); -} -.turn-label { font-weight: 900; font-size: 1.15rem; } -.turn-msg { min-height: 2.6em; color: var(--muted); font-size: 0.92rem; margin: 6px 0 10px; } -.turn-msg:empty { display: none; } -.turn-msg.oops { color: var(--bad); font-weight: 800; } -.turn-actions { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; } - -.dice { display: flex; gap: 14px; justify-content: center; margin-top: 12px; } -.die { - width: 58px; - height: 58px; - border: 2px solid var(--edge); - border-radius: 12px; - background: var(--surface); - display: grid; - grid-template: repeat(3, 1fr) / repeat(3, 1fr); - padding: 8px; - box-shadow: 0 3px 0 var(--edge-shadow); -} -.die.blank { background: repeating-linear-gradient(45deg, var(--surface), var(--surface) 6px, var(--soft) 6px, var(--soft) 12px); } -.die.rolling { animation: wobble 0.12s linear infinite; } -.pip { width: 10px; height: 10px; border-radius: 50%; background: var(--ink); place-self: center; } - -/* ---------- Wardrobe: dice skins (the same in light and dark themes) ---------- */ -.die.skin-golden { background: linear-gradient(135deg, #fff3b0 0%, #f6c945 38%, #d99a1c 70%, #f8dc6c 100%); border-color: #9a6a0c; box-shadow: 0 3px 0 #7a520a, inset 0 1px 2px rgba(255, 255, 255, 0.7); } -.die.skin-golden .pip { background: #4a3200; } -.die.skin-golden .die-num { color: #4a3200; } -.die.skin-golden.blank { background: repeating-linear-gradient(45deg, #f8dc6c, #f8dc6c 6px, #e8bb3a 6px, #e8bb3a 12px); } -.die.skin-golden.rolling { animation: wobble 0.12s linear infinite, golden-sparkle 0.5s linear infinite; } - -.die.skin-wood { background: repeating-linear-gradient(100deg, #c8914f 0 6px, #b98244 6px 9px, #d09b5a 9px 15px); border-color: #6b4420; box-shadow: 0 3px 0 #4a2e14; } -.die.skin-wood .pip { background: #3b240f; } -.die.skin-wood .die-num { color: #3b240f; } - -.die.skin-candy { background: repeating-linear-gradient(45deg, #fff 0 6px, #ffb3c7 6px 12px); border-color: #d6336c; box-shadow: 0 3px 0 #a61e4d; } -.die.skin-candy .pip { background: #a61e4d; } -.die.skin-candy .die-num { color: #a61e4d; } - -.die.skin-glass { background: rgba(170, 215, 255, 0.35); border-color: rgba(60, 130, 200, 0.7); box-shadow: 0 3px 0 rgba(40, 90, 150, 0.5), inset 0 0 10px rgba(255, 255, 255, 0.8); backdrop-filter: blur(2px); } -.die.skin-glass .pip { background: #1d5fa0; } -.die.skin-glass .die-num { color: #1d5fa0; } - -.die.skin-neon { background: #120f24; border-color: #3cf2ff; box-shadow: 0 3px 0 #0a0818, 0 0 10px #3cf2ff; } -.die.skin-neon .pip { background: #ff4fd8; box-shadow: 0 0 6px #ff4fd8; } -.die.skin-neon .die-num { color: #3cf2ff; text-shadow: 0 0 6px #3cf2ff; } - -.die.skin-galaxy { background: radial-gradient(circle at 30% 30%, #6a4bc4 0%, #2a1a5e 45%, #0d0b26 100%); border-color: #8f7cff; box-shadow: 0 3px 0 #070617; } -.die.skin-galaxy .pip { background: #fff; box-shadow: 0 0 5px #fff; } -.die.skin-galaxy .die-num { color: #fff; } - -.die.skin-pumpkin { background: radial-gradient(circle at 40% 35%, #ffb04a 0%, #f07b12 60%, #c85a00 100%); border-color: #7a3500; box-shadow: 0 3px 0 #5a2700; } -.die.skin-pumpkin .pip { background: #2b1200; } -.die.skin-pumpkin .die-num { color: #2b1200; } - -.die[class*="skin-"]:not(.skin-classic).blank { opacity: 0.8; } -/* picked real-dice buttons keep the player's colour whatever the skin */ -.die.mini.selected { background: var(--turn-color, var(--accent)) !important; border-color: var(--edge) !important; } -.die.mini.selected .pip { background: #fff !important; box-shadow: none !important; } -.die.mini.selected .die-num { color: #fff !important; } -/* pip styles: hearts, stars, paws, dinosaurs */ -.pip.pip-glyph { background: none !important; box-shadow: none !important; width: auto; height: auto; font-size: 11px; line-height: 1; } -.die.mini .pip.pip-glyph { font-size: 7px; } - -/* rolling styles */ -.die.rolling.roll-tumble { animation: tumble 0.28s linear infinite; } -.die.rolling.roll-bounce { animation: bounce 0.22s ease-in-out infinite alternate; } -.die.rolling.roll-sparkle { animation: spin 0.3s linear infinite, golden-sparkle 0.3s linear infinite; } -@keyframes tumble { - 0% { transform: perspective(200px) rotateX(0deg) rotateY(0deg); } - 100% { transform: perspective(200px) rotateX(360deg) rotateY(180deg); } -} -@keyframes bounce { - from { transform: translateY(0) scale(1, 1); } - to { transform: translateY(-12px) scale(0.95, 1.05); } -} -@keyframes spin { - to { transform: rotate(360deg); } -} -@keyframes golden-sparkle { - 0%, 100% { filter: brightness(1); } - 50% { filter: brightness(1.25) saturate(1.2); } -} -@keyframes wobble { - 0% { transform: rotate(-8deg); } - 50% { transform: rotate(8deg); } - 100% { transform: rotate(-8deg); } -} - -a.btn { color: inherit; text-decoration: none; display: inline-block; } -.btn-small { padding: 0.35rem 0.8rem; font-size: 0.9rem; } - -/* Real dice entry */ -.dice-entry { display: flex; flex-direction: column; align-items: center; gap: 6px; margin: 4px 0 12px; } -.die-pick-label { font-size: 0.8rem; font-weight: 800; color: var(--muted); } -.die-pick { display: flex; gap: 6px; flex-wrap: wrap; justify-content: center; } -.die.mini { - width: 36px; - height: 36px; - padding: 5px; - border-radius: 8px; - box-shadow: 0 2px 0 var(--edge-shadow); - cursor: pointer; -} -.die.mini .pip { width: 6px; height: 6px; } -.die.mini.selected { background: var(--turn-color, var(--accent)); } -.die.mini.selected .pip { background: #fff; } -.die.mini:active { transform: translateY(2px); box-shadow: none; } -#use-dice { margin-top: 6px; } - -/* Math panel (human answers) */ -.math { margin: 4px 0 12px; display: flex; flex-direction: column; align-items: center; gap: 8px; } -.math-q { - font-size: 2rem; - font-weight: 900; - font-variant-numeric: tabular-nums; - display: flex; - align-items: center; - gap: 8px; -} -.answer-box { - min-width: 2.4em; - height: 1.5em; - padding: 0 6px; - border: 3px solid var(--edge); - border-radius: 10px; - background: var(--surface); - display: inline-flex; - align-items: center; - justify-content: center; -} -.answer-box:empty::after { content: '?'; color: var(--placeholder); } -.answer-box.right { border-color: var(--good); background: var(--good-bg); color: var(--good-ink); } -.answer-box.wrong { border-color: var(--bad); animation: shake 0.35s; } -@keyframes shake { - 0%, 100% { transform: translateX(0); } - 25% { transform: translateX(-6px); } - 75% { transform: translateX(6px); } -} -.math-feedback { min-height: 1.3em; font-weight: 800; } - -/* Answer timer */ -.timer { display: flex; align-items: center; gap: 8px; width: 100%; max-width: 240px; } -.timer-bar { flex: 1; height: 10px; border: 2px solid var(--edge); border-radius: 6px; background: var(--surface); overflow: hidden; } -.timer-bar span { display: block; height: 100%; width: 100%; background: var(--good); transition: width 0.1s linear; } -.timer-num { min-width: 2.6em; text-align: right; font-weight: 900; font-variant-numeric: tabular-nums; } -.timer.low .timer-bar span { background: var(--bad); } -.timer.low .timer-num { color: var(--bad); } -.answer-box.timeout { border-color: var(--bad); background: var(--bad-bg); color: var(--bad); } -.math-feedback.good { color: var(--good-ink); } -.math-feedback.try { color: var(--bad); } - -.count-trail { - font-weight: 800; - font-variant-numeric: tabular-nums; - color: var(--ink); - min-height: 0; -} -.count-trail:not(:empty) { - background: var(--gold-bg); - border: 2px dashed var(--gold-line); - border-radius: 10px; - padding: 4px 10px; -} - -.keypad { display: grid; grid-template-columns: repeat(3, 56px); gap: 6px; } -.keypad button { - height: 46px; - border: 2px solid var(--edge); - border-radius: 10px; - background: var(--surface); - font-size: 1.3rem; - font-weight: 900; - cursor: pointer; - box-shadow: 0 3px 0 var(--edge-shadow); -} -.keypad button:active:not(:disabled) { transform: translateY(2px); box-shadow: 0 1px 0 var(--edge-shadow); } -.keypad button.check { background: var(--good); color: #fff; } -.keypad button.back { background: var(--danger); color: #fff; } -.keypad button:disabled { opacity: 0.45; cursor: not-allowed; } - -/* Computer's step-by-step working */ -.steps { - list-style: none; - margin: 18px 0 12px; - padding: 0; - text-align: left; - display: flex; - flex-direction: column; - gap: 6px; -} -.steps li { - background: var(--surface); - border: 2px solid var(--line); - border-radius: 10px; - padding: 6px 10px; - font-size: 0.95rem; - animation: step-in 0.3s ease-out; -} -.steps li.big { font-size: 1.3rem; font-weight: 900; text-align: center; border-color: var(--good); background: var(--good-bg); } -.steps .num { font-weight: 900; font-variant-numeric: tabular-nums; } -@keyframes step-in { - from { opacity: 0; transform: translateY(6px); } - to { opacity: 1; transform: none; } -} - -.scores { display: flex; flex-direction: column; gap: 10px; } -.score-card { - background: var(--paper); - border: 2px solid var(--line); - border-left: 8px solid var(--c); - border-radius: 12px; - padding: 10px 12px; - transition: border-color 0.2s; -} -.score-card.active { border-color: var(--edge); border-left-color: var(--c); } -.score-head { display: flex; justify-content: space-between; align-items: baseline; gap: 8px; } -.score-name { font-weight: 900; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } -.score-pts { font-weight: 900; font-size: 1.4rem; font-variant-numeric: tabular-nums; } -.score-log { - margin: 6px 0 0; - padding: 0; - list-style: none; - display: flex; - flex-wrap: wrap; - gap: 4px; - font-size: 0.8rem; - font-variant-numeric: tabular-nums; -} -.score-log li { background: var(--soft); border-radius: 6px; padding: 1px 6px; } -.score-log li.pass { color: var(--muted); font-style: italic; } -.score-log li.star::before { content: '★ '; color: var(--star); } -.score-log li.timeout { color: var(--bad); } - -/* ---------- Game over ---------- */ -.overlay { - position: fixed; - inset: 0; - background: var(--overlay); - display: flex; - align-items: center; - justify-content: center; - padding: 16px; - z-index: 10; -} -.dialog { - background: var(--paper); - border: 2px solid var(--edge); - border-radius: var(--radius); - box-shadow: 0 6px 0 var(--edge-shadow); - --pad-x: 28px; /* the bottom panel of buttons reaches out to the edges using these */ - --pad-y: 28px; - padding: var(--pad-y) var(--pad-x); - width: 100%; - max-width: 420px; - text-align: center; - animation: pop 0.25s ease-out; -} -.dialog h2 { margin: 0; font-size: 2rem; font-weight: 900; } -.result-sub { color: var(--muted); } -.result-list { list-style: none; padding: 0; margin: 0 0 20px; text-align: left; } -.result-list li { - display: grid; - grid-template-columns: 1fr auto; - gap: 6px 12px; - padding: 8px 12px; - border-radius: 8px; - border-left: 8px solid var(--c); - background: var(--surface); - margin-bottom: 6px; - font-weight: 800; -} -.result-pts { font-variant-numeric: tabular-nums; } -.result-bar { grid-column: 1 / -1; height: 14px; border-radius: 4px; background: var(--soft-2); overflow: hidden; } -.result-bar span { display: block; height: 100%; min-width: 4px; background: var(--c); border-radius: 4px; animation: bar-grow 0.8s ease-out; transform-origin: left; } -@keyframes bar-grow { - from { transform: scaleX(0); } - to { transform: scaleX(1); } -} -.dialog-actions { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; } -/* The shop can open on top of any other screen (e.g. the results) */ -#shop, #wardrobe, #stickers { z-index: 20; } -/* Quick unlock confirmation: small, above everything */ -#unlock { z-index: 25; } -#achievements { z-index: 22; } /* can open from a results screen */ - -/* Achievement banners: bottom centre, one at a time */ -.achievement-banner { - position: fixed; - left: 50%; - bottom: 24px; - z-index: 45; - display: flex; - align-items: center; - gap: 14px; - min-width: min(420px, calc(100vw - 32px)); - max-width: calc(100vw - 32px); - padding: 12px 22px 12px 14px; - border-radius: 16px; - border: 3px solid var(--gold-line); - background: linear-gradient(180deg, #3a2f1a, #221b10); - color: #fff6d6; - box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.35), 0 10px 30px rgba(0, 0, 0, 0.45), inset 0 0 18px rgba(240, 201, 106, 0.25); - pointer-events: none; /* never in the way of the buttons underneath */ - opacity: 0; - transform: translate(-50%, 40px) scale(0.92); - transition: opacity 0.3s ease-out, transform 0.35s cubic-bezier(0.2, 1.4, 0.4, 1); -} -.achievement-banner.show { opacity: 1; transform: translate(-50%, 0) scale(1); } -.achievement-banner.leaving { opacity: 0; transform: translate(-50%, 20px) scale(0.96); } -.banner-icon { - flex: none; - width: 58px; - height: 58px; - display: grid; - place-items: center; - font-size: 2rem; - border-radius: 50%; - border: 3px solid var(--gold-line); - background: radial-gradient(circle at 35% 30%, #fff3b0, #d99a1c); - box-shadow: 0 0 14px rgba(240, 201, 106, 0.6); -} -.banner-text { display: flex; flex-direction: column; min-width: 0; } -.banner-kicker { font-size: 0.75rem; font-weight: 800; letter-spacing: 0.12em; text-transform: uppercase; color: #f0c96a; } -.banner-name { font-size: 1.35rem; font-weight: 900; line-height: 1.15; overflow: hidden; text-overflow: ellipsis; } -.banner-reward { font-size: 0.9rem; font-weight: 800; opacity: 0.85; } -.achievement-banner.minor { min-width: 0; padding: 8px 16px 8px 10px; gap: 10px; } -.achievement-banner.minor .banner-icon { width: 38px; height: 38px; font-size: 1.3rem; } -.achievement-banner.minor .banner-name { font-size: 1rem; } -@media (prefers-reduced-motion: reduce) { - .achievement-banner { transition: opacity 0.2s; transform: translate(-50%, 0); } - .achievement-banner.leaving { transform: translate(-50%, 0); } -} - -.unlock-dialog { max-width: 360px; } -.unlock-dialog h2 { font-size: 1.35rem; } -.unlock-price { font-weight: 900; color: var(--gold-ink); margin: 6px 0; } -#unlock-message:empty { display: none; } - -/* "Shop · 2 new unlocks", "Wardrobe · 1 new", "Stickers · 2 spares" on the results screens: own row, in gold */ -.nudges { flex-basis: 100%; display: flex; flex-wrap: wrap; gap: 8px; justify-content: center; } -.nudges:not(:has(.nudge:not([hidden]))) { display: none; } -.nudge { - flex: 1 1 auto; - display: inline-flex; - align-items: center; - justify-content: center; - gap: 6px; - background: var(--gold-bg); - border-color: var(--gold-line); - color: var(--gold-ink); - animation: pop 0.3s ease-out; -} -/* Stats tables by game type: bold title, muted details underneath */ -.stats-table.by-type tbody th { min-width: 7em; } -.stats-table.by-type .row-title { display: block; color: var(--ink); font-weight: 800; } -.stats-table.by-type .row-sub { display: block; font-size: 0.78rem; font-weight: 700; color: var(--muted); } -@keyframes pop { - from { transform: scale(0.9); opacity: 0; } - to { transform: scale(1); opacity: 1; } -} - -/* ---------- End-of-game stats ---------- */ -.results-dialog { max-width: 600px; max-height: calc(100vh - 32px); overflow-y: auto; } -.stats { text-align: left; margin-bottom: 18px; } -.stats-summary { text-align: center; font-weight: 700; color: var(--muted); font-size: 0.9rem; margin: 0 0 10px; } -.stats-table-wrap { overflow-x: auto; } -.stats-table { width: 100%; border-collapse: collapse; font-size: 0.88rem; font-variant-numeric: tabular-nums; } -.stats-table th, .stats-table td { padding: 5px 8px; border-bottom: 1px solid var(--line); } -.stats-table thead th { font-weight: 900; text-align: center; white-space: nowrap; } -.stats-table tbody th { font-weight: 700; text-align: left; color: var(--muted); } -.stats-table td { text-align: center; font-weight: 800; white-space: nowrap; } -.stats-table .dot { display: inline-block; width: 10px; height: 10px; border-radius: 50%; background: var(--c); margin-right: 5px; } -.stats-heading { font-size: 1rem; margin: 16px 0 8px; } -.practice { padding: 8px 10px; border-left: 6px solid var(--c); background: var(--surface); border-radius: 8px; margin-bottom: 6px; } -.practice-name { font-weight: 900; margin-bottom: 4px; } -.practice-none { color: var(--muted); font-size: 0.88rem; } -.facts { display: flex; flex-wrap: wrap; gap: 6px; } -.fact { - display: flex; - flex-direction: column; - background: var(--gold-bg); - border: 1px solid var(--gold-line); - border-radius: 8px; - padding: 3px 9px; - font-variant-numeric: tabular-nums; -} -.fact-eq { font-weight: 900; } -.fact-said { font-size: 0.78rem; font-weight: 700; color: var(--gold-ink); } -/* Results: Play again · Main menu · Show details, same size on one line */ -.results-actions > .btn { flex: 1 1 0; min-width: 0; white-space: nowrap; } -.results-actions > .btn[hidden] { display: none; } -@media (max-width: 480px) { - .results-actions { gap: 6px; } - .results-actions > .btn { white-space: normal; font-size: 0.85rem; padding: 0.5rem 0.25rem; line-height: 1.15; } - .results-actions .details-btn .icon { display: none; } -} -#stats:not([hidden]), #pd-body:not([hidden]) { margin-bottom: 16px; } - -/* ---------- Settings ---------- */ -.menu-actions { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; margin-top: 1rem; } -.menu-actions .btn { position: relative; display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding-left: 0.4rem; padding-right: 0.4rem; white-space: nowrap; } -.howto-dialog { max-width: 520px; max-height: calc(100vh - 32px); overflow-y: auto; text-align: left; } -.howto-dialog h2 { text-align: center; margin-bottom: 0.5rem; } -.howto-dialog ul { padding-left: 1.2rem; line-height: 1.55; } - -/* ---------- All-time stats ---------- */ -.history-dialog { max-width: 600px; max-height: calc(100vh - 32px); overflow-y: auto; text-align: left; } -.history-dialog h2 { text-align: center; margin-bottom: 1rem; } -.history-empty { text-align: center; color: var(--muted); margin: 1rem 0 1.5rem; } -.tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; margin-bottom: 6px; } -.tile { background: var(--surface); border: 2px solid var(--line); border-radius: 10px; padding: 8px; text-align: center; } -.tile-value { display: block; font-size: 1.35rem; font-weight: 900; font-variant-numeric: tabular-nums; } -.tile-label { display: block; font-size: 0.78rem; font-weight: 700; color: var(--muted); } -.recent { list-style: none; padding: 0; margin: 0; display: flex; flex-direction: column; gap: 6px; } -.recent li { background: var(--surface); border: 1px solid var(--line); border-radius: 8px; padding: 6px 10px; font-size: 0.88rem; } -.recent .meta { color: var(--muted); font-weight: 700; margin-right: 6px; } -.recent .winner { font-weight: 800; } -.history-dialog .dialog-actions { margin-top: 20px; } -.history-dialog .stats-table thead th { white-space: normal; font-size: 0.8rem; } -@media (max-width: 420px) { - .tile-value { font-size: 1.1rem; } - .history-dialog { --pad-y: 20px; --pad-x: 14px; } - .history-dialog .stats-table th, .history-dialog .stats-table td { padding: 5px 4px; } - .history-dialog .stats-table { font-size: 0.8rem; } - .history-dialog .stats-table thead th { font-size: 0.72rem; } -} -.settings-dialog { text-align: left; max-height: calc(100vh - 32px); overflow-y: auto; } -.settings-dialog h2 { text-align: center; margin-bottom: 1rem; } -.setting { margin-bottom: 1.1rem; } -.setting-label { font-weight: 800; font-size: 0.9rem; margin-bottom: 6px; } -.setting-help { margin: 6px 0 0; font-size: 0.82rem; color: var(--muted); } - -/* ---------- Wallet, shop, achievements, toasts ---------- */ -.wallet { - display: inline-block; - margin-bottom: 10px; - padding: 4px 12px; - border: 2px solid var(--gold-line); - border-radius: 999px; - background: var(--gold-bg); - font-weight: 800; - font-variant-numeric: tabular-nums; -} - -.locked { position: relative; color: var(--muted) !important; background: var(--soft) !important; } -.lock-badge { display: block; font-size: 0.72rem; font-weight: 800; color: var(--gold-ink); } -.mode-card.locked { opacity: 0.85; } -.mode-card.locked .mode-icon { filter: grayscale(1); opacity: 0.6; } -.mode-card .lock-badge { margin-top: 2px; } - -.shop-dialog, .achievements-dialog { max-width: 520px; max-height: calc(100vh - 32px); overflow-y: auto; text-align: left; } -.shop-dialog h2, .achievements-dialog h2 { text-align: center; margin-bottom: 0.5rem; } -.shop-item { - display: grid; - grid-template-columns: 1fr auto; - align-items: center; - gap: 2px 10px; - padding: 8px 10px; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); - margin-bottom: 6px; -} -.shop-item.highlight { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); } -.shop-item.owned { background: var(--good-bg); border-color: var(--good-line); } -.shop-name { font-weight: 800; } -.shop-owned { font-weight: 800; color: var(--good-ink); } -.shop-why { grid-column: 1 / -1; font-size: 0.8rem; color: var(--muted); text-align: right; } -.shop-dialog .dialog-actions, .achievements-dialog .dialog-actions { margin-top: 16px; } - -.achievement-list { display: flex; flex-direction: column; gap: 6px; } -.achievement { - display: grid; - grid-template-columns: 2rem 1fr auto; - align-items: center; - gap: 10px; - padding: 8px 10px; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--soft-2); - color: var(--muted); -} -.achievement.earned { background: var(--surface); color: var(--ink); border-color: var(--gold-line); } -.achievement-icon { font-size: 1.4rem; text-align: center; } -.achievement-text { display: flex; flex-direction: column; font-size: 0.85rem; } -.achievement-text strong { font-size: 0.95rem; } -.achievement-reward { font-weight: 800; font-size: 0.85rem; white-space: nowrap; } - -.rewards { margin: 0 0 12px; display: flex; flex-direction: column; gap: 6px; } -.rewards:empty { display: none; } -.reward-points { font-weight: 900; color: var(--gold-ink); background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 10px; padding: 6px 10px; } -.reward-achievement { display: flex; align-items: center; gap: 8px; font-weight: 800; background: var(--surface); border: 2px solid var(--gold-line); border-radius: 10px; padding: 4px 10px; text-align: left; } -.reward-achievement .achievement-reward { margin-left: auto; } - -/* Achievement confetti: above everything, never catches clicks */ -.confetti { position: fixed; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 40; } - -/* Bottom-left, newest at the bottom */ -.toasts { position: fixed; bottom: 16px; left: 16px; right: 16px; display: flex; flex-direction: column; align-items: flex-start; gap: 8px; z-index: 30; pointer-events: none; } -.toast { - max-width: 340px; - background: var(--invert-bg); - color: var(--invert-ink); - border-radius: 12px; - padding: 10px 14px; - box-shadow: var(--shadow); - display: flex; - flex-direction: column; - animation: toast-in 0.25s ease-out; - transition: opacity 0.4s, transform 0.4s; -} -.toast span { font-size: 0.85rem; opacity: 0.85; } -.toast.leaving { opacity: 0; transform: translateY(8px); } -@keyframes toast-in { - from { opacity: 0; transform: translateY(8px); } - to { opacity: 1; transform: none; } -} - -/* Practice mode */ -.table-chips { display: flex; flex-wrap: wrap; gap: 6px; } -.table-chips button { - min-width: 3em; - padding: 0.4rem 0.5rem; - border: 2px solid var(--line); - border-radius: 999px; - background: var(--surface); - font-weight: 900; - font-variant-numeric: tabular-nums; - cursor: pointer; -} -.table-chips button[aria-pressed="true"] { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); } -.practice-preview { margin: 0; font-size: 0.88rem; color: var(--muted); font-weight: 700; } -.practice-card { - max-width: 520px; - margin: 0 auto; - background: var(--paper); - border: 2px solid var(--edge); - border-radius: var(--radius); - box-shadow: 0 6px 0 var(--edge-shadow); - padding: 16px; - display: flex; - flex-direction: column; - align-items: center; - gap: 10px; -} -.practice-top { width: 100%; display: flex; justify-content: space-between; font-weight: 800; color: var(--muted); } -.practice-bar { width: 100%; height: 8px; border-radius: 4px; background: var(--soft-2); overflow: hidden; } -.practice-bar span { display: block; height: 100%; background: var(--accent); border-radius: 4px; transition: width 0.3s; } -.pr-array { - display: grid; - gap: 2px; - justify-content: center; - align-items: center; - --cell: min(26px, calc((min(100vw, 520px) - 110px) / var(--cols))); - --totals: 2.6em; /* column for the running totals on the right */ - grid-template-columns: repeat(var(--cols), var(--cell)) var(--totals); - /* matching space on the left so the squares line up with the keypad's centre */ - padding-left: calc(var(--totals) + 2px); -} -.pr-array .cell { width: var(--cell); height: var(--cell); border-radius: 3px; background: var(--accent); opacity: 0.85; } -.pr-array.counting .cell { opacity: 0.3; } -.pr-array .cell.counted { opacity: 1; box-shadow: inset 0 0 0 2px var(--edge-shadow); } -.pr-array .row-total { font-weight: 900; font-size: 0.85rem; font-variant-numeric: tabular-nums; text-align: left; padding-left: 6px; visibility: hidden; } -.pr-array .row-total.shown { visibility: visible; } -.practice-change { display: flex; justify-content: space-between; gap: 10px; padding: 5px 10px; border-radius: 8px; background: var(--surface); border: 1px solid var(--line); margin-bottom: 5px; font-size: 0.9rem; } -.practice-change .up { color: var(--good-ink); font-weight: 800; } -.practice-change .down { color: var(--bad); font-weight: 800; } - -/* Invites */ -.invite-dialog { max-width: 520px; max-height: calc(100vh - 32px); overflow-y: auto; text-align: left; } -.invite-dialog h2 { text-align: center; margin-bottom: 1rem; } -.invite-dialog .field { margin-bottom: 10px; } -.invite-dialog .field > span { font-weight: 800; font-size: 0.9rem; } -.invite-dialog input[type="text"], .invite-dialog input[type="number"], .invite-dialog select, .invite-dialog textarea { - font: inherit; - width: 100%; - min-width: 0; - padding: 0.45rem 0.6rem; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); -} -.invite-dialog textarea { resize: vertical; } -.invite-dialog :is(input, select, textarea):focus { outline: none; border-color: var(--accent); } -.invite-row { display: flex; gap: 8px; } -.invite-row input { flex: 1; } -.invite-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 12px; } -.invite-players { margin: 0; font-weight: 800; } -.invite-dialog > .setting-help { margin: 0 0 12px; } -.invite-error { color: var(--bad); font-weight: 800; font-size: 0.88rem; margin: 6px 0 0; } -.check-row { display: flex; align-items: center; gap: 8px; margin: 4px 0 12px; font-weight: 700; font-size: 0.9rem; } -.invite-result { margin-top: 14px; padding-top: 12px; border-top: 2px dashed var(--line); } -.invite-qr { display: flex; gap: 14px; align-items: center; flex-wrap: wrap; margin: 12px 0; } -.invite-qr canvas { width: 240px; height: 240px; max-width: 100%; image-rendering: pixelated; border: 2px solid var(--line); border-radius: 10px; background: var(--surface); } -.invite-qr-actions { display: flex; flex-direction: column; align-items: flex-start; gap: 8px; flex: 1; min-width: 160px; } -.invite-qr-actions .setting-help { margin: 0; } -.invite-note { background: var(--gold-bg); border: 2px solid var(--gold-line); border-radius: 10px; padding: 10px 12px; font-weight: 800; font-size: 1.05rem; } -.invite-summary { padding-left: 1.2rem; line-height: 1.6; } -@media (max-width: 420px) { - .invite-grid { grid-template-columns: 1fr; } -} - -/* Numbered dice for 8 and 12 sides */ -.die.numbered { display: flex; align-items: center; justify-content: center; } -.die-num { font-size: 1.6rem; font-weight: 900; font-variant-numeric: tabular-nums; } -.die.mini .die-num { font-size: 0.95rem; } -.die.mini.selected .die-num { color: #fff; } - -/* Lifetime times-table grid (status: icon + color + legend) */ -.fact-legend { display: flex; flex-wrap: wrap; gap: 6px 14px; font-size: 0.8rem; color: var(--muted); margin-bottom: 8px; } -.fact-legend-item { display: inline-flex; align-items: center; gap: 5px; } -.fact-legend .fact-cell { width: 18px; height: 18px; flex: none; line-height: 1; } -.fact-grid-wrap { margin-bottom: 10px; } -.stats-heading.centered { text-align: center; } -/* practice summary and Stats: heading, legend and grid centred */ -#pd-body .fact-legend, #history-body .fact-legend { justify-content: center; } -#pd-body .fact-grid, #history-body .fact-grid { margin: 0 auto; } -#history-body .fact-grid-wrap > .practice-name { text-align: center; } -.fact-grid { display: grid; grid-template-columns: repeat(var(--n), minmax(0, 1fr)); gap: 2px; max-width: 420px; } -.fact-head { font-size: 0.7rem; font-weight: 800; color: var(--muted); text-align: center; align-self: center; } -.fact-cell { - display: inline-flex; - align-items: center; - justify-content: center; - aspect-ratio: 1; - border-radius: 4px; - font-size: 0.7rem; - font-weight: 900; - cursor: default; -} -.fact-cell.unseen { background: var(--soft-2); } -.fact-cell.mastered { background: var(--good); color: #fff; } -.fact-cell.timed { font-size: min(0.62rem, 2.5vw); letter-spacing: -0.02em; } -.setting-sub { font-size: 0.82rem; font-weight: 700; color: var(--muted); margin: 8px 0 4px; } -.fact-cell.learning { background: var(--learning); color: var(--learning-ink); } -/* Fact speed tiers (Achievements): metallic bronze, silver and gold */ -.fact-cell.speed-10 { background: linear-gradient(135deg, #f0b98a 0%, #cd7f32 45%, #8c5320 100%); color: #fff; text-shadow: 0 1px 1px rgba(0, 0, 0, 0.45); box-shadow: inset 0 0 0 1px #7a4618; } -.fact-cell.speed-5 { background: linear-gradient(135deg, #ffffff 0%, #c0c7cf 45%, #8a939c 100%); color: #2a3038; box-shadow: inset 0 0 0 1px #7d868f; } -.fact-cell.speed-1 { background: linear-gradient(135deg, #fff3b0 0%, #f6c945 40%, #c9950c 100%); color: #4a3200; box-shadow: inset 0 0 0 1px #9a6a0c; } -.fact-cell.practice { background: var(--danger); color: #fff; } - -/* ---------- Small screens ---------- */ -@media (max-width: 820px) { - .game-layout { grid-template-columns: 1fr; } - /* Header: ☰ logo ⚙️ on one row, the progress meter (if shown) on its own row below */ - .header-right { display: contents; } - .header-right .icon-btn { grid-column: 3; grid-row: 1; justify-self: end; } - .game-header .progress { grid-column: 1 / -1; grid-row: 2; width: 100%; margin-top: 8px; } - .game-header .progress-text { text-align: left; } - .progress-text .long { display: none; } - .progress-text .short { display: inline; } - .board-wrap { padding: 6px; } - .side { display: grid; grid-template-columns: 1fr; } - .die { width: 48px; height: 48px; padding: 6px; } - .pip { width: 8px; height: 8px; } -} - -/* ---------- Wardrobe: keypad skins ---------- */ -.keypad.skin-candy button, .key-preview.skin-candy { background: linear-gradient(#ffd6e4, #ffb3c7); border-color: #d6336c; color: #7a1340; box-shadow: 0 3px 0 #a61e4d; border-radius: 16px; } -.keypad.skin-candy button.check { background: linear-gradient(#b9f0cf, #7fd8a3); color: #0f4d2a; } -.keypad.skin-candy button.back { background: linear-gradient(#ffc9a8, #ff9c6e); color: #6b2400; } -.keypad.skin-retro button, .key-preview.skin-retro { background: linear-gradient(#e7e3d6, #cfc9b6); border-color: #5c5747; color: #2a271f; box-shadow: 0 3px 0 #3d3a2f; border-radius: 6px; font-family: ui-monospace, "Courier New", monospace; } -.keypad.skin-retro button.check { background: linear-gradient(#f0a64a, #d9822b); color: #2a1500; } -.keypad.skin-retro button.back { background: linear-gradient(#7d7866, #5c5747); color: #fff; } -.keypad.skin-neon button, .key-preview.skin-neon { background: #120f24; border-color: #3cf2ff; color: #3cf2ff; box-shadow: 0 0 8px rgba(60, 242, 255, 0.6); text-shadow: 0 0 6px #3cf2ff; } -.keypad.skin-neon button.check { border-color: #7dff9a; color: #7dff9a; box-shadow: 0 0 8px rgba(125, 255, 154, 0.6); background: #0e1d14; } -.keypad.skin-neon button.back { border-color: #ff4fd8; color: #ff4fd8; box-shadow: 0 0 8px rgba(255, 79, 216, 0.6); background: #22101d; } - -/* ---------- Wardrobe dialog ---------- */ -.wardrobe-dialog, .stickers-dialog { max-width: 620px; max-height: calc(100vh - 32px); overflow-y: auto; text-align: left; } -.wardrobe-dialog h2, .stickers-dialog h2 { text-align: center; margin-bottom: 0.25rem; } -.wardrobe-row { display: flex; flex-wrap: wrap; gap: 8px; } -.wardrobe-chip { - display: flex; - flex-direction: column; - align-items: center; - gap: 4px; - min-width: 84px; - padding: 8px 6px; - border: 2px solid var(--line); - border-radius: 12px; - background: var(--surface); - cursor: pointer; - font-weight: 800; - font-size: 0.8rem; -} -.wardrobe-chip.selected { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-ring); } -.wardrobe-chip.locked { background: var(--soft); color: var(--muted); } -.wardrobe-chip:disabled { cursor: not-allowed; } -.wardrobe-chip.title-chip { min-width: 0; padding: 8px 12px; } -.chip-need { font-size: 0.7rem; font-weight: 700; color: var(--muted); max-width: 140px; text-align: center; } -.chip-preview { display: flex; align-items: center; justify-content: center; gap: 2px; min-height: 36px; } -.chip-preview .die.mini { cursor: inherit; } -.chip-preview .swatch { width: 14px; height: 26px; border-radius: 4px; } -.emoji-preview { font-size: 1.6rem; line-height: 1; } -.key-preview { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 30px; border: 2px solid var(--edge); border-radius: 8px; background: var(--surface); font-weight: 900; box-shadow: 0 2px 0 var(--edge-shadow); } -.motion-preview { font-size: 1.4rem; display: inline-block; } -.wardrobe-chip:hover .motion-preview.roll-tumble { animation: tumble 0.6s linear infinite; } -.wardrobe-chip:hover .motion-preview.roll-bounce { animation: bounce 0.3s ease-in-out infinite alternate; } -.wardrobe-chip:hover .motion-preview.roll-sparkle { animation: spin 0.6s linear infinite; } -.wardrobe-chip:hover .motion-preview.roll-wobble { animation: wobble 0.3s linear infinite; } -.wardrobe-chip:hover .motion-preview.place-pop { animation: pop 0.5s ease-out infinite; } -.tile-preview { width: 40px; height: 30px; border-radius: 6px; border: 2px solid var(--edge); background: var(--accent); } -.tile-preview.pattern-stripes { background: repeating-linear-gradient(45deg, var(--accent) 0 5px, #f59a7b 5px 8px); } -.tile-preview.pattern-dots { background: radial-gradient(circle, #f9b7a2 2px, transparent 2.5px) 0 0 / 8px 8px, var(--accent); } -.tile-preview.pattern-bricks { background: linear-gradient(#f59a7b 1px, transparent 1px) 0 0 / 100% 8px, var(--accent); } -.tile-preview.pattern-checker { background: repeating-conic-gradient(var(--accent) 0 25%, #f59a7b 0 50%) 0 0 / 12px 12px; } -.tile-preview.pattern-wood { background: repeating-linear-gradient(0deg, var(--accent) 0 5px, #f08560 5px 6px); } -.tile-preview[class*="board-"] { background-size: 8px 8px; } -.tile-preview.board-graph { background: linear-gradient(#d9d2c3 1px, transparent 1px) 0 0 / 8px 8px, linear-gradient(90deg, #d9d2c3 1px, #fff 1px) 0 0 / 8px 8px; } -.tile-preview.board-notebook { background: linear-gradient(90deg, transparent 6px, #e98a8a 6px 7px, transparent 7px), linear-gradient(#b9d3ea 1px, #fdfdf6 1px) 0 0 / 8px 8px; } -.tile-preview.board-chalkboard { background: linear-gradient(rgba(255, 255, 255, 0.18) 1px, transparent 1px) 0 0 / 8px 8px, #2f4f3a; } -.tile-preview.board-beach { background: radial-gradient(circle, rgba(160, 120, 60, 0.4) 1px, transparent 1.5px) 0 0 / 5px 5px, #f4e1b0; } -.tile-preview.board-space { background: radial-gradient(circle, #fff 1px, transparent 1.5px) 0 0 / 9px 11px, #10143a; } -.tile-preview.board-snow { background: radial-gradient(circle, #a9c8ea 1.5px, transparent 2px) 0 0 / 9px 9px, #eef6ff; } - -/* "Showing only … · Show everything" in Shop / Wardrobe / Stickers opened from the results */ -/* Shop / Wardrobe footer: stays at the bottom of the scrolling dialog */ -.wallet-bar { - position: sticky; - bottom: calc(-1 * var(--pad-y)); - margin: 16px calc(-1 * var(--pad-x)) calc(-1 * var(--pad-y)); - padding: 12px var(--pad-x) 16px; - background: var(--paper); - border-top: 2px solid var(--line); - border-radius: 0 0 var(--radius) var(--radius); - display: flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; - z-index: 1; -} -.wallet-line { flex: 1 1 auto; min-width: 0; font-weight: 800; font-size: 1.1rem; font-variant-numeric: tabular-nums; white-space: nowrap; } -.wallet-after { color: var(--muted); } -.wallet-cost { color: var(--accent); } -.wallet-left { color: var(--ink); } -.wallet-after.short { color: var(--bad); font-size: 0.95rem; } -.view-toggle button { padding: 0.45rem 0.8rem; } -.wallet-bar .btn { flex: none; } -@media (max-width: 480px) { - .wallet-line { flex-basis: 100%; } - .wallet-bar .view-toggle { flex: 1; } -} -.filter-note { text-align: center; font-size: 0.85rem; font-weight: 700; color: var(--muted); margin: 0 0 8px; } -.link-btn { border: 0; background: none; padding: 0; color: var(--accent); font-weight: 800; text-decoration: underline; cursor: pointer; } - -/* ---------- Sticker album ---------- */ -.sticker-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(64px, 1fr)); gap: 8px; } -.sticker { position: relative; display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: 14px; border: 2px solid var(--line); background: var(--surface); } -.sticker.rare { border-color: #5b9bd5; } -.sticker.epic { border-color: #b36bff; box-shadow: 0 0 10px rgba(179, 107, 255, 0.45); } -.sticker.missing { background: var(--soft); border-style: dashed; } -.sticker-emoji { font-size: 2rem; } -.sticker.missing .sticker-emoji { font-size: 1.2rem; color: var(--muted); font-weight: 900; } -.sticker-count { position: absolute; right: 4px; bottom: 2px; font-size: 0.72rem; font-weight: 900; color: var(--muted); } - -/* ---------- Players: title under the name; streak counter ---------- */ -.player-title { display: block; font-size: 0.72rem; font-weight: 800; color: var(--gold-ink); } -.streak-badge { min-height: 0; font-weight: 900; color: var(--muted); } -.streak-badge:empty { display: none; } -.flames { display: inline-block; color: #e25822; animation: flicker 0.6s ease-in-out infinite alternate; transform-origin: center bottom; } -.flames[data-level="1"] { font-size: 1.1em; } -.flames[data-level="2"] { font-size: 1.35em; text-shadow: 0 0 8px rgba(255, 140, 0, 0.6); } -.flames[data-level="3"] { font-size: 1.6em; text-shadow: 0 0 14px rgba(255, 90, 0, 0.8); } -@keyframes flicker { - from { transform: scale(1) rotate(-1deg); } - to { transform: scale(1.06) rotate(1deg); } -} - -/* Mastery badge on a times-table line */ -.fact-head.line-mastered { color: var(--star); font-size: 0.72rem; } - -/* iPhone-style notification badge on main-menu buttons */ -.badge { - position: absolute; - top: -9px; - right: -9px; - min-width: 22px; - height: 22px; - padding: 0 6px; - border-radius: 11px; - background: #ff3b30; - color: #fff; - border: 2px solid var(--paper); - font-size: 0.72rem; - font-weight: 900; - line-height: 1; - display: inline-flex; - align-items: center; - justify-content: center; - font-variant-numeric: tabular-nums; - animation: pop 0.25s ease-out; - pointer-events: none; -} - -/* Locked steps further down a skill tree: a blurred placeholder with just a lock */ -.skeleton { position: relative; color: transparent !important; text-shadow: none !important; overflow: hidden; } -.skeleton > :not(.lock-badge) { visibility: hidden; } -.skeleton::before { - content: ''; - position: absolute; - inset: 22% 18%; - border-radius: 6px; - background: linear-gradient(90deg, var(--soft-2), var(--line), var(--soft-2)); - background-size: 200% 100%; - filter: blur(2px); - animation: skeleton-shine 1.6s linear infinite; - opacity: 0.8; -} -.skeleton .lock-badge { - position: absolute; - inset: 0; - display: grid; - place-items: center; - margin: 0; - color: var(--muted); - visibility: visible; -} -.skeleton .lock-badge .icon { width: 1.1em; height: 1.1em; } -@keyframes skeleton-shine { - to { background-position: -200% 0; } -} -@media (prefers-reduced-motion: reduce) { - .skeleton::before { animation: none; } -} -.shop-perk { display: block; font-size: 0.78rem; font-weight: 700; color: var(--gold-ink); margin-top: 2px; } -.skeleton-row .shop-name { color: transparent; background: linear-gradient(90deg, var(--soft-2), var(--line), var(--soft-2)); border-radius: 6px; filter: blur(1.5px); user-select: none; } -/* Wardrobe placeholders: all the same size, so a hidden look's name length doesn't show */ -.wardrobe-chip.skeleton { width: 84px; min-width: 84px; max-width: 84px; } -.wardrobe-chip.skeleton > :not(.lock-badge) { display: none; } -.wardrobe-chip.skeleton { min-height: 92px; } - -/* Every dialog's buttons sit in a bottom panel, like the Shop's: full width, a - line above, room around the buttons, and pinned to the bottom while the - dialog scrolls. */ -.dialog .dialog-actions { - position: sticky; - bottom: calc(-1 * var(--pad-y)); - margin: 20px calc(-1 * var(--pad-x)) calc(-1 * var(--pad-y)); - padding: 14px var(--pad-x) 16px; - background: var(--paper); - border-top: 2px solid var(--line); - border-radius: 0 0 var(--radius) var(--radius); - z-index: 1; -} - -/* ---------- Accounts: sign in, homework, classes, kids (js/accounts.js) ---------- */ -.top-row { display: flex; justify-content: center; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; } -.top-row .wallet { margin-bottom: 0; } -.account-chip { - font: inherit; - font-weight: 800; - padding: 4px 12px; - border: 2px solid var(--line); - border-radius: 999px; - background: var(--surface); - color: var(--ink); - cursor: pointer; - max-width: 100%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} -.account-chip:hover { border-color: var(--accent); } -.live-banner { margin: 10px 0 0; display: flex; flex-direction: column; gap: 6px; } -.live-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 8px 12px; border: 2px solid var(--gold-line); background: var(--gold-bg); border-radius: 12px; font-weight: 800; text-align: left; } -.acct-menu-btn { position: relative; } - -.acct-dialog { text-align: left; max-height: calc(100vh - 32px); overflow-y: auto; } -.acct-dialog h2 { text-align: center; margin-bottom: 0.8rem; } -.acct-dialog.wide-dialog { max-width: 860px; } -.acct-dialog.account-dialog { max-width: 460px; } -.acct-dialog :is(input[type="text"], input[type="email"], input[type="date"], input[type="datetime-local"], select) { - font: inherit; - padding: 0.5rem 0.65rem; - border: 2px solid var(--line); - border-radius: 10px; - background: var(--surface); - color: var(--ink); - min-width: 0; -} -.acct-dialog :is(input, select):focus { outline: none; border-color: var(--accent); } -.acct-form { display: flex; flex-direction: column; gap: 12px; } -.acct-names { display: grid; grid-template-columns: 1fr 110px; gap: 10px; } -.acct-initial { text-transform: uppercase; } -.acct-note { margin: 0 0 10px; padding: 8px 12px; border-radius: 10px; background: var(--gold-bg); border: 2px solid var(--gold-line); font-weight: 800; } -.acct-privacy { margin: 0; } -.acct-card { text-align: center; margin-bottom: 8px; } -.acct-big { font-size: 1.4rem; font-weight: 900; } -.acct-sub { color: var(--muted); font-weight: 700; } -.acct-small { display: block; font-size: 0.8rem; color: var(--muted); font-weight: 700; } -.acct-section { margin-top: 16px; } -.acct-section h4, .acct-hw-form h4 { margin: 0 0 8px; } -.acct-list { display: flex; flex-direction: column; gap: 8px; } -.acct-item { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 8px 12px; border: 2px solid var(--line); border-radius: 10px; background: var(--surface); } -.acct-item strong { flex: 1 1 auto; } -.acct-item.live { border-color: var(--gold-line); background: var(--gold-bg); } -.acct-inline { display: flex; gap: 8px; margin-top: 8px; } -.acct-inline input { flex: 1 1 auto; } -.acct-code-input { text-transform: uppercase; letter-spacing: 0.08em; font-weight: 800; } -.acct-code { font-size: 1.6rem; font-weight: 900; letter-spacing: 0.12em; font-family: ui-monospace, "Courier New", monospace; } -.acct-mono { font-family: ui-monospace, "Courier New", monospace; font-weight: 700; } -.acct-join { text-align: center; padding: 12px; border: 2px dashed var(--line); border-radius: 12px; margin: 12px 0; } -.acct-join .btn { margin-top: 6px; } -.acct-tabs { display: flex; gap: 6px; flex-wrap: wrap; } -.acct-mastery { display: flex; height: 10px; border-radius: 5px; overflow: hidden; background: var(--soft-2); min-width: 90px; } -.acct-mastery .seg.is-mastered, .acct-count.is-mastered { background: var(--good); color: #fff; } -.acct-mastery .seg.is-learning, .acct-count.is-learning { background: var(--learning); color: var(--learning-ink); } -.acct-mastery .seg.is-practice, .acct-count.is-practice, .acct-chip.is-practice { background: var(--danger); color: #fff; } -.acct-mastery .seg.is-unseen, .acct-count.is-unseen { background: var(--soft-2); color: var(--ink); } -.acct-counts { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; } -.acct-count, .acct-chip { padding: 2px 8px; border-radius: 999px; font-size: 0.8rem; font-weight: 800; } -.acct-chips { display: flex; flex-wrap: wrap; gap: 6px; } -.acct-hw { padding: 10px 12px; border: 2px solid var(--line); border-radius: 12px; background: var(--surface); display: flex; flex-direction: column; gap: 6px; } -.acct-hw.done { border-color: var(--good); } -.acct-hw.late { border-color: var(--danger); } -.acct-hw-top, .acct-hw-meta { display: flex; justify-content: space-between; align-items: center; gap: 10px; flex-wrap: wrap; } -.acct-hw-state { font-weight: 800; font-size: 0.85rem; color: var(--muted); } -.acct-hw.late .acct-hw-state { color: var(--bad); } -.acct-hw.done .acct-hw-state { color: var(--good-ink); } -.acct-hw-meta { font-size: 0.8rem; color: var(--muted); font-weight: 700; } -.acct-hw-form { margin-top: 12px; padding: 12px; border: 2px dashed var(--line); border-radius: 12px; } -.acct-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 10px; margin-bottom: 10px; } -.acct-grid > .field:first-child { grid-column: span 2; } -.acct-grid select, .acct-grid input { width: 100%; } -.acct-table-wrap { overflow-x: auto; } -.acct-roster td { vertical-align: middle; } -.acct-roster td:first-child { white-space: nowrap; } -.acct-link { font: inherit; font-weight: 800; background: none; border: 0; padding: 0; color: var(--accent); cursor: pointer; text-decoration: underline; } -.acct-remove { font: inherit; margin-left: 8px; border: 0; background: none; color: var(--muted); cursor: pointer; font-weight: 900; } -.acct-remove:hover { color: var(--bad); } -.acct-activity { display: flex; justify-content: space-between; gap: 10px; padding: 5px 0; border-bottom: 1px solid var(--line); font-size: 0.9rem; } -.acct-when { color: var(--muted); white-space: nowrap; } -.acct-kids { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px; } -.acct-kid { font: inherit; text-align: left; display: flex; flex-direction: column; gap: 6px; padding: 12px; border: 2px solid var(--line); border-radius: 12px; background: var(--surface); color: var(--ink); cursor: pointer; } -.acct-kid:hover { border-color: var(--accent); } -.acct-detail .fact-grid-wrap { max-width: 420px; margin: 8px auto; } -@media (max-width: 560px) { - .acct-dialog { --pad-x: 14px; --pad-y: 18px; } - .acct-names { grid-template-columns: 1fr 80px; } -} diff --git a/docs/assets/screenshots/classroom-host-setup.png b/docs/assets/screenshots/classroom-host-setup.png new file mode 100644 index 0000000000000000000000000000000000000000..1709fddb51299718c804093558693459d734e5ef GIT binary patch literal 90795 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 F{{Ujh{22fM literal 0 HcmV?d00001 diff --git a/docs/assets/screenshots/classroom-join.png b/docs/assets/screenshots/classroom-join.png new file mode 100644 index 0000000000000000000000000000000000000000..03de57e6dbc057421b0287936d68929623d004c7 GIT binary patch literal 64202 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
btV6Rqr=X#PnYr(n27rcH3o_P;v zC~_sz%Bx4OBslpq!7@3&E#JR8KIFEe5i1-|4@orn87#U$A5rK+#1P)@uKOU)F?SNZ zzbqXO>E+13Xl5b?LeuU;5V?~`@4+DyhBn&lsZ8NCO7 z!*J9g*^p#ZtVqewCEFv2X5V={?$cdfEBV}N!*9rc6*??$$_ITg`=rTIg`Oh=j zb5|E}t28h8ZAQ#mm+d4{Off-J0fZMBm{&gk`|^Ds5*BsCqXYnwvt<6mUyz)e zQZhzsB6us}*{7hOw_RZ71WCQ{OgIY!lK*|gGcZhi|8D61Xgb%t=hUAvT-?9CrvDp? z<2K6F%4oB^^Dj&P=iN9o?&AO?eBCH-6O-g0MsJqT)IQ=K#IC#lB>qgF;9Gx~nv#@+ z%G*)EXej^OHpwg~{!~D)_?)`2>OQ&xnGEH}PF_uIG>TIvCSbHa1cJ+7JCyV0Q32h< zypU7lg{a<_FI}Q8x2ZCV&_)y2xqeiW>{>i(EC!19l9Cc8B%-IQ>nSk+fZN@>cU4mJ zOujXk2L=XGYgN5Zr%q2#qbYb~B4BVWEl}2mV#i{wUcY_~-{yMFNeHe7KB}kLViu}G z27G`;*5fczRr|FMb>Ga)?BT->fujD?0S2x;W_DQ07&KH|h{{YLz-(M;|Xp&zyVtrhFQPh+=cLCVPe#0bBR>IVjiuUi;0Pu zA)-*-BMUqO&29n)-Hj4n3zZz%P$Qos{rvg!>({R{GuAP75XI9%KIg>Ckugh-j*cW} z5K{uAFcODuxx?YbMCFu}JSIOyn?^Bv!KZwHlEJo{`+}QgnMESX9E>PqzMj`4cj^53H*Gujo#-xhNY37zUV=N1_qz-> zxygpv*=cDD!EnEJ?SPWS??gbuu>CcC!7>m&mJJwBlmNq@7e#s-6%}<>DJtlg*k>S< zEoCxM9HQq^121iGC*VpR#>U1bCL>3*^#OkyFMGJ^XNL7!Zc${c7U4Dlb`0BQ8rbdD z2E@>3ZORgxB!2ml)m|$z37zDY{%6yfD=RB-lzhn$4DY)4Nj7X{0t^uab~bd{ zeGUw}mcXQs1eG$*UeoKFEur4a-6o-%KtaAn(dwxzr_Pm)B8Z`VMRsJ*4B8V9fJVU? z>DC$%%~%2?ww&yx3I;0bjJiwNmv!^MEK&T-cIp5deKAnJtl^wr!)f_lmia>mTkaj< z-L=JUk0BX=$Ba)6{jH>fJeexl9ml}Fh6;l+M3zAm19RpEzdz5&DxD(fnNfR9MO|p? zRa|z?$L#R}MKbf>w=H5D8X9t|`$UMNj?gTJ{~ik9bv+?>{QCLzraZekt8znL=znu_ z$0WoYXXhs9XxqGg**XdP`X&OjKf88|ZWb0?+2b$lulTe)j#fKA%@aG>HBJVM763tD zld51RF-wgefP}&X8dK~hEZmie#s^f_W-i&63}iO!UA&`?xV{TE4JV~vFm+}#J>p)B z_mMp!{k7JfRmUvu$B7GwiY|VNHk`Wl&B+40>CHslT@xwLexmM_Uw>X-E|o9`5Rvyx zPPM0E0Ly_`6326Cw^JZFl&X zENGfkxTO5*)vG3%rTgPs_=#%b?y!%f8>2|cS{dlJER)8?Q9J+}T6!AVx>_)XyQ5d1 z_aL_r!szyIntZ%#S=(f@<%;q8n)kLG0M}rn!LMI@#h>^fO>--P)>Gnvu9`@x`D4hr zyW@B!F!e!#Y2>%Wz4T~W7K?u%GopsmE3|%e^X5+giANe!89RIk?lNHJX`b{}3FdJR z10o+}zb`=9@Y0LL&Zf;eTiz*t)K=l`SH-Wgl370|EN$J!*~3g8u2E1b?rnB2Nl3)C zBVfOIj%%JZFM5K^zmo!PQ3+5mW-=apM~8nav%%$pzVK}$Nu1zi2R+z z-i&!XcG{)&iMBFoDFcksCT=WU3N5v~)c)$}3$ZcQc)x!K8MXHXy+%zt6o2P(r<9We zDRaE(=w~MQKGkW9gv^G-%ZR~@8H$G<(Mv()7kyr6@y@NmGyfG-wkd_ni7$TUt$7uT zL?^q9qW!A{5Z|juY*Ey-*qyd-!_|;D#e~=kfT+DLfxs&$4=uGOxpgPUvQEX^)@5KrWq*QW(|)A z-Ea$H)|uV>S3iZz%=u3^(tn5ELl3QuY1v86Q5=1Xu08u_!@9Cc?`=7`{AD=Ic_h`! zDHU%8_vjypZHnZK5=kh(2`9FZJ+lD_um4*YGkEL&q*Dsn0=!P1`Txxnb=V8=@_BO| zP+@raZU6flGHE36I`#hw4gLxK8NEkFXPI>}X9=^F0k4}|0V29~%8AAX?}qIQ@ep4B z1{(iw2NZQ!-m(9^MTVBuFGfOJ>(pVMtN8uX8w}UDf9T}RIscl29d4QZcn{?JceSFT>Gp(IDyGr58??3tQ zz)!KNX19}7_{|@IJP6M_`kwvW^`?>sd5&E|3WGf938aI32{V@Oe)9F{#_Gm#-G8h` z*miT<^_p-8w~hQ);pa>*M%>eIkGR)ReG|dQo9&Fa`6uDPxjvjtLvSW@Dw|=3SdFST zPiR2+v2DAqJ}Z|ycTVb})MK9D7#Qa8U?CSG|NS z_ACBGumac7W67H7P&2SFBlkzmw9_9)3_ucQ>vU~KC7@x{ad*6Jl2@TCgO0g{QryQQ zqpz(kq)^)3{SIuS#zT0eQR;KUXP55Szo7R{6RQIU(=;s$AQ_k<6~rG!9JuG+vF(5( zX{TJ|jZCq3+f|BMTXQ|;HbtAKjj*7jLtV=0OE4;7WeUK0A9hG<#C z?Ly+G?tp|FA_YPv6d1ruO=x;*gL#~kOY5iT#3adcveMFG;2s*}48v4ll8XPGO3+^Z zn3JVOXBRUm{^-il=;gFOs(bNQi<@4#rcTNndv;Lr46!3Fe)!$Pi0+7t;}NMe_brGC z4mhp9Kb;4D3XKlTD0awVxr;nUSPtGZtx39S^#Y^s(O?Gs)_%t3<^pna>P9r)W#!?e ztzVmSR&AA?4Y}pp_mj?1s$!JML@H4*OJ$zM*LG@Sk@?-2hQZ(8JC+T| zm#%roHMjyA7Jub}wjbthIDbE?8ma*%aqjbGyMFnkjt7!!^n`?rs-9Tm8QXjIJJy+3 zK>of$Z@CBYhi?EsDTF#^Bf*+ag;Y<@)jCRF%wvsb!YjpY){IN0YhMw;&Z3LIxOzhN zxeN@U|2?cv_{(a(MdEZePCbnzJF&gwVpiGe9?2YwCOScJqm!`68LfWmg;2?Sbd`J4 z^|3VwWX-tsc{pA*J^H}ER3v$a)I#PP1NznvcC()*Uu;HIq|NSs%=E1lE@m308a=S- z#TXcllT7zGi<;jr#Sn75+{hK4RzkOgUX)!)SnDN^>_EInGu!LFAdXNr+NHAX z9H5DYU0o8(a`i>4Y!^lP?^t2Fym_IGvx$cyO1vz3*Kc|%G*|zV&cJQtf8fTP`R(74 z@4qT9RRowUoWAzAapr%IJ`=8oPDFKvwq3n+7#fo=BO>w`Jp3+Y>&V#=tQ|^^965q4 zr9G2$Xt^^^pZtM*_2JA^XHs00fdId*wp`mXjkvW$X(p8MAt~n%6-}aB@Q6+#1e|r| z0)G)48{3<-Npp)6x0!yYq3)<}2-y2Kt8q9Zpo<1)uu=EKA2`6!B?#^hUA4@WZ4A_g zT0-R?@ih~53GTVGc@m?3c~ix3!P}4#B3=Z*7>)@gT#iGI9gd=Z+w^v|GPJid?1sZt zJ89HO#w^k8feK)Jq?BJfmc~8DrIGugpSf2je?1IHntp!kIJxCGCe6$HhMKb`sY5%n zc(yQ33QW*e(Xt!MQytU5gJNUZ3=;b+C~eW=&v7|i0wt3ORD==cs4E+bXnexkx2|{r ziFh7O{)5JIiR`HlU`xfRi} zyL7@&4eg$@`a{}gXW^=&;%0lvMvRhY+f>T(yE2B(L(lF47+;aPi6*7}h={QE;3;?kVc1bw30#&i1okgJ~L>s!dN&r73 z)RTQbV`MjSBLPl8Mxux3bksuzd4VClR3GEm2&IPCqRjvA8-jv`;bgo}ByXZ5}g ziE-3rF_Qc+c@H|@m^s%eQ2%~0(kDz-qXP*e1{qA+$!s)eu?`1&B>Fih*8}ZmTh3a}0_sm?|@6cOK-~F;C3J(P$6E zgmRjwns_q?iO0Fh1$@owe`6hoh!YD}9k0f)S7fh;h)T%~ z*Y$bLBHc6+lGk6tb35~K8{0`gPPUB;8cuUE3A)V$SEA}(TSnu#+1bwuvnj`jE|fdBQl z0KU|)h2NIZ=*@v``}-xHE^bG)4PiVb-Fr%uOfRb{DIdKRcX8v_YTw+3CDG4iymt!O zZ`6mY9C#q0uYwXt9X&Cf`tqQ?YLCdZ&tCfR#x9)Ss=eocO%_#9tVr}x6!tCb@lg&APeC% zc5WMc+EzVjad~-j;=I5_ajlwX%BmZ2YBIzHO}8EA<@TrIvo`B;s%I2eony8kz8?UB z=7B?^qOG67?!~8>-D#X0In;uE+BdhiaGM@%lHY7JNT_X}+IMKhN_aPK41qb1&jA1* zVJT#+j~PRd>t)tm-`_9hC&>V%TI)SKanHiKbY7O-^>UN$B%iLe12XYG&fonl+#Ps zX!nO6KQ0el&MaR$uDP#>6Kid|@L2yz3k2T3`~rOMZJ&4F!uwAb1h|t2m!tFk$|#)0 z%^b5Zf8 z)(~hPD)IX9_=7n2%*HjYxWV=1z|v=X`G#w)HHmx8n(2o9>OnxsJCn zP=r~Y#<(Sx8*=jiX_aK7M+7AWv|rvlkGYhpozO*)^`FwVJZ#(at>^G9!6|%4okt)B ziQV~E>iOG(Av!Vf5ZLJmU%C9~iabC6C3)Nf7KwlgChZ?UF)N|+Wm>(+q)D|ojEj5y zHVcdW@NNFdW3`1oJM=eb6_V3@WcFORWK|zl$@&aO*6vK4`KX;PAzt!VQg5=HxNGHs z%r&j#TZj8@zfvwqzk%xh_ymnP{kdm0!4QKCPj{c?=7A%fykBe6S^aO6` zG9MI=V30AVQ|7AVsQ&ZoqqgH*ac*<{{PJ&A-@V}zp0LGE9xpdsiw@w)c_*;P->qWc zG1|P(hr*L>r%vbM;c+E;5%s(${hw=d8d?dhGk+TT1M?V-h}2GxsC(MRKjqx3mOSj} z+1G zdxXa}_mR!kRMmU;TG5H#r0$%sS?Sf8Zmb6+QowUCPEoplFJ97Q3dV~84P@$_$mFo* z-splZB=cHKrM2GyUo|@sJt=Q%E3xV2Qk5c)n@^?tPo3d2$;arUD$j29s_xx}IB5^6 z$&-G7%a-cT4Vw@e61n#&K7Q<=@GO1p#j43tvB8OhtlxK$Tuc0($ZsA7@08#7O)KNk zxB2>kvi&my9)D!CeNIgc;))i)PS9cnqP_`-1G4a`!TKl+xrS2b)OWs!*X&#vzkoQH z3NtYK<`$Nv+%{=#V#!=nZf$fS0xBR#U0RWKmZd8Y-@OaWu@nBZNUh$T$*UdDMz6P(WX>< zm0JXp6c&Uc(A{RPw7}zw?+pSdQ)~YA91pK-rSuq)U<)$|G=$}_niVh_lmsz;hrffm zxI`}>$q^&O8H^^#qe$^JvzxmsA3<*7r{D`;1}yK_ibzkS%qr9nAY}0BS3n{TI-@!O zMaF{n9i&_Y;9}mAdEP)l5rh(Im{_Hk2ctdY*$O~wUJByH;q0lSxbh`|66y<3$B9CK z-+Yky%PZ^m^be1T)rnLj4Q&%D9=>ymeZxbt$!~ z85&Of(TOUrc$$J3pL();-h2XgF*GEXV&PavUY$numpI-!9L;QK@9wT4B~@U&k67hp zdICD-dH|V;7{J*9^8u(&MMuRElNie90_@ixE4puHbW~YQtgzx4uv*`g*~~Hlh-71+cg0W zZ42J@jc2T92I$Ed{O7-lC;<-jUwQ2|AxB3;G3-zFSIe81x)eeqNdes9RNlWkt44YQ zk8fNtF6K`i#4Ek*Y)jj%JO53*YZhpREK>@yXj-?(PkdD8dgCrF2?SqES0%;*qjO-5 z84-+TcpAlk-wki{zWf!0Sg4GvdgR`iYpJNwHaAB9bg^U%A&PF$?LY!bs*q+TYPY87 z&yI)FHZyQVCxKOjN8AAEh>V<2nqXIoWCvXgR}mIj$!h$BdY%tK{NIGlK~K0qo@XJ# z3J^L$R|`Wm%$c>bnlMyIDwV0EM_%F3q#l4LlR`S7eFLi;x=?QB&fA-%Y)69MC?l~z85(1sG;e_ zJ6iJ~J!LtlY|Q7@-RBJNNRXMzv%hv_JErc5 z7P)MFouvW-tDGEF(9;iiECAEzOM=J9wme4ubx>daDIUmowj;m8?@U!=TkfEl5DAAP z0@FX5KWhfKpo-AObchW*c^8sGG}gw)Y$~Iw`u_d<47hR+JSp*}=z!$dB{h&f86m~( zXw4>dw4br2;`bl!D9~bI`D?#|(Y04)S&gjds7H!yFUBlzJEr$hg4WzygN=+y4cs{l9># z+~`05{}y}50=Cb~uUiRuTBtcjTGeiOc>!`{3M05G?{Hxq zruQ0$FP6$3x(Mff?*Y>4Us*9oJu#)220FWKtI}sG2zU{2)_!vzi1xva{A~;L?17@` z?JD>7INjR4(Z0aVZ0&LV$o(ra5k}e%ieqT_OdZJPP|znJFtMEm{OL*WH&0yffHkgP zzJblVIXQ03Bqn`~oz8JwStP0+&f9vS_%>DLw{6pC39k9GN51bqB%ez}VTg$de%#1H z+^}oRa=t$NnzY`-Jl4IeFLkJ>5@GQ(whQ8xVTD_;B#9`RXqcMX{DwbzpS1nrfr8hN z^Ol8Pk!>g*PKG7c18-@ll;U!Aq%Q#g5rDVkfToMdo zjSC-p%Pa02O$V=U<0K={aWe%%m^fnJoVijN60&_(c!6$L)r{?;(D6x2UHtS@(e8qx z!J5k>e(02p;$p=)4sCNBbyvu3gQ8f*Lxx8`T{d88)K8{fJu#KYPUYPx+q|6~Ei!qV zXO+hq_VlpBHtfd%_hJ%75#@JPvGbEVa1&{>>=Qnh@33@?*;olh_)f1izuw_d67?&MHv>9g+EZgczaa z%@BpmcZmj9>}LF}P-E|XQm!u66ZEy#?usNE8h28!-l0j()(sM@hvK_P+B*7toZ7o% z-!(3dMxOQ;RG3RYt$!Y74b3?)OTte(3qfp%+GcQLyBhJ96(K^Uvr)@Sjc+XZ3BsJ} zXMZoo;N~qeHs7qn%=DN6k<3;oYID@%bv;6o*gz34Y!TmjO3gYi@<7+xSTWOvCU_=o>IcM?mL!4 z0&`DgmpPwq(_wFpUuBx2EP+ZN?_+qG4rGdBiKPDN?uuE`DDNW4p=9?mR*s9QZ7N;z zlLEuruLbMosH+mKEmMSjg8MntzDme#$BEw(4gXY4WwP3ecY+|sa5KP4_8I7< z{qSe;33&OzbcTk+uK5sN}1*!n&&*e zA;q)hjXyz8LZTe(CUycWr2wKJKdpo>$QcSt|kJ}`@=-|(}{(U~m!A~$@xc=dB_ zOP8SIvmu+TXHq^*I#HQ&2S0R7WQRj-F;KAuSP%^)c(SUxI%iTX*J9Lr>S`eB4dnFn zkzT~54_2R^;jz8~uDU6h?3UiP`Al@3TpYWJiCxFfz^Ofi{ltvf%2wrT?tRWrEF5qG zky^;c2fI5M@s8CR-7tu1)zSGsizdb;>3R|u42V*&#%@y9vs6kua7Z~+#dV2EQV3V3 zeH-wn0grc?7M?6??vVPmaQNu6Uc*;LtzYFjSHckv8J&xkmE%-Iv~)(FpF1DaySpDP zrZ7h-a`UitjWiJlrCfE2!(89ryv$MQYDDqzTSJMt$Kvm$DLuM68%{_!{)3zw<_ z-^4_F5@H{BLXkVP!3IT(jx+2jky|Y_?f%uSXX54H{rn+{3R z-z3{Byoh~b_csCh4QR!HfB)^!rD0Mo!(8kt8jlBAyy2srK#jYr1cU^Juk)% zXilNwe;$LwBdEMZTPMPv3}7gS}}&i z1OdauB+w{}qDM;ns&xgVC)vUX`Po?%2)mSG-Ehg(h zzg~a;b0qq|yTK2hBYJU;JOH~TCQO};05|6!ETEMSVR~#Vg8nJ|hY0W^(;?qkroxXI zm5U_sH$e)FDdIm1IT$z3!DTde#1^3Ve<&vY zLm*CYkDg%&%fkT~-0j(k?i(^lH3~2;);8qfYxCX}dVCaTyNHmOke2rCE_#Gdt2Dac z=F!1((?}4))HPhkq$tvVc#013R{e!`yczxKHdpbhKbSw!{_rD9lSSLLROxpmAA5Q; z#MfDN*ZL4*Z9oDFnKC-MIzMD!6)209Otm}vwbfTXK321``w^f@r>{AU_6TBWINyDR zZr9?;$j^5i;RM%e2)_!K=>LD z7;lAEQ7?+>OSN9N$AKroH7)>YKJC%}`GVKBz_-4uPB?x5txgEho0moK4yk+ZtiMe2 zO{nKb@r>zv0*D4_(Qe}+Jor-H31$l_&$WdQtMN%Iz!yk zTAmSRe)#zD=QrOB`I4$C@Sv=zp-`xrMY8mi)@Ow*#aw~Q)xHH6-qA^Dul2uX06l;- zKG(ShGd|m$j*U-@?13JF^QwdMhKK*LmynU6p``@{MfL7mwLSX1A7l*R2EoV&88XE= zEa-e}h=5M)wlR>fi~gYrFc>@u)>qB>98b^*8(k>8tgXL+H^B3EqJZ$JedE^@z`ZiT`F%7$Pwd-beRqRW%|a1zFO`*sf2! z4v)`0-0MvwOVU`80IC~~I2Ca&8EfT1KVZPUKh{`K08wCXkRg(-&!3w>usPiR3Q$xdEYNH`dLJ|_4H>SF} zM~e;Qwd@?_OjY4t=Lic%=~=d0(Z6t1h9zG!&>H2Dc3kZ4N)o=VzHZiM0Vdbm4^E2l z_M55EGh2l1u|0dXDRaK$>9u~cbv#wLbk{~I@Pe55=CX_IXD<1f+#2`Y)e^d6t)D`?rvA&0vt~6Os4zhc)4CP zaZ@t$(?Uk?=S7Au`jQ;J0jMw!eO*qfIGsSF3A=x4~ov!JXk`W#wKo>?V12D>Sdw{vMPUMx863vBL?rf|#bb2Bq1&2Ajit8X~e-pL4D5~05;kdW;*z>Q=_L=B=PJiZ^P#4d5c4qnaQB^EN&tN_8-&UQS4JJ2u{gR()ZeEV2Ze%4yb4N%{g>H6Fw*@#Pkz*oh zSN^~l9Uc~;|3=xO;MxXSz;qvc5Odqu33%Gx(ba$TYkPY(7_Vr?Go4Aa3P>xcB3kR| zv1Vx9L%hPXvXt`_Lc$a@vYZ;-rZ`;7D+~Y3&-Ae^Z*6@f^$|!^D+nYC{gVo6DwAPj zr(@oVw9NSE^aXprOrTIbBlKpfh@6UwL}ZZKo_2qIo|K|$#5QVO%=K;Y&#^t8qc4Jz@&h?m);LS4E zx&2(r=t;%l`FR?S)ST?*`y~PQZ(YzCwasnC$q5d=)AcRHQ8Nce+EZ8l0Zw4zmhs`M z*rynOe?!=fXk3yeEwVg&X=-Y%*q8)@RpM#B3SXz0r6rJ7l7>wZGcu0M7mQ#7zWJ{P zu>gPn9FdQRzf3SloZpdQ)OfNcuRP1>y7_Wl=VZ07ocsEA^dYH-b;28ZdFBX-am6hu z35mPojqwx`m;!yPa$P$w!D8y{p)%ie<0a zn+^wh@mvA|Gvo6sCL6Y6H8t_AZNfFG%xr8Ydrn5z_8yd!xau56fXyvOmww20?g`9R zO`Nq=QK$eHx-8bl=zgPz%ZX~tDUPgS&e`#C?5W>tY+IiwLmW;(TDbz1g=;``QbqhX zScLCSWJqnG1BQ-UM-Fi@q9oi$JQMv}HD1?8L~(8^g^TH{GGnjWy9^EUF`kt6PxO4` zcW||^K=iZQp1-)jM=^RZQ4XY#3}~n2$>LfCJ%~-o*c7En_trz)<6g4R-c+b)rC!P^ zYdY+07Yv5Ne!?3D_M6zby<%kz0oF*tT$NLV0V?1xK2c^U^$?7EYc#mK!4)(*Rkf77 z1(TN#iIy+qYsV7Obk@OJBh8JI5B#Z$h{3h&u^03&^`L_ZyQ|KC;1N__z00P(iEgT*%&@} z#4i2rmu>nap@2~;?VEuogdZei26}r_5Ui~BiAxr+>BQdqPfc=~j`It*wJ9hd!)kCA zuda|=TX7m2qilB$h7V46s$Pd)f$TG_(8@|`!GNnARn`Oc0m-;Fq2ID}Pt~w-Y04wY zEsS0qd%9n}aYIHysS)<;!QEL$Yuz}M>KA#h4eKgC7_OXr+MD6)E3BBEjPF&x#5+Lw zr1|OK*YZ;uv#HL}x3=Odd+JUm-W-~;5KD_4rcZ8BUrx428JNsoB)N$jMccYUGD~lg z_<4=BT}S4oy3TXLfXlx82tMGg6|3Z>wm40tL`-{Yk2i{}PvwOiw8J;EB%Z5Ef59b{ zAlv8+;RVLT4n^B7`rQiJQ#4ydA0LEy$OMFjQeMg0*cxQM6;2s>-Go1yxY#MYS*SK? z$2u%=T1-hLw?lj1(&TYTAKjIiwkt(oWjITWkH3PfKYGbHE<*2q*D>YcQKr9DQetDs zO~+lPl9$1$rKxG*_U(7g36EP=QpBu2zH}aMbYMLH-DQ;m)+k<{ ztE}_0YJ*S_7Kv*Tk}$RS88fh0+r?~Ptfp%YmE9E>3_bZLglaa}q^95aCN3^{km&e> zx=I0dF#JKoUGlAWlf_ME$QF(_^=X7T=FKfBI~N<9z8g0~@#WQ}tymCjKDN$cjw)y% zB!nIveSmE^YbUSBN?Jzd_iI>p9^Y%aKeppOh}YRC;!E32?neeo<-~V5b6vZ86N7VH zku5bR5=v5W)OUBDDuwg;*%vfBqwBs?7%x(=@|5%Pa&pWp_EMa+w3ezJcBpdC3)Q3F zB3b0dRXW*^bPtAF0!b=0HEuJ=85aNSp9Eg@BN@Yf_&D;c|Z%cNcJp zi6x6X&vrBX1|RxppY2FZFE%eW)UmM0etEp`ROVee15w(kqJdl}%s^Z&(PV$jDg4zXP{y zH8VMGbgY3sTcKsFGGI?KEleUsPVUbMo?^ymf4C?8KCMPe{!CqIX=ZhG!gfYPgezfj zq*Ai6`-vRXw&uG$snpE$N&EXiniFM}sUQ(@+Q|K-`^`^UJ7O`ZL9mIuDt~`}UteFA zQH3SvTiiloeuJ1>VmTiuq8=Z*&oTtlwUYFIHo4pZ7JMb zT~SdbfP4l2t;Lu`a7Rnj`DHOI)~HqT&!6A(Vk=@^G`?`XWE`^9*qpv_e%IoAjUkQ`^o;hA-PG&Lv$&z-`foNj{H-lSVdiy zc+fSi_f0PD(KC$SC>C1FPwO%`j6C#kihaL4pdrr9KW?F7qx;1xIO%miq_x=RTBfr6LTvBkWUCnkqUsmz6wvE?DEB_l{L( z(xuw%ypm{Ye^_O6Y)D$(K1-%+;?MC><q1j$>g8g81x5KF z-0A52CgMo{NJ<*xy1a&Gxloh0t8BM#q#PS-a$;=p8PTbC4exvd_j$BJaWeF?++cLT zhZ>1xH3kF&4lxn`>x=$UUItuGjKgrUnu{1d|4)gAmmueashVkCwIdMrJa?iKz zJ#fUf+T{J`tC<&Uzf}ZMQWm`U{p57>lT}gFktT?EOvUAebn6>9D;c;C7Z+Lw2Vo#R zHYYc%ZdaCda&>mbFozrXXdpXGfJ?MRJ*^}mu}`1n4UTQD5cR81gQ{zP{v56o6|LgU z?{BAmDAcUua8wxVwA)}1oRf^1=tSQNdZv1}fO6~MTaNqoN#;ng}4xwwD85x3Ms<(>$%xwIksDI>u^^_{kky2;OhVusy@a$=;ig zBbc0%NS{1;c!6?pb(yVbDk#{L;tdg(6j&4>XOzj^YB(8P{PpVBVxh8IBe;sprv zwNcI`M!)aWA$sS*F{rBHv=IOg6$__rft{8h4G+;E3A;Nzfy;hP`vDxw5-wPxk?=vE z8`bjt`-%}`eSl{9wrFSPyM90muQxs#!3A@XqLpo?g0qpmJW?xETwf)l%&o zXm$Pb=POK=z>CVnhhgyNe!lv3x=>mE6DvYDox8ko>E~-Dl5AgTO9`pO5 zJl(y%2`-Wp#V(ulQ4(ig0GP=f^eprbb0^+`mWx4?>^F8{01r;X0S8hgSEt?Xil;{; z`%f)6Sa%9%6-26xH-6J?|(&fn+<;a9dD3kdN`O4-@MiTRRIHmRKcOvzndkXd;as^{|?E) z!4vrQcijH{@4p}LKpTwF%$%I}1+bBm6VveqJv}|NsBqv=31MPkk)tYo1@T|8k)N~% z&E`A2V#UNqkYYkGN4$v$@bUTF&Pvq^bc%&uZr>QRV;uJjo)@|%`PUjl+0KR?PUtEDs&ws^^{r{^!9 zx8YXBbUcG-SG0XB2JkKV2?pra{T8un(Iw&wwsKt}!l{t-V5gqgQp%rr&Lb3*fc2!s z$v`0O%`sXOkwW5rexXBHUGjsZU3Zi3c`ZJeW01d-^8W<-j8Y#(Zmh54qxl0jJZ8X= zT9}%LUL7NeH-JQ=fLH_6Drq3X9~hTGm3Z1#WS>DirvbCIz<9tXAxh8%6{49iwQBQXvY98k8U;W?N{7Ezhh*kfd zL>lzyVhB}F24kJrE2u@gIElQ%%G_Lo?rBs{d$nk{UB{_(mApj}*9BT)wXB6j;;qN6 z&Ed%Aj?pq2|D#|wD{637f0qXb>6$X}ru_ezB7Tt*BO~w6S#sK2w)({1SS>c1vEgTi zDZXAs!GHnLL;VBdf8mT4R$tCSr7M3(Yh+7A7pf@nr~8;qSpl)3x^5Fxb}I}9du9z< z4ARN4@1ZOCCZRD^*ecKeD~aI;YAGu!y35G^nO3d6K=(Y|?RS>-A`LYa)nG!y{Ng0p z(UG|1Hw^R$l#9!pasG1A>4}URpc8+sIo)9S`GK8avezN#{EYnq`*bqCsC1*bYbpTN z)z!Htm6ANp+QoB7uz7kFP=St#sngT*F`7~R=fPlNO+WX1b*z@W#vtCKyW9IKvGw-# zy?0%BZ5c}HQ%qb;CyEdfq8DkZ+iR4%Oat|a)ujhc*zjPZSdqRT+Z#`7?%iKg;y1C< z+F|y~6Esyk+ns^!?G1!{4-Wf%y}n-A7j2cm>%`6VS{9SGYN{0!%~Fex1j%;PbuF!Q z%GwIJ9JvurH+uACN)gQ*+uY73j+Q?8Jq%sC+aty2$}Bz7QBVl}L@bFbM0>HZZW$Ex z#LCz>x3KW^nJd}}M$CZuT&0xVuiN-IwKuc?m{;4jFM0Ke3?uwQyQ(U|aU+|Qj27c5^_y#Qdq=s_|BCm6FGG)d3}ol!%Ctme$N{e|YQ4qrQQ1d@AV2 z*RRes8l6cdTcAuoqM>1SdU_Kmx^*`*|Hl+sqcK6ek(7es;?+PG%k#ayG}64hohQNK z0N7AfjjgGfv9TH1Z#X|ozQ4Jgv5OYGSbl+ladV`xFgweWGYyLP=SD|G1uYj7q_(1w z&-UgVw#P5soKS^VOG(iEa29|z5UUyL-ri>*no@Q_NI{iJY_Uf{*uq!jjR^ zpBm_A?k$wHX*1od3*KvU#AYVVB1^YT4fZo~JFGZ~)51l~-NygIC ziB13zaM0F*L8Dq6`(|OOVJJ~Nx1?k(fOK%I`Ffue(CSjl-zI(_`hI^%D!A&I&Mm95 z#Kp&lMSyu%mi%H(^m+8JrTTC|78dc(f@OZQv#z!0=jUFh6L`Wx=?{FolQgxK^y9tP z?|o4yr3MOoecA3CTHA!GtmcSuh>i13e$G|FMnjNC#}ojqxF%fdW{;>gE6H`gfkI~Q z!7XSHXu{S2;vAOPbO4sx>$8!wxK9lhD7vgCsVkixEZ(jh9v%+Mx)_QQavpbc+W)BAZ! z#7M4#u1+#Lu=%qn0k^(y1s5{LP}K8Z!U0c3ME!xtqe{l)Fe7PN%ks`f{Lh4Py{_jV zz>t8aCYx<%je2p_t1MSS<#xBWa9siy1#SopUbX!y$;e2I|GwQeRkWm>%Qi&pEhRdp zqLr9>X7Y&+69Wy6jk9V5C~|XxWigGsEe|E2wvOIx?(=6_@unyrOWX$Hg|T9=(%N^L ze0@z55+)<76r@~1LEdVNHj%mRN#>#R<6&zv^^KuLL2;U$tB{+Xz`)Vb+|PBDqrV&+LhNNmrgBCZ56=!kWCWHr zZf+u)YEJ;XoP^f|7^5L!T$QBOtGz09i+P>#vUUpSmVK8wxO`yvxXrWjsHjK(>MDK= ztl?op(H|*&g@tj-jT=2K{xFl0?jIZsTbc!cB%EwYD#DXa99`fJ5@xQjdK|X5I65`J zHg{8riE@7c4}L5PbNkjQj5PBQWK^OVK4&33sk*)O1xb&%JGv(uNJtpYe45p#61r^o zG(sCvmUf_rlBTA^#$ZEA%FDt#!8c`>BjE<+G$Psyr@pyezzocHt40eLdJ#N6I8O^F ztnhzO*4-D(p+DSS?(uaI*#hD4={ePU*WaHCtbg7ca3{$-XM(@KmZ9(V!@JPD z7&)fQ&6Pi7#ZsVdFsf z&RfOLpXY$-Fz@WpmI?p1UBcFUi;s)TTqZ@k!q4+$L|B_QKRl?!uKrn>? z(?PAiHkc|ceDZ?w=Slzf@$2*8ErsD)Uca}w zUZW=fHt6p$pZN*8epxOxicJ8q`yCn>5O|gN^~ICVE>NDi;WBll^zP2bDYTK<>@wPX z`UhykDrAzJ*RP-`E%`kweExaR>@0G~sf5E#A}8)|+q99306}*SAnl>3SnGCvHYIzY znfGrjKufSW5@C#0-l7YFg8a}r@{fm`(|{8i(>|!K$m;K(2$-NnuaKeKoVz@arId2< zI%tubRkpo4;htU9aT`21U@CJwjJK*6dK~M&b-nB=cEtE-s%FtH2|c*+9yIHAa6msP z$RrARjINZPStubTmFgm}GupMwNI}GzIi|0%z{Mx2!;4&9oc#+n)!DVXl-w2ayB_2l zm$~6SuXB0f*6~HVN`n7YCTet{)K=E(l4()-LfJg_UDeaG?P?#e!kC#YhKEOCGuk|M zAAq{gO=P4K;eIprtFFzo)YVO$b0wHiMLb}w|G^K;&HSGMGi+Ux#3WTt^nSEvoV+Kn z=T9-vWk|WXxJ1rlV|Pe#qDF}d^Q_W!(?+zHk1uI@0{bsTxQe+s9OD6+-ACM{q(x{DbDr8mEzh1M}(vm2gD<<;*cnS+On(&B4Y^WK>ca$Ovvc@zHK>orkZ`9?lT0S=rJ zz4TZ)Uv(voj6iP-pmH4Y?NM5)f{hJQ6|cR0c^ZN<3{S0jm9>yAWn!B_v%WK*m7bS( zY-nhIZr?L~JE}*2RY0h9w)*O-vp0y4(TdTKJH(L=Nn%|;o6TwCtl{85wuI~39QrB9 zPM!pkOmYAiVT?#xS~~r;EwmGKRS--Pq7=yeF%~ikN(OfPP*tQvB}U>O6k8C|vdefq z!IQ%4IX>+8YK)D@`L@B7=sDk_&Yv7;RnFP<-L+UAEvf1CmHlkvBFSEjU37y;YRoiy zKl-Nqt(KSFRRO1F>>_Vy+8)Kc&O_y3?%i$AO13FLunB@UO0se}d$(5v3b( zlDgV#wmc~F?5*wp8B2<^&mNA6EzFU*2SnjOsZ zew4DQy-oi3t_9I|#Q9*^({KUW@cVa7NlENt*|0*j6UZiiL^_s=FD2o7J=p&NHEz#( zey(a?E`x!JN4)Tdn@gbc4fRo$#T~$s<_=R8P1nzaD-++loAzp($zC|8d(M97di?M~ ziO77`PMWzPOYEh4(%!Vg;PflhVwe*--?ADgeS3xg>rOqCc_ zFwp$2uCA|Ak(g*LJ<1LL6Amho37hoX7&zbQD>|E~`gz*^^lzgOqB)U&Y-~SX8o=?M z#cjYAVNsvu_OvWQKwz;(-FDsMx2l}b1vFV8t-{@MYGAs0GghTV>Fd{FalwMCrFx}j z1enT8!;&3cTYhT?-X)VLEvt%bH_@qQjWky{<#HyWtd}QRa zqM~R8ucelg;Qfq*HBL5(Kz`awevkC#Hs2@9{e-zDm3c~Wq@H}Fgt;l)q8`lL%r$0H z9J%fCcfUE`_T*+4|KgAw_792HoTUgywhKw%^#WGuwSWMX8ML)9yIeb~R?;OsAZYW?1J(LGuuHH5<6%~W69 zmV9cxKf`L#;A;|)waVF%Nw^5@+8}k^TT`#vcXZ0DZbR?vqOLxF?%($g5yFQ8Xd{sC z7IT)|NGIn@$s=1=HOzl>f&d|9?%_EMH}aR_?sSR4I&(>A+JljBiU1BngNF~V@_w26 zwtQG?0jvH~kNQjrB`QnrMYJkCNBz~bZ&)VxA{8G$n$@H!(KtFfIEICU4O|PA_0E(t zGm|AfktJs=N_Tk7*idb>&ZhVjBu2e8FojM>x3_EgU3kBaNn>@EB%*P%v-eb*6)N=f zYmh#}x4t|&wKHM_Dff-zp{C3SG-aAsn>$4^3$;oIpk;J1~qK|Einm8!_m~XP;0iiTc8cD@Y#@?d0g>M3bW{ zsfBUQjm$+SdDn_sN0BcvR+y+>Ww9WFSW+?qH2Z=6*69|2FVA2LkulYiU0ng5C%|_> zMO`Rib((_iunuWlt$!A!yMBLt&D__sr+9+%WXLR)8~{%yF_z;Vjh>B+jpbd7B&2X$ zsb27A$cq)?y>=9Tf5p0Rzpc`w-{;3mRz^`YJwNWceZp;6vuxz3tX#X-t)v#*U;_;m z9qaoX7g<9*=ip5fX)#+KP9LY7cfF$}Gu#|NO0u!7cN|w2PrTobBuA_Ciy8Ps0@u5F zpX;jRI!+mr-M2P2j?exG(?*KE!kTeJ`GosgI$p{UCas)j2tlbBNsZ{FFm?mp{CPiBOMFNzhc! zQ$p3Sx3sU;)(`rzEoZnE15sN)W6cZ=agvkgzr1f$%lJ*c7&V~UZ<`Z8QFTre2@|~D zP1n$I*-#vYm5~I-#mASSZ8>GQT5Fo@M8wCbSEH-jU2mp2sZ5=PQgZ&qHvS?isQiQ$ zlDju%i@&}JTrGdnv56I35H^skP@{z}a94|X)h$sm zrY9sc&&sye9CzNzWAi(Ed?*pqRVht;e=Y!{B(ivgpQ~!((~#ERLr_^`(fvcxoNfvV zGEE!lZoULSRBvto+sh-InRw*H&HiCuhURc-K%GSYe__+s0+*K<{sI0uVK~1XhlFrU z`3rFqHxsH!^zELjmEiMmi#-P&kKk>JJSaom>tZT0)?F4g&)N)ITR{SBfe+ zfN7`3WjJuKbJF)$V*UafFP;Bje+vNLn%Ut9ZE%F&2Yjea76-r0(RFL8d>KI&_h*-J zR1A_|2{+p~Ztg+aW-hAHQCT{Y911`_-VE+3; z04EHDqUV=*TCv}$EIHL8<)t{0--P6$9N|O%#rQ10rrzq2E`LkS^Em1BWDs@t9i6{) z`{Cc4=`TY)NniXz4`=5BSU8`HmKm@+%wDYWFJg?D8%`tHA;pjmxmbEhFbK%)%eq*@ zsHJz>#r9ILRoYKT%0o1?F?5+VbcX8j3t#@XB(3=dl)19IN%9}0c^R#g!|)M5pXDNL z%&3fwnHwMS8}IY6?^S5u{>>W=<-<$dM1wyp=EQ3G{{cq9VY`)o0JJX=|C3fq{fvOX z6A;M+1+}`8zewBD0$}(6K!N1{Il%G<42U%apb3bD)-M7AByh#mjZ`?DZsk95+#%dy z{?Q2!*8$j>t__nqm;Fkw5Js(chZ8vfTK?uo#OR);0>MTWUnSJU>e=5YfJgU!8Pr_< z-TrqR8y+P5-KxAJv=O}L{ts%YufZx5nkEYh+`5Hu$!rdpcU6C5t2}WXMxs*!aAm$I zd`ye}hf$jUEQsg-;FEs(fEXps2fFc!F0^S=^3C`L`J$YoJiK5`N1*e6V|5rz_OHwT z|Kyqa8n6in6i%G|Gt9&@wgDq?KCz0zq9JXn^&NF#HMN`UZ4b4VFh(IyFdXnzW`|?9 zs0M3*%C)$H`jFRg-O$M3$QiszpHyjmeXa$7sssgzAEQ2D)T*^)i3Pdu4G)H?K(@Du zF%bx8Az+lC(zWrx!voW>XHAvY~8ziYCpfH?bpW5~(Qt8A1de$NCT1^c%vhSap%QuE)3q8Yu(tVS@8-u@5RNkQA z{l_NE6pR|XuG!A4OR1tI41tB1CAPdyJwrZcPs&%?-f-MkK2{`01fx(lln$J&5FX5H zBmiRTj5#2-I@Z`rk24Yw?3ldGDJM+^=lIS;$x0CdPQv=%49`D^_H?P4+1_jVg5jN0 zwDtB=>2u;klR%ykZKm-_4oOnI8k~a6=#~{ab&S?y)_;us|KP$_)&uATu(QMnV-m)# zW_}Jd3j}h-!0Kvi_oRjwM|z%51p>-Pg+mA!JfglW60d>+KevT6xv)0nNf6LI1wb9p z;_RG|O&+M{zesp4JX@=%skyKSWUyj0!;(jL#KgKBVY9UiG(J9h>w6;qHky{Uwm)Hh zNkWzNsc95pFWim+KJ0KR_2-X{o#fUQjeF(T1JJ!SNx&l%dDE}8RcI!64W!x6p?c6W zs$m(m+|8l+KmfNC=9$TUr8O1+o(U0Qd<}$;gGY30YHBE2QDtg^sXzJOSU_ZyQp4qy z4{&$aA4(?*OwEsr_uN3k77d10fp?X<;%IL_u5Q)Pn^flFcEC;VuQ=4?ac%icV|{Cj z+vBD^-BVxxWPV1%G}7=KwrBp4yK$c02#;>D{;x#_SsF+Zo*MV_10=Nq81T^s_}6B? zR)fa6CX)fi7ImqmwPIJU_oJFzkPtDXjy5{b}YiQx#9h#R#Wn*Oo4i|HLzBMdMvSbP|hY! zf)K!#5WK(Ow=Bh`Zrp(>_pFtI7A&pWsKqFdj8SB6hYS*quVPSLhbKWfSABd z80&`lKcQ=oLp+5?h0dWr6|exEgUeZ4Z~}i%%I+M7XmGyUwE*B_n+hsvtFS1`^3E_w zy{v*lSZKSz#S!YI6RE6x#>t!TAcddUfbMgmpkQ7Z+~9=L_ATlQr3T7Udl^I93#o;cYs16EJ;p(5*q5C< zD13ZLpE}}VVs6&s$HoIaJw3(3>ZP^(x!3RW7ZyfmH9srl?d<^qL}md?IHnG-lchs) zFfH^yl|R%F1@+*1a-M&jiE7qZN%3=j{@II*Pi|wz6}8{{%T-BPV(E@!Xd|$p*uSh} z45SvU3@x_jFW-pz`XD2pDj;2A2DP`1ts&uXEJ$J1px0 z#(J~x^4i+_t-{hHval|%KYpM4E53?-9T7{L%jYf4o#5AUKV;`5#v+fe@XPLUSO^_t zkbuDCN0}T%0IjL8(9#T2`8>d-o?R~d#7j>vSdkX+o8Ys$VM~D=0y65ArMUZ&vH z2U$|Ir2%j|Q!t~Uf%OwHAG<+{Pf^q5pWE}Wqf0*50MOPfi7KTYkm1MEwi#agEq!xy zX728~uBbRMg10AcU@tekAzoz$eL`6kYM&4wI2LuL(E@0#A&-|x>;GpQIBET^Qb zFhKzZEEjZi`S;deQ}=JL-~Y0>vcD~TmQx`01jHLn;(a6C-NwSg*wLO*-1V(5W1z8r*z>xyXHm%qdCaA)Uyx z1BJ5gjBS8v`GzLnwhIyvKw};+o$W8!;OBP!q~;_(y2_)?-`-FaqBPdm!vq+7O2ky5P7M28?_maIfE23+jZ^~8GwiEtW#uuI#zI1T3)dIUVxs=)zLVHwOgub6>Z)4t&m<6HRNtgBJol!#8`+z$d9ycJ!=HzD zz9yBNAyYz(OiZviYR--J$$%fiMa$s7&cW};6WvPBsX|fUr%Ou>aK>b-`H zxy2`y(@cwflG&-S5#~YCCX~%_!>HJ{Lp^zc%|l) zUkhSjpo4yJUKvGt+TLEeE3+1J9ISAP+TRpTa8O=(>2y8s_MAvGSIF z8@LpY;#Kb&XvceGoY&rd;Vdl7in~FdMjU@XRu^9>-{5p(t?Ck~Hpa@wm%2BB`#fsc zNaJ%|J|ct*@$YTF;o&K5{B7f5%HqV4ne`9_iRxARj2jF%^k0R-Vm4pb2uUlwT!hNL zD)w4eCxZQWZ&<)WN!4jrw?7&dBzt+%v%O7JR?1S}&>-HITh08WrjA&t`ekK`{PD2u z?5iM#C_s$?g#+{WXBTLncnUIJy*|NdwB*;3fB$}tzz0FrjB*F>!IFQ$ioUiAd#?t4JW}!@g-m ziVI65@8K_JXIpy-Hs$69a9Yk|KJha#=^0|WHxUP9SBn)u4pHze|0~BzhXcql&BwU7 z1TA`e5j{BE&IfTv}pnIzSd$^ z{{FPRee~CY$%0-JOG{O$M}O)=6aoOI9S>?(^nwp+f%6VPFOsSzFR$!3Yb_S#-zJ%bOj4JeyQYVH*mgH`Y(M#Z#Y1!lY2n|`!!s2$VZwiUz=GSVv-=J6S)!kYI zBaOD5nA4aex*>O+`fK1tB7qLw5a-5BG-RN?b)e%h(8hCik-!h2xj~E_hmIYiebU$p zuKs+nub?@ny(0~~DLOmWvrW?6AH3CuV0MHNbKCEGGAlg-)IS5(TmZ-=+$th*8I6es zH3Fms0KG9W+59eZ1pS?k?AnGoTl_rY5#-FcRyC^x|4&!v9Zz-tzj2Y2v&fn+3`JD3^@7Hxb zuj{=H7mE)I6D$5gY9EmYW}1Wb=_6p6+VROH?Cnqb0a|-xI^Xq;Qa#{x7ADEDlB{++ zJC2_|y<}mb+czzDtNTMxFoNDjQLzbak|hDE@>LY9XWvzjA(h~7Z&K0Op_gwTY*GO~ zOgmxvI)Z{?ZhkIp+;krpzjN~j7fAf{4aPpVl1G*-X}6S=a!pPMm|(9tTq(Wt!@@O( z*weEfBI4{2N7l)@BW2Ym99{lb-A~>WZ((_w2`;FM;wzb`>7j@ihAk#6qY0d_T>xb`4 zc?K2ECsg{tq7~>j5l-(GJ7nW{hkEzt3n@HN88AfKwE%B&I**|7$E&$pN3~^T*QT(b1ldubh-FiBS1Ih0muBFb^!Z1*PSPg?Cm1B5DMrwp=U&>x>!GT{&)-K9Mrh;VN$;mUCsPlzgY3$Kg_sHXRe`(4L`J0W3DM;;O$_41H z2GQHszq+}(KU(_JS+yJK&6S=nZ)s}D?|_+_3b*L&-QHH09dp#Uw(Cew@WBi(3gL0w zww8id1i%s>Tfvx_qHJw>3!t@7Qwa%F$MPG47j~AGaW7Igu_sFodcvQUcM zMl%^X{xaixe0onFP?52K3w~0RnmS?3&1o$RcK0pmvQ2y!n%ObKy)5?Lm)8DVu>Ic$ zN0q7YDF)_ZYz$9M{_3^c+3)!$2phV8yI2mz{Z#NtZ}F3}m(zlj@Us-ZAxns702EMDziANEq&-+`^^eVMj~FAaX7(7ChGXYGJFKSd zw!f=im$#jWjRvEzDG3leGY*n1ei1^i2K*$2F5*>P;ZMX!G zB)TU>{r&x8+nLFf33#1S@CE+ere``OArVpV9T!BQRdTM3RApa*q8?vSsdVh4pevqz^b<0yf2f9$@xc51`&fqu>15*-WaH_8bE#ZQ5ZHcF-g091uW$OS4?&FYSGsVc?r&oVH)0YTDQ1dtvy9Q zoIa;%(1-8btOpQmp3d2~46-biQyqNNL3(;$B7TYai?K$2M!47BVolpFw#hFDvE&(X zn3mqt6+YtRu#%i`l`QlDf6s=?b<1kGQRRIoUgFKcH&Ixo3Bk1v{7C(EMZwU}^T zOW-p(TO z1>F|c44Ff1L@-z^Vy}%w5%#H(dF5Hm|+Pg&=87CqhyU;cR{=oGGx+zQNqK>#fJ$8!;pf1guSeA#12L}q4Db~bFt z%4iW&Aot*kn7EG!kD;fo2=NazS~*fAI?b&D@fU2RwEwWkfnM_-P)`OYp{u=W2KuSX?O8n6+CvaW(eiRyb#C*$ ze7bu5u!Y*G*!*7rlzAQ3Lu4m^t)jvSL*(^*}aqgf1Mgr zK8;Se-ur@UG1;j?J zn}Lo-d+W%U0YF#_BW9IVKN+m*h3)34K)wfpH&&>gr=(-JK?ra&0-T^zE&Cwc3L3jO z1-YkYX0F}6+j^7H!(uIr@xhzg(NXdQImv5$?Cgu#e#k5y--q8qOjQlp}|ZlNaS>m8Q%uT$|`2R>t1C2QfFSFoh&d>Lwh|6ZR$Wt8LsH> z?5<%8C?~`&OSDy#nyM1#xX=y>y? zp6)?qzNX#sbV+;bi*rfxkG&Ilp7KV0y;=_)Q%xu+{9&o*6wOReZ$4IFLD8AUz_yMc z;kBsOg1rKeHz(&)>HcX8mx37RjRK$k)BP*c-3y%3Hp*&+1qJD&zJ8KfmvZLUC#ptF z3P}a{`CT@qA{V20Hzb+wSx$Y8imvp=7MGURq!jxs`g-S&JAUxoZmpdQBTcC2AH^~- zq}5}m8I9WZH^yPGszmkr=cjvGo zuBjZXaoA}igo6>Pv1FyBiSXW}wI=yi$ewl<{>d3iOmX_mk@*-GOj zHQX_Nm+3sV$gdn6KzgRHyNk!gO=dmAJRIZW9#mzfG4R--sElg;_CfQN1dix9m7c-a z5{to zFzTdahZdg4uk}(=zs*0|Vb*9)dh=l-;FsF4pG+)Z_}unxq^fQ`oQzFu?NR*QF9gR41KNs3W?0Eay2uy zb_SZMMq@f~j(Gc25>4{!Dk=tdJVqZVV(!w=oUE0GW3rjLniaES^m!N9)1}?l5-QEJ zlV+M%==$t7Q%XuC(pCmdv4IlZ>~la>2zSK+ku4r8^nK<%RMJUPlQDk|@WQW)?cI(i zpqsShpNeQ8$`Lda=`{%a!c{K^OTg7x}G?1 z)v-!D$qmfj>Z^w`H$uOBxp%K*c-Y}O-o*%ByhMCL%p+rWa(t9~-Can?r()({cQ+^~ zDC3*9=2!#qkLv0RsJp0wy=-|;$imLo;_XY)3Q>ZTdfC<&h>3?<(3aWHghLo3rTi}V zp-^!elTU>)TTgiUREacbVS7^(PMfbWY$;@#Z{9qg3;{fk6lAc+Kk#Be;Kij~5b+=j z3H)xdIH)H0_+DK>T`?SP4mPjB9(*w&vlvPpH;W7ArF6rLOPMAmiq5K_mc6|E(9GQJ zaAKI<&p95_)Tz0Zjxa+cpm?^29E`@dTS}gx)~dSY-GRF0;OrbTZk{S>vU*|KxYPBh z;c|t^&HAqe1rNvx-j1JT+x!CpGH&N%J$A-8c6T?CUB&Au;N_bU1@24$GZS!PC^MBY zi{cio>JVu-9qlF5kjl@_o`gS6kQkEYjrJIO@Syg1NT=?DeCO9WB>E$&*qa>Tml$#zYIAk?+2ELGmhKkQV)EE^V{kRTP=45BXJ&N|S=7a3S#Ic>iy z;UQ}wbR%npa{K6*e}-s;y2{bG_cSeJHqEVD<%!9axY0pA^OxW!ogeK!KokWCT!_)} ziM#Aaj`+2?DNn?m?xENp4@sW|re9_-bTvpVbyQ3XZmq%`zBXaVlrT7DY_%%5*UT?3 zKlrn`(DfqK@q09wpq{b4n|3VXWTu>(*{cZj5is};-OOqrIQ>*!j3nBc#`;8ftjL^z zG4!LixADEFMFMb=fX80Z2^P@Je!6K3pLMq7PJ|?*So_um4q|vBJhmDIK%oa{ zGD`bWw*Fc=r~Y^;1XfNeBrN<|OHm?(x4cYvq=TMHky)uQ3V+7%`@5F%l?_3L_>Wi)hjBB*OE^yczmcXn0_3y*I5>;IXh76 zsjCAdDfGb$LwX~&$2bs*`4b_l;gwhyTW6>gHK6n|{iQ}_2A!QPuL3MUBiX^MbZ+V{)8~LdyC>{prBg)~3fPQ~y&<3d%9S|M~ONVJsB} zPu43TKAH#OeAI!;OCZ?he-ZNAOqMFgF<8;X?iuv43|2cKESBCC9Aul-B5K#vh>c$_BfjCJXdE>;$bDf@Ff?=Z`RPGNlXo4hJMCGs`4Y5NriW^e6EO*zJdxw_I+P1!}8kH5vM;w8Mv+ zRjXJd>Ip#qxaA2FhgV%j=cm5^olIpEd+?%ThHlo*u4v&Qg>y3p?*7%!fQ&Cy6d4Ah^^mCH74DSkmm5{M}gN zZD&IvPI^IlPIC-Hok!Gkg;H4fqp;VPatl&I1xB_od-7{6GAwmW+43!!MJAd083%uq z>3nV5e@LfhPghUrDzl3q>653ba1lhfL|uk!ID@Va?8eWM9p<2FKj~5KcCcs}c<)rP z%2ruXrWBvNPW3Z{Lt?Ls1&ct4%l58As%2IrCyaJbxg0i)CMm{!c8t9xcYXTygmG}t zjoy^XnS;7~7{T1mASE5E8E3CO#w(L06@661LPda|&J_)bC zlI1%)@csFoCNoOW86*XA^72x)r^x$55^I)y?8{kg$eaF|iT~`+q20{XE$hGdyIdqb z9Hj8f+CW>DfRp*rG@bC3D=JS)1Xykmth<|POlA<=p^p|$EQwN`gBi~D>7_N6B_pqa zFuJnBU25uBPU;E^T(*ANnyS=&eWOl-R3K`D`HIi#`m0xRJ1(_gwzZ^zDW|F)1crpX zH_EGANCHJ2KM6Tf1va5ExAWcXh%iimpf`LBfq3O-0~H;4$IQ~iu3}`nz!DhwoR}6o zx-nhu!~rcB8d7{juxgKMZCyYcn4+H6C0IO~stOH?SO@4EP4?RBkWao{^K+?HKS2`G zvVKmb@x|LCg%_Z$CX$VvsosWRcDA46QAf$J0&5S4^T5DBi-l9N$uh`^dV4)P7r~Zo zS+Dm3(<2_Y7^8tiBGb~6o@&Hx0w3 z?IZ5vu14HtAv&v>g7w)X;Y?Mt6Le-j?)Vt3C6*#a|UioK*hj5@~o3^81;2wbHco*c#p1e(jCVs(J^T{b_rxZTw?m zpcRY6&)4KbP9d^g>EzvL_Yhcp>^RppTY-(w$2ei1R6POmq1-x0s(u+XNAC6w^695C zT)AKXxWgN<6qIwBbg8vQvdV8ii15WfUDs*^Wp#ANYYD><_vJ%D+6AmeUXPk3ohRYI zL1SKsii$d_KD-hb7_5;bAo} zx0`EmQdWuEL#fMr&Z(Ocb&mNcQ&p-_$?i2N6lWU9&Dh@7p4Il!#-=;U-2cfNBvOXK z#Jd~dZrfZ1+G}N5WHK%)%r`=UgOQ3Z=#JG2vj7J|=#Cs;R+M?O?Cu6L~~^7f6VQX3ux|^TBPVx_jfpsUxz02i4p1e78~S)>qMah@ypwu1Kjf!N zDrHjTVR^9#^mI8gm#;e?T3aZujR&??h^3srlf!lSZ; zZmKn#CR#0Z9i7wx1#!@hS-P=*=hii#WrjsWG{k>D#`A3y_%UrkJ$Q0$C7oxJhb(?5 zjc2opS)K*yU!fGxI2B((qDHFX8dB^2IiHyvujI2d;EM%XUB0j`UC7JI;-v{9ix1K3 zQcyqodSUTDPx*4yh!;YB)(!#)Y+{J7uOA?=hm{;Z`DoCB zWIv-BM1%dw9U9Y0ywLq$2)nOB_y80arYmxtN&i{!9?lj6yF8cfg{Z?80AMd=(!Mj2 z;T!jA9f0w}|7{T{xym%Z4L`J>YdP@oNjKsOw$=QLzZN_aIv~n-)pYG5w@;%TQ0c{;Ujiz-o*E=I-g;w>!uS-~z03n!qKH5f>;@2J&M+_7gAoAx0CK=Tk zir(Mp0j(pkC4HfpsgwzXc(^zfd}dXFGqDL&O@h;!id|=QZO!P%d3-Z3Exws|^-u{Q ztHk&tOu@hVjQKrdr$hc?r`J$x8*7Rh?CzJkJ7#A^IO#!o2`fGQD^_x&%YJfN>UeAA z5Lhd35cqR%{d4Oxdl?xSqass)I18ZvARx%Q_-}1*(^{`TYl0Sg0F%BO+5!-5fp+ZPlcE0F-u=mFOAf?fBJ9%&X z1UOu%NNPOSLm_$+zUZz%LkIgl@eJj8I%(u4cz;m{cC;qVT3+jC+$QqgjA)fM3E5{{8^|rmVtiMtH*$EFn&-Lh*k+ z?Z0c3zmt7{%iurwXI@Y=W#|9;jQ{P@eK5Y2>@Pr$m)jVrVIW)T=HD0MDo^D@a0-5F z*yZhRX;j%&nBYA8XTBCCpJh--Ybz}YtKP}-Dx*rm{DLm>17BBfZ_(UPNobEc`PF%EiH{W!9K)$ChkL9+%nod~|dh*W% z-sM@_%-*V|>_xgYp~NPGNw4kNpQt39?Ft~KI`=AHLH08jg@yk8KD@ayN145chktx~ z$9Qsiv)bjPS)kz;Ey6K`hlU9q8C{_eZRqIijQnqHL=y@q5B*y8r3Merj#9ad`+t|U z$!N_~TOY&}x#!nw&NLdb1$WT?Jw2^;Wrd&w_WS&BIe05>`uq2_%rzra2?GlYEA!7L zHa4Rr&Qo=c1q9vD?QGCule?(N&3wN4$1rMGQMQH?zf@*kK+~=N_0`qELe*en0?R*f z^AmBL)wP^~6mdI_1j}#_PuWhCzaOI7Tgb0_68@`>A02@>PKWwpp0o8`r2gXIH~W-o z+&AUs=Jw*Buu1dr@I=!nT39*Nqlnys!>i2}Gt~>S&(5kVDd)z=qrnm4l9CcqT}{X( z`@Pw{SPocH{g+Thv}@;7UPIPzC2IWWOY7?j)RYu2!@0T|wuDNczA{Sx#7O?y@+!#* zN%6+f#TCZYYUksOqDU*gL~xmww)SwIn`6yeH21EK4*dof>yt_0E+svetKHLsKGS+N zCtC1aW?;VC-7%YIp;}Kwv;Zci`_;upD9Jx}t!}rC=pe0#8M$aSF38s8NWD3odiw(@ z=NlNvd22P4BCxfsE%1uTVXI_pFr{7={L>vJGdp`s%G%mmWn*hunwsEgBoKm34d}s> z^S@7N4(x=5DbYMVU%c@3$FV$zPt3GWzna3LtbZIDtYp-Sm??ueC8Wn7i{MCI_G&F(&J069B2s9=Uu{wbN1ah?>`K3jtr{6m&7KsHzfi zGwRx&fTX^0w3(P?LqN?l{2hN-s4m>+8}Qf6%*>I%&g~Sv#p|=wBw_oH2YSiUoIu#G zGO}CEx&Gk8k>S9*ppa*bB&a;!9r=6}{a#S;acKUh65NQw11DY~ zu4u)|7RVRBu}ohlNI`6>PM~zAy0~9qLak1>~|*Hfczu30>Qxv&fuDD%}Qz(y!TJY%oJ$V4!C4yv0;; z2eflDBsjF*Yt^PJ%sM3G@grb`CG^-iI$8;1ty0v@e95^w=v#?8?7DWEO!?=Tz$)5CQEe3)4bSvA`ww@=&6la z{GaP5Rro9DtQ4KBGF#3m2k>9@`Sk>vyH7r2osk?5c5F)5stBwL^g}#-n`S%NH3f4@ z)7HfiGKCZIP<$Dp1Szv>mMq|4;ZBT64Ue~zh92$?`M2;_a7zp=5h^hpZa zaYy3-RwI{)>uVl`doq3vO_h#l@PI!TmW%)*AfL!Sf!~%%YlZp=kM$(-7p-BOmMF4C z*F8+}nP^D-?cpEixv+PmL{5A2CTE35v$G$8;FMwRQbJ1EW1;W%1geruT2C{_0}Ee7 zOqFl%tJBGqi!^MAMw2PZ_!jY-h}2KkuwXw(PgO-~(aHZXHmP{w!#zm3bVe!>^tQr= zTa1ypfgCBD1LCpdS*hHT1%U|Fot)09lUJy>zpi>s@DImY&h^EXz#6Zo=gw8lqn+$}_IQ`Q6%hiV6Y zsmCRkC}(X|l*niE%19daQ6VQ1i&2S8*z4xU#|e{6)1)_!`|CT-Ua6?GFER!O6o6B= zrwW9>fH~g1(@Ok=^Z0RR*yVaj=p4W80<2Dbm8*%rVWq_n#04KKC@3m&l$M@8#E&v> z;kYGC&cZgoQPSL+Y5bu14HX2g&Ccq1E@5|g$PAweT=j|wLozc;U7uPK%Ww$T-Q3g+ zKyO$GvFI!I=J@pMTqKJxQb+lR$%QPwY=-Gyoorh8{HjQ-p$ZQ}o+2EaY;@Tuk*W=r zjG3mj3js1+$Y}o}-~D!^3`fsz&oB@uw_3AAxUDUNhld9PZ~Miy1}A5kVW(m$xAV+8 z3qR@&8aFq$ukSnC`QJjWy%Z`U^mKG?u3pnkhOluswh?T5zP7Ju7I9<0upv}KJ^X`} zg@uKcb>*^RDv|$SvHqMXqO0XwBkwfX=j907#U{r5{BS)`UYxwD=^nvG`I!P?|0Z5 z_8L?S<1G7%a+g{HYmg$897;*9cfOs(wJ=G6fO%{e9ds-U~8ncl9uu6C$IJB(*A-}$)d|--rgXq zT9sjWI3cKreu~+rAvyyisPl&B*c8=^0UJyry8@9>Nkc5v|J#yE}2C?vt`~`S=r0^?Wshij#u^{ffFoI&`qBi`y6>_`?SyE2{>h&lCASLqZ=1 z9STTyAvZR%$?rZU@!t)FJtWS`ku{lM(O}rU5fUk-&^{L;>qZXyfRn0)6^V!?YiHvu9VvJ>8^12u>DVZYi`6l za^C!|LYgNJ!>EKkPiL(~n}-q-5(WoxdGqV5EG8;V6u!QqASah2pP&}@q9P-+F|tyt zjWnEi$&QAB$b9qz^=xdiv(C$RHuIJGN0x~^)e4}ExkE|y^t^>fmJWZ>QUO`s-6RGg z$$1JZ40kZ#U%xOY(e`kKd`*Uh$^-4kQoLq!8qp%m1RiG_JO^U`r}%n8@Pg`j zR)P-E41cZXHHzvp?yK03ZJ0M(6Xv-dw^!C=bYqjp2nRhQBQ+qakL7Ui+iJfvw5@CH z{Rv5aL*MkeZ*;V^MH$w%dfOLkD=!}sN7&N+Ue!EGh)pejt$p)^`i+Yn@@|#SI)Nw& z^&t*6TsJRW;`3e)CTsCU4`91u0TwfU4$ z&v2DRxfE{LP zL(O#Ib@G(d*ln;?y}r7*ti1PRicM!ycYt@)n~6IZNK2);Td!%GI%UJYff^f|A9L<# zWR-^`md@-dKuRnY3poj1jvac(ye8WfIVEviu?43qmu$3n3D;@R5zeyv3ad;g7I87z zi{O#NsW}1eo0Ac0W4QnD2_@zAo$o=IuGEeFVElKn<#HImhI5I5we@pNKgBTEX$|zk zYgo(tGyZ#+t(g)vivVQmj|dY}2%(OeQGa(?^WJQA=j5cUTWwucBchP$E}%YvlHh2; zpHH99XuVD-ZJfzpf1A)4!e0Z;TZ@T|vA$kY*kLWR8M)cr zZT5{uM^`tQ{rnuyNaGbZFU}*}dgNgb4015MVrOMlxIJ0?*_?iV)Y!}nOn~Xb`e!5~ z$4t=Me-f97g~#fHrjAZx^mqLLrO0T@sp2xX2?JWjjYh&$01e=u_x*U0j|I_MZwo<^ z5D*yJ*eI?dbGM$sM^I2eV8^(`#8H{x0$#_frzVfA!;uFopxBv#5p}8maRC*o z67U(#k~du3+`POeq_|7)ii1vJyNgKFTxr$OUnNt>M%Qx@T3W$WjGH%hjwD;KlDUDz z^HqY#xS8qci}!|*xjI++D6>zwbFXD>BTsvxjE+tyacOpLS>ReYJ3;bq2X^a|e3+P@ zPx-gD9}M=44|us00m~CGFtB+{RkT84WI);g}ospGQb$sr}qItWwvorLu zK*{bZ=3u@oT{6@Hy}_*M{E+3@LR)60k)>sU*i`1um#SJ)IH{JF*5YDQaq$W+zPpq4 zFKE#mA|x#A`EhVC5i*^Vlan?diyuAO7jc2?g6_R>b6{k9m4POC`+Ecf1d5J3J^$nM zMf!C_R5jmJ>&l#qw7EH4vv^^kUfay9eU%Z`G4a#R4zqF~=T~E>(Q{#$E6Xujp$cMP{YS%E|RPM=jLj zaULOOdF4`t8~4{QyN|9}xpC4tl0@BhHfBO0MOF)S0@m9kSbjpzo-&vY`}22qjYxLg zH%%@_w*3zw2){520R`FCZ?`&B^UXX=n<+o2MK1c2sy@)nIeuBFcQV2v=ig%z8~V`@ z$LXM8JlMfRoe46cr$llO-1Ywkbpr@FmrF^B8a_k4@Hp>N^J6%@aJ}Uar&7rQ?eg^R z3nCU|jNWLrqpFN5G8Z2$E49x4yr+Mr&)4UjeWR?MO7Gx;6uWNiK>r3Af85~gtoGi* zQBHDEmCZCC*1q>RDPx#B%Gw;8pY=&(Iqt<8JX;#gKzChU$k zS8;ymi>b7~;NEaH1aU)<#T+nvJ<|98U7+90XWeps=t6yX`V`c;F;EXcdxea+YHJ;< zEBBx7r}++_EfmGRnS>)R3_V?3G8-Egyzd&TZ%!Dky>j}-TuIUQH)5AT^aMBukjm_c z2mm3g?(Mdl?}UfHg5y8HdiA;Wi%F$V0A30%*_(~6kufNhaI@=)o`nS&b0k#2mo+Z` z3kT@qLz<}H)4%oo16LuImVNIqn;oxB%Rva{s}esEDxq@K&ImRXP+HQl+W`T|c@`<- zYaXkU$=lH(jUp9hSkWjqm(#9b(t&D`HV@Ts$#ub03Xg+njJQNc2$@j*XJ73}jTj0x zg)2sX5xI=7Jq*=+8o?y;Kl`1`V8^~Suo1&eIytI09*Q}#gG4}1W9wI_;6iUoz0q$t z&QxH>-cV!^1=yZC)2Y;`Y}T7Kl5SQRIl0u=#v|FyF%!k~`w#Ocik)K)>I66$B3t?ik}0W`2k7X=pC=hIJyf zsA1AD2j#ZG_icGE?_Y)ad=0m&n^RxRp*m0Nv6v2@OKt{*l&J9VXOxsDQ~GmseKS^0 zO*=If6T6r8Ul|MU&?Gf;KV2VXa0Ik1pIk-~Q3-oBd3K3N*w^AV;A`+Eq`W zgwo{GP)g+aMPP-EO)<|JE-vnXGzliMNSlxC3$;BS=7$0eM>6(XGh>|UPTL9wr9_d` z801Vtfldvs173U%oo3}ana#lXxWj%@9;9#{jQrjRIZ$9!dBYPW*1n2g_e9-~&|7{m zsZn~U#mc$}ZxDX za-Fb#%fQmC$^~#U3rE3P^9MGnnG2?BFC^`!Gm<2s8Vt#zaBA?$yDxKExJs(#g%oC&I}YQN34Rr;&f+n+~*f+fgwIPM5tn zCRRvT>=3LT8JDwJ)1+;!&Auq{0P4@>eqZWyI)LP)ey1Iuh{zS@l3}` z^r3F27Sd(MD3>WB&k_!z=D9|=pF zc{k-Pi^^M%&B+NqKA3;W&a|=FM-PK^av;@aSZ?ykU!Em*CGs8MU_<+z70Aw@&n9iV z)AU?zL;4SoPGN|lrcDl5Bz4+masecDs;=t7b{G!fD7myRSj(%eU_9Hq-4hS$S{+EL zSm_9Mj-)3eBV*C*H=j7}D@x(g7m-Ff$G+lwi6j|av2dhl*&Bwg@n<&EAnZHgx?0qF z5!A}u6_aqG1l@ujsK(O51^?|hZcpK0ICB!Q?jw*lDmkYcu|m<^QCBO~!g{dw&&9;4 z0}m1O!jHAzYR8cmWefd8PqLO_6(q5&TIw}6r`E_qx_qogkdYYL^&!&$#l`Kv6!!vS&)M2UtL^WUtnBZc`C}um8@*< zuB@zWDW}VujpBx>Xt@N6imnb-ZAxAg$X`b}!mTsS~SIpH|E1L4|@QoF|7ad!ba0)M>nO1s2 zCG7&=c2XoGdYPv+XE|w>nT>4IohWr2PHa*+oJm9?s$ae25n>RFdyWn+@{ zz7`|rGkI&Pr!xvTPd?#r=X($KWYOGclddJZW^jw{ll@pG+r6^?%N^-a?K}1{{Kz@%Ch;B)YBXi=e63oppfXKn8+6f4s&;A z4<9`TQlHsPNkKuymqo%~vufwr-n6t-_FQ~?G0_4W67iJ(EJ6K~rDfF>)RmRka%$g~ zhn6Vt32wAse}6wfr0*{{fA>1J)EESaHpYAfM^%tKX#aKu8eNM=?Ez4Fz{rimIJF!| zVYWfSfWjGVei9j_`Y<{>e5v%UZ _V@k~CQ1ZuSJ%wM#YOm43?XfVIYY$rE{kE>8 zIo9VpnD=v}8C;$2T}@qOX=^9dJ5xFB&LaW7%!d`_Tjbfz<<3k+tkaIbbXV6y zVzb-n78v|90L^Nq8ltUTS5>v172Gvr#mCI@hN@ZT{4GBI`#Ql{XPXqd`^kaX8$56B zYHm1?-Za{iV`J7M4Kf893VVCj3JReL3JQ-1WdQi*;IMm*ClY|0{16w{n7G<%Z-jK;ZVYyX^IE9wEH-9j_(@WYV>FF`ccrwHpb?b&o6RfZyzR+ zS%tP;Bc1FjV=Y;4ns{!%2-PB?xD_nfL9LCyz)62<9}_8rUw0dgi14)pJF zRSd-SpTM=@!M)^20%PR=?F{+u&%N85H;b)qOqC=Bq0u73!pZStT>vHO|KB>|l}{J+8-IQ|{hQexT?pf@cxd+wZG6=@n7J!XDH9Ai>t z`FkQ?t4M>bCg{xRU)^wt@$LT-EOY<=5kGkzt{vTU%rPqIyEdJN1_p*gmDY|f{w|=G zk&P{^tSmh~-u%>pJ(PT+NGC^-+4FVkgA3-@6k+KRUi3LZ`}@TMiF{w2@}-yD3lW(Q zQ3W2L{;9>Day}3S*mb4*M*}8}kB<)_70A_@swN|&hduK@kpcM`=>KsV??y=$tfW08 zQUU2-cwN1*{gWR){6%|ma&mt<-En7ffJLpes*)$8;-B^8AP|V|A6rY|rHM8Bdi731 zFMOp)G)gP9DlEKus-LWQJYC9JJ-OuM{K}!<K+d6A+x22k(yX#Y7zREPwg?4Ka~WL7i9HXBw}e9UYzK=3l+^&vb$}LZxL| zF6q2c>)iGxk#{wGyu9j~nwvoGPvps$Q=?J=t=+Y4R+w<)_z|;_mFUzb6%5ed-*{Ss z*_&58H61{|owF{g$ze6~>$estNePvyu=_`Md^6k8p^K927R=o>XPX&Bt&fZ_G%+mvrFT(oc6GD@b=u+?UCy z7w7X6!^00vO}i!vV*hHt%AA%{td3twkv9Ts^L{&-(qBX#DeDm;VfA)Qbm-v;36c>8 z%F|?{qoXwkuf?&W7{dMvwP}$IiWD-`HP)gn^UO<9z(r|j!WX3hp5mx)_I8)K*WG2KttFfN*xxZU)+3o4{(57!*Vg|>_?ojLxIKZvS(g1{p zEXB!2K{%mzSXfwbF)lNPU(uho)fb+-<7GI(UfO@3ROD9Pr_C^Hv*m?)D0<<2jM0Z5 zG&|FWxt?Uz8KOM!z|DqI&er_1+Sb_`TLo%SObh~nWsj$6^UTN1&ROYi)zm+Fpn5tu z7RldF3HGb4)A$Ab`ZZ8o5r}#w=b)PdU2`CB(+CQNiP%-u{tZjDXR!qmO`RupGSW|^ z@bAx)`tLk2&69tgsVgAhu01|bT8obO!w6C?CcK2zk18*e-G9pcP{aCvZC&`^+6w+Z zbVT&NzmF*Ze&5G0i+ZptFP2wA2Q4i=Gwyos-#Vp6I>?9;{WHI#0kBaxI5@<~G@CNxyNC}H0hg&b~N5zk60>U*6DuL|_U*dQ!68*uv+VJnN%IIlqqXUY}EswGQlcm4epLnNF3}%7>8@;+KNc_#kCH;Iq8*!yUdFv1%jcT2LCL z1@H+7+*j6WQj!+xoJwoDn8|<>%x3cQGUuD#?RdAO4J0~`*=cKELHXiE?B5q-qi~IK zPufl&ufh9%{9pnAG!~H+qvErf-(Nj9W`vxn*Xy2u?U?^uIrbqx)?7yEdqhKHAy{4^SM z>kLaQ`?6dJm={P8c8?x42waSC?7+XZYjI4;t15Fm_M(y_tU_tom`D;90HxGL< zOLF9ZFIQF}Dm%HLmfw8w*Unr`3ZO;>;Mq7k7O}2YL#*cq+mXpaHO{@^N1YQH;fJ3kDY~?aBeE4uE)faGJv9jHl{vxdk?ai>g-IvpZ+P<+hNI5TZEV{?=m~!`3i|8Su898!RmYW;*`?rg=b#Y!^ znV+A)!gsHq0KJl)QyTdbj1)EM2pNPn}cw@!xPNk&N>mWZ@9e1Ek);1-LkqC6 zpjNAEUOO$n3cfohQrP7}zpAgZSvYPW*N%f|Js04WD1XJt84qev^1H8??J*qsQgd@z zs}S=_g+%|O3NIb`H4*~y`Y+4Wkr(TT9?^c z(m^jBV~}367xwL!A6EN|o;;@e2efGSpbi0J79;BLEL4hJ0T0m$nm0(eDf z5c7U~G;$`jv-6x8ki6YpLPCmi%k$5(E`zL)qm_ZcC{s*C?16@lymq?IR2UG_fuX^$ z`U56=Yr4~$nH>-k5a5H*tp?|F1zyu3Abpe4#Dj4ni;W57s&^y8setTnIi5S2qr}gy zxieh`(Qo$ZVpWort@BvVUi1G^$?35Q6Qa{O-I>a;j6G8?_1j~#dT z_-)jnDTfiL5|ZMB`iHtY%2~3C!fp{;TCpSq}FW{#VxG~-o^}3;Z8W>j&P}=!FgMn9m+02eMQ1T46^5Xucl{kFihNQdwZLFvg8@J4Tgez ze@dgnSavdyu^v5IX^z{QtuoSx6Lfs6Fo^ZQ=bNvjkbuB&^J~BefFOwjil6Vjxl6!p zS06re>&{*dRYwf6qaA^mtRWBETEZfEnx@a)sGJ)M$k`}hPk#?e3mNqWYf zacs5YK$#CZIwR&=;a-uzCnpaK4rZIiS5lB5_hiQ{Ci2xv?&u`|V~`T#)mI#HKIL*$ zVVR$6*!P8($F~0?+``gQf$<7(uEFnLW63VuqFXdn^Bv)VtnHm0EiIjDn+b;{@5RcS z42ANFj^;};GJTa)28PbfL8|%E&vRp@94}~pIXF6+X*eHej^L0_(o0A{*pNcbE(=#h z2cj-g;*!SLx2_K@RoOb{jpr-5L;e)6; z8jYZ9o0(ajJ>@V4Y-!ak!!D8CAq4|j0~e86K+!`~z~Q8s4zC5(qEpuGYE?L{=u2~O zj=faicnM@opVBs3T4+?Bi6Vz0xo)E~17NXJ4X%A0`nB)3EFvOCx+%`PeCB&zb$T*4F&PghIy5SD$*t}6RkGnadUG~qs!_nxNt->=`%G6PU1 ztsRJ{fqr>I1A}<2jZtTFo{i{efPu!Qd|E=m30ST8`J?9RT&GLF*_$Xm?@+FdtbjtP zRdKPf2KxImQ=dh^d;^r|jWIE=qq@$&G@qsXwmZ#szygyEpbk&E3yw5+1F}8|$6|xa z>Dh8y4yk^w=n<$}e!^f;EWyl-&5qBR*%Vm%d{Qzc-MafZbytTc`G>Hs)rH@#np`@0 zk%o)GbA8?^i_T~w#wI2aPgU}cYrdtGe5_@zy?x7$g7P+!RR-l{{cD-tN*s&JAW`}v z#8Y!~zjKC%;bf|5cJz$J;}ZnVHlnN!_E(fZ6D~e?iPT13PslMg6$sG0I=BLAoo*fV z(W}t^xPVIiGJ`)=1vwOyd(B0xZB;;prTq^evaN~7nBY4&2LIeu3nK%*7aY=YO%-CJ zN1b7*m;1?@k{CmMJFC-QxwE9Op1sM%yCh}Jk|anAqY}Mc_J!us4N5{t=k-;x-`*YK ztFr4i0WKo;UZB&lyK7wsfT807=j)eTb;GlDj@GtjW=!$I&ii($O>IM|%}L?mTkDsf zVRsoXBo$-k1M5IeL{@a`n0TpPcQ^EefH7bRit+I9&_&dufBY6ROxXSEB{OrNR$P3% z>wc@VE=FNd^T$OPq@|lp=X}2LB)mz>dyPDPx_h7!x?d{+(rqMcvMlpz*6)+0Z$t4nE-Umg?r>I?1d7dv6-!#uJmEcZwxS3dx# zav-LN)Zt_z&{EFQNmG4RQgw9AFq(GRSXqL7m8#J>=q z-UkpJ(q8)bwq=?oCsSmZntNOWoou6<(@dSGt(uX%fdN-mL4l!}*(VbdL4%8>7`DXl z)O__FlSHJJ`$c!p($etO)`xFku)QYPk*28 zqcA|WZ#`RSHvgN-+AFrKb+NIoI^DhA^*pggZ)Nr=0XLTmkdLb@>ZkJIiaHm@dEX_M zq@c-n=et?M;fIx#JjHx9QZ->?IZ))`B0uaD*iE5P_~FTg!1R!4%g}-ShYt_%q3LOI ziMN2oakkoeB+pO>-rhdTi#!1~Tb$$FP&^Z#8X6qju&V-kn0~f?a^AwRA~`8|FaQt% z^9LG>Ftbl?rdw#cv&`wShd2m~KI~m;dZ17DqD{3#Z?W@s5df0!2XvD*J&)FYebl+< zuHL+y1-Pqk_s_83z`y_?8&|2HxFG)+&K9z1EjH>fwj=K+PqVm(fX2iCZnr%D^AQ`c zvYHH~{N=eoYL5XplBS)n;i&cd6NA4i8sHpe5vGsmk{epfIpO94WTS^**No!9jW4wc z6qIai*4DyksKyWP9S>e6CfT^SL@(7!t;b9C0&t4}{RVtV*WDX}b=|+2h3|bFl3hHi z3XhiWqbM%+t7^m`*EEa42N)P|yn5b>&-(=6NPv#9vs1z^NL-AJ3r?C~X=nB#zdAv1 za5Mb|-vpcMawUb8Igs-vWIWs(njPchhQaVamfDN!{=Ptjx^@QgRz?p$mxZwH;wTh8 z0=r78YHH73IgIj`13PGfp^pLsA3hBfzw*z1ARABim#YGyOOng4{%SS*)0s}T0#WAj z&*tMJDi{&_sFq8^5(Q?yFSft{r%U)lhy%ZOZa7h}>7u4Ny#A>Bf?LQfTq*$bDU1`b_Sxvt^y7{D|Cf?G!+jF02R&(v=t>~ep>9&lPvFewt&G93hB;`KC? zyXt<2l@|T{-wh?d<9~0+3k>kjpRp%B;Y-8L2Tc;jB`Wq_Su}(_BD$Qp*8y^=Z-irpXg4p-`t~ecSM>+-X--W(p;Q)kH z-5d4|{h)Z=oS0aUo{qwd2attq zbm(ar85soy?}FY6SlmwrQrFNMWaLO{7N_Rr;Q?MS3G9&(PJrw;N!cDV{lZ&Qce>3> zVDu=C9>CnZmOPo*0k>QUgBTYODj^fQqJ+?`$OK+;r|oI`!Ck*Uf1W>}!Ncqdipmc8 zT)_*(xU&sx^L8Y*6Ir<~O7}WiM5n+hG`$TY+)nJ@oDHlX?eUv84Px)w%+j?^7=+!O z42kc~Pqn@E8tcZoLJ%QB&ikBl#UE?gpZpj@5q1yM1d6}c%|%{aU0tFMkwUeJwDi_` z-;~*k_Xhq$FclB>E1Onnx0Z(wc(6;62cdIlXuj}cK}SnmYJoTjovgRd|BH{uclBQa znJX=)>dePFXJ=*~Re^3JE&G>RZQVMDOms%mt~RN&CXds@ z_MUf8YFLVpEpd+w=`IWs2U^Q14?g+*=migt88Nt}5?Qb5OwQ-=WCbE|PwfE%TpOF5 z?0K|86lqTczCN?&nI_}G2iqB30J6)lPg9d#A|IRi@_zEQhlnUM;AXENai})zI-|f1 zAO_MvhL-OD(kk_K&WF>GVd1w|a2{z5kvHmVO}p~4vY7sAn=y)`W}h({(7G}}3{6-9 z3PmmTvu95!3FMH~_};0Z1p`&D_$z8^tjDq?0OS#0O#;Mw`O(r{Ms&2A_LriSm7~F6 zVk@iW2PUx`x&Vr$?13Lb_GW=5Oa3Y@GLn`M3l60AdhUYcH?Ag^gve{gilpK$|*oM@nx{V7>Mc0H?F-GZl~MB%{mCYp9y5_=HF7G zchBXerEhKo4uQZ*M9z2}E*){3-szlgmYWQa-7}A);zMBo4|JYkijMjxt8WSeXS>Uw z%x>=J)SMSOY2 z|25j*UoiP>UqrqFgtVIH^qdrDVZlZX!u&=MO@6! z01JO0-@YAw7eN({xX8rZ95X#P%aGO9p8X!!Oeta)1U%B{Y!=O8K>8XL3&6E*Tx^b( z7jm*O(fEz1f`U_P{ci6e2rP_L;<3mO@}W7J)v)Z#;~PSU($B2E*Nyva+G08 zcZ6*3$uK`u>f^PjLmuydwY$5!rx8=t?d^2H8p{Zx@sPR=bvDTtw$r`peP?^H^bA#8 zn||zkc|gxw`lAcEDT|KL1U&fgQAeOyDqFDY*7XwBIqtAFHhf*h*w`?sJUlCsGBP4$ zo1UE=w&Fdp3XU1bIPnD=Se*vOi9%T59u`zx2EoBIBkEo z98G&u>j9rl62K0;kW`SOq}T;gJX5B{zQWdOFeAzOY`)bXBU*$0m{eE(@50A~a-Tkx z{``qrc9EK|HuIx8SwMn!)vj?*xGEZLT8Lr8KKg)8@X@5y-Rr z!BII#Y=jTW>ZDlfN5RX&WCkfaOqIaWp#zD$&q3=NeZ(uAF(vxV3c&emnRy*q(D()( z)U@#n0ZajVpwIAL;44Too;%EV7lv`#7%#2#|celj0;t*1j{*KE(&o(?5Nr`t(=eYjkftP!={b zh>3c9;%lLpKz4DlA~m`e>ODQjNU4if%^(%t0fjz#4w8K-BKVzZ4WT?W$xWl2@I>Qp zJHW(FEH}_P8$tZkuDg11!>j`xTd_swy<}aUc{+RxWIen`dh63$MVe z#rEsefMZ4>o$wANpi|4C>pbLE(Xz0}jv+!kmVX%CB<95hu<0LIe`RF63zGe>h-N?B zjN$>WSnNmyU>_N5!AIQ5|05at38orZT8zjH z4M&5(WqI2D!Il8RP!m&8Ij*gkYHfRw@yozZ?71%27iDN?=+4ti`gTSUm%}$quX$+S z2m(nJ*y=DoKHZpLnbGMOH%CqUXMp_NwLxA1x8C!b_ivY0>%3wrrnfO&m_1y98) zM^Vq?;;>dazd%dgfg)MN>jIDplm{042zwUlm7=0l0e0%2jowV6d(kUXYw@ZJxvv;*SW)>DEx`$sq=j0T8Ioa3u7an;V-1mrJdcX^RC)hyE z1`y@}gfb>3lg&uI&%M@jya$Vo$%u-^YljWM40`Wq=g#5q^OMNR%-n{Yyo=ViDnQSWQCZ65sd_Ve{@OTm9!048qIFV}C_B*jy?T7VJ38~7qJ z5z*pQ9KunlgG1fjgM+l7_0T%O#I|({I|sn?R&{!a*(m?_KLOO1V%6R#`w&u2`w7&ZAXVIVi5gs&>2i~`G>#k2LBBxBv^E)6VrC5iTWP=z(OAGY?dIvB( zrw_}y+E6`d+fbjb(vGP{;UnJ(NZ{$Zffzd_#QiS=&@ZvdFCO$_x&3`2g+**}V&X0i zfKoPwzjUC`myuxz(Lbs&;uY*81oq(2VJn1$nv;vL0jB3?iO-&r4#!H%%l|^x)twb@ z`AjS;Nf6$E1@hjORtyOZ#v%&*4&b4b^jd}hzF!svd8-~&z0{wpX`(pxdm`T__qlEBd5g|CFTk=1A0)um!vqx7sI=mm8z zv)k4v+6P#7BvpUs2GQ0xN&^&t+3e?S?>!0?~1J1u?RHmj7Qlm%4Ko*rfgUK5~3 zqeGpkD=I3Wl)_W>N{re)t6d!NIY~(`pMpR?-`?Z6cF{~e+=@ON&J`e=9-L?gs18;^mINTEmUqQ}3UY_v)yo2+MHAM;cxzgc>bv?iQEa6tc#5mW8 zJ+4pS^QE*y;8nW{>JR3sbGBuy1w7+`g9G5`q44?0;3gSiq zDU&uJ#^v?uvSY&1^C-^M(V6r^`mM$yS6xW+ZbEo)H3PtT_m(ptsU?uvUYSauumF?| zaf8oy;KdOnYQPOW>w+Tvx`B4dbh^azgnepsl#C~r5t#Y2haQ*|FEY0;1TS{v)}8r^r|0jGONhyr;{fWJS1vh;n@yC2>ZVbsDW zXQ7*m3jxB5jj?)zt4Dm+^ULSVpH8xq0|R9q5Gp-q_8uSS2_p2h8~=Mv{$qgH8zti{ z&dKW`4ivvWqd2?U>SOI+Grc=GA1qBegYf@t_f)8Iwfl9XvAjEIJ1D-%{ZBc5uo(qx z8!FH!-kHr(N}g2!(1EDM_yb#8YB&)9HM8a8C#tQd0OUH_)e~hpO8aY&VbT&(6t7ZT>Pbw)*aai@1k}n1O+ctZaU5ZC-||gj7J#-dt6o)>ty3 z3~4hy>B#E#-7RAY%*CY4XyG*vmL=usis&M&~t4|`Pi_kn*FuzxesS7ixfq@|4{?C$AVzNu|P zFFAonL111lwn%e~WB_dk@O6ZMqRWrHL}Vc0*9g-7@1}fwlkZ1HhEkfI4MYA9zTP@6 z%C2i09uP!SL`eYwr5mIhlKRY5cGKWI+`h!OjOY3q88Es(SeVo~98Gn{rUs;dHPjKBPJn|$ z*2KaHP|=A7C;e%s?Q|jAOCTh&v*tFtEeA61h;$$E+avsICSv$KuaMih+F0~{&JUWi z%_2HUam0e|AH3jeE=O@Q&Gw0ZOqq3SLjvJu{b_R*8a4K2@T;T#P-quC?)KAV?=$3x zbkGo6q6(R)^GFwd`R)nJ*{013{jJ8E=T89SD;-k*yGDrqZ6=HDb^0Ug#B^>8`OMHZ zaK?moe&HN<+7cAyy^W7ciQzZvgX1yYddIL&iF3L3R_^aVsH>KimL_C#bS*g-!haL@ zu06;>qhyXE$QXW`4ODk1kY%T>Q@u+`k(+D@g(q-_?Opia6k^R#PNCw|1lBg~1T!^1mbPT%tQZX7|c z+BK4=YwX*%Z}H3qAc5qZ+g#sdHQCVY1A6DSwqU$3U%nJSx?V%43(mW8`Y=nL zsxnt*y_Zv9w6nAGfQIhwWbFhhrMh}fJd~4bu$4-i-3Mrh=s5X`ozi+!)=>ksw2nLtE6}e^AQ`;O!eCf>o(W8i7P@B5- z@5;#%b%t&^-8IsK#J{_pU5glV8cm)5mfN(lw3KFEKDuZLOW0nycvJpsc#AyJfBcN} z{99u5<>^jejN94fRG|arC61fUd`Xo`rm`G+5F%wq4KBxSP{+=mZDDCWI0F_D5CW*or# zcCI|!ugxdh+v^OMF8!apSUWh^;oM2#HTnYBf=IqTkHE2_`XKb!d|hoBY1xj)V^wIQ zS(ag@aoitMQvUBJKW-U^4uslQEdL}5h`CD1%6@qJmMl_2t2y$^Aa;iJ^r;kc zBCADZ4yJL#X)$QyQ7Q<)ZZ9t?#zjpTbvXXu!lP4~F+E%zV)dggINrxkH0((N#Zc4+ zsLAJx;SROTe!)F8nJDH^Yontb-E_|9LtHdXb|9u&go&K?=jWtwyPW>na*}nqz2302 z>tGK)|QdaLh~BLE4fK7cbUn@ulUmovQ_`nn(C? z*FPRYq?cwU;h_0mtXA|pv&{{UIq~fFrXwO^;NBHxx`5ly-jSHP^Z6gb?rV;~kuhYI+8h z4Ipk0Xr6}zjI!61U+%P|BFJSkgAx5uw83%X<(0=?Yg;oBJUq=S zDKRlBJ%`)YCnz{HIt>uE^Ua2aWb9V>7MHLjU++6NvH_~P?R?E(U1(^i-woM(Q@k5= zuhwiYGh%_c4e7(VRZ+cPeQ2y&HH+I-*o}LEAgW4!2xacO;{=PkI3QkiJX{_Ws&|AQ zM5*;gJOltXecT^%$V;J18Mo7!18W_p**Z5NaGV0T+`2`-i5G)t)J*-WPR;4X5%u`D z*@c}QA|fKNM>jW>Ep}(LKsOa#m?V^N-0fzwxTN3i z=-lA-9pEiiw)exy={+fjB~9eye6pit|LMkT>@|P83%RI!aI^D6V!`j$-IW)BLM$cU17B>Xw=WinW(CBc z4-?;+XtBZ3(FjQWQSB91CtHzQ1;OzpQsQ9=Y-T~a=!B41R{JqkUfl0r;^M|a5>?iZ zvvJKUfpYdf^2>XOzCnIyMS1za@^bUL;x}#e>;gNwU6EdI-P|KN?{cx$3DVo&-FI%Tj!Y=#)2wxe{2p8e1Tv62Nb{0X zbYg+_1N7qXB8sv|-P+h@taEhLIvUODKUlzR+51-@TzW0usa}?`v9KJs#yq+htWnJ8 z8b4*~fYjZjXDsdFZ2Ey3fMTbsOYA>=q7srCoReg#{Hg28$-&4m{*ab-ezv)9N_^MN zN~2WYVbA8E7zL2Uz5uhESz$XpWYpEvLWK3G1=7Z9pQoqDl);BGVLn5XzH*M zwP^t|vdwuIGs$%lp3CL&$`@bQhuK4FT677_y-tUpZ~r9iLP4`=vg#_s`0o}MNz`Gs zL_grp{ES(<=4>At`U3e%;eg>-{W4g-QWz40FVHE~cZd4D1)(kiGn6OS-{HmWR>oVd zuFeIy6n-7v+ftpHu@m)2$^2-U-w{;mzOs$)20b{sQqaQJf=uJ%+R`L`Zpx8Rv8k5?Vl~zEG_}E z%6{y#9HS?9aPM6qm(ES^F~?yBgbf}6Uqt#f<~vk#vCs9%kA78*@|~{B^(hYoIvu`$uORPyg@*~ zY9r~Szt2&Lw};i0n$S}vTGDj+EGg%FP~~_+GJ;T92?%y3CqF~HaPpMcd0quA{I1Vv z6eTm1e#`zWA2RSL19}(e%CR1xNQ9-t^$xLkb}lrdep4W_MZ+K`NC`KVP*&M*cRU`WO_qJU&$I>;gVkOHM4a#f|N9u;S4Bibmon4V`<;JTPZXahK62HZ?bNy>^(Os+-udOmRP*yDc zzD=r8yKaSPEG@Onjy0Qw_uaoQbj_EVkihM_&Ay?3aCzxEUNW1fG8daKN2~+n@L3VI zIf{TtSgbv|(uPKAI@YsgH0{_lU2Uhtz={{lP0~CVOfs<1bBvpN==rks=br;@f5Z` z%TOHvc9J#i++2kVG>acL!nt_&GE^T`Q7i5N8He?`Es`^x+&g8`82Z3 z0MBR7o<$DDO3uHEG9~49vElmTd~q0>BdcHKk%pj)|cVIDM535*oE; z%zTqKI#+wMZnF8L#qTUFd11RyvT&(raRHahsJcm#XY&;=9RNZ3qJ+nGjy=gcLjQ1m z@Q7SOVWq(i3hh1JxuU4BQPa&)frRMJ%QH2(IqZy-6&pKtMNJB6SGb0nM*W3+`^Ip( zH!TZ^Ny@_;|JBOLM90oB&@<8aB{L9oRPoqr+K)fJuKI3dU-=-0H3ukxgsCt|_|YFf z22Ek}!HK>;zZj9+193u*LZ|I@rexBYeFZr=>)*!*Me2-Hl+xbCgP9&%MiNfkylOFK zd~eB*W*cq`^%}&Hwt$mXf4Y!I=YnSr1;{=DU=xfB1(Klt`-kM2h zqd=MVgk*y7=~Hk}VhF*#GBO5$X8kFxK)25M7Qu9cHgUsZajX4YL+ghmnEOFH5)?-V#D$e@f~m=&^F{%|Mgcqi%g$~JfZ*Rx z&Tb!F*<2rzs5aFKM^4_FZ@}tkyr84_sbEFB%Jcm{NSbVBAN6FCHgt%Mof4w>p%Cu~ z;nn3?%K!h;nB5@>2>4C-|HI3-miDEWrp3Iff_V93X@hd|)A)t+>)aaxx=tsMC?S|j z|8gr7({l1Dzyl{7LDG%C;K8w7TKIh`&rqK4|FEMi-vvb9U(rh_#*&?KZScoy=Jc} zE6@zZTg-Zks+EW$P2h^;Mc3^#aCP|hcPjIt8^hs`E6S^axUD_V3C%G6zY(1pd=eto z_l2#lx>2OD|BKlE|B{^$zxFk&!HLwx#p8N^aP7uz4=8bv#Z2D6-(FqSZm&W_O;ET} z9)jjwbA0@ngzY@gLG0_r7_iUG)*(K-QBNOnKgH} zwiGfb1~P6yK?coQN#d9tFVV8JX56xH1@P83kBkMApYVl73OQ27yT%l8CAlZ@fONI` zG9RrG>9evApt*QG)GB*F3%JD=2s5YI1}7-=yOX0MJ1Z-NqAEIiprxwHi7}23ys`eKQYLt z1Dr}~mURCL_j{BU^UUaom@YVT30=nfG)oF8YPI0I&@=m+Nx+w7$UUj^8bD3Le(jTC=49K?j>^bZIzN zX(`oYbg|r<^G1QFK9}c1^m<oqF<$9NSQH-Pu#ws6Yw zkb483j~KFI);dzP(^;mIt-z6BY-D7a#gZe1FfJaVnc2O!qtg#o0-zxhu(>|}tjy`S zUCPANSE}FSan?t_S8>jy+EM$dZ6k7jHPqj~!wp%ndhz{|&1_>^41gmgE0j<&M2Ur5 zS9gf|0mkpvc(qF%M)DzTVlp>ia^b@d zs~a61-5f1e%a@l!77`a){(R4sS*JNJ4`Mm;HEv7GrR5P#%R@mdh;(=^P@ah{7pRy` zSJ{e~fxZ-_7ERL>5M?^<&F!tw-YP9x+7}mpOX9HN_iQ!S)0+jtsB2CuK|MkHv5`pf zmSuXFLD}W`!LTTQ(#9*U8alumYx;Pr{O;XTz4zwknwej$Yj*W6&-RJ|_dpmG5a#wy zifVvD0`wM0xD0))V!NS+R6uf|+ zRV;B?W|!dOR*f(*Nouhjt(QQeXVAYU66dCWex%gmvKCvDX;89<+}(g21|-fnaCc=p zB_Kle0I#E9{3}q^jx~l?rgLL5zR56L%^07S~{P>i6o}{BNDPA&%>KuS@do?!?4Lr!Qh2} z9T`YsnLq`eTM&qT^v(EVkfXH@2H^%@9sfing5)Mc`Aq&63w`>vdTtwb+znS zOjx)=PB_poRo2w>Ty6ixXgRuYS0+L5$3O~S`Ehwa3}jccbvN(2D_uY%KXZq}svf`5 z8gRpJ?(diz0==JDt@-?~UkL$1T|SKpT_<|y7pO1z9P@zDCa~PZ|DGgC!|QlGf6qVc z6>5h0LvA3QSGqoulKP&o;)s8YD@L9{JOo!Z_^r(I(>$Yf=qSmRJyC)ya>y3ea#F! z_tjLFejRTMR=PdcHv_Zvfm_sX;17bXY=-)W zr@_sAf%!BEj~&Q*zf_|oB_;tFe|I2LZJdet0feTYdPh4sA?eo&6bbM&=z%!$p+Gz=%vlTR0P+8Ub;2T8{t*ENX9tyuY+nOvhO4d~f z^vM!ZFBWpTwVT870#ghKtA@UmlhEJweK8}!rWKpV2j)q#M^(Rt3=qfsdIxc~vxNKUqd;0IXxH7@xzJbqsFH32XnK!!LkJ#O7_M};=gX&`9h?>!l#cZh zV4q+do8K;u?V#b(PXVrGmPs&G@8!>*nL5?GAn9SFqUswOYBcG|0|&ZsJ@-)ReBD+= zX(_3mf10a05Zl`=N7(4K^EG;OGPUE?}o(LRW@&HAfZZ}!l8~$Yp zj{NPyK02_htf1g^=<)4^S$vVq3b2o<=!};Ssa)8DyBtq!9E$4cc@Yus8L}nhr^|b1pF@@V!{x^GQn`W}3)Hpuj0 zg0Jk0o|xR`N7#ZwM?0Vlyjf@%2oDa8cgJso5sRuoBA+Rp?FwX74ccCJk0wJ+k_0<( z;WF7*Que%+AJ68p&qy0L{JV6G7FSL_0ZlbmX*$3U0|Ejzr)^Q>gjvSJ-7j&3u?hC( zTu1~fz3;Oft(JlY9*A-(4UP^`*y%#B)1W*^iC#;S%aP5ceBuhQ1FEr^PmgB}&4Vm1 zE}GwFsWUpcR!b)r0?B12wj4{bc9l;_ooCw^IZusj#dnqlmo32xH!QF?kQCnm==Tb9 z`+BefoZ7vy$ocZOS6$=bB+Ek>by(hLm>IN?yqO{mr!K?!a+9NConNuQ2W@e=3ntRm zs-mKd{uS#VLUUYd3h{5b7rB|4^7$ZJ9A@|-MC2r!&N0~C{l#nXr(7TsB*O>Qy@@yG z_mE3^ZYDmq{psf9%GjTZvpxL}LzPIWxd+M6V0WA`=yKD)dTlY|lXwp;H1hxk5*oN3w=`HdoH>K69^Vvys$;=r9$|*Z zTr%|qwggk5y5pPWv^=W+t}g%GV{^pi@_cb6xt9Cjr0wSNgy9{`oM~hA1eUKrq|j5T zG&?hMycw$!j1Lz#V;f=vRh5Nd&)6(G;p^8N&2DS0UD$5nq}XT1o@cu^JA9au^0M-z zz)z$KHjl?UI15S|20g6AibiG14q%`$ydn|4?fMZORTlV6Pjj%jwe2e2@x%Og^S`#R z61~1-jtrg$rle0u1!?EXT&qg;1mv9jCPlLrQjus{0Hv?m`l>;zf=bt#>5t{)w1#H0 z`=_~4?HU{4E16intPfEgEiBm@E46bfjY~}h6`#M29v{fP!oBEtJdZ;tZqvD4*fDMc zokOI~3_OV?AAuAh*^Wt>Ji~e55jYc@v-6n3e0*E8=9Bz#)fGJ_xu|=|7n4=16nh5C zU7|tfX)uB9hfqZIE^GggX3U1|e6_WGUgYVGo2`z{)=zJ@y-7E2AaeliRH%+%y226i zYy;j+`KjZM_OR>YU&j_-Nr|yV@Pj(zYhb4-wPp+$x)#?4!b^^m&)L5_6~)PMT2J9V zBj7QX6ylW$>KGpnr>(TOIsvh9c`zHfIT+8NIT({?zCARhrlCe1S>t7e`LM*|&2KVvH$^ zAr{=^D zuy{_Nw&cb`H>O^Xt51IUU^+3r`X@OlIax|*w9JT)SBm+<`V@*xm#wtqT58Z#Rb8F* zUnV@>jh=w(x$1wI@PGjBb&czCeeKQ_W3q7Rj_cy?M!>~jCozUK6H118%bZO%4|!`c zxT<)`ZMF-?>t$Z-N|B*l4}Bx?dE6$&=Y24 zL}0y03A`{H1(+i^Qip!SK@vL*u;F7W8fOvkin2E-W3J#aNLil$3JtI^F&WJ6a7yJX z)WD~HqF$`6TU33ur9r}udHC0x*<*HKKVV713CLrimm{}8opE#q-(G`Hc3ch5xb&E7 z=UWW>0dEKgFwo~R3nbDxU16YNOcnI_tWNpVSjLl-y(O*1^84t~?rFH=ec?bcaq&ON z4cv5eycDAD`88!r;#d$`1Sd>^NCHN}ar9UmlrDO}Kx9oi42VdW0N~}dH=sT#1lLsu zwPhqTbayZtd)U0uW_A@YcUkb=K#q|n1WQblQ+NFIULE+A(yC-6B1d6=7>?7C0<$&d zLc$d?UjH3TN!z3_8=%c&asEhpdA>P1?RM5)LkEUjtZN`$2N*MWDUtgR(eRRcbD7SB zobd>d^u&MW5kOt<;5ONu0f#CD6&1+Sr=b1E@KW8$M6I2{w$l0b8&VJaz{Nc>vty*g zlZFhb=r`WK=G&X^NmvAf#w%D@(#! zJAZjXA4W1+_1@Q+LJ|y~f}X7}ubT_OZjWah;2@$Q5BvVL>2n}sCl@A7W-n<0y%j%< z&AfaQN&TMDm7b?q;iSQ~1(IyYm@S?-<=c&n!)K31#WzkWHakMt|@bL&!c5vU8>$uv$? zfizX^@jfPwF*?|Z$sAUa(xGv9W@jZW{^1GBTU$bw$CdZ4YHDk_qsv?S>W;nom+!{^1B?bIY?MO z=DeDXP2r37_wk8Lu~#%Q5}EoAU#tHEMowhOl>4?uygN|o9q8kQns;r*YaH)Z~F$dd7?{Opt}f)5Wv! zFI#rOe}v;9A^uC9gg=j8L};{l3IK4X+F_c6kVitI%HNt<{0Q);|3E` ztbBT%aQf&wtC2!AkAWZhsk|QJUchlvk??wvEU={Gik2rtogts+DvCYwf(ORh+VB@s`Cc;z!f0p|Kb_ub1di2Z& zRLylpz;}g-NghCBMwh3<{niUV=2H2BnHSR8+zS2bFHe%sp1k0nE4K14GxD>ZE7v)+ zlZopc8!-l+$IE|+6q25p4>op{%%W}N8w3BO^*%@ph8&}fxTQK3b7+y3BxqZw$BWxN zaYoJ!{I5c(_H2P1IjA_P!FhePyk8`KTN5HyRA{^fr=2b9_GEzgHXXJAZ1M2O(|%^A z-Knm@ZLP7Opg@;sXrM{!h^a(Pmyq*o^cJmQHBcqmV{DECA0AX%o+!6L1u%~Xn4}45 z?+jDJ^_$xJdyy47{(}YRb35hqBHK-Z5pvZZM!oYe=iCJQPGTD8ee>6@t}xT^8lT*B zC^aSN3`gx^yjpVlbiBS1TXf6H!2uYM452AfF2J)pr?j+Z+XaZu0wPrqS!SZ|aQSZKJcDQb}?5synM775{6dAw<`z00T);AH6ISeejhZRorjWu^e{X9pKw1 z%nbHC?n^9#S>Rb)YQc7)3;}$yN(>j^WM)BcK4-?~@PUtG&GUoF#%NIua9jGLODs&( zh0AiA5AJ;P*WS((ALOo3vWFX;gDL=KGa+0?d$O%YvIN^4D|XYV*)h~U@%{oxwTtG4 zb2An>6gW@-1czAQ$jJw=vX@cSHWS9gNK(j31>~VVL2H6Y-wBKhh26W?ca91kV6+$O zaM>11atQ^8D**mI2IEM9iv0?yT}Y@woS-|7fs(@EUX=3j-rGSGG~Xuoi?{#@lyweJ z$0!T(@bLkMmtn5AD$nEMfWvI{3pP{D(LyHh49?&gq$S3^io3eHmqEk#d9ub!NH>ij zuV_SjzJGl+i4CP@J&@FN8Z_T@;r?xg<~AAS&dxU*8$9#=(=Dl?K+VXpvs_@`a?7iD zx!UwlQb(s@fvF0hRjT_|wa)x=iEN9_zOME4F6nA{d+z}m?Px9jYT9f3o7T?Eq(}}9 zjyrgQuy*B&?~ac#t`nXU``k)Zo;xBqlDzXvDZ2N zk%y}Ch>ueu_#YX#@I{?kaqDsXM8#T!F^vNa2B>s)0G&toeW1{3BfTUHKP_z!ou=R| za4B~oc%GQ7HCge1UXYGvyTZ)wEe^AQRsthJ?dn+VMe?HyJ@WWnSojmuGR@=tF)mhe z#mkSc!0CKw|H*-h0na)#2lgKA@e2-FXLA{lkst*?3|7;h(z zB5s-dZg|n>eGelKBq21&0{5^>s`a7HFA<9KNNGsSm$>Q@Uk5P<3^`;IWB}GcR>lj( zhNWXu1U(M%10~hf$NVsKZ09`(ignN=(5?VW`0-{mO{Oq5r z=ARtuc9XM^&sK}uniw^*^zv4uFu)oiLEgm$V$=d67rRFfA;J%dA0fbQvi6J@99~g| zE3bNZzoj%c_|hHH->!?9On&by)tr)c<1)U!HpzUMuDOxP*vn=nOTn|Jdc2ZN-hMsc z<<9q@1$to`_To~BkB{%x9J|ZuRoF9oPtAQfn$Kh}&=s2(+_8kUfMQKYk{NP&R+NxW zfl#%1VLdq65)NQwqh-h7#aJ3_n29Z84Jfk zGR@v057w&O)?gDGlF4UaPHK{C`P%kX6T&DHNYNx;(;$>~-G;j}7J%bwWNj^#fiqI1 zu?2YQzQ5riw>Pe7S7%RWeY}P)>?mH|YA^w#QAKrt1ixnhU+<`Li{}ooqSUR2-fRrV zr{D9wCx}K#O^HoI2Rw}=(A@lkaFaN!Cz?g9>Z+^C%BE_Z*bx0I&>#a%wcfkdT2MXI zLV+5L1eiyt6JrQw0-l4P;3Z6%fe$)*WSEVqPBG2|McoZaA@};?-`$9n(9KUtfBfN2 zVDK6+`Q%oJrb!VW$S}Vvy#Dzt;@Wqw|21g!KXMD8yUY3a{r^3`l?xR`jiN_yG05GP zpMWdo0MA3Lq^0w)xUjQEiNRV7j4tw!xcgu(2!Q{-{?`Cle6ZZUqO>S{ww-UreE1|V zSZ?8fH8d2XNWFNZ%t!_Z#U|-5j{0|i?Vd|Kob{V+lPgESfg-iwZJ&5Lg2OH;W0Z8PmsYG8e>FjY`QTwoGhOY3AvlkDol&8 zUJ$0#ZSf2bWFZ(FEQ1~%AE%v6JL~E3ltbEM;^M^5eDEwsKGKKc2uptCR%uCVE`fs2 zfY$>Ah+Eh;GgE2ZU^-e@cOTU^g~x*j%r;^P2==Xt$<`Zj9BNj!r{?ld*hk&-rVq7|J<+NR1d`7vav;1 zTPMefkE26qcrW9>w0BGPFIbY3qbmyLCR!1WiA z)^gv;sLAeF;#_+g3k!ne+qt%7MOLh=7Cx&m8@NiE-n;fDCx4%SBmC~&dR~juBNPL{tY5#3d5f>xU{jTY zr#$i008CyzFFWe304ornlerdObWirC-4=JI&nkbcSeA{Bw!I~PvMq%BjAXV-M+X@a z^!c{8%ud-85Op6wV$hCXcz@+Y>`hg9cXuG8RFLXJJpyfeszW}f)57EJOAMKb57ow; ze0)F?et(CrGKaa+@a9SV@$BbtSUJ-`sq$nRTU^eJf8iP5grXcxzluCRl{Nze=OREF5;eaM2YF9|R^% zdH~EwutKe`YiO{7Bh4DLoEr1KVc`>h1l&o?7U9pxuf_{k(vp&z4@&jexrLzn!2HX8 zVtWS0Bp}d~c%JU*cXR1bpq#5L>Kx*~CJ{C43OnB?pt{|g%l23jcOVgRark@fP`^A; zW`DG*fcMZBAKvhDWVk}3<35JveBDg|L96g0z)z`eZ3n@y;51@$-j&Y3FoUMUjG{UX zr>dF`7BtAiUuV%mU=y$ShM!R%z*TFog$?i2VpEs@)nF?UKKrYrQ$v#Rs)S0yetY6o z?rXf4)?N97&7jzAZZ7fjyKh|?9vaO2?go=o(8MH==rlS?#c`Wk_EBr8-s5W_)YVN2 zpb&eh&B!R}R2V=b%7!A zia;IgraPJzumtN&|5#Qk4*rajuY8K1{FE4%USD}IZqnsaa_M3rDhj0AJHKClMLP>|+R;L^WnMG;sUbXOV!Y?`g$a#7gVLIKH%F@NQS(3(L?u70 z+lc|$*3Su3QyR1j9(G7g}a~ObDG61vwh3Xj6_oDtT}6D4=qjf zC8hiLLiU98gM9qf#s*?EFb(k8Y=l;%41lSyc4%=bsAc%?K_#5~Tff{V!XR zPCr4%(ezvJxTAp8^VO@tDgDI+Ea*4<#glxA{wHAEgP`CoG>V-SY)84&@&x(mY7&OZ z>V*gvQ+w0yQK$i=snq(s(j|TOv5KQ(x!LdQ28W|J=H@6}7NKVc7~J-kL)j9{0sO}& zCkbz5q2F|gczM()cxG?=Q|cIip>yAJPY|eQuKSza$K#=2GPb~+gV@+uT>SWt2QA6a zKwn>4wrbKp&w%omMSwB2nK|!~7g+L%b?9=<0}PK-Z;m(EDrTmqnfduQuP%2ITMMG$ zxw(NA_W+O%4js3%ln0o>6p)PP>>M14^g-~N4|1SwgarJY;89E$^Okwvii+w(e*t!w zY9s?!Z_kr*m4?9&? zFz~NSGq^(Iy0jeo_PMm*j4(Q%B7f+6ik76t?o=`3?B~Da_diU#3cu_WgzXZt4ZQ-5 z3PJaCh=dye)Ngm~@`TU4+spGM4?dN9!|HLEg3Awjssd}-umHIbnIh(#MG-`K zOQX`}cj8d6-TBuYKMo}Q#^6+ump`pc<)>ry^Q-Uc!{u|dT-n`~Ue2`5INRFybaWis z^wnqG7qBMYL4A}rG|R%p^`{SS;2an?)V}f7zPWU|8KlY<$9%!^9nFK$)E((E$i~LN zhE%g43L$SUR4h`_zZ00q542L_*Z##dYCn{oQSuCgwVonJh*_K=M<}76B6n(_@3w?A zJ(kwt;5y*Qx}!VF{zG?MnX+6-`eW7~=q46uH-#Hm8?2apvK41#6@UE)Jc;?+x2>kV zy30E|Jt?=Z;i_n-dxBQKHM64r&5tu^v^i~m;g~D_WM=|?7ddX>nc*aYxq}?d4#x}oRW7H6l>w{u3%Y5W}wTuZhsR1m?z9Tw?N?ggWYmT#_YZ8lqR!kb1tU_)y z4Wk;cLAS6h<9-oGMVL21 zz(7tyqh^a))G(mUGri{etn{4Jz~r)t*?2{k1koCb`JB_cxLLmQf~Ta4XZK_S3D}cD z7U=Jay@%wTSm@>D!_ABlPq;0<2p5@R@0b$TRV8Deq@Vh8+-%6zm6L2ztv-xG?sT>j zIJqp4O?S%i31C%b^AK2%u`?7hw6fE+w42!+u$6cdh47gZQhH@Z2Pn)8c!R(VS}ga@ zeC|5pW$@V{Q>yt=g<~71>nu#I<~yb0b0u-@sp5(PE_ss<(TJhw*se2I`TS+<>4_&L zQ-653*tc4T2FW3Ity-@w!T2CxJqPBV2m_WfV+}$?@5(wi&0I=0@1`IQ4wlRiwXWJ4 z6PRsld8vUYQnAZYiG$dBz|W? z^47j7QdemrY2%wVtc^o!OU6vX-v!bB9IkX)Lb-xhMz zTsy0J0S{#a>jYM2^zYtICIpy)*H8@9-M%Dwq9`VYt>Gyg%m$%(01V6{ycxKjM0|P- zBiLe|;w>*#UnU}rIi-)wAZsDp1s+Rc3D4tQ55DW~4T_!^^EV{Ao8p73LE8UW+qq09 zQ|MW)tR8diw)%*LFKh^)2k@f+JCDKy^`d--9y|8c1I(Zsd(*&~>+l`T*d^`YC2f&E zKUC#({M$#>mMkl3d9x~UUNP(alEF9431l5&y%8G7xi{Z06B8#3uJuv~fjxMAW8Y4x ztxAr@$~Cl*!11aNtXmPnKVrhCqBiajH|`KaU&kT&<#rSiCl$FrC#al?1G$-bKQ4=% zn;-E$gEU@$FH)O>u!6nnQ15eYc791Vb>d1b*Sm*WZ_>}_N-s$IUD6Z+Uj~Ew{m}U5 zeiM#;m-ncis%7Qbic(KKym9zBQ@KTrjcLvBcF9pagIS3n5RKEg!zVgx2R>B@XpCFY^kx!o62ro79bSDS>U8zr^92DtHn~ac&7tsYU^HJKr z!E!Wlz>hPy1iIK%pKfOeTcu_OizFlJE{H}XAN*QTYB|9_U^AWnRBxSt7?(_BgA6OeT<0qURjFc zTx52@83{Z0x7$@hfRCtrQtT|oeFb314N<(jdsrGQhl^Xk!me~y8mC{hkPCuPoxSg}D?nE15_C!PPn*#wpUglScVPQ{b>F%@;U%x$V0YOW$Ymk)X zbBqFkLP_vBf!&86tLCGY(lLbaS!^p?X3 z@`Tr)!7Yv;8a_j5tVry9X=`Lf`TyQG;|$CN^!nz^yhoKj8H;-?RZ@I>6{5_=NiHoH z6Dhr%Z@482{?t36#Y^?6r8dNZET1oPHz=#@SnDt9Jc(Zj`&)uUCl-?k0(P2H9Sa97 zpCB*aD<(c(ezwkJK`b5%-i8eaQu&xv7RtoVQuD;fb+HyxRCfx>U#si(h-Jxo_f2O! z`|y6dyDKNBAXz85cT!T-i$yGrp4{$xZM{$KQzt}%p-60r0-i=1Y5M2rhrj9^diS!B zeG2T(kQIlyD8L(v)&DHMWpKX|p^s!r1g)xd9muNVDuK%gTe-J)2YCjCg?fn%yJMMO zm4Fg0A$K<7yF}YZBLa_t@}0e^(voY~W-3w;Ycv}Kg=Gp^nOAq${%1WR)u*CuIlfTz zk<{e*y4CGr*up}5x0=g=O{efJ&N8PbWi1h_dOO`9QoTt*=n0!2ikaaHI~ohxwbLP~ zA{!a+H_;s5=$_(co4%6;S64tRX2t4{2;9^QtIf@Y%k?kIT(ZAt``3*wp+=Z`9!)gj zy^P2Ih=zlvYrPMaaZw&pEarCH@TiRLJ`mPq;L!7MvBjWy@W`0277P3~4L#6Gf$JVB zGi@t0x%+&LB7>qVz}&_DdnMrlN!~-u={ese_4u#%F^DAIh<9L5>ha9nK?-CS<*Vp6(om+b)*T9#oqEB((lCpX5Beu8LORV2F9OLgt4IS9% zvydxiwvP1YV$B}4o_UJg-SY2S2*j6)j!vEAq6F}MqlPG=LPMtUF2vA(oUt?PR9do+ z?XdiC-Xx7nX5MflkY=9{mCrhk@zH}l9nDaycpVAysKmXC5O6VJt$)79r<=b`?~CLm z^=V6ttn3POoVoVF;9tH%9N4zVj-4dyzG{;rBFVKbBGf}VeN?g|yN*Xi3Q#GXGYc=-#=NB<{afFJ7G4oO=Mq~gmI0T1MxJcGVMiZ?L=u9caH?;;b` zKVj6{voum{eK*bBHJ(Pmmxix+Gc@3Oh*Y=hdLLw&5aA8_|Lxw}{LVf^ocX0))&Z>h z;-2Va8T7bnvx@8x3*88wluC^K27>eaf9_=nUNDY*$_Efu5QESH&c0oD)+UA2yz0#5 zR_iLy46&`~On?6MWtiJNo2D^Q`GRqUz@ezXa<=E(ft?C5aaawmK`gJsAT)F-xY_qd zya;2m{oaamj)6q1J^L;ZlVp3&$+pyPUB~(CfKB<9?;B#vSQ4t^R_dHcw%Dh2NFUdH!?4kzqNZm=AL7obuFFD@TSwHuK`BhBZ1$ zQe;KnD)T4K*>2oL*9jIp5IRJ~#H15L&YGeR=A2yurB%^)#hYy#&95T%{KV$e6hFB( zb-WYxBK=!C%JG~v7Tm($Xm{2na~0bfcgJZ~Mmzqt3kG2VS|yz%xQ z#$lhm*?Y}3*IeJ6^ZU?S!fgG~;=xHk-RE{0v|c|Y79AN`p2}F_LH=OWQ|?T7cZu8l zGL~sB--74o6U%>K0jA>MTYpynPSGmd)EAzwv>Nh8>>h|s>Z#flzq3`!7tv3<$2#$w zlDIE=7vcT#?tr`!wqoe~oWUffygLVRXKkujg$qt+m3kPIR9@aPjBJrE`VRpW?R71e zrwweEkM_FB;CW;^jrQ+He-<8HBjJdNV`pV|l{ZWcRMC+K=9@7)zSx~vjI_N3QEOPT z;7qSXw!LiPSpDcxH}`$u&h}upwjhT*()Xi0-a)F-11x0>P8D}%N-M_d;={|m%hJq0fM$XgvdNF-PAcFsf(6; zXyyxnF(b?8o#AE?$+KvM>k2J)vvX>n`Du(!4eILNNMut5*Z8Amn-a?$qp6QR&0Qx% zxa`qeo@aqBY+y6Pg05ViykJ}r0RTutW0?e)B0xg13xo!uU_X6_+Ga; zgJC-n6-pXfwl_GK7#f(8JkobngI(TW=A$DlvEjCF*%6dacL^Da8rsky_)$X5(%vC*_rviMoq0XE&w-O(gP zb);2grN`x@HB_a?1*An~XMV-DUlFOC5V7N_Jp*7T#FO-|NxDk%*Tz(&;c%HKTZ-XE z8D^RS{aIjf!@wCRXZ+M}B>2=JaY-Cydi^ATG=;N--z7UC;n#2NW1lda(KT*wyT4iI%OsXubB{(eSFj}@cQ7$uXR1RNQ#SwMmC_hz4c3tmhYKJF-}aEJ(I#SlPz z=iu)s{?@g&A3eexIesu|_h94*bM(PCHu`N=x|*NF`3Q^kY$ij61O)etc6a^($`zpU z3R#YS2qhMXa1On~c>A7t6|p6N$UVkZ{G7(ZhWGG8RAi(uq?=&{!p_o`^AT~YJr?kI z0fh<_R2NU4J(GZR7o?mX?-)zK3d#?rpaA_Ux2rXLI4zKm%~BfTz-Oh61wO4Vnl6A$rHWRrIwb z$=_S&h9Y~~`~tEOr+65MCap=hR@Ju#h#cb}%5m?yElbuw>M~n?3@AcE3^y>LJYZ0! zrbKTMy!oEVg_1ERK#(TY2h;^WAZBLf;_`t|{{+8{yPF@Q)&&p4Ks*Pi{X_8RcC-9* z2}cX^N;WJOd;L@s*rnVZKm_ySU2*e|Ny*vz!;imnlp`pEYV6kKYGoMc=p^m!?UDCN z@86jO90-=$jrDfx8=zcv?J+TNBIc$FtI3onFeEO&QaunI$N}2KU;hEVrWYO!^&CF> zY(v5P+-ALm4Rj4sK>`ZIBQAQP`^(EpUIK&O%d6%{4Pi>s1pkqc_J%~#v8GHFUl$QD zX+vFZreK?IjK!Snq&|u`qamG5R!wPQ*s?s;$lutpINlu1Q!Tok4)*ql0$Ra;kAJEuAZ8pe#J|V?Sv|x5-DQdHKyatGc~0wj z>!I#P*Ly=ejEtYbyuC|FDPp0Vuav7WIa4pu6AkL?GQ%|Fjrip@sYEfnve|Z$OHSTE8P0 ze%V2~IZ4mltuQ#Qlq2#`5A|c?oxodDRP`%JUUM9SfA-lj=R@56|NhN;J#vgB{GC_zOjp31Ia=bqp|`P(skN z;#_b*+e6n|WJt;gVxQ9nv;aWoveY76hvCjwIFf)#)mo@dZz*VnyF%@GyB2>J_=N~C zq$%&fb6xB;b*M>l4oZoESKO(4_)(N$KAt0qgoG5~Gp`;1H%cAtNTPjI?03Hqeivf! z#$&98Ffbw>6oetX)EOc0fO7OCpu#|u2g|t&+4FHp-?_C*{NWpLABX`0dTu+|IM%p$ zClC0=A#y?v&-NEVckJzuJgDm8^wjf5bZ~0gYXU2i=={py{=t0>d^Kh3c1s5S^)G3U z|98fC6?z^Q!QL^awq4h9;}5i%X|yd+^m9_L|i7Rul)}W`p}PLYOTY+3ip~`TvHX7m6?!V4@J>?Fe%uS zX`C8Pa}Nn+B^$Lb<_k}*m#z0yIHvjl6znZwZm*Ecar2WwQFTtf)mfY=WX@zF)0u)104;#Mrx`Nw%+KS%&^r;)b4aTUr^&1@|w8{Z4{PV@tsNnA*!g=E1LgE9H_4%f0Q&?y0dZUBbbcN*1TF??Iv z@xk?1V$AtjmQqx4G=nCy3=Z2$4XcW2%_WP^S+r-D**8+ww+fZ>33vNY5UWj!7#MEP z1HmGooVMT*77D-dA)}3K8&l#a|Dc-lBg;%cf>2TvY>}U_vFq4n0YB zX{hgo>{`D#K&Omk$}iAbR(=-H-b-sa*B%Im&%D3skhfQ)yHFbbT%OA%y=fQ2$yjde z@TR3VfIS-nrdYD5?_{}?t<6m@C4QzLd)%IdQSo~D>uZK%=a8YiNhN8?c=*1-!Q_nB zozbQOnKM86 z6#snU4bx7tvEWF|Fk-mjeWMe%bkswgA_Qxz<+M09AwS-be=ia40PAOMBU>=~aPf)a zqZa+A(oaKs8jS-^m>4{mJ?LOVPmc+@2tTS!td8`Eo{@yK)lQZ84V^82Ip?OVh|zqk z_v(YMMdf^1==r=s?}5C|tm>gf4KdpAl22Si7>HQ0iKAUV;=r#DT~8yjpmx&_0W_(I zP57+TIH$%x(_;L6Lf$c}6vKTD79Pd`_cO1Y&=E;LU`dUxnngQd^!(Cat@1cBx@g{; zKE9n-PyQ#5?3pYRd$X)=M>IHWoN&5A{>YF;vc${PAXhM5h+;w7;2NNo#>pvFW&T#bd&OK)4mVAb&^)YR@KPw#SD=W*y+m)R6#DW~7Zl%Oaz zbr!Kp6UVghb{N7y12DNwfhD&G#`I7SjqOv%w0UEd(1+O~5lp*7?M+s#u8)}bu4hpV z{VihNqrEZ0j*eT>Dy3T+vS%fpd0fFj3C&tph@ioB^?_b9j`W$wGQ(RWpQCJ}ZI68G zZP)^mO)3S3hLWekeWWjg6Asg}GG8Q4rjGP|ToXPd^S@LX(iFM44n#xA&LfP=8;X=p z4<-yLa-FQKDTo!CYLS#`a$0e=xUQHp9pe}q;mBH#YP~jVZ4q}3(Wpf=@1mmLcN|Cy z*&w2{ABvJ<_FNV1H$o&JHK+28NDjqRE|>^t z(GRLORAk|XA&3&7TJ0^8+yyQ$WOFO_@Adv43=H4iF+i=$HXjM6rrYjUBl01Vy+|Z% z;6H9eaZTnZ3r)m+LyFpztkA#Hf8-XAM?x(n{b8DOqk^>4eCoSMDuDYt3X&o>@l37; zT-=xz+lVn)%-)MQPi>c$6(>@XttnnrhA3DjrJ>3j-pJEo5@3ol>MnRpH6%Lkhl+*f zj+=9`h<#?Atrhk9`b$bDzz+TTri4{eza2z zHAWXMnOe}W-JQUqJtGI@m)+?7R4sZZjc##;7vo-;@n$xBQOw9V_!m<~Mj{6;Q?F{fcM1 zpQ~Owy=}K%S(Xx&Z0B7VPBM&D;sV7Q-P7<*?3qqvbb{SS#rAv`b+*>yK}S>0tx75A zfTjsq+*Bv}&C8pYK9d`i_(v@t7U1+AMPKUk@60Dt@^Bje0}EIu7mi?C5cU?Mu7B@f zN8MHZ0-{Ck4R*@E{ymE)cNJ}m*v7eY6HQYQt5cC|C%51Yl(L!dicoDw^Oe?M1!{q6 z?PpB)_R}N8LfgtFjeIWlwP(Xd1oU~s#99lcC2G}F?WfTD#*|<~MoGgzfA*~6&G83l z@F+X`%WP^(k@LJ3sadH*_(N}A#b6C63+%GIN@$m3#p795xnX<6rh0jt>8N?`^!t>! z)cfY#D&rhCHL7?AV^aF#N7L|OWOJ14b%=V3CD%=nS{Ye4{*xdr+Syk|)lP|13}4YS zu4bY2lKd6obvD9s0ZE_tBS*`AOuPRIQGjfSWJw9SzJRFRB<185v$7dn^}K2Px&&L; zBr@I+fu-hIRO*8CX8N>rKfQnx8LogAxp_ zBuZ_h;}@$h4+E2x6=O7I$qC~8Q}zpLQgdsHeJn0-%E+~f2Ara^REEPE(Pvb!H<)OQ z(A-U&R5^-qJ>n<5>Na5?`~YJ2T~kE+<7U9s7vnh8PA6*(UMy0{AhbhqKMt$Sazhod z>!}u<>rDe<*7aXY#2F?N+$-wM9&n+@@Ic4Wxj;&}*nW7s;`m@R{0Fv&^&FB1lk>Zk z+Ev6lA8;Ge$O@Zut)Ifvak@;9*b^@w!icpFH+4TlGqMWD^-@tXF2vChWQ#!@_GXK!`$0tWuj=6BfrMtf2<1qf8= zR3}o0j6vr2QeF9vKe9AN6-IS+`B?=<<(rJ>7mTrR*n>wbxQz~hJ9fmB&~Ut^c)Y#& zo;Xx@qEAIb*pxs@Fo1bDjo?AnIaUFYG%H;-&VCO1f;)AFy(K2kYR0u$MjrE-`Wxii z#Sm=PfQVoU>^-)5xU`3r{P1tuZm~WfD3}2e7Rp_dQHVz0YIDRqJ? zl$hS9^^&Gp^46bURz69=Tr5N7hn*IjEhpqH*Lb96lzNgebDmGk={P8x{;JmNMn4{? zHWm7P%5OL{s5y{F`2D`Eh@F7A3Jde5U=#jWBK{@0I0+FNqDS;v1^)!x=6kAfsA2(8K_B$<&2f?8ey;E*;7~RqabakpXlyg z_6na-vW#VF@=HA(KDM!tqmqt2%1l&iX;!IR{PT^V@~9@H6HgZZZeQ%0J#qWO#xOUg zZ?72l%|q!F0O(cOt8bK%h7#kDz)urM&3g`=nl)txtn=a9e5OAc;0>)Vryx3h>tKUC z-7a5hgIS>77fV$tgn}Lf7ncccv75)Lv5bq#+bmmep~uxJhNCn`-eE)8Kf5RSJjrNq! zb_)IP9KZF-l1RPb&UStQwk!)fb1;jfZYMjR=?t7M|Cj5+HWrTAYSn0AYAYS%W%Dsh ztwHT9ZGD}D51BeFladGWCcRquJYsuiV_CEl30YkE%r~f5=tj4TlB!zF5U!v!AhOv2Pp@{LuP^;HUXTJqB=M8kGhcZ7AW( z3lD@AW$e1k8$kBcdRO8($^{yi#WCj&W+TJ-RT!m#dzt&jsJ?G{8wwvN!8xCTy<81c z<%ujjA{6Sc`HsF|s&D;6tXZ|# zOTOnR?9!6UM;u*F)~lk|x1x~l__szV#(h#r_WT_z6K#4-&Gm(Im{@FFEHP=zsgu<7 zL?%xe)$#-SBISYxCr-XxGo*z|rq^QM=Gi|MWtoNI?U7&aE5BDSDdwf|Jo1Yf3F-qW zf)bNM_kK(``Y-t2bJJM?Z=?v5ACfKl4dbOtuxWaZn-1RiMCyhK?Z`0vQrEKiz3PNK zdoh`V=f?}=7U?GxQOGIAJ1<~!l8tkBE~tg?s%K(sNJVuIN8h+gvPE}Flc|Ss-)(MaSFG|- zuCJTT$G_N4V;>XQoi~BR4dc0Bw05NqTd#SE5Q@_-Nv1aUl{r1cr1|5@Y>*Cnod@~& z;e8E)eeJn)rUDDSObcBp)`>DcD9a#K51bD^qpy&%mEkm-@3Jr;rx@7a=xl7zA!>wr zIb4RkyR6k^dIZr&2P-oIO@N@b{5wj9Q_m_`tYm8`L|RmB^hk~yyaVZ&t@qtwu$m8| zK9Ac(;xB@-11w2n2igt~ShVT_7gds%THA-cSEJ}F-ryZ7FWg*BT8D*2aEF#;zE^9~ zYvBU0FN|NRYKf!007y-WLmzVzmpo{?2BN7=b$gJ-8I#fkjg@(p?GikL$ zRZNwMtBr0MXZz5o{WA%bUoOu{HX1nsTPOl#rL`)1n55*LaXp#S$895>a>T;qc()L{ zWx%Q71OCeNZtI4}oqvh_qSYhL^{8I=6%Ex7hnc8v$iRI>JD zB;Gm*J(yeck1K4b3Hu(`Q2dIGi-#uobVIwldgcj5;nBT_1;oBI*z&PK#_fwcBT+!y z={XPWONV7AWSzFcCvPMaTVtc|Ag+r3r5N!S;`na?e*(o{*F3(o8u{M?RZs8Tf5(z? zgRfG%Akd)L1RQ<%gRAK~5Q?RkCPd<4?~tq)^u#S3E<#B9!;u8;yXh%F)(|)T51h&F z*lukx)2W=Eygv*{Aa}3it?EJ>70HEj4dNtsUm*nusAI#W0eJzWlwPHx723!CX(jLO zrjH^pFD~3nk{5F6P;1Z}^w{$N?cZ|sSeb#`!;gc5!-2Pw#34(^+*Ujsv%y#? zB~9|L!Bva}S=B=gA!JfO8U_etoUaZvM7HWTu9>8a?8PNmKH*d80bq8Y;ww}#S z@}BqdXbnFdxOd01n;wW7q6BB-;6I^Zo;Fxm) z)IF@^Lu*kCNRHG&w2e86)IQZx{E;sjI(Sq9qCuKoFf^(C4;A;6VIYneNGs2(9I z>8@-zF(3j?ginBYCqPg34-lIS|2AoZ_!JuhSoGiH zD=KjGe~(sV#9X>P^pF79_V+0M7dHOCzw9u@fQ-0!RH!acZuwCsobHp{J8;Q{_?5BW z8jc;mqxh1pSLTU7=w)0cCxPy%-Zfx^M^40mxRWPm%fAdmO!9$|URQ%S2a!t^58}l` zuq`Vb`i!?0T!VmU6zgx0&A&bVe?K4MT?FX~N-s50k2#RB8 zGadO^R+>LlV{Z%0O9 z#1s4PfPESHUK%r2G?xVEA8S?mNcec-zY_t%G!x?-7Ho_C{rmju8Bfojo>A37j~2h^I>-re5AtS2YO#(<&b6tLlQSWcF@ zp3hZRR|D=5J>aS&=N&x3Ly_5ec?*FCbwN}=*j;S6s;PblmLq3*GWzDY<-_~;^$};@ z<_sat6i_Jq^phme*UXkBwKUW47NjsTGh(sto&YLaZ#{l|__uSH`<^;76@>s<1Hgo) zOZJ#j=9z(kf#BR$tz!~+m+r*3)+bO^=dvrfNbY?pF0ml+R(MdA%UG9`w$5d>AEf=q zX_j@yfF5u&tpKY!1oGPTboU=v07!tX@HM7SjEsC$?rpg%3nUA4$(~g)9hqnHVL(LN zjFA{pP=}0DBXK>y-0AtJvHg-gT*#yg%2(yk$NOmlD_mzGz#?WJ8rnxgAqR}vQu?an z<3DVg|7nlZ%c-6Si`D}ctwX`baJ^OCQ)saZIw65{VxH0v`>UL(pYQ7hQ7&`x^IwNq zEv=bIsL{~U;?~WVW@n$ahz{fo))**i@H}lgQYokf>;^CgC#$(IF!Em|WMoo{)6$;P z6)ESlb0<`@I>{vi5_6!rRc2%QBO3vzB&Z z&^d;Qh1J*JkA;Z|$fJ&fiSJ?&b9V{173*WUoIK-qKC)}_C*^ld^JjH(as$HynbU7- zhSJ=Sa;BVS_@_5FKi?QH>T?eOviBObhOApqt8dD`LQJ!_#w;gfELAJDH^cM!T=yb1 ziYqOx`JlipQS=E=pP!@NRNL_RTr07&urRY6FLZVePXMoN=&33WP%ZG zPhbu0Pne4V{iL1?@Jz{7lKIsbpm8F%q0krS2vl$~gg^@l7V+E5oc^}%9GJG%!_ zfa}d=X%Fip@MxRkU{N$VnjwB?s`@i+>bMSy35-l&x8a$+YvvJ>OhPeK?L(wJ-q?`!9_{)y8$&s7uyRwfTKL&)MYI}>V9aM}uH>Y$l z<0U1-_NL<-(?ypfxpf@dGx?i39@p|47bsvUVRl;)+Q?m5@NoWgxWWe(#EsFup4sMn z)gq{~M@31Y%rc#-rS(2sHVcUP%FVjxP>;L3ybG96hWGaNOE*=Csbv_eI(yXusarFJl$0KzPFT*?@p#PB za%?V3R+_+8d9?Xy$jJqnR{J#EH&5Jlw3e1W)Yb9#C)W0MbR;W0Y%AP|Erx+ESHj%} z=@q~WC!5Tr_;IdK3xn~?&FomW5o~1!(0RRu%2-(J0J02f`Q|?7y*>Y0zg5QqC>V77 zBPOk;GbkGykiv>b*9@qZA+B|E;fQkeKoS@4s;jH>_LC{pY4lQ<5DnqJM~S{sRaJLm z1Y>*{^6;C7b*;a@d}Sf=<>{_k8Jh&fHxiB)G_Sj68+LZIo&t3qM(sL>`32>RQ*{aT z?x}9Q=CiP%rySHZx*+G+B2vdfVA!9)`O~8)tB=PPmdyL9-z|Du18xDUc$=EJ*21XK zz)nUMTe5Ap_iFVtlZJ){@UXorc17yj2FC2!6&eKm8gw zv7UTL6;dc~F^mTUo6G59s+Vxen=F{rL#M=rQ>Dq1^ z%YPK`)YXZUvgVI+uUV}+=st9XSO2!l>ur$M(mMY0*?f1F-(<9e=G2;(*J<^UW{sj0jU5jMu5}qoQF^OtVZ*SQ-?IoOg?duQ zy(LLOJ6N(v*_(FOD2{Vk@4_x$;(mRxtrTH%x29%FA|L>8?2vG{ufVeVDLfcA&{9qn z>;ZQp6if8#zK!r%|T@|!=j$Q2%fdPW7|21Th|7AWukTcu$zslaF!6L%^?pC+J#?&Gjq#nPYbwoa zK+YT#|4Wj(waug2mWgp4E|DTf1Mv9n@J_B0*2t{?w>7RF>Op z&eUq8iyY_56}fMNWrk(;{1Lf>16yv9Z$9AiRP60_8h9SZYQjo97HNHr1W75WvRy?& zo$iV0W8$q~YMGqdchtcyd~GBQw~$n5v6tK)6z0E=!nAWC@; zXo9j+as(33BRfw|ZiMeU#fjDbX0@0uG*TLY7fJHE>>PAQwLQaZ?I3Vp2u>ALZr^+? z|3@1ze^_^+HWeIK9bySJH&@4aB=9<}Y#Cfoy%QC!H9GCrL>C~Vrv7j+@`(g~ zAQpa*k zpC4~q*=(Y*#QeF4wDc&euHLPx`OEzm)Hg;~9{AOFk5)XiZvLd=u#!M6?ksqKngqb! zM?RW9ZE4i6oz@&(;6k&^M5A^^@s!7r_`?;WTi%=RUKtsYAk4wjh~TNiPH3jOK34iw z=i%tTrkvZ1m-Do#_xG15Bx3vP6OBv2zem8N1?Xw^Hb%`h7&$*3Jo6beFp>T#fk#e3 zb6j5TAw7QX{N_vO;44MNSv81Y6Zwzc%>m#6!{0SpeCmk~0zK7f>1hKxRoxU`gpKgz z1^C7B=GJVvd?+QO;Ao!nQ3$dDP7d$Kckr~z#qa$3?=>~+0N>HX>o)}j^czKvMk^-VeO?f}DIF z_PaNWk&$qnDbZ`R`}=iNPjd6~r>CX{8#`i%i%J7bbanjberVUykbfTMY->3s<^9fK ziP#yx-Gn{*wJ{3+;`8>&Q`M&+YU?L{ii`V+iYOy9JEoY8J0Q*4#>VucR6>dx0|RCu z_7m(@+F!E`B&ri72C@PI*!ELfleSe`n-77ZiB%RR0-vf;K(t#{G+SRY>@-tTfD0cl zPz4%{9#uW-Jzjb)?NAht!C$jJhVpx|D0m`4M7(;fDK*DidU|@Owy3AbPk0PtgMyTu zo!ig+ORIU-->JEMqD8t!;V|Few1{Q?B-Mj2-5nnLXCuSoa*i^ds164W@WKUs`+n6#^A>M!>|j8d`f{5_7)<6^DF{@yphfo$x?&B?iKH8kIw8OK(zyu7?-OvV@F z=sBDveaKv)Qd?0FGu{ETGF8l-RON>sjb43%ZEt;C1Ia+B`vN^C>-A_)?Q#`=qhk?c zM=;OXIU1lWffZcp#yzhZfI8~E?Ye#2{l`xo!Qz}pNGNwD3-^+{YP1~4D(QfKBPi5x z-Tdwv7e@q^dJscq%V>#7OS_!*zhGxCG-kWFyZq~>AYDZE`l!ostLi(TUCm$@eLyO9 z{~HTEB8X835rNm)O*s;kHSQR8~MzM(?MXL_$-_4-oQ`IxXU zH+6n-ajsnQuEsQx7dK_Qh~LAHPr00lh(CWLVe#hIO+5yoc&bk+Fuj9GH2k{Nf6=9y8x;5fVoeNt8X#wDWN1jtRp+fdu_B#+b7h-akQkJf z)-1X^U}~C2%w2Y_KeT1)d-0YI_E~IoZVHfM#|M9zZ9Gjw2?hbP0)|)Z^QfsQ(E93f zvZcB9$Tt#c@~1m0gYwaX-d&-5$D=()iAZWXI#|z2-_wDF*Z%PaA3nZ|KE4b5)=M(7 zuW37siPP}I0hs{!r-#T_{b!9zwGJPRhtlr9y%7!JVl^Af`J;G!=M*^w*{9VH%YucO zSxQ`-kk5hJ=eO;u3?NE}nvUiXK6}<)%kkd*=1-HGXvj0oXpYR-gy*)jb)OECqBC(n zrJ@4@MxKhIY~eR{c2c^!d+nGW_}%fcahw4+<(Y+-54A~iZHi@I7j^|(OdKfsp0^rt_BS5eoaoCA@5COg_C6aF1pPZhcB^Vnb zE-a`dkxP?nG@u1&`9r~;(|h-{`J5exl92E_0pnhOv8uAXA;2Xz|NKQeG)?y{`APBd zi}hCp(VoBW2)+h_)ZaXc(E#C>4p`y9fz1dMp}%ySU$@^VV=PEQYzyxW&P ztXe6~)}Lm47F>SW1R^q#i{v0WJlrL<<$nX>x_uxc67FqDGtY04xL*Up^`Pli_EFuE z7H;T3N6O<)xEJ14`x-|1&sX|U`2T`||6!6fLlZ4CfJ7VODaQ?>roXT#p^_mjZ8ru% z|HfoG3y$7@LhlgD&|rlGN;r%A|?YT++#xV&hvY? zd+**o8+(`4m5cV#ZF485mnidD{|itw^;R|ja*qcG=LONl@ist?{$ip^$icymSR_`u z%f}}L9VMAhCm!0j?f0j8FY8dum#rt)hmbbv&RK59Z4V^ZGS7#Gg>mU$49CV+l`xf9 z%rMTY0V@ZO2FL10##Z2|AiX*2lHDtb3u~(T6ah{uVFuAUoy)Z(fn|FeMKCePSHuAS zZ3ezqlqLQ20nzUWB>k~5{J%IYgcgm)v->3<-}UcFGXTo;_w)eib3m05@8yNOnCxq= zr_R7l%g(@;{)y#sg@FiPla`#^Kl)&QKPEiOylr!(KmLR9JGSS~FMdzvBO#G@(*^_)jcxwU4Epj#SX~|LM9thcf3Kuh zJ}k)at(p4MRL{yfYCH+_Ug&}y!K_GI$6ulJ_B&EgRHQj-A|~E#74EREzc_l|*ZU3d zsg&53*xDRi9Ib2C*O)YQ@9dCjH#!V-bxqcNg4QRnnmpk^Fxj2%@b`6g{!pwmgx}1V zyl^#J#C1O1+Db4}-5n+Zua=sI<{L3bYjbmkE-FEcBV*C*#ra(tI-03THOJi{c@-7k zthvV1qiv0%KYxCLNMA!E|2~eNfuW(cb=~E9sm=#sR&&!SPGX)4y}6Tuhw;hD9-Ct= zF-)^Bsj_2Zl{TR%wMj`bsRqe>Zp%l%?VG%qWJmd3PE4KE+dJj2CC?bqX-{01DxO?F zc6Ga6m(1o!d~KUhQj(^dn~?DP&0S&z1qDi3!mtF;w>HHGZTyvyfy>2Jy^rJjPW@)n zdhmBh0K1Er)f01#O(OhEcc!^G8B6PnrA*o6>(LQqK**rJ{ft*>`8AhZ$Jmof$hMgy_0!Ge3R)D`G*k^q(yUXGrg zLs-Hbm8mf<))Mu|WW_dFTM$^f(sIrg+Tx7{Gzpo@92Y5%Lx}n2iaa>bg>#8eBTi22 zbDjYW2iw)KSvU81xp78T)~lo3o=AhAl||C_)CbuD7Aw8hN-KUPj;?Mf)t=p5>rtQI zD+j<~3b2ZglREh-L4f$n_sf?=PV3i9P#|)8^1B;^L^6r&>LMZ+3qDU(3xB6tMN)Ee zYWfETk%C@@ii(w%yE4<2q!)`J>qkhE6IeSWq^rH(Q*v`}0P*``M?bvUhLIrAW^2pz z%oU5;jvV6i^?m9i7Q;!lVC zC~oy8Fp7*D9B+F3|NKml_6*j}3kE_wO0#VX7fI zN)HbU`_5&isiJZcyzM7(qb+gPaIy;}XP~Gz9%7YMI(w1J;_45rav2Tgqemodw;rbzVsV$cE(z+G_}YzzL+-nmEorjn ze9MK(==kU;jPP|@W1|js+jmU^^aMZ>1-OA9Za_&`bFyXd7}S5N7Rihpio12PnW=Yo zN&x(#?d;K(-^1ki!%LyvITw-7U>6=lom5uB40NTsKn$;`h|B1(Jr+!-$e3a=6X(T~ z!^LN7GMquOyFKeHAt?!)&ds47&5^H{jB)7ixA2NupDHJ|4^g~hrU(m9GiVR#&4nkV zaY|6gt$GY-Cn_i^(3Hw8_`YX|XH z+bY>*nowyRj`#LRUZ*8TWfD?QDvc`T7^b#sl$(AuH&0C=v((q0EK%0qeVAx4k<8$1VnG8}oI0aDiQ4Y)Blc&@d zTDV6AyI~dY98^x$UVBsDWXJf99^plueY$(!e=TR?c++8OQeB??a}S;TVi!#pS9P^g z))kidQd+*?=3o#!Jb9k`sTsF%aDM8 zRNJ^Wg$OMQ18<0RZ_|^u^1TZtpgD$FDCWQ~XDwh)I)M8$^e8(^4$mLdvYyVDij4 zR<`HoMRQe>65!Iy)o^u!4 z4Qzbmuf^5XCA74n;?q>i19TZFDEpJDCA@V55)$YQFxM89hR(yoK^!2@#OMa%Qji>T z4n@vkt&(v^52?Nv@tic^11p=veK#7-AL`}ZY0xZ!#L*vWC;{~m5sEtX&eIE<+bC;V zah1lLx&rGDwX;Y*q60SXxl3299Mu688JS?pJhPnkLw!RTJE0KnEPLyvEw1^gI$=nP*=>wWjZ^8upqGZd&+;$I50+?N9kQHTCb z5A&^KdhH3*p~EC5X0XLre9A$xbO?ls=6?TvXQl9~awJzj;(L_+^^{wH?Gt#v0}09O zZ+dfcX%?3BeAQ2mQ;QdS;+tdA+O^eyAh@kwNuZ|lip|jI63)uw=!)c z@zI4aok$ll${W6^du<6kdwq^fH!kO6b{BN7*xBpsBPvx`H+K>{yt1as%pH!q>-UOc zE);30o$po(kgXdAT)D^9TF>ox2tVpa&XJ;&dg6T2TMor9 zSWde4>ixo?0zp_zOfrkn0su>*8D<1h+1k?DMMgiG7gSdV5smZMA3Sb@Ym)Nk8h&_X zztIWuj{tfP(_=^XjAt*c)TL}=2a4Xr9X*w~Sg5+caRZJF`-wbOzAc^~jz=lu0#e!2 zX-jhn?Uo^Zw3&uu<3*?o&H|48ab8YtUThpuaexWnHhepRtE%{^Z|dz_p2|V{qbD5h zdw3l@`8QKSVFe{63$exC82y<^s}?g;VBrp`@XiYe@b@S8Y4|qB283{y2Scsep)+n1 zd;I9==;Gqy;Hgu2kr#DnUKt(Sdl#LmMHYkj9RDdG zhc_o$D}Bc2%m@hf8fb`|k1v9y^3Kfdppogl z9DdyL{auOWa8NtRj?4qvKSfa*>SZ5CYV2{)mfy~tB}30KpU#4E0?02WJ+tv|?Tr27 zPavLREoy*_8_ZsSh07DX_C_}z$zjC&yBYZd1!V(~FkrlSYo;M2JYw3~!GE_eo`Zlh zsN3W)8t)(5mWRAx8-IKJr=kl9aU?);`B()hZ3t2O1^LSI3@;rO6nTJLxxx-A8F|ye zq1szSr4TsC`mL0{;SVCKV+Y~?m&ZZ;xHY~&i-o9`QwNkAJjXn}y&m}ZzgMnO6BJ?f z4=ez2F$m z8InaB4rsfc&b}z5XJ9BODpG~DJ5T&X0#OUdxkCeWb%c4ID=Qm-@Kjp*n1qk}bQ^Z9 z5eRgEmO6)EN;zsUbwNRG4Gr6&w8qCc5q5TEfZw%ON*dG{On9VNHim(`Q`yHMyYa%m zqZi-iI8dJ!wixSy<`AbI63uN9};ulDwU`}yqV>Q0E-8Emey7Xg8iDNS?G!M^Po z7*#*~>M$lU61R`rWks$j4nT;S@8Jy$d3m%TjfxA0^`ySJ2zknpfUpAu#NqifUEi3C zi))pDaj|2dyQjzH<_a$cxwEg2&!r#KO!y}2q?COt`<^?LBIs6Qzun4E4hl>vEua9- z1hTKMSx1UgE5{~^TL3CU2-mz`XTeu^e_gXoz^HBO2p=rc2>~f-0PiwtmJM$o^GIVJ zBcV7))u}{&t8!g$HzL*9=mVk-#&V7QzpG(69kPRG094!@DQ0kD^!`;{>IZ6WB*euh z8{uay9Rq;6b97XBb1aw5w!PI<1hmP236H1SRv4bEx1Fnn$NJTd*I$M>ZgIY{T@h|? z#Qp`ez4rQ_8bl$Yn;hbvB31K6HSF z6-1>(M5Y)7y&F^Ii*1tQ(F_`rc!iuv%q%QfLZIm|=OA|I4cN&j{9Mjx%|K*~mq9te z^b}iqb2pTLL1*95-TfwD8N--KRPiwmjs|G(XXs6a>bLHFM$9#VD>76$F1dtWyb-pM z>gG8QORjCJFm!Hibx;*#Ip;ixO$Y$V-dAyAqN}6%@gUSz{3@g6z*^_J43+_!?tAy9 zG)wFllm|zYR<)|*lc6RDyP&VtxaNmNl=<|Db)rif@R^A`j)V+Cuc6BXIe+HQn>xmm zwej;zEG&qULw`j97G)TWC5McPPILy5RnDL}Vg$GH^YNLhahqE|m*gJI4rzf1P!H_@ z=3o5djnR!^du;oyWKMevAS~*0f_FqfjQ!*pQTMdLXo0GvghKeuY<0Z%;Es@N5jq~f z+lk~tGQlsf|33xUx*UN?DUdWJ)oDE4t*1sM%OPW6P(BD4@&ZL~^A5~;rg_n$ea30I zxz?LweKbs;cIsZlAg8oax53NiHb>K05YrD^QVi){?FHr(dbi!)Osy&bEy$Hv?R)HM zYr(H<-)mdUj;tRhCTJ^UKeIQNPw?2z^Ulj-CiW2(7O8iguW+0T3k`h*orBPAZg`D> z&S{`-p9VJY#KbPEJ8u|oK*qlpNKLMbIsZv1o>R}v$$5f)nfueeM}5bWpN=lD=5F9v z#-1mgoMlq}rbeL~i!F|5NBj}~(ZSL_UxfpEnNx?%HFc@g+Mquwh9NTq8<3Wp0#%f1 z_#biLMz-I53(Ch!0Ls9uZ1#W;@>Jo|?v4jg99&#@tX{Dp!F2b*+FRl?rC4SIlH*a^ zV;bwOHh3h@M5ASCMTH7L&Kq^eE=i6UMX`35c2MQAqYM6ML*6i7DxiCwuqX4M1B8DL zXl+ndSd4xfzH@72^yDBBozud=!ErjnBij!3gO@A@HMrGyTr9QpnU6O_y(A?bQ9vi- z;fY^x4csuii$wx;?0*gXnyz9~>U&g0NV+Iy>RIysz>B?ut4`@>+!>^cbFG0jyR&uf zI$N~g6zRdUGgc6Px;f4FoY{FaFX)>GN0HYk zkYc!F(DbO{$5sp->W7Sm^XZghYfiVlvKCa?hr69Q)EnA7PJ2%7+S;l4wp<|CNV>j! zY})wdatvfM=vWZpw0T#FIq83-~9^zM?_L| zQcO%o11z-Z+W4;1b-NbIO`fT7PPysmH-vxvN+*sM^|`PxI1+caM?!Wr(bCeyq)FP* z`NWHk_tS!uKB$PX#Zc5Ch0@Yd({4>P1eZV*!ZSU3*gn!%H!He(12w_jSr_+w8xaxT zgoNI*+(jTu?X=(gnvr7vtm`n14P<$;r4nNz5@BWHEtpSlaGwpTR0Sv8;X$00aa-Fn}#yh znX}f}Y3w|iM^OZq(9yBnOX1HZW*pvYx>QtAsfZ!i#l2XEbx2A|PEU^7aoYMj_uiZ> z7SYyl11VRUV=eA7Gp)=32PLK6@Juu9IxDyRVKx?)LyagCY3HM7re^vNA62lFR9a%~h*f_gjU;+4|bsO4irD zU`ci9Ya~l{ZXsgLfmH+9XEzrs7y)B~fr0Y%l@A@5vEn^q;{|4-;-C8yd0yQ_6#_0_ zuwK^N9IJr3QDA|IhnK_Qb3W>vc2<`gqw%wK_CSM|-DeF=IbY#<%0xHt2@D$S_6`p7 z%`WYY3x7@*3Xy??_X4_RG6@x^RXxDC4G|O}6x`sZlRbuX8~fbhm8rg`RHnNVxca{hH&0 zVOV%&_U8ULvK;N_6UoSQ*TeS2eMYzL<*fg~+gpZJ*>+u{OAt{IF=zxVkPuKhBt$9c zmKKpN=?+OHmG18D4naY>yQRCk)}DC3&;7jn+kd`adma9gwOAL|b)M&(bIdWu#3mgN zlSatqmlXXWrGx$y;z8+u^1p^v%$-lxKt+8}NomksDRu}e5jcXk?srFBLE!uz^tVn- z@!4g*hIr5mAN@g)7Ia%r~FFSi2xCnobwCa#R6hOd%eLS8t+bVd;6?J+0 z^eN}>fqh!sz08$dq`il0A_^*klKy|CsM65tuR@o%ta*hdtRSl54gey^o4J{DER*H_}JDAqm_C^?&CZT{3B2Kb*Q|%YnECNA$ISY zx<(XEqfrp)c^cz(QfjKUzWzr;!|qgmR7TB-_dN7QHTu;0O4%Q-o<6Gk(Tpyk0)wf} z&)pzJAv}{*=rqzfGQX(E$2`u}FKX^r$!V#R8?7kHK|cxwRo*@JXW^B1gNV{bG({kWtsOa?DnoM1-T*Z`Nnmh!onhYRY$9n_IUv8 z;l?~#$^E7voAOHTHT^>hCOVGtnbSwWx_=0Npmgxd0Kc0luAeh5y-1 zhpbj*%$^7f2~ksLgl;}lF;<`3BrDWg%=q!+{WZi^On(U$PH;cp`Gn@<*R3_+9pplW zCUNz)^c{^0xLJ{MnGY|}UCd7RS`G)Ix{4XUV(jY7H>~@pOgagiVI$@#(&DodWO5$; zy$&h34go^F3`Ih{!kxD(qn_|^P{i3)FmCv@2uXB^N@z}f9m97#z~r<&xr>TOM_`e> zR%CD7xqM2~=zJKw1X|k3-S5PTsNsjUbA9Hy)J~dX~>pv!8Zh%gyfSgP~ zhygrb1kY`0M8g-mN!@2OX+Pj6&)C>}w{KO|o!UB$*ETPjBjDFO-}sSw|L@g({=~_v z5&XSA1%U&~0T0u#?XaXpS}v}LrYoOc!Id_AtW8E-ZlND}?l>;bTRU1(3*4V{_P`x^ z05k6$CkhfA`(vqibNDxj4Dk?(xTt_JS&Umjg{8JSo{zoGq>Ewp)SC}&bW-c1z zZ8^$Ty&^<`cY_G7`-ve9XKTZXna017A$YT)dAyB_gL5|2S_hNX6B84cwI(V{hO&=~ zB6|+GJhyt1(tx0R{T2q0fMHsWabN2E0`00|3N8VG#nF~2C?Q}_I%F@}3#lEEETk{Y zmaLg~Rzg;nWpub+R)5f(y@|Eo(JpGgzX6s}Rv+g3bp|vjb zV7V_Ss@&;(K~qpd`p>VH*On{&a2O(ZZb>6Ptbj$-y{Dp!l_*eAQnqgaPwl(s12kTA za)5YbZa#bWYvfoq-gb8~;D77aP=`EO;0Mz8vEoC{6F za;ZDi>F!YO#F>=I6-i^zC!pWDuUKuv9kQ%rb=3Xh;B`kdbI7$zIF>IX81o7|Co~e7 z`Nu@GUkET>rgMI2shwF_@uG9um@lucfS6B86)F8<2!Q9a(2R1Z1_VuvVR)e z1pC{-)Qi)m8DS^1dV)ls@b}dUwOse zP)A3~!7}t>DGmbPjOGdkTm-*MR)mQ@8&f9l`cjR+{&&4j)NbYm%Oct%3XOZHBO%aR>y|KxvV|1%SMy>O4?r6B5 z|F^z~m(Mj3U;YjV*1jY>pk-k2#@N|0d+ZrtAX&#w(hhYW-+8eL8i$Ko(SZbq9Wk>$ z3dsQs9{JR?H>H)FV!by0B;EYa*=1?rXBSO69Jgn1Ef*F)E=opxc)Ez6SH9fK zpe~-dvvlT4RjGLQ#V}le3FOVXpkv&Y9x7*BI`CJcj_jXgA#WLj>Z(`l3vRvILFOcEmtIoA-b7HOhGx=}))SjV~YjjkA;B&AzlQsV^e+ zH%QvWBAGOIL-$>sMoGCUFM*S82JO8kE4QR&WyhW0>#UDjc@QPI6`AYM_udhfmE8kl zJLua;A}bOSu4K8x#Ideub{37Z;_2ycU%m)M^2J3~refXPN3(o8@hnHVY`8{ebxjd7 zJzr`O$Jyg``cN3)`Lj{ZoVasp1f* z>XwH#k;v)ZK~6i1xX@C(6g|qLMe+QVv9h5!nlQa%-5_kzvmvfB-Hok3ZWX6!l2249)01wo=H}aMpGB3?k+GHgY{!u~ zc{-!$XyM$u_nJf<2Z>F}FgJ#>CIyXE%Y0`Dz?c_5$I%9Kf`uNTQTpB!?$LA-9 zsVXgS)}>?n$?*l=Rkyn1(Rr`)^zcRsxk(V&4j$hZyCZs4vvjqIhAAEw2EqOU^-mke z=3#Vd7P6AE@oP;7B=)WN$gnfKA2y~*4Ux>wwKm5`#Xr9rjj>9fb&0VuWD01$HSP_! zyO^qRK0fK#^$EMb(x1^dkK#|lCHxf?_-wD-PFW2G!>i8cQhfYH`{x%IfkVus*9!z0 z+eYsJ->^Fba-Zn@(1!L!W0`_De@0hfxa^?F*8ki^4Wa5xK8nMDML;Jq%34?K|8;>j zlF4w`B{EUm&`y}=lup&TueY~2|9M*2xRF64-a2*w3Fk<$@Yoc`{(WXMlt$$EUVeAJ z>wxDi;^f9DyW)wLb!z1=%y2e^v4YR^r)p^djpN1OeMz{aFpmMc!wh7oKf$wS?MqsF zbpZwGGD}C>ef^BD=lzIFtDQHcKIPWdht+OVh}kOG^d=A=GEi3*lBTn>MK9XL>U2bA zrloP(d~)WvrgfHH`@STQwXKuHo=O~0uf*v%qKN(d*mt>fd5Yw}Vy?XUz2Q1`)Ce+o zPy?33d&^qjJk($`of&pKwOU${w>>)==}ypeb#=v*BX}Nf635Ne*Vnt8Bo@Q*`4Qbg z%v(oHQ<)_-#Q1=5o5C~WiB$6Wfb`juLw3x7`^RRO*rP=~Hop(JF!7(&*zVqH%mp+y zG!$=@H9Pee#;jNH;jxa~-pW9Yxz3~<0wC*-l={I2M)nWYA1|1Sa|cS;7;}bzv5xlc++~@xAEiXxD-AwHt!p%oJnQ{Brx@$N{Io@{>G<2!#q^&v>lloKysJGVfuJ>6dmgvUNFnp4 z$g-_7Lq;2y%QAXLP$Y~tnZRp zBA`Io4BbbY_0bMnb0h)BM~hYY7=YcW?i)>1lCvKE+V1mp-e9uca7juc;QEj+rD;!0 zY(VWx%2loVQCG{T1@~EI6bzvLN=zX}Bv_}>TG~HNPjKhX^6ab#+KJo#ERtr6R_RPt zSKLbc9l>U193W!PkHl-w#KZtr8!eErr<+n@*f`ZXJ7s%*ap1hYp9|&bgN1K*@dPcJ zMIK^Uj`iNjhrlZ>Z6+$}TkN~%8xd52;Z%F!{7fk~Gu3XX+jynBw4`LD(r7kYahF0O z_IRm>c7r3RQME8BrQv{}km(RUkIP*KmXpq}KnW{50`dtsBLJ<#ZH{t94~ zg-hGS3*}GxrjTcBa&u9hq^ytB*z-(K%W-dSUXPD|puVNy&F4{h<#)hrOy+~~*H>$j zlg=h6z~GuxrSVBEeSfUCs4PsWK1icIqW$~akyISDvgz#z-_XN$x1YK0HLW|q%-17%??I+{64 z!SHNx)1Rc$)H)$+TKP*F-yzC$>9uuNUq8Q0g|c{3*@OLDClp2J9wxt*g}Oh_Ugm$Y7F(b5XR8QMKPq}&V) znIc0|gL&%AhQkj|LZnMxBt5lU8{U|#5zKzGU&~FT+24FaE^DQtqDEsYMt`7mHa76N zbNyXLnV`b);zFaU!95eM6xG3;vAsV(1@o{c*@9rdunArHl7WWloc6i^6aRijpBz7b zisdR^pN{Asx{4=Ihl+&KJv+*`&dk^8D)rsFS?@?e=?>8f2~(D%W7KQy7jx6n#>!0c z2qD=rWyy+O`bo-NcsM7F4xH+fH#i>B7Nej$UNO5POKDlOw>BaM$Sp^cBloHFV~eYU znOeIEKd+f8P+%s2qLGj5K&5~5Xf+Az*8TC43&&RF# zV$?@?JaM48iHR|)rwSg8FUT^8;KP5LWi(kmJ1>Gy{Yl1Hr7k2Sg6b*#Q=3P;Xj3Rx z<-3pj~CxbavfK_%|F|x8I>DGxTsCjxEXxksI57#;#qSJ2@vl{os zA{#Nou*s_H29)|z{T4f;EVmo9cX4H{&iD#>6S_mCB_UdtCuKl0b{P)(*B_vX^BsOuRPnLx?hH9&6vfSp zj;^){jkYu5QemlamgOF24!6AA=rM6k##Oti5}Y#nyMJhfg)2F&ZAmiCoz>s`p*tO@ zbr1t&>nk8zEwzSQo8$QB_e-s+7uCzze0rE)As)p313SjRnb8r>9$}B8rK>v(vsDQI zenH%JytGnq8J1E|n5DbDwZE@iXrq-8ko~tQh4s(RDj;6`ZQ1g+Pbav*f#C~1%h3vh z-6lT+@6MIs0+NoC^FzYy_SSNtdVTV2mvkWXZ}d|Y@x8{JdP4c=QDCY>6o*%cA|XDN z?7A||gImT^wIXrnKQ4aq>yW(s6s+y2(7V!d@2S$q)j?IQyoJR@iTUH*P!P=A)&qYD z1mfa#o#n=Gx)iFIn3$d(l{y*HXv}JUZZ24>e$Us}#U;MoSMyiq#(xN4 zf}})E$awwyd`GQybCQx|ni{hd%9>j`LxX~}>YC}*KNnuVd9&8(JT)&s9oBGU;J(5PKVH)&1Pn1 z;$B)7mKOH+Wv$Mfi~?WcqML}o0m6LVIDR!J0qf&9B(2=Q;gC|-Rfv}m~O0> zkee$`W4n|5bF6F=VD3umP3b%}5>P%550^tO4K!hzIA&tvdm@@3dM_~;n_rmYc03W4 zFAXQ%Z*R}`!6$+9ZMk=gteQvP zK#y7fqxvV)NSL{Y8yONAsc-nPKh1>x@#ELVCuMk4#YbhCxGJ9ktn|Vrt+QFadxunb zo|LP~3ILlGaA3)pxCv4zehUsx&dgLw_c+{~00KPGeVpjn*jyQYczC2G`Xdu?czDn5S6Uhb5c5Q? zl*5=@t9=3RI4AD}K|TiEMGo!lyLYWt`XjcsKAlKI=hV^QyiTs8s|y+DrwqO{LniO| z>48FXpI&`;=j1jqu>_fZ`|zE?!BALq;J9wT-%HCvCtl_)etOHu*=Q66%+UY(og7I3 ztOcEyrxZ`H2)c)0swM@+!gAlD1St=VQjtWQ+5%o`XJ;PeqrkARp}sy^*;k5+Uq8q~ z2DO#{C3r5F!jBk3n~+d0irGHW-TevLoLONZ&Iz=p@)T=o-T7OG&0ax8UZgFalbIJ5 z9DcTw;`7-_4i(kW?)Ud3Pl#l!43Aa@XcSh7677yi$?J}*gE274dD792;bDA20v%;r^Ok?!$N7(NfMCkC0T0A%4xDv#`$}RHPaKOXH34*h&1t7Q!6K7o(nwMNV{@-QZ6&TSFh?jb16Wj z)B4w4QR9 zCK?(7^`xw;KfMCr8>MB^#FNAJCb;a5+ygm5(2g8@q-9v~z+0B;BCVmFP{vQpx15Tp z*8cFy?7Xkt@YqlqRb7|j(e$67jZ1(7vu9iM&6~OH*;}#%Z24s50U*$@-W=`f$TZgL zDOoisKBpSc`tU)N=i%$u=ZQKmssR$yJRy__XA8x+MDKX+8^cp`Nn1q6Si=noUg(Mw z!;;S4n443vm}q_H$AP4}P>`2bD_z6h7`(Zsrl+@zgOsry-4hPxh6?z@)TA5s2G8ZJ zka)9K7`!&ew(N|Qw@O8&hAGe@Mwk)`ywYgg@|;@e-F-%yQ#;0HXJ>#m^Gfv`hZg46 z5X&=8Ym-w@^c)@JRN;gjZ1lyn_LHpdtu-$9>0q+(b2Tyf`$)u7#$ZUH(a}sBmuZ`* z2VARvId zyys2Q?jN;I^#XxGw#r|}-uTvqT%~d`F}h-W$H<75EmHao9(~!esV#+k(7L{AS5QNrOt?Qd|b^KmnI&enx9%#wFo#k zUO$7VWsdSIKI4Dl2LL;VyWe8#x*#@hYxBiTNjll=^2zPAqR!Upvk&Na{#vsvyu z^XQOC6$fseXOs}h0;jbR)w>*{F?q2lmc);KX}`T7!h*qzJSEzXbMLED+BU<;vR*i- z%7hF>Rn*jClarsE-@vKz)XGjxX*msj-0--(qw{D?9n}0$oL1wMzEg4yz_2#(2P${IsvZ99BDIsY4TXl|ywYnX`K~2zA(bCou z7Jh4{W3l)VxteihbE@i4bXh>^@RzVABvLTrmo|KVZ+X|#kLaB&Yk*HcTCRdpH6$rx zr5n^5-x*3((uq7Y`X6%{jXqIVch3Ct2rHx+= zqaxsOvN*NM(ev)x#kW1S4K7Jo8v42G=J@ku?P+3u1h<(i4m~%3oaI*QslL7zY2qZH zO=h=swZ5qH_TGU~Kreo0`XMUDezDA24fMyrUhByd@+CnLnI#?dC0Zg@J)yYsbIt(B z2f9BvGcr2ZVrg0`hMI%^DC{ndT$4VtVK!D4q32bvwef& zzh9`edn>N17K}EcSd0v{_3skg4i1i1X}CPi;~`^%Y`o5HIWsxCzfjIxMn)53U+-jF zGtGFiTEx~1g5O|oX<@4IAcLttg}s8LTbBEou;JANs+73w!iC9yhvIY{cGpy2uz z96nws=X1ip;qu&WZ;QiHUutcA?W|C-2>Fx{oNlzcA_lAZi{j(o+l7JRlrrSKu4KvI z%k{OPPXnF}rQ+RU_ICz(Ls9+2O1@s541EyeEm_QW_OSvzZnc z5g|NZWlj1?E0@p0!Z#p*p7o>lM?57JK!kEVcR z-YHmhK}(iW?@{=E4lS?ny;ccALgE_7Rq-2CC(4vo^KI|^oO&Tqf(n*!egUc-!4WJd z79zQU2C7zL|4~N=Hp;i12kavwu0hbDjxYQsB=T(e5gi9h;QyGx_KHpv*}~E_dBV z2{~;{R8Ue%3L!D^Z|L4ruzDQ=dZ%IMI&1ykb_LK&;QDO}&AgdPvyGcByZnr9hfG+; zzv0BL!;prbO1@;bo%~)|5pf6l{rT;_I0u&h`-h6QlUzTPne%99-JUk1@OsQGE|UM= zjoo|rd#d2F+5dOsD2{}%qL_fZxPqW#d-Ek50A(i8)x3~VFDx-V8@_dV=_zF0}Fv7g!3-+}P@jAIxR74d_$rIki^nFzp`95rs+ z;cvA-`cgne)JpJ)>gu|n;}%io$@VGui@p&{@=i{c6@9biGMPi%pS8V} zq5S^+mpW(34<8-@D+i39#I3ZI^q$Hd1^*U4Xq;r_oE2Hz95svEHn=UR&J=~X6c9La9q4i1Tt zn`o#O8rLP|_$2X!Rkyu1W;_4Gyu3RSCZVxer6tZ598Tfs1goXP%> z2nP>OeEx-|K>M+yr(_M<+0fDt)mKu?)a_mE?}pmTG2M`Xabb*ZahPLgJmh)t*#_^a z$!UKA7JmH!+Ji`GnZElZd64LVli%;ACfuB@g^sGP_flmDB(y|CFo7#(vMGTRj4@-5 z?zIc8{iuj23_M7EBfiJJsvirm^NH(P9`Ol!N)j9rf^{>~>FntE+#Z|A!NFlDN4ZfB z+&CC=Sa91rI#5thpkuSSa5B2x%6{Uj;vvl{(l|Bql*ZD79TN!LuDglUwPFBu-@ew% zz;MzeKcYxTs7JM|*Fv~V1-l6r23s#fAil#|1a43CKsBk5N`19V48Qci4tD}hUXFqCP2dsc^0 zr!yohs17;J;c<@Z?4zD&MN$=8Q#msHj`4yJ1Cm`Izppj6vz>9ybf7`0Gn?nYkM2@` zuJh~5LsC*Tw`=}i{`So`XUH+%jj)J7w|ltshQi|BvN<-{%Z-7m(|HjE!c9EWyRMBi z|NME>7P1m<-Y4{&Ca@J@Q=zb0-4$bqS`Xd(4;H|{KyFbg^9_%%#hxV(+&sVhnI-Bs z;kM}pPJ&9giyzeP*@~w+(jR)$rS4IR)>v=;@m;QK6B5}Rt`L??gYLbE#d7ag%r{@Z zOEGg+Malx>q^+eT!L^O1EN|eS-tMVOUadHNoAzH^l|1kH_Q6H@#-{ViU=EkTV1J^I zf zW!L1ty*11pFjG>>C+Z;Kc6e_60(pjJ-b#uLJEwKR75r`BczUy?xp`^4V%8hir{Zg% zdYr7vQi0BR$1Swhaz*MLyt+39G?#p!pA8LAsOO}mDK`}2j!CL?? zGc^0tlYZx{lpHM$jfIsHj!5Szr_ZZH#w0vWksOv(#bf0fJ}D{u5XQCay$Uaz=P@xg zb_11=56CE-t+Dt0_9vkV*Lt7cb#&fUP++R0RIPRrKKza-Dv}7_5Jr;xrK`Msr-6-4 zPb3ljJm>xSYcKzvt+QiPD4>ns@|Ow|PW^KysBh_9`I0nfq$K@L91ZEh+Y}&#iej_4 zK9sHZINf}{bsr5g6ntOFII4Bp2C+bcrukbmk>COyE=sVb1E^Aoyus3UfBUb~6MP9Q zRo{H?e11YpWN+sL%%=ptQxpZd;7Ud63p^gh78(^i+;NJ(w_>YLD4E=QS*Cm~$|9-2 ztLol8Zir%AE$yi{Xy{igNO=6MbxHutSz02X?$1&X1QxROCMlQwHuq6w9WudKIZ`@J zJSRE%BN>^x9^vxtqJ+}B@Ll;ILv_Ht=)YnQ%?JYnn#i4{o<7B*1$b&N?JRt%I+W3- z5~GzpkxL6^CJ`UV)Of*i>J5#s$DNTNMV zWIXKFD)Ng=XFbQ;0C&$*lG|)R6I}TsFD0cnQQ5dhD08*GBBEkBFa;Bndo6}~ZKR0y zG1u$c8S-1S?tP!#s#wm8)?4nSK|(`9Vg&op`B^LyiB2A@Q6#%XOC3X2DE9YmPA~pY zL?qhcxuj>l4=59AVEJ4K|A9b|gFFiHfZ~$c`Om8WoN2*TBP1UKL`qH!R2P1GX6A)| z8FifZ{zm0_xBr7>GQU{l{rAmaqeG;lC7W7|hPa+1deP}V1`(L|c=~VpQ2c<1Hn^8c zcAMDls{kfXM0E8BJN>&PB{Y;-5N5qw+J!XENFcJh?EKex^wQ?p309QZglM|kkX?EUq+zt2rkF>g7kyhk)? z4`|XTsXR=u5Z+-?Q9&^gp&=2Wf2pF69#UoG=p-kmHeUV)m@9eP%wjq>KAi}6A?$ms zD-Fukv+8IIG8)~ zOW54oQBeS!j5{So7n}{CGmd`;T*1s4`{-0T z4_3!wzDE3`OOny(jHc2Qz}7-M(Ejgl20NOZ9-m2pG83U~cNEK$jO36eJix)ZlcLpPzqCAHjR{=il`%1ir<_x!4ShMgAn5 zMLL^**47O1hssng83{UF>|j;0wTl@X`kjT58MyEgU`^KAOToN67=DI#c*pr#1+3krJUx!eu6a?Kw=I`ZN&<+9!N95o8s9s(Wm0~M@q%o z5>4*l;ud{uSphd%U9RP$$zRDzJ_};dfZxf9TL9hG!3Nh|{J2ZE*RAQ=BJJ~6_lC}E zsttzx$yrOR)@m;}p{e1_qm1A4W218SCq>q_hqBq$1v) z|8zbb)C8s_8P735^*#OuN8MNIklf(+o1>9*i^+PS{VRAT->yzR_}TDCttm=R`14$TKob zYr{=jtf>l%Q|AT3pOF{o1SfzG5tkd{4341?1j+u&vfQ6mxl9N4ALpl=v>*eK{(Nhn zQjiyYkm-$pKmZA2_S~THlx@D|?<*X=ISP4rHSMjfmB{IqhPeoS8AM|u(=RgTGe`_l z(2_<6;vh(7{Sx=0Rr@K+~(rKV(dl~AB zVz8J@IvzDsZR{#3E1w*l)HrVr6lfBks07O;FN0g$ICq3qvex)3+^R8Of1m|vbELea z40~lsA$C&Z9>9BARxqpGI|B^a;{7FBRSH8qNR{xgC|1i8ho@;_ zIAZ|>+~4e?-|9Yp^fGYUzple-^TdnKm>Oi9)YR1Z49Z2?dWXg_<>pt@t6ENH(^AEV z@G><&81AjLSW;iT<|%pFkA1LIa1X1IrCe)Ot%(P(&SJYfH5E1|Cg$KXO_?MCEYcSa z-QKvXq%R%#yrxmn_hLCL$33yea@DHDTJCVzg@%SsmYbpv7>*<^=c*E?f0~(NZ%ytl zxCnpodO+l+AtK(%trSiDV0j`(Hzp9N1gMyRCK&Q0d07vSj!3qzt(=59MtM|>Y$sLW z;NYHM6TFr>8MCh5ifUN6_q0b1SQ}u@e9`?UXuJijvC`?B4%!H8whq`iH&fU+tyb{w-Gi*`QX=Rn_@*bj)JQ2?rQ%B# z87?kVk}xj{3bA3x!D4CBGczs@lmm<;0I9b61@V7}RWL7d7OZgXQ*zs+{{lB&!Q_1ERGo|4mYLZ{uu6!&i zDoViNb#ihtG_20_HWLye=&;+_s{k=~y(?2#sQ?(x5+Z_A=ff0{5F%M>?w{60<4L#wh`Cc+9HD=8=dJ4x*j>e0*OVS4H^*MOLHc7~EF zrhX~5)m#8WZSlT`Y*~nvQ4Z&gW)DmSkTWTK<*mH9+c>zn;d;aez3V^eiQ$|YYZ zDS1cneZJziI}$ebVWF>woyVTCb zWg7+u<5ajlK?a2R6ztYbuF4;*E{pdvNT;yySA9XE&yjCnxbpGU<)%HOabwts_nt5^ zBjeA1mF^Fp3!A2_2Lt$sUr2Q`Tkp@Pb_V+Tl6WqK#BkVIncK74wSzyPxPj*4HMlcK zPaIxyNoa7xaN#E381ad>u&Pyybwg89&{*35pBT)ssd)z!6~>D@Pxe>B#+6i5Tq7c0 zFgMh!ja%}5lB6VI3w?nmDG;m@ZwjJoeEJ2|3A^ov5|e@CSwoP_@=Upa9AJ4k%-t@_(=Z_IXMW zJX31u8z{dPzYw|Ro|@0#Feg3IpAT{SWN##lp}Mk?102i4U^K%2$S0Lb3|m8Fq@^Dn zg&`y{Nk+?3Khu;MOwqt%7+7C7i5YzX_sj(aoMY}2!4fY@ErC%05$0hq0wEhG z`)=Xu9ZjE8(a?;w`B>8}?Jdt{FlNrBlD@Pl)R^@rVz$A@oLiWeI+H-#7D- z8Ky6QZStP~?xGlM-BX>uTvn4wn2VxSJy!MP#UzzfZ^@eerSQ(h`Dal#wO@gzjdfh2 zTO%sEzm&$G21S}s`uLQvzANo4TKWTE?u1)OYI%6R;_0tz(JiTojtukuaGSI7dhdIXEPOaK7r8$HL`UOigdNdSwDXMhAvVfH;12)XGSi0dZ7Oe(F}q7Pf2m`qFtDALU0L zJ|W>m9x@$lcQfwe$4xI1^Lk{+G(7?#UgE}-bG5I87J zT$1kz{rO z2n7GTci|bCnU$5bA&|iKW%VXFu5Ql?4iCR8c$==ZO98gStmgJxJ7Pxq!$lubVx#+5UnUb=9iih&Wi|=2_&+L3=4ibkcJihE|9|s)p*ZtQXl#i5Fe#*S`w{Dr9YS1uHvG`bL%#fC3X;cpL3I`C-CPK;UA*_XOGJ&r3pr6aWvH z5>_4KeI-oWKNZ8Vg&QKkZ-|?wy6W zxVSXM6e6&-rDSDC)540;|NrVi<+uMTA}K-cn&eyDpY6*8Dqc&Y*C1ZKLd4+-Z5+bo zjEH5uHBQsh#4aa|rxNNP5C~k!@BauXFFyti9HJ~ec`f+w)XWUQS>HXJgP3y19+^e! z@HUjvhOMm`2jgb{j+V> zK>65B@D-=!{XUDkX8- z0a=Fvs1TpEcYuB0a*Er}V`whWhI4AIxKqhc;3$0@%5|)=+R&7wEB2@XmOz#LwIg)u zEJ|hhvZcu+kpNOvw9AfmAK^X8!_LXTO2Y8T@;Hwe01~8W81p$5a6ueIRSl@nS70A>n&W%=yCp*q@(GwGMw8 z;;V^(a8+i!o;>&Q8c$s$DU?a{#MjB6Jn8T6k7PFBqjz+0 zn4Os+4EBl3bC#B-N<%;o2H1cNjq}SMm}%FrH@f0cJh6zA)6&{PfPB!=7y-US<2d;E ztN`jqg1Sk}1N;)4A}(4(UU?7_t$=MuLx2ApP->2jf_1f4F>%83rE0RuMDV8o7Fa;O z=Hmkni=glQO7o$GK;@_x=5Il&H#2(|m<9wc>E+E`LPW&Grp_dbooct+z5V_FFc^Sf zc{(-wWYxqNBjogeusvm2`1NThlWFUm;<(6Ykytcajo9kc%7D>mjdESxkg;!o1Z>_1 zX7F&yp1pkLN^{+~H6HNlw$8m_zQz1XV5H1Wx9kCRM{F7ot zWy>R)KN3CKmdHI0!5*GEp#+Bicl4@V61V2*s=KCwWPvcbBQ>K~qVxzGqtr zyiXJKd!-x4V}3QIdOoOAEK?&n!OT>fh;D^`4-uDsVjtt;k^9x3`qwTlt~?cIyGg|M z2(U?L5b8qf*eM@rU4px87W9UG-1+Bx$XI@YrV^9MREqg#t)mwJT)n?Oqn=XJ&@iSW zRu0mf@}Xt&{K9qtmKhD30$<|RrlK^8eLtBC2dVYiFc7ScPWF|J--b}jQQnEqQx9nc zHgJ`Fxs6R_?o@tcl!1YP44(ny_cPKgAY{DB`$UD!Il}CFZ*Omnoh8}xe0niqiLP?H zeF4SLbAy3G5L=qg*VvbluveNKL@&##)%jcVVUbVYC+Sjy*f&}%?CY#GmC>gIXT%r0 z@j4=>=dV#wbAddnkpABAAOFUM5daM7&+99Gv!9806VTgJQjwFl z%&RQnU+hfbt2k`!&?PXRkP^CFPnbr|@C#BG8x1MR$hdhze{ZtJyw0Y+z+_4dnJJ76 z!Lr=<+LHLN(p`{K@M2Sa<@;kVX$t>spfl(h;PV=vR)GMsN9JK ztFEKN;zd$jbe{2K;n__2f!S6(yoCd-RhfsZtoW>7sLK@cR8IFue^t4Gwg5zDSGRf6 zXp!yaJnGIH(?_5L9Nz0 z<&>A8*0r}i+;9uTPmb#jLpwg)&sK@$www{y1Gf{>L#lF~D?~E-f!n>o>iWGr^(qgk z*i+_M&{|ORmg%`(gEOxO5gZxcKNlh5U&>!p40d(#&|S2xpR<^Znl4ShASPZt#$1pL z_!a)*@xdDVrCwc}#Qq5d1rULxpH2b+n;;n9NO~pu`-z=~PL)-T!C-M`M+Y&B#m!mu zTW_NdcXh}zdAX1(G3Te)Fp+?PG7JKMxnmCy&)3H+=P4i^thQYh^^i*Pfk^zhQdfMAvB^sb$KD7CG#}M zsR;qM!#+MFdN9bG{dXxO!|hwrZJ#(t>V(T|32Ra<(|28u|0J8{UHWr*_u<2bW@bB< zs{;`{&Y>|ev~D}StljkGSD-@BYd*J&+yk*U%pMx(uC2|CytK@G&pQqLuB%ZK;vQ->Re4JLNKO@T0-p8bU3`W6d0jD_m;OQGF-j(r)#lX>v`^TArJokOC_p1l zsC$t6+~J4~gi(}V$7}4vJTRT(Sd4CI_1PVgzPSf3XX&n=KVN*SN6ly`-u@33@b8@y z=e$o<(eUH*x$gTdH_4|7@p1W-9Gyv@>jP$OQi3_(OJUC)tRtUOhE>$jCnhZIeMa@f zcd$L|bliBMje;}REom5KFl3mV;I8E{1x626tCuDB6a?Kkwm;tj0~stwqYZ}WBwN37 zGdJ4E>NI*c2E@HIH+NKppr~3^maSH@0cRlCC_uzKxh7{Y+E2jZ@Z7^$*8I3o4u{7; z&w$IRy-HH6-j#ePZ=$d)&-tiX&5r%!Ryy#yzi2MOE+vB;OX zz$Czl1n2bb!R|iV1sp`Z{Ajfnmz?(5sm0ouJq$`wXwCenr8SVQQDfW2v9du|JPmf- zWyT}IP@_E+k7bCxQ2;G=Q=J9tmHygkSR`XTztg7LVbG)9!OFonpzW`f4+uo0kVYMm%(~5OT|AaMqCm_5hl1IrfV;BqinLMrU0^WB-25&_ z)8>S|E|8i}D8k!8-AvCz#{BjrxaU0U$jWxtB#qSZh)*64k6 zS!=clE8=#Q{h^bU6$>tf1S$RS_3;C$wweBfiL}FM_p6h*TuMrjV68;PZ6g!LW)zL& z>3q#MGu#uTa#m;4;#%)^%J_#xqk(|PTYPhLJi8E-geyH;207!J2&h zQJ#uQ#MLAf7r+bm@76h#}+@~;mx(oGEhdVRQ zl$igzIibD8ktAqo+4b+QNcw+j`|@}y*S76NX;=z7N~CDZoMfJ2ndd3X6rso%GRu-7 zo0JThWhS#w=2=CQAwooC%skH--}7Ge?A?B!XaBzU{eItf{ZXvRz3%(Euj@RI^Ei*g z88V@!mLcDV{Xs;mzHS?|_C&X5JO_gHV`=vtKE##V-q`=me?N=cvopQ6)Oz2Z%?H{L z^V-W^iUh_d)D9J(=w(SX)ii9K+?6>jaL;7cmCCN^>Dl`-1t-3=A8Py#aoav-uJ%Zb z;%IYJUV2&@0{1O>fv`?!#KeF*6N_j;s6Nc)H%u4|hhV}653itWf4AaiU<|wBJT~3t zWae2>U~eSq4)SdU`E5}vW6&Xt^q9_1*%0v#8xoV9A$Qx~qgTwCZfa@*u|T(#@qz7) zbKd1|o|^^d_51Zz!_&Kd(5jWSAqok{>px^|Z0;!N$2HSIyd~;BU9Wuf*s+wT0GI5s zsMJ@O*lzfojzd(!rv8`B2tf6#%(Zi!ihEq;DHof7fDdfAh_U7yIXqGD5|c3ZC820Y zGR3ZIT%J=h$MXbzWc-5Wu3XW#nC#8b6Fh}+SQlV-`!?;$C=toNn`1DLe&FyijNP02 zOR5uJUK)o%L8o-OC)>L5UksC>HcS3DXth}A%0%s)U$|O~EC){}^YPm5tYdxwV zS_Gm^hL0t5Eb6~lbRj1n6)s&ED0jHi+V_U@+}a&r1 zdHuZWB0Xt}Ojs=Wd;p4=c*Sm1HsTxWA@ClJx+Hvzx~uK;=XV1GE6`L3VQ#0C7RWbh zSoReyMY5j(aow_uVxYc9F8b1LiD&*(j%=xoepgow2SRL9d-yLCmb?0@pbD&{!_Xl6 zR_1}4s92$YNJXURANv8nqC7zxlPvf?N$zmTv3U9L#Qa=6Wlc#xcMN8sXSVix=VbfK zf$`RvOb|*y1dVjkybbSM?KhzKlKRB|P9*fCT6B(6#1$AfrpF7fdu*)=@^h$aYQj>R zdb>v@oG;}XP}niJRUT$$0d{tM@Tj|kcQXc3I7b|iB=Rdx!LpU-d(8~>0u zj<5(3}7BkOTQ|uuFlH%yTB!`%e*XFzaU*TyeMXaf8v&IOEZ5y&Fw7pr6d=+ z9kXc8kXJ(MKhX7wSu>g0uyT^(a^TSb8(#PGUZ7S>b+XV=2|3;fPS8Jo>{x0VIM=?h z8K`jEY+q_=0fSAlBp}v%co2wGY#8q-R&K8?3}mR1N=izCd{VZ<$mUdQtgEW1HWnKz zU~%y+dl6yRYeYRKLFBMOwWA3tf?&6#(sAnU#QH95O5?4uYAvc*$S>}i@k~w~I!uK9 z3_AI%6HN?I?*foFsT_i?RH9L%QJ@G|ekv#~&0iQ9Nl!{@Pgf>xs63D|3(?KVE_LYfz2W3B`3 zhOhvLG5Fn3&-{45(mTacBic{~ht4~)nQtfJw#aQaAihW&qOI1=dpROB@@DN3-=)JO zvqi-@^4|b`hRgvP5~%sQK`gXzLMbXNY_tiau|QO4pzJ1bMAW72<+u{K*MaK-w}eG! z3)rhcKZ@pR;HgVn?w}eK5Jm7l%_S?_u=w15mCIK&O~3r>6HaLW0$G|{BB~X?r^u6M z2n!CsG0i;4oVw7R+q~-*%uLjtBF&vwn&`3UU5WXSSSoOy6Jqq1_I59iedxbqWMqUI zHI&g;w=>X&>Zs-AE|SuB)oxW2%B%C8f9%mrlEVJr8+;Cv=6HsIjEduSx}}LpR&MUV zGg)n1FX)UZP%UCn2$R1BnA^*eP?VK#em8kW&&15+7EFl@2J9s|CSgRqP*zta@^?=n zhLhCI@0wm3W*gRiy}qOR+q>saSIEi9v)B?I-#jQGS+}oS&Ic&SQqwhm%h^p(G*d3$ z9PH`20}^P^4ZBS```obBCP%9krX{$z0_n*VKFDJ(lx>UO968P^^ zVU$wxR9LT9joq-Y;B@M{02#nCZf7#9=kT4aFUhEc*wFCk3)V*cIocZ_T(!_w9Gu>6 z__{s$z!@Iq@q62wcfZCH1nt;9*~#YkHOz7F=IP z`=6JUgV57(9p0L&DT_9Iy#=-m3=B?hZPg`o6wU zA?=l=F3n6Is4cjy^(pgeMu@uOG_usc4c(`I&tj-@!s<(jCbXBssk|bBQTs5noR$+# zIP`&tSxynl&C5GiIw*8xuYIx&3yDR_u1WJLUtoL7$QV}QI=4renvQ_+n~s9N3)9t| za!8?%rNYWOggJkA3#X+uUOHXh@Yc?kW-n~IOuFV$BNVdAB-VQh6AxOSx)+ zN<;GjIxSDe{m#PjUgQjElKrqpX=tdrWp*}TPu`mtpsep!DD&~&mQzruf7`G$vnb(m z{P@?f-$qA9S(4MUvpZ_sW7p62ofC4Lg|sG|=-Hy^`Sb5P>zcpT?7^-8C};Ur|92ww zol{-k+q6O4uWu! zoe8)i;MS(;_WTsGJ$5Vd4a(Iq5Ov5-E=cGpvN7^SE)^P6$HahnQ_HYnhhcH{*P+){ zdK)uSQ@ojmJ)h=UtKCR>x<0WGwI_*-OfS|t&3u5X3l?OMW!~<|daEa;+SB`;!Gs|5 zB0IyU3a7`1)~jZY^cCM3T^lwQ6Qes$Jxz^p=`@^?e_wfy?0i?Oc5^8$&Jl!O5U~1G zt2J7iK1Jal6+^hVvWD^8vxkiuvLD=1sHC&AGmWPp3I_K|qCLxNPDq~yL0-Ix?r zvpuz?Ngx&;ZA3}S?R?=lQARuM;~_Nlecj?YbLNV&M~{{;M0w8u7Ha~_JE%^ns;O?5 z*MJN^}Lu_`8c8l$E+sqOnpLHN5T3uQKs>#<7Pt5T3p@ri8&CN|aQlg^5 z!q%3SdvojK&FFymE2b@Sx&+V~BrW|GIKPd-9D`MMesD@q4;da6)!Q;g0!!Gqid!xo z@>hz+wryT~WhV(WPmp5Dr+oU}Tah91FR5f!ktsD+&Ch*Hmjvv8aB*WUAcZt! zaC#w<$`q;!F)lApZzPE#QFQCDNXZ`vll9MD5-pXG1`y?jNXZuzEerC6fy2J3iSIES z!N=dNtpPuueDU`uo7mdRG*>D*xCHDdQLIpw;t&+|Us6Y*p&aQE{|i+TAkzmwSgnpu zU5x=W^+4jMyTmHMBpimPwLZ81izUm<06qia*YpsrHxT$d9s1-+*QberBS{Y1haiQ3 zYE${G^o$D)53Y*0y^3?b__z0d>sNu=s1_&wt#?BsJo6JP|Ahr;w|DRc1RlFqeGlBG zR5EBTflVk>qqr0w9u0gEi*#mJnLm(PlRI7Cpb$DgS^ zI*cuS^@_}MKdHW63UVXZ+~a0jik`O=jrmNrtWq46m$w$7Kgr7u3u`SkzUK!mWUH;F z5r|&l=I-X@o`2)TiQzCiwEw_b{i4+=S0WSr16URdDtNsF3Ylii@kIdvH7_-twPa*; z6>z%tGB^blXFh1F2`UP>D7}YPVC9Jni9pH>JoxKWkn#*ith172Tq*<Y~cr~b718jX#-;*+mf8VNaYIx~>6WFCQGRh28I8P7O zGV!~-dc|pAKpV?z4V_4K`>(?$C!=ABYl#(Z?e7mC$GLg-mF{l><7?F!HepcY52oTS zaw}DN9QEAZu}YqULw`*FFsI{ZA5g74Ogdk=87%np>C++;urSV2!e-hojT948=5K8u z287|7o~}}~K!M$}Lqw&+zyYeOqfZd=ye2I>(cdeHoY(r$U(7gi!wXRWLor;42BH-k z$4B#IbpglCTL@6Xd?7*9I5}QF5Y+o2MJ}Dsz1A5MdITyn;2f~Jx*8c8n!+q}@#0SR z+_p-ap@H3%v}Y^%rwy=@&usK7!E7tb$~-eaL)#Y(K`j{Jx^D+7s47N1d6J|v(AdZ! zz`XjAJR>J41B6ASD)h zJXR7KZ1M-MepdCBIX=^Q7c`O(Lvq4zQXQS#2IW=hH#g=3!Jmw!?4$W_SnTM|&OKzd zEglzOl_54KUuoM>F2CS7B?4<(+sT*DDm692w^q-} za~RfosIqv?^em|Fs79!s8#KOcCK=)iE&<@MOnO%6<&D=Z-npOh4c#Wz*I?uSK_ z_O*^;U$I$E*g)u})%Dg-Yjc?&vBt(DDhXHkU6-RJa=NPBCjPWs&qzYftS{T`btDX{H}d|EEAqjQjQpV;Qs<=fq0(vJCB;bi!l!mG*~tj4z| z|EOdN<>M0NXUaVuqpC)6>j&@f6)vm!F(`iZ*d_ZnxarWV!+*T!^LD zh?pMu?9TLs0hI)a9eE|)ZP313TU$7|Of^p1dnwXv7VRC~Mfrv@+* zocwV>(POxPK2^OPR95XVF}?m^X(3u}RMikOm9AWAPF4k(n<&g;?p6F*7HJYp;CE=LCV}v}M2JJcWz6r5Crwi21Z9CG)z%o`0;UvB&l6gwj36 zjmJQ^uD{Bpw6ang6vkc)Dfs(e7nfLWre`hJ)UR~57{EDqy`(+&+c20{cMyaN59R%4 z-t|J1gJ^L)9-OEFg-;Y6U?v(`o<8m}ED3NWx@7R#_8kC+iB*O#of@L3*g+x!SayBR z6VVxD>%}+_RqFEP>hgVSg62bT?v-Y9aGwS9(b0W&4gQtY)%CqiK`t&`ZEZi?=IAK7 zMMbgGn#5!5Pt$KMF#QIT&**3!EmOcqB>h%1?l)=Y4;U1gGgMRZW@cr5eQ|XcXlw%m z7&%47QmfvsS4ph7@5@)Tp}jYg%VkiCO_4b7EHvm_U>ogB6fS}9FW}Hn{zT9+7@XiQ zb9bsQ?Eq05g<>0CnLjW!bqttbfP4R-g0X2ll`sK)iYHHA)cL8FmsRn#=U3p?Lf?lq z^jECW(bF5Z54~(?_*6DSn8^WYA1MT-*tT4zp!WymlNn>k$YTkD1#>Ym2C;GyCMdn|9e&2uA zNE5?*r(Wbqet?j(IV6gT3yKG%7rMJ6XL@pnOrbOk==|G=nVy{1yQLR0TdS>Z8LGCw z0ehUit(jopC4yn63}o}L1qkE1$bRgIbdkSoh=_j_a{B*nxMcQY@<2#Qgk(VY@l)^{ z`Pm3Bl7oNrzd0-6AN$9>{(q){k3UK^1b_tSk&UYh!%ZJS04#fTAB!w`9<^&K;_}*q z8jw%p4{{OacUR~MBO`iA_@X<6^t4y(j-%0Q8>9+KO1F%rn#$*R_zErF46#Af1TmA3 z0DxEhanDIlHkHKkI56%21^Wvu z4DA1zr-YvcFa0RIo}>g-eU_VE`ITWb-FFDBV41u(52F133fX19QtTmQvT>y2t;NMuYz$a*iw?AVkd5XK039FLiNqu}nqa(8w@s2@)G3l`6UI(jtn zC*$fp7q`0kPSRzv?Fzlb3&_iLX1Dq%JMu z9S9h>W@myBm=aY_mGHCq3g3O@6$)k)xVse{i%=i>Gl;u>MD1GOS$@m)Sqlw0C8gQ! z;yd^5NxXgp3g`~@xYsqEKmq}l1jZ9c{kO@;nIN`VnP5+62Ug&D$gr)9x>D`xKpbOA z^AmZO;D2Tvkz0_EO#E-jL^Aaotd%ZvF81~y11(j13Rix6BQlabmhw5>1I$U}`(<7P zp6@E~SWH_JO78WtuG7L71c7|7M-?prE!!Kk@0*%{Eaf9B<*~XaRR`>&JOg1cuTVN! zV$)mhxqp*&az-0O@Ng?zbBi~PHx>qaGhd33>jUp(b91RMA;AXR(m_&lB3j36BI?@H zYlsqUIVUG)HSRcgB0}WE#mTw8wkC`ls|V#hQ1rYUB7+Hd(%#N0By{uUu=Irggev|@ zuo7QCLWhQw-p1NmbM_)2Z>_OC1Jy2bt$GuON$M)_IrVL8s6C80gSITOKl-uG&RFw70aUOu~@!!$+*x37)j}D`VpkV29ahn^n9sfMq zSPB5B6#27f#sD^Fk^LtI6v%ItI?pv2>8&g+#bZ3>+s+-B?y^xj8V)1?n$Mm-niBtw z0_9ijIEB^*scwEKAN@N6imjv=1dM~IM6ygClN2eNHwV!2e**blO$lLV8;G4_R<;9D z3hUQ1@M%#55%e$_fRK)KAMvRVEJDp)U1MESbs*22qpKP0O>S+StCbrAA~{J=llHYV zH8TVE_|%Lvte}X|z-~byn}1W`=jp%nXz`S=(iwt!ZGfx4WMog{BTWyCrEoy@Ffc z0%uBrHe)UfoLN=yk_K?+`H2yIbn;JAB+ z&~q2dZ=G?F;8TXRk#=+U+fVFJNz#-6n%63&_Uy%%yIIKHA!c%)n1D5R=idCj+s3); zB~~HT?(TUD6-iKM1T0p4xDC@2C+rq7vL9brBG-%vU;Azu6L$lvj^>Ivg$sw*lP6@X z4xLatd+hF@K;H7S@Uj5&SWB<5y}f;jGw&2TcyD?vw%aJ6)x2b#GB4ZSz%{dF1d&_C#nSXs1UPlT)0Q8vXdpkCaO2N$iPp=U%gxLtDC? zVegakRiqJ#iEZ(RI6hN&V=>;65|1ybJYsE7+&JB#mDcjx=)O-uVL`O{>mRqG4-NQm zuBJRQQlY^p`(7)|koTO68(ThLYMRay6!<6&+_l_{5Pa9WyqqfM^fZCT<`Czab;HUw z9~Awj*qL$3grmM9RceihjQo|mwx09L1efT=IBVnemBq2(x&?Av=NTA$E3=043tXFV zlu;RarPiqdGD=D^xv;&@jhQVrMKL|IRj8k+U@SRpvpeWQ%rajelc4xMSXsnh|K|8cK3(?I%2SstK;WGzMJq+I8m;Y#ISIafaAtin_S--N|-yc2Ns2q4CxD3iv zYwgt3r)?b|EkvIT2n_J&E3p33utIsMvb-ie?jfudVV#emd0oHOE<~9Anf;@npr<0Y z_dxh5YsRClp%@Ut@$^pBhZU- zA_#HML;Ja9*JVbD`tLs%1jL+~Xv$(rxManq&gPaz?vNt}<4Fw|s6FLA)=I0oLU7dZ z>2)jNr#s|@V2AQK7_<@BOx8M}KKSLrX^>+A(SI{WrTGCj$ZkT|Ivc8-=NPpkL&tXS z^H}sftfU+1ZWaFi{X5t(K+z^6>i8KxX2{?~&);lM@&F2wpxtsibi$v;JXyEhFp6k} zst-79q-+j$=Ru<@iT7q=XNff%_4fBI$mFDm<`1FA01>CK_79a>9gVBK@u6l{7(61{ z%j_g$HYbP4;nMxN*ui)E4FvJ11YYD@-zT4apBL|@S8Sys@}<(*`sguqN4{>vNEmP^ z=Ds}kw2H0u6aztq2M-QWEP&es!hDUT9f%-Z+W|6E#*qw%iJ)*?l9ZP}V%utGrDT<( z6(4~D%*WujT%;{-M1KKKw0@n#;ko9vMkuB6X4RfU4hh!e&jp#)fY@;#OjP(o2@K1fb|>gez`$j)Rg^uJbZIWVCLqMhrhe7rneSy&K1Jq(s4CKBn+*21vs zPPc-HKyF5Ia&onMHDLN6U_Q*Mb|eEU{bhLdJ1mESg5N9FrolHd=W`=lD6nuKfm{Jz z+q?F|ao*3SW)-VmtKn23Go=oipwc!!~18^SDTVeDvZHa1o+vWcR0z zq09bk9cXHM+;cwITKx#0;#I?vM^0&1RtQl;OfQOW%NSEm+&+d9OC2^q_5)=6J(~d5 zMCkayGjT=5Fof4`L$Y9z1jG&Gh4oYjDM2e}L&LUt8jRYK-ausA+jADL7xJ+17sz-q z>_1}9%(Q=I>_{*Ei4zwwiJCqL(FPQZ{uzyKBL1b@;0X55D4B%?bd8#(rZa=71kDHU zlfw~J{as5bsp>Cc59mtVyzqb4RDzs~pXN6-%QaUbc6~{Z3;5K`M0%z}Tm8f!1?pa+ zPjq`bij>uSMk_2N%nUgzbnCWR<%{Z_nNV^%N&efFB$t^(YnE5;O@op9$O3J6bo4uD z7zHNz|4@Lz%=Gzf(|%t9V-JH5$M%kYN!9s$aT(oIaVTBAg9{&_dyzsn0uwv^vpZH1 z@nGy48JZ9o7m^Sf7T*egiJqNaF{IWz6dh=C>l@@Hu;~-X5onO$Wq2w8hsFf$6 zWJu)c5A~t$VKzMLc<(y6AYZNKVm^L;`P)A} zMk%}w-dP>-1@RCVoa-ZLXW$@6NA7W;zP|hJ%l6~p@tx2?rVB`4lT^Hp;gKh8%LfR( z4e0Rq5nWzfLiI5cbZeWL92~R&*&u}%xJ9}A{>mTI191{QOD5w6u&`Ncrxg^2BEVTX z6AsiXGE!(X@dzmLmfV`s#V7GbrqKUIPybBEqsHf8XGhl!C>E;(U{NtKCwACsmREWM z&$x8w4>lmjDrWUuzw+DOE|VkkI}xLw#iC4-_CP?~ya{gFh3#dREl3yHT~q(2z@i)ELe3TYlK)6(E;?x<|SOU1`Ri+vi)bixZ zl`7vHB93y;qv##zGlh{dK68GsHt0LwPIZ}64kIjwU5^%gQK_2E4O)@oV3A|ExOem2 zMBcGx%AGTKlq2HA!UNC#zxzm1=h_w#O!`T*(vi1>3Xs=le?-rEk4_kvMRYI0EPQmpZ{fp7xU zL%%;4(8bdB8vT{PnNy%+_2YVO@1XPdr4K3nWq`~AUW_cOYDcXS%IjO>59eSbeyzx4>^3;jRW%W*FI$(@{>c0Vpl zNJ+_f8Rs83=LKd!3E|+5siWQk>2ANJ_ifz@R>{%o`n`KN94P+6v}}?W7gxbbO3KO> zmc%P}rXQdFR1CMn2pXw?;m1q5dDWlodEnIemk7)MQ$Xwg)5oTzA|;liIVdns2a9w7 z&Iv$t5Ps1i!v6=4ZC+)Ui@KsG;f*K`{q=&sy}I4|auFS}KK(o;VqO2V>Hd7d|Ly8Y xh*yq4KI9)3BQJpd$DvD$+#+q$5(KV*!-jq*n!z5;~zIBq$;x0uLe}H3-t1^b!aN z(mMzNLWj^p=mCx`TCPLO1D6#kKbH-1G;p4)jmTda(q?1^8akm zexcspe(AFa=z5moSw8JI7xh!9u4mo4^yiO?+_%q1N}lt5>pTDWpYQ&onbpPX`i#8y zjvpTc@@NsSKY8;%N5lU{6hBsW^gH{0W@C47vV6i9n9>-XP&Ev2Blu?o1;gh_5yz;!jZT9`9l!%DY@sCAl z-p|p5DbusTb*?EHN2_x2g}`+9sMcxw`$gwU0s{lH+=Ya?Q`h-xbkBfNzDiCem-()& zeI9W9tt~T{XN)S>t8iv*4riO0fo&P`(y<*V>TX^Kn|6;^u6gU6($OjEFvyGe^f4sl z*@y6uj;^kayxD2ol1^qgHUNH@BwfY}-2oP~E;e7mV;%;3^x4c@US@b?Fb6lcw$`5p z&HNd7P+cYM7)!R>KQKnZGfg+j43k+TUp?K>Z3#!h)|wa@8RM#X3(>M*^6iCCYCXR`=qfnMnxScp zH=IcR4z-`fG;PEY2j=}hd%Q+vUBX%rv)Ex4pn#dBBw;ElDoeyHWqA0($nIv&{go(h zY=w(n_QknS-75j7K!dst4%M*M?@ZTNs;#E`4J}|VI$sB(@jp^htlBJd>yGSo3=AL} zOAC#{$-)O93*{jlPHMTM&TD1W+p}M5NvCZx!a_oXwZUva!S6tc!kYPNA|h-c?Zk(%ZHOmhYh{Z1IvVb-qz7lftNeCBcYECw_bAM9!nH^ zyZpdQwOTjO^Fhl%ApOU}wVTemFFu#Eva*u;I|xqLYTbIPGgkgKuU-k7qPe+n4rgP% z-E*WMovu)*%qft_+nSo12s^}zPD{QWQS*^-9HX(8f&%43 zI$Y+*zra!6D^ob*mZ5HC_!|7d)Du6y_Ygl_-A&Ye1)}4jMI87mK-~{B&1O|v1w+(*#ZLVSeLXW%O0@T{RSAi&j_cN*XxrY4g%^@H;w}@a}$J;zV zdjAzXI1G3XzLyK#{``jd9@gP%B)aTfG zT3cD}`xzBljGyI;0Odu5Cymw~9&AN3or=jl4+{7RGp%?%R*81ky4q4L}5bK|i| z{&=yeC=R=K7F1N4c}7(_#Qx^y`B|bRew1@1he1FLYm!7?OD`W3hE^oHlp)n`D7o*(%3SkiGzY(>TZi2t1l7QVS z`>oL?=OqU1L6~1R7!qfLHda#*3;GL6?=$_kAU|h0(GIklIoAnub!W`^P zHed@2SrE1q?~Jwk3IZ)U;@Wpyw2ri~7G(~Bu=4V9G-1MRl$SUuBXnzOI7Uzg_gKbu zYn~Me+wA`FE$|Fq2q^C|6pEr=V;2U2GJbV88!X}~CXa*;H`{pF1d`$D85&G*`4v1h z8(dQo7r~Y5d1|jhA%@}FwZ&KCazBWu0n5AlLgGmdvhq0j@s0%N1zyW;${NO!fr5!* z6928xNg2kH-ZKZkqL2(XVs|;&_>$pVxqe}+$V9iD#!N3g(TaRA5g;Ic;^X9;rCgSe z3bRA{%gPCI-rds=-UX|K*JQ#e)uU$*cDF2RHzTg|W6rY4^fecj)dr4_Rc%f9dm9|V zYu8%w(A+Y2DpwPeYBG;;{?O44rg@#8*Vapp1GM5<-o3e{@83I$-Gs;u)jL?v#yVY$ z5Wat4U}v$h?eC-Rv;yoLyE3sejm5Yl4 zc=>cHl~_Eph0uY&ufC_#hBJA6oJT_y=3-;<3t?X7nrE+IWz*JfEM##bUxp!37KZn7 z4rjR$^V3M51D>0G3?V37ryJsWh@)Ewr$L}^?rqO(#7=s@YG?m?(I>Q*@ z<#A+OE+N4!AtF*~*CX`EvYl-Fg+9PGKjAQ_KwvA&>f)a93C4K4JrSHf`SzPyCL}^u z)*?dQ>_eqz*>G4kfEFr>cqsxn2-swW>WAm;yZYy*va*JM{jwQS8*{k%Tb zUAl0ASH=H_c{V}4;DC!M5)Wq+3w zwRM8_lD_{L>(;Kb^iGHhe9!G?sIbP-ERo%`Vt1cHtg}7va`TE4-G^7zllSX7evwE> zgu?FP>EE^Wq59i{$UWZKwfldBAAVgbedaOKpgH@ zv_w#crY0x>+wT*z3~c$g${D_%M*xEL7ocpB8cCS;mg?s;b_jCDE}z*mHrh_Im8Pd> zV%GNz4d=>kiTQ*5S6oU>zs4(HOq)u&KpL}~u3XV+umZeS6gxsnRCGRLDaJw))?fGx zZELti?`(uAUMsu)T+*Liw+QNOo}1lPH=WMK{^hh{G+(PQS=i%EM#@|sIn|iSjnrLh zaOco^Aeg0NyuvOJ1R9e93~STc&V{(TMR=Xr`g2&^QBq;UCur?q`aqt2+Kg}ni&SJ( zl-{y!JvAMh;9>Db8EEWj_<=)#CH$HX*+yL*#v53{H72GnYfWAkG=*?kYWAVk)zySr^9=z?A|_chGfT{T1b}FC8tLla)KKh@bB;Eiu1<0m=sN^>Tbja zSqfhQiJ)=(L+#U+G&G@be;NXQveG(G4s}_;sB(C8luuaFWxAm$bv+QHyy>fWWKl=n zY*FYKDkb#3n)|>(75(eH2q=YVtNNaUMa?xqzP6t~CmI_yh@x_GFB|J*RE4-(UJ~(j z9M2_$RlA3VhQgQ*y+>S~o!4S5j|BJ%pUoa@Ar3mzx)E}b)_X$FlL_jvay5okVnTSWw7T!ipNh5H7w z%)jN3!O~ZF=Ea-_!CjndC({>^hy9^J+RQLDXph}l_C4I3&D>e>UENU$SzTRVPFaZ3(poO2U6dracs`bjbYG#HH>_j&?T3LVbsd%1T1Q!=pWHZw%(^78WdQOCkqrSvmab+gJ}y>r=M0*1GDa|FzO3vsxeZ8st9 zvHKSCktGto8CL+wdzS*FopKG{ls{GyUkwDv!k_YYvJgM-+}tdko?e)!N$e~02$=_ z`=5mWh|bPD+YZck{XL;xO-=EZa7Jer7akDkebpcl6q=grOWIvCSkCM0T=lIJ;Pr|j z{~E`l+E6Zaw>X{aBk4y&n8IEiy-7>BsG1{)%+OWxo;Z)s2XiQ zm`BJ73JM;xiC;%ydj|<;gQ>#Dm(BJ0qV>9%Qf0%#mkA5-y_sAByFAQHhD%bCxF7op zzI~WWIoSRCP5C@Oa^cKsx6z@;!xu~5wFV<2SlUA*C1oS^7dY{G`EIyY9df^?rz$gi zEa&tMXNzUG2$Mla0TAf<3&1CpB{~RN7IE|Nc-IHW%Xs6*(O}{jVF9r<$m70-nIsDg zxEvkULWyfdfbGc0VEfYEx0fs8t_1A#73rZcmI$;iXTAINQ=m$J0RaIAsXXdXJRaj?B6zqlsvT+n@eDI_Ur9E0qE z;qh7!d^M!p!@^?1(FIQa2`wCx`%wY2AIOf?zzRw6&@HIE!Tu{9cUaTrXg z3Nn10P(Ci`Fj1Tlq8d#KP;xWIsotcB|=Y(dD3^q=8ze!5n~T!LLL=LEjBQDIP0I0rqiH zvt`m=vZY|*LTLheE2kb`&)X~6dp=Ax>nc6Hxw*O0U_U%Gb~WJmc2+mGyBXl{d`TA* z$RYVG5R{a|n6mf~wiowP^*@0ovlcP4;jnK**?QI|QiRmKTb8Z8*i!phK%~got`P1^yTVd03 zWeJA|Imcogs6-JhB7SCyd9R$0oUydHI9xT^&G7v7+8FZXi;Hy~MP548$IZT;+6>C( z08Q3sDtUb4l~uDkBr839$fFef050n@T^Op=cYQgC^|g-HbpTB2@4D1c zXnq-ZMdrn?DZtG9ppsth?pG@wyKmw=78(HFWdu+hGRspvPjAnChz66S(SswJ6ZQ=P zr5tctevSXb2FBbR$_@=R4N({Ex-EQOZ*KWk`KCG;8=F$l>$w2CnQ%CD_E6v-Iq$E7 z)DylypAF1R_C;JGm7t^jC6pVaEVsADIL$6O@jKMmr}}Nihf1&*=EKZ$TtdtSmiOBJ zzj($!nqUs}>}Jrj=Tp}f`vonlth*(*?%ywdw=tOY4ZgA85=_f@>v*=la&88N+yYJN zdU}pQ*}J9O#h%{OYoUV^dVEH@x|EKah*-|i=qZAPRDLzJroG{bV5O1w6L=sY@oL*fY-G*gj45EKBPKz^Z1%_{*2Y}HQ@GN zfaU_cJ}LsS7I@wLUmI9)Y2OQ`W5*(ae2sX{cW^|1OjPLRP#u_>MD!#zW2e?#}0-o3m+W0KfdqU9Vut8;C~BHKM0b`?^?sX^`cdQsR}nZ$PjTGMS8-Jh;QdV>jIUu&{<)3JgR0hvbA0y!CGs?pq(0 z&^@%sY)P}ZHL&2{8P}sY*w&?Kxfe$sps&1gYVTEtp))YS_CtAXJjfbO;K$cocJsWM zC7o}w!R>L&8jpmTF)nQpRd+%9-^f`sn_5~^$~d#ri7_z+x);h!1h=2p@_o;=`*c%o z-@#!t))MA}G50_AQ16C+xOMOs)|jFUx}J9+iYh8{e^&zlhN!T;Dk*GyQenbGhD@OhdJXdq{b0N0SY#i+|`S(u> zf7x2WcyPh@@5#k3*|haT8m75W+M<=;D(@{$DOzrx=FF`Qo0}b7af`t5Jh5Y&h%7>Q ztW`z1H}FCW=Q=pB-mC2LPF73Tuf^iqRt;3W}eSmh^nho^e6iC3%g>Ct9_;Dsc z+3mi(eCp*H5T^%=Y~K!^Fvp(cr1Ip+#cPj2=!d$Sw)?BIvx2~nwLe8@dF&^C@l?|P zECEsw+lQ}k=bs7SvmWRqPEhlluRVO##SDa;jg4oUal(hd@>PcHP8g2%y;bdFLlBn+ z%|Tg@PLqf?{3u=Vl7ObZxw-kSZGC5j_jsYt(~a`4i8(qt>TE|drhW#z3a%tHHx#5D zREqEfk}gzDB2@oM*C+>Qn6I~ibXeb=#c%9xF$_KwU(T^=VWO28dOBRB+z>AQ*`d#L zawzExXiCER%>Q54_rLl^qvNRr4UW7GLcjr1UfhR%j8&?G^DzJX$w5!>*O!re@VcUN0GW3uc_;nsTwX)y-lQJvV_8{QaIh)mz>5Nf zu*yIx)6&wi_!1<+lc=NKqL)AIdXh1kz6aSVnVSAj#^``ZuBGmOkB=|oHVwN1Ta9f; zcm2zU4`p^i>Ag{)r3TUoU-h573w2 z1*B0z20j!{n&|J>)em7L_{V?#eEZWZxr%0V+tC)hgF$YY?1>4#oDQlJnyB#&27Ss- z1pRx{+OnO%qL+)h~t;sZvvY2B!duqV;3ZrOwBj=^+ztblQ` z?CqJ)mH97T%$Ct+#*24o)_tx`@{eFMx6yj^sIlC6{P}JJEz@si(Yg2ErXXP0S-!_{ z#)XL>0Y`hWhQH=@j$@X{d$d2fC5)Ma8IWn@)~EFGPZ*zsQcr0dIg=6glvk7qNe30#?mUw)2oV0IjGx)!YuX*t+t70Pss{X%P5mt1_$r#GNS9$NU@ zf!%+*y!;+%z@^=P1;Apml;3Go=d@o9@$-O~yi$^R{ z?yD#Q{EGKeC25I;(96mGyKZ<0ddtS?T++@C|r`y`uE#Po%8w+?WB!Y<& z0K1{+TX_L+DInJt^1;fB{oZoh02Y~7!yY{vgI(j*_|fzq6cX`pF3Q{HCBUp>3=Bq} zmpfsz)xJK@yUpr2`3)bi&H~4Pq2sjTjjJ76yXQU@NW$%Zy_`6V(+&w=NG}dm{EoXVTU@Q ztmX0SaH$6m^2|&2@+&NuB%Pns9oAYv@l_-2b-78=iLm{tZ)sF_=}#a`<;zz!q|rW3 zEFz8=2Dk?pBRxH7X#294M+dqq`ch~7TN#f+!-1vYdep)4Xaz-RNJQZT^Gsyn>Re39qhFe&O>=2(K|_1-#0Y9R)>pHwzs!u^d3F# zSsdM2hF(ZfH#>dof5v>%R4!AdTY`#;+=?kXW5|Q;B@X7<=1;v7So@VBa~JgfLx6Mv z`L3hAEp|K~J@==p=VaVwYz$saKf^37F^=*^>huNE-AGnjU0TwqdpNF(5mjz!Xym*) z6J^7>c2GfWsqt)J_X&Qnx4JCCDbJilsNPB7`NTe0r3c;r`EWD3!xFw<3$cb$L&v3t z>+?^rT!2hZf4}=OeblSYL@q2KRIU!AGhGxAjDdiHZa0`i>prtA&OG?9XLBgV$Z&uDX>q|mLde!NVD*cz8*46vPTygadnsld2|9)A4z z@tLYB1V~EajO|vxTEA?*Gyuo2l)sHW{z^NjzrSDN5amzWHD?eq`>6G_v$OSDvmL>TFugYP!@Bm=>4Qsb zzbAkGd^qMjUu`bJb7Jr*AMbE+cO@K=`s)k_G@H ziq}Z_!%YtCn>Q_%zPSA1ufz6QXuvP^D7YT^d=nmr3>I1vqKpZX8+boT*^-VKI-=ieE>+-6k@&B{ub^=33iD_l^1JN<66%5A$qmeaVxIjXlK zzU*kZ+;RUSSH=%>egmOCXG{zGN}ajL%abXp|InQ*cht;G=xDAbYJ9ynD+usMYbq8N zmRFrH65@!N2U}Pxe_vMik-lypq?V`C8D%wbVuArzx+sw3yV8&MaIr{wZe>W@KuNe0 zjD&~jJR8O57}gbBaK@;eb7JUZK5<(jG2efE2PEtq{hS{8qhWwGe7y;!CXJLR{2dl{ zbfL4{AlS$ zbl47axdAMnmbv_cLy5WR8CTYS^`((e-my@w^sLjS%n_q}4dP|mWO%$VBb+C^u=y&J zyNUed29`56AFQJdK(+3cGw<2xBD>XFRf5rMD1nD_jmGP88N1$cu{Vmah!v z5?4J2{C1Xkb3RJ6ys~*^0E@opj7dswf&5&(cY@At8sbOGoW=PS6cF8sRRbCF3|Ftl z$H#NC7jsy`w|0o`Ffn=a%b44~?4U(Nd-* z9qYxvK+gOpj>Wa56Iu7;_Y$BrPQO*6ss{teHbY3@bwn2TVov0bmtpT%9q+6faay-9 zJ=<9p?9_ez`YT%rXfYV%v|K|WD|y|zd}}f1-v4L_s&@q_eo{<~(1p`LLJD~J`kOFV z&Cb`eH#JX?N8W#K+-8xbNNTmD0^RHk^|(8rwBv2OaP2j~UoO4;2MSHpuAIKCPNopK zBsGxI_&yC+cHupn}zu~Lg#zl-(f|3qC^ zJ0M}y6L;Tx^!LJ}N00RM2Id}1%yqb+4=uW{*5j2yCwH-9xo|#_xYh7^0CBJs`#6~D zj4SH67y}4$j9UxB0$$b1X~vaSksxQuf|FqGYWME_`=xs{z092TKr0aFU&Tw6_y0`V zV%W(zr@Fsxw+5zb8fNS3WvKs0f1ZSioj26nF^TeCqiN>V5a6rkKTde;USZwF=H7*u zaPz&ELkhK$&K^t*4%dX02{u)EDCh3NA-A!B3Hwd>FT(-xvQ(IKk{ufg!X*8DYcCWLaa_Aw67`6 z1oMnfmfA;&8bprsuV!a;)_Uf`P}?azke_3JmU)&}mP1PHPG_Cr;8`U`?@1`auPOHO z1u64Ah3JkDZz~j^S|R>z-0Zd2KyUR%rPCe1>fSDzXy;w>7#I2~_b28H#KZ)(V84Ci zPWn~)uy3fld?xoeo~-`q;-wp;KoD60#`;b_dMEPi$`uAcdlh7ME)cqI*v4W;jPQ$~GsZal6^_r^x_U&Oy8=Kt)$n~^L6 z{tHQl8CpkK>t>5@Q{A3LFE<38-``t6M$c&SvTz8pkfXa8322kd@ZhXFKvt_LB|Q%2%Wx0~7t}&#I2=X=&~syA!}+ zt6xO|s+<`*ub*s!tcw5@u_&9V0Mz>P^tVtPG6NMZTGk@R_Qv(c3FSOx?UA1Brik6T zwcb0O#`9KF$>L*ps8&|I@L$E=KfJv?@+VUYkS@6e=F*60Ibq`v64=EAok6x{zEh`pIbS%1U!HnJ&=C+-!;_UflfwCr_s_9 z8`Mnr*>eh{PETtpQBhG_EzRI$`nv<#>o^_pBRSk1Wj3hS@s3)TRTqPNd3(6n@&qk+8%4_8Yk5*7u;)d<7 zpr_U^a(rAeEi`fsF&@8LA5uLJy>h$_^zTC;kSHFlulMZ^ljVYeBCXbY%~3zCdlVF! zhK6=05#B7)KC7F!FQa8i)t+E);<*3ZaGDveLwLb=$SuK#@Dv=xsiH~VT~V;~r&zSj z08y*0r-$h3Eyr{dCbi$bsx>+E`eKYQ7D!(CO8vF0(}GX_NgYt!GEaE)QRWJpoG8GGkk^bOPfgz~8ZnZ{NbSv?3aV!!t88CBZ|l$A-T- zuL+G-iO%AO(Zcuc0ejnPZyn`47&ThvC?{)Q<78Hq=zj0Es{Z@vbXyu^Ms;js@MSW2 zi=9DWZzaD1SyEmORDmAtrMa;A`HU4LQKzN8fNn27@avQVL=7o~&}~sslYE1UXq5AB zD|`XTp&J+g@xl?a(8aCgpLMYXtm1yIKp`Zq1J@y$C(Z4hW)DN{q9ya?*9#_6*E@q&6^_g}w1Z9{T^5{4(%uI%UK=ZH>SAYyg6;cBx$=wdrx*6JrcF5Yh*b&D{^yZ$XgoW)S3NN&Mc;LHv zwCqz|XkLO_Kpy#Qwgvl}NbsjM4CO=TKBQiP%FB1zY>(~{y3f+Yyw>W8`T1;qmztdk zoAcZw?r%&XmYc7+U)L54)?3ai-QJE7OlYs@5Wf4sPizt8g7DwRWs6>LTu7GoZEhY_ zK3DVUQzszi*ZOSmfrn+_tG_=t<(|8=9t+(hSB0t_S@3_-g0s01&Hd-QNP6>FAQ|_X zr@*4jm%R5)UN8aBUT$1_JxBdln({dQc}4d~$&~~N{J|^{H@Y`IhT7=kd^q~NJMlv7 z>2ho9g|bt7t8wc$1mk01C?F9(+N~MItpQ|VuduLGzuLJm@+MhMCKlTxx?AqK=Jc*M zi4rO-7n2-Q+Q?f-NWuXuAbUsQ!^6ml~gv zdn|}vh2;GMlLvDvD%*O$oqqJFyZwz>T-v$IYdo1iNvk0p2ZYe4Q*PTD^5Mgm#st4J zR*X4>T9+@7-#Ton-3N=_r?D``%>KMUD2pzcqx_P~Nu z<$7|^$_nPTgb;iuN26hH?rs4rJUo2h>=c?(?f5v2g+8X|W%F2C(nNJxMQsv3Q3}ZW zBT5r-1F1{RwAexmjeO&h=A~Yb)8`I4C;}B+_c`y2ie~)2vihEOr42|Hr>l{Gd>52> z?hA83#{@R@ZnIX6_jfXF_VbRVZ^13-`;?LtdJgxm6|o2mJKX5YXZOLj?2OgnLw^-u zyjgP8r?}t#KPZUm=c*TbKi*9G6IXs%pSG2eT2i&JNG`Ng8>a%3O<0E!V)r;k2D^;) zl_}>eMu9rIo^KkTd*GOkcF9ff_-&DO8^&BqP4wraq}tbg?tuCsCn3=%C8JMUiaODuTY`2MmiBMWzwD?#4;tonr$STGnZ@y^KP(EFvK zTIdV>ivbs=cLN!}O#8wg-!QEC5|o~P*R&5RceqDE!zC^G1n%2~IN-*jAuB_2rV<4B zb{}c(7Ct{_AF|d^?Ip*fmAtrW*75V_VJ7mhwWmXPN3b+4(%p6QAn#~5G)U?CIJ+b_ zw}m`rCnqgI7Z!|sz_!`K&Oi4zsuN%4fcbK|sl?7d78#p~hj|d1-B%?A#Q`&V9}{HA zAbEjSj>jW|MO|oLFUmk*(sf?Fk|p1(0IxrgJzoxn;%>S#`scPz2$>?~$0c1x#XW}u zYDsjYQYY@tW*Bj{fDoIV7;WHYl zU4-jgVGvr%cw+VeP|0|6dCD9|uvp~zi@Z{5=k!O?X_};czZu~^mphLLQcEHIA*zhN z0{8FhWjv9JF)d8aRId{qQ&wvJc+u6%3+;2S`mJ1*xeZamX>4r?*Oy{FuFLSi{~&Sf z@1gwKBy)#0b@;{%EpcpfHztJWGz_>b`Jl{{igjl{=~XfiKPCiK+4G~-+WSb#VaphS zi>a|6!7k6Pk6=wUi9Cb@l}m-Rx`P+c(7S5h5#|+2RPuLcUJM9GoQd3Df4aa=(G*(96x1LrzGU5EFYz*C96==C> zYd6;c5Z;Kh$<@yz5r-BPemnEf_uCzvou3<%H{I68f)agH3M7cmvg-vF9f!t-KZ~_w-HwWFnQsW}nUchxM#w=B z8-yL+=YD=^f*X2M!J4}$8rD5#!+g`lWB3m{&W?Ej3HyMW#vhY}< z^JKXeTwMfC{(*!aPz-2#NWMiv8*mc-(LVXNUQ{XR;r!rmYDv6WbYwC|Z0rtDdj=rh z1aYt!c=WNY*#~UCDK#VALbv%XETp`AwuLrM0TJR{U^p%v9_|qL3U{{%wzM|QNAv)S zQ`R(r!XQa_s{*3%Xg#n{{E^t}DJh$$bs7nf=xUoLV)@kE{K8cx`^m=Wz|phUP-9SQ9VB& zLW3@>@9@{8L)Y76w!9Xin`liw{ad}d0HiQ;lwABx8Q`?p&sY;#CM>IjJMLxA@~2#E zeX|lobCkS4q2^7dvZ5SkTmQ%~u2A4qi}P4z8W=k?cGxcVhj~v)7!~^rPB*rgRO~V; zSZ4)1`=iU&gGDwbA~!j#8Y*eTj+u<&mH#GvfILz=^9(gX2HEeFIWJlNk+vvg^y_`% z4O_L$*cDVl%n;H1u!P7e+yt%F%xh{NF98f`qC;FCgPP3+3VxVZMXy8VlW)fV7=wTG4%sqs72k=6mhYUmA=T zZUcq-%wl<%G?f;>)VscJ><{K<0|(VUU#|L;H?@Q-des%PwQ&13XBw_m7kdoknVbM1 zztH^a7lPa;d0C68&ST+CR_icekGqR~_VpkHnDXUIu3Bt?l>b3ZewfC}(1d}3fli;B zaLxMbHfN*^)Ox%cT#OD1QUZKDU^)55Z-hTZGh@-UE>32!x%8WAYHIs8W?L?o6qQAm z*dQeGCMDnvKQxZkK830!%KD~SG9zoDmkC(rznBxW)84;iuDZ@iZC`Qw!ClXVZc6O! zm-qYcv#<`*b6;}7pvw}`wjFD;Em}${P4n~fzQ5g|t9_&~IWw2#Kp=lsYQOL;utmsH zGus?U%V8kiljADsx^2b)-Qm64O5EOl09&jaYt%>(XOe_uC7{&WXfH^4&96_-E_ADW z&rI*A6+|clS{k2Ml*?fH`g&+btjpgDR6LnXlmp@zYT<9BWruYmb-Qto{C)Go0BML* zTK*54jhwx`?{>%pvt3eq_rk&gv=YTedkLKgv)#>fG0oCn!MMm=x#o^@|CpPgMzAmk zTnO(-;^V#kevKt`)= zdwvGKSz~kNl7US(q!Z92d@=`Rt=8px?*}<3o&QEtk4w#dX?{K!DB)HUMoap*)^R*0 z#a0)QcF$g7cf)+)B`TYJFst#2hAm2SOS+PF{fXlWCeBU}NH@Ss0M0B1+fxLpgaK~F zd^BEV_49KJ8_Lpo^zB(SoKXD1uhXJQ@82hvm6Sy1B4xa-0R$U(j+i87|Mrp>2Zx>h zjhxS{MitI0^BO2_O_q^z^W6ISsdPj>P&&(cZ$=0|rpMDWY=mts1yVKp7|UE+VrknJ zxBb$Rps7T(|9_)5krSoE`PFw6UhmDXoW;xRX~c=%C8wGsx-7&jU@`qp^R5uToD_u3AinHvbsx%VA9w!o1n zgyb5FL@6Ru6O4oZEsisC>ka7z3N~!TqVJ(xCY^yK)a?B?Cl73n}-BS{0R%V1UDU
layZ+Tx$L!__4}a@lz!VrtY3B0cC!V-Z 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 K>zUUKe*G`&`@3oY literal 0 HcmV?d00001 diff --git a/docs/assets/screenshots/classroom-teacher.png b/docs/assets/screenshots/classroom-teacher.png new file mode 100644 index 0000000000000000000000000000000000000000..0c42e9f313f16a35644f1f694e59f60b773878e8 GIT binary patch literal 49235 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�bb^5yNQi) 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 J$u}Q7`5%-E%t8PF literal 0 HcmV?d00001 diff --git a/docs/assets/screenshots/main-menu.png b/docs/assets/screenshots/main-menu.png new file mode 100644 index 0000000000000000000000000000000000000000..4d125457c16c5c88b1c9ddae3ab82f0c985bc1ad GIT binary patch literal 76533 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

>pQS5L@e()| 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