From 5454f2d6e834f3eebfe5538b9e8343cc93750c0d Mon Sep 17 00:00:00 2001 From: Casper Date: Sun, 10 May 2026 17:59:33 +0200 Subject: [PATCH 1/2] Publish bridge flow updates --- .github/workflows/ci.yml | 42 + TODO.md | 4 +- docs/architecture.md | 38 +- docs/vijfkaart-hoog-systeem.md | 448 ++++++ index.html | 53 +- .../systems/five-card-high/explanations-nl.js | 28 + scripts/app.js | 1339 +---------------- scripts/app/bootstrap.js | 110 ++ scripts/app/hand-start.js | 143 ++ scripts/app/helpers.js | 209 +++ scripts/app/public-api.js | 125 ++ scripts/app/runtime.js | 248 +++ scripts/copy/text-nl.js | 8 +- scripts/feedback/controller.js | 264 ++++ scripts/flow/auction-flow.js | 85 +- scripts/flow/contract-reveal-flow.js | 50 +- scripts/flow/hand-finish-flow.js | 48 + scripts/flow/play-flow.js | 131 +- scripts/learning/bid-explanations.js | 49 +- scripts/learning/lesson-board-coach.js | 40 +- scripts/learning/lesson-start.js | 58 + scripts/render/card-animation.js | 133 +- scripts/render/contract-reveal.js | 27 +- scripts/render/play-plan.js | 21 +- scripts/render/render-app.js | 651 ++++++++ scripts/render/render-auction.js | 71 +- scripts/render/render-hands.js | 67 +- scripts/render/render-review.js | 281 +--- scripts/render/score-table.js | 17 +- scripts/state/review-playback.js | 120 ++ scripts/state/seed.js | 101 +- scripts/state/settings.js | 59 +- scripts/state/state-transitions.js | 61 +- scripts/ui/dialogs.js | 31 + scripts/ui/menu.js | 86 ++ styles/auction.css | 31 + styles/layout.css | 4 + styles/review.css | 46 + styles/table.css | 390 ++++- tests/browser/smoke.spec.js | 400 ++++- tests/run-tests.js | 1 + tests/unit/review-playback.test.js | 90 ++ tests/unit/script-order.test.js | 12 + tests/unit/state-transitions.test.js | 67 + 44 files changed, 4550 insertions(+), 1737 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/vijfkaart-hoog-systeem.md create mode 100644 scripts/app/bootstrap.js create mode 100644 scripts/app/hand-start.js create mode 100644 scripts/app/helpers.js create mode 100644 scripts/app/public-api.js create mode 100644 scripts/app/runtime.js create mode 100644 scripts/feedback/controller.js create mode 100644 scripts/flow/hand-finish-flow.js create mode 100644 scripts/learning/lesson-start.js create mode 100644 scripts/render/render-app.js create mode 100644 scripts/state/review-playback.js create mode 100644 scripts/ui/dialogs.js create mode 100644 scripts/ui/menu.js create mode 100644 tests/unit/review-playback.test.js diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..6c58add --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + test: + name: Test + runs-on: ubuntu-latest + + steps: + - name: Check out repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: 24 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install Playwright Chromium + run: npx playwright install --with-deps chromium + + - name: Run tests + run: npm test + + - name: Upload Playwright artifacts + if: failure() + uses: actions/upload-artifact@v4 + with: + name: playwright-artifacts + path: | + test-results/ + playwright-report/ + if-no-files-found: ignore diff --git a/TODO.md b/TODO.md index 253a7b4..23a1047 100644 --- a/TODO.md +++ b/TODO.md @@ -6,7 +6,7 @@ Enige bron van waarheid voor open product-, bied- en speelwerk. Opgeschoond na c - Basisgame blijft rustig: zo weinig mogelijk tekstuele uitleg tijdens normaal spelen. - Uitgebreide uitleg hoort in developermodus, AI-suggesties, review, woordenlijst of toekomstige lesmodus. -- Houd UI-wijzigingen binnen de passende `scripts/`-submap; `scripts/app.js` blijft vooral bootstrap en gedeelde helpers. +- Houd UI-wijzigingen binnen de passende `scripts/`-submap; `scripts/app.js` blijft alleen de bootstrap-shell en gedeelde helpers horen in runtime-modules. - Gebruik altijd de sterkste geimplementeerde heuristiek. Geen keuzemenu voor zwakkere AI-sterktes. - Maak claims in de UI niet sterker dan de engine kan waarmaken. - Voeg fixture- of smoketests toe bij nieuwe bied-, speel- of scorelogica. @@ -72,7 +72,7 @@ Enige bron van waarheid voor open product-, bied- en speelwerk. Opgeschoond na c ### Architectuur en onderhoudbaarheid -- Splits `scripts/app.js` geleidelijk op zodra nieuwe UI-flow wordt toegevoegd: gebruik de bestaande submappen voor flow, render, state, learning en copy. Geen grote rewrite; houd tijdelijk compatibele globals waar dat migratie veilig maakt. +- Houd de nieuwe `BridgeAppRuntime`-modulegrenzen scherp: nieuwe UI-flow registreert via `BridgeAppModules.register...(runtime)` en gebruikt geen impliciete app-globals. - Introduceer pure state-transitions voor kernacties zoals hand starten, bod toepassen, veiling afronden, kaart spelen en slag doorschuiven. UI-code roept transitions aan en rendert daarna opnieuw. - Splits grote regelbestanden per bridge-domein wanneer je eraan werkt: `card-play` heeft nu losse modules voor uitkomsten, speelplan volgen, leiderspel en basisverdediging. Splits Vijfkaart-Hoog rebids/competitive later naar auction families. - Houd de gesplitste `rules/play-plan/` modules per domein klein: gedeelde helpers in `common`, sans-atout in `notrump`, kleurcontract in `suit-contract`. diff --git a/docs/architecture.md b/docs/architecture.md index 0aee228..5a03444 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -161,14 +161,26 @@ Verantwoordelijk voor: Huidige kern: -- `scripts/app.js` - bootstrap, gedeelde state, DOM refs, shared helpers en top-level orchestration. -- `scripts/flow/` - veilingflow, kaartspelflow, legaliteit, automatic play en slagvoortgang. -- `scripts/render/` - rendering per UI-deel, zichtbare speelplantekst en scoretabel-UI. -- `scripts/state/` - pure state-transitions, localStorage settings, `situatieseed:` codec, herhaalcode en herstel. -- `scripts/learning/` - lessen, standalone lespagina, woordenlijst en bieduitleg voor AI-suggesties/review. +- `scripts/app.js` - dunne bootstrap-shell: dependencies controleren, runtime maken, modules registreren, bootstrap starten, public API publiceren en de eerste hand/les starten. +- `scripts/app/runtime.js` - bouwt `BridgeAppRuntime` met `runtime.state`, `runtime.els`, `runtime.dom`, `runtime.constants`, `runtime.rules`, `runtime.transitions`, `runtime.media` en `runtime.timers`. +- `scripts/app/helpers.js` - gedeelde formatting-, seat-, kaart-, bied- en scorehelpers op `runtime.helpers`. +- `scripts/app/hand-start.js`, `scripts/app/bootstrap.js`, `scripts/app/public-api.js` - handstart/replay, top-level DOM-listeners en compatibele `BridgeApp`/`BridgeAppTestHooks`. +- `scripts/flow/` - veilingflow, contract reveal, kaartspelflow, legaliteit, automatic play, slagvoortgang en handafronding. +- `scripts/render/` - rendering per UI-deel, render-orchestratie, layout/status/guidance, zichtbare speelplantekst en scoretabel-UI. +- `scripts/state/` - pure state-transitions, afgeleide review-playback, localStorage settings, `situatieseed:` codec, herhaalcode en herstel. +- `scripts/ui/` - kleine UI-controllers voor app-menu, instellingen en dialogs. +- `scripts/feedback/` - feedbackdialog, rapportpayload, kopieer- en submitflow. +- `scripts/learning/` - lessen, lesson-start vanuit URL/oefenhand, standalone lespagina, woordenlijst en bieduitleg voor AI-suggesties/review. - `scripts/copy/text-nl.js` - Nederlandse UI-copy. -Richtlijn: `scripts/app.js` mag bootstrap en gedeelde infrastructuur blijven, maar nieuwe UI-flow hoort waar mogelijk in de passende submap. Als een flow groeit, eerst extracten naar een gerichte module in plaats van `app.js` groter maken. +Runtime/factory-contract: + +- Browsermodules registreren zichzelf onder `globalThis.BridgeAppModules`, bijvoorbeeld `BridgeAppModules.registerPlayFlow(runtime)`. +- Modules lezen app-context via de ontvangen `runtime`; geen impliciete vrije `state`, `els`, `seats`, `slotEls`, `seatEls`, timers of layoutqueries. +- Flowmodules vullen vooral `runtime.actions`; rendermodules vullen vooral `runtime.render`; gedeelde kleine helpers horen in `runtime.helpers`. +- Public API-namen blijven compatibel via `BridgeApp`, `BridgeAppContext` en `BridgeAppTestHooks`, maar intern is `runtime` de enige app-context. + +Richtlijn: `scripts/app.js` blijft alleen de shell. Nieuwe UI-flow hoort in de passende submap en registreert zichzelf via het runtime/factory-contract. #### Herhaalcode-herstelcontract @@ -232,12 +244,13 @@ Richtlijn: ## State model -De centrale runtime state leeft nu in `scripts/app.js`. Belangrijke velden: +De centrale runtime state wordt gemaakt in `scripts/app/runtime.js` en leeft tijdens de app-run op `runtime.state`. Belangrijke velden: - `phase` - idle, bidding, contract-reveal, playing, complete. - `hands`, `originalHands` - actuele en oorspronkelijke kaarten. - `auction`, `contract`, `declarer`, `dummy` - veilingresultaat. - `currentTrick`, `trickHistory`, `tricks` - speelverloop. +- `reviewCursor` - afgeleide kaart-voor-kaart replaypositie na afloop; verandert geen echte hand-, slag- of scorestate. - `playPlan`, `playExplanations` - uitlegbare speelkeuzes. - `dealSeed`, `practice`, `feedbackStatus` - reproduceerbaarheid en feedback. - `developerMode`, `guidanceMode`, `showPlayHistory` - UX-instellingen. @@ -253,6 +266,7 @@ user action ``` Nieuwe kernacties moeten bij voorkeur eerst als pure transition ontworpen worden, daarna pas aan DOM/UI gekoppeld worden. +Huidige eerste transitions staan in `scripts/state/state-transitions.js` voor handstart, bieding toepassen, veilingcontext afronden, rondpas, kaart spelen, slag doorschuiven en hand afronden. ## Uitleg-architectuur @@ -319,8 +333,8 @@ Zie `DEPLOY.md` voor hostingdetails. ## Belangrijkste architectuurrisico's -1. `scripts/app.js` blijft een zwaartepunt. - Mitigatie: nieuwe flows naar gerichte modules; pure transitions uitbreiden. +1. Scriptvolgorde en runtime-registraties blijven fragiel door de build-vrije architectuur. + Mitigatie: `script-order.test.js`, duidelijke `BridgeAppModules.register...` functies en harde dependency-errors in `scripts/app.js`. 2. Biedprofielbestanden worden groot, vooral `rebids.js`, `competitive.js` en `explanations-nl.js`. Mitigatie: splitsen per auction family wanneer eraan gewerkt wordt, niet als losse megarewrite. @@ -328,8 +342,8 @@ Zie `DEPLOY.md` voor hostingdetails. 3. Uitleg kan losraken van engine-regels. Mitigatie: `rule-copy-coverage.test.js` bewaakt dat geteste `ruleId`s een Nederlands uitlegpad houden. -4. Scriptvolgorde is fragiel door build-vrije architectuur. - Mitigatie: `script-order.test.js` en stabiele facade via `bridge-rules.js`. +4. Public API en test hooks kunnen ongemerkt afwijken van browsermodules. + Mitigatie: `scripts/app/public-api.js` als enige public API-builder en browser-smoke met `BridgeAppTestHooks`. 5. Beginner-UX kan overladen raken door developer/testfunctionaliteit. Mitigatie: normale flow compact houden; geavanceerde uitleg in developer mode, review, lessen of woordenlijst. @@ -362,6 +376,6 @@ Zie `DEPLOY.md` voor hostingdetails. 1. Bespreek of deze laagindeling klopt als gewenste richting. 2. Voeg een klein `docs/decisions.md` toe voor architectuurbesluiten, bijvoorbeeld build-vrij blijven en facade stabiel houden. -3. Splits toekomstige uitbreidingen van `scripts/app.js` standaard naar gerichte modules. +3. Houd toekomstige app-uitbreidingen aan het runtime/factory-contract en voorkom nieuwe impliciete app-globals. 4. Splits grote Vijfkaart Hoog-bestanden alleen wanneer een concreet roadmapitem dat gebied raakt. 5. Houd nieuwe `ruleId`-fixtures gekoppeld aan Nederlandse uitleg, zodat de coverage-test nuttig blijft. diff --git a/docs/vijfkaart-hoog-systeem.md b/docs/vijfkaart-hoog-systeem.md new file mode 100644 index 0000000..19f750c --- /dev/null +++ b/docs/vijfkaart-hoog-systeem.md @@ -0,0 +1,448 @@ +# Vijfkaart-Hoog Systeem Voor De Cursus + +Status: intern cursuscontract +Scope: alle lessen, oefenhanden, AI-suggesties, reviewteksten en developer-uitleg die het huidige `fiveCardHigh` profiel gebruiken. + +Dit document legt vast welk Vijfkaart-Hoog biedsysteem deze cursus gebruikt. Het is geen volledige wedstrijdsysteemkaart. Het is de didactische norm voor de app: lessen mogen eenvoudiger uitleggen dan de engine redeneert, maar ze mogen niet een ander systeem aanleren. + +De huidige basis is het in de app geimplementeerde NBB/Barry's Vijfkaart Hoog-profiel, aangevuld met afspraken uit `Start met Bridge 1 & 2` waar die al in code en uitleg zijn verwerkt. + +Belangrijke codebronnen: + +- `rules/bidding/systems/five-card-high/` +- `rules/bidding/systems/five-card-high/explanations-nl.js` +- `scripts/learning/glossary.js` +- `practice-hands/catalog/` +- `tests/unit/vijfkaart-hoog-*.test.js` + +## Gebruik In De Cursus + +Alle lessen gebruiken hetzelfde conventieprofiel: + +- `1SA` in de app betekent hetzelfde als `1NT` in de code. +- Nederlandse kleurafkortingen: `K` = klaveren, `R` = ruiten, `H` = harten, `S` = schoppen. +- Symbolen in lescopy mogen: `1♣`, `1♦`, `1♥`, `1♠`, `1SA`. +- HCP betekent honneurpunten: Aas 4, Heer 3, Vrouw 2, Boer 1. +- Zodra een fit is gevonden, mag de app fitpunten gebruiken in plaats van alleen HCP. +- Een biedkaartje kan een kunstmatige betekenis hebben. Voorbeeld: `2♦` na `1SA` betekent in dit systeem harten, niet letterlijk ruiten. + +Als code, uitleg en dit document uit elkaar lopen, moeten ze samen worden bijgewerkt. Nieuwe lessen mogen geen alternatieve afspraken introduceren zonder expliciet nieuw conventieprofiel. + +## Openingsbod: Basisvolgorde + +Voor beginners blijft de denkrichting: + +1. Heb ik genoeg om te openen? +2. Heb ik een speciale SA-, sterke of preemptieve opening? +3. Heb ik een vijfkaart hoog die ik natuurlijk moet openen? +4. Anders open ik een lage kleur of pas ik. + +De engine hanteert deze praktische prioriteiten: + +| Opening | Betekenis in deze cursus | Beginnersuitleg | +| --- | --- | --- | +| Pas | Geen passende opening. | Te weinig kracht en geen geschikte lange kleur voor een zwakke opening. | +| `1SA` | 15-17 HCP en een evenwichtige verdeling. | Begrensde opening: partner weet meteen je kracht en handtype. | +| `1♥` / `1♠` | 12-19 HCP, of een lichte Regel-van-20-opening, met minstens een vijfkaart hoog. | Een opening in harten of schoppen belooft altijd minimaal vijf kaarten in die kleur. | +| `1♦` | Meestal 12-19 HCP, minstens een vierkaart ruiten, geen betere `1SA` of hoge-kleuropening volgens de prioriteiten. | De normale lage-kleuroplossing met ruitenlengte. | +| `1♣` | Meestal 12-19 HCP. Kan een vangnetopening zijn en dus vanaf een tweekaart klaveren voorkomen. | De laagste of veiligste lage-kleuropening als niets anders past. | +| `2♣` | Sterke kunstmatige opening: ongeveer 20+ HCP of een zeer sterke speelslagenhand. | Later cursusmateriaal; beginners hoeven vooral te herkennen dat dit niet natuurlijk klaveren belooft. | +| `2SA` | 20-22 HCP en een evenwichtige verdeling. | Sterke SA-opening. | +| `2♦` / `2♥` / `2♠` | Zwakke twee: goede zeskaart en beperkte kracht, meestal 6-10 HCP. | Preemptief: je neemt biedruimte weg en beschrijft een lange kleur. | +| `3♣` t/m `4♠` | Preemptief met een nog langere kleur en beperkte kracht. | Later cursusmateriaal. | + +### 1SA Gaat Voor + +`1SA` belooft 15-17 HCP en een evenwichtige verdeling: 4-3-3-3, 4-4-3-2 of 5-3-3-2. + +De app mag dus met een 5-3-3-2-hand en een vijfkaart hoog toch `1SA` openen als de hand precies 15-17 HCP heeft. In lessen moet dit rustig worden uitgelegd: `1♥` en `1♠` beloven wel een vijfkaart, maar niet elke vijfkaart hoog wordt automatisch met `1♥` of `1♠` geopend. + +Voorbeeld: + +- Zuid heeft 15 HCP, vijf schoppen, verder 3-3-2 en overal dekking. +- Opening: `1SA`, omdat de hand evenwichtig is en in de 15-17-range valt. + +### Openen Met Een Vijfkaart Hoog + +`1♥` en `1♠` beloven minimaal een vijfkaart. + +Open de langste kleur. Met twee vijfkaarten open je de hoogste van de twee. Een langere lage kleur kan voorrang krijgen boven een vijfkaart hoog, omdat de app de langste kleur niet wil verbergen. + +Voorbeelden: + +- 13 HCP, vijf harten, geen `1SA`-verdeling: open `1♥`. +- 12 HCP, vijf schoppen en vijf ruiten: open `1♠`, de hoogste van twee vijfkaarten. +- 12 HCP, vijf schoppen en zes klaveren: open `1♣`, omdat de lage kleur langer is. + +### Lage-Kleuropeningen + +`1♣` en `1♦` zijn de openingen voor handen die niet als `1SA`, `1♥` of `1♠` worden behandeld. + +Voor beginners is de kern: + +- `1♦` belooft meestal minstens een vierkaart ruiten. +- `1♣` kan kort zijn en is soms de vangnetopening. +- Met meerdere vierkaarten open je de laagste geschikte kleur. +- Met twee vijfkaarten open je de hoogste kleur. + +Voorbeelden: + +- 12 HCP, 4-4-3-2 met vier harten en vier ruiten, geen `1SA`: open `1♦`. +- 13 HCP, 4-4-3-2 met vier harten, vier schoppen en slechts twee klaveren, geen `1SA`: open `1♣` als vangnet. +- 14 HCP, vijf klaveren en vier ruiten: open `1♣`. + +## Openingskracht En Regel Van 20 + +Met 12+ HCP open je meestal, tenzij een speciale reden anders zegt. + +Met 10-11 HCP mag je soms toch openen via de Regel van 20: + +```text +HCP + lengte van je twee langste kleuren >= 20 +``` + +De app vraagt daarbij ook dat de meeste punten in die lange kleuren zitten. Een lichte opening met losse punten in korte kleuren wordt dus niet automatisch geaccepteerd. + +Voorbeelden: + +- 11 HCP, vijf schoppen, vier harten, de honneurs vooral in schoppen en harten: `11 + 5 + 4 = 20`, dus open `1♠`. +- 11 HCP, vijf schoppen, vier harten, maar de honneurs vooral in ruiten en klaveren: pas, want de lange kleuren zijn niet sterk genoeg. +- 10 HCP, vijf ruiten en vijf klaveren met de punten in die kleuren: mag volgens de Regel van 20 een lage kleur openen. + +## Zwakke Twee En Preempts + +`2♦`, `2♥` en `2♠` zijn zwakke twee-openingen. + +Beginnersregel: + +- een goede zeskaart; +- beperkte kracht, meestal 6-10 HCP; +- geen gewone opening op eenniveau; +- bedoeld om de tegenpartij biedruimte af te nemen. + +De engine kent extra randgevallen, zoals een "lelijke" 11-punter die de Regel van 20 niet haalt. Die nuance hoort niet in de eerste beginnersuitleg; in lescopy volstaat: "zeskaart, beperkte kracht". + +Voorbeelden: + +- 8 HCP met `KQJxxx` in ruiten en verder weinig: open `2♦`. +- 9 HCP met goede zeskaart schoppen: open `2♠`. +- 11 HCP met zes schoppen en genoeg verdeling om de Regel van 20 te halen: meestal geen zwakke twee, maar een lichte `1♠`-opening. + +Preemptieve openingen op drie- en vierniveau tonen een langere kleur en beperkte kracht. Die worden alleen uitgebreid behandeld wanneer een latere les daar expliciet over gaat. + +## Antwoorden Op Een KleurOpening + +Een antwoord op partners opening heet een bijbod. In deze cursus is het eerste bijbod eenvoudig en herkenbaar. + +Algemene basis: + +- Met minder dan 6 HCP pas je meestal. +- Zoek een fit in partners hoge kleur vroeg. +- Toon een eigen biedbare kleur als dat op het juiste niveau kan. +- Als niets goed past, kan `1SA` het vuilnisbakkenbod zijn. + +### Na `1♥` Of `1♠` + +Omdat `1♥` en `1♠` een vijfkaart beloven, is driekaart steun al genoeg voor een achtkaartfit. + +| Antwoord | Betekenis | +| --- | --- | +| `2♥` / `2♠` | 6-9 fitpunten, minstens driekaart steun. | +| `3♥` / `3♠` | Ongeveer 10-11 fitpunten, inviterend. | +| `4♥` / `4♠` | Ongeveer 12+ fitpunten, manche. | +| Nieuwe kleur op eenniveau | Minstens vierkaart, 6+ HCP, bijvoorbeeld `1♥ - 1♠`. | +| Nieuwe kleur op tweeniveau | Minstens vierkaart en ongeveer 10+ HCP. | +| `1SA` | Vuilnisbakkenbod: meestal 6-9 HCP, geen steun, geen geschikte eigen kleur op eenniveau. | +| `2SA` | Ongeveer 10-11 HCP zonder betere fit of kleur. | +| `3SA` | Manchekracht zonder betere fit of kleur. | + +Voorbeeld: + +- Partner opent `1♠`. Jij hebt 7 HCP en drie schoppen. Antwoord `2♠`, want er is samen minstens een 5-3 fit. +- Partner opent `1♥`. Jij hebt 7 HCP, twee harten en vier schoppen. Antwoord `1♠`, want dat kan nog op eenniveau. +- Partner opent `1♠`. Jij hebt 7 HCP, twee schoppen en geen kleur die je goed kunt bieden. Antwoord `1SA`. + +### Na `1♣` Of `1♦` + +Na een lage-kleuropening zoekt de cursus eerst een hoge-kleurfit als dat rustig kan. + +| Situatie | Afspraak | +| --- | --- | +| Na `1♣` | `1♦`, `1♥` of `1♠` kan vanaf een vierkaart en 6+ HCP. Met gelijke vierkaarten kiest de app laag genoeg om ruimte te houden. | +| Na `1♦` | `1♥` of `1♠` kan vanaf een vierkaart en 6+ HCP. | +| `1♦ - 2♣` | Vierkaart klaveren en ongeveer 10+ HCP. | +| Steun voor `1♣` | Meestal vijfkaart steun, omdat `1♣` kort kan zijn. | +| Steun voor `1♦` | Meestal vierkaart steun. | +| `1SA` | Vuilnisbakkenbod: meestal 6-9 HCP, geen steun, geen geschikte hoge kleur op eenniveau. | +| `2SA` / `3SA` | Inviterend of manche met een passende SA-hand en geen betere hoge-kleurfit. | + +Voorbeeld: + +- Partner opent `1♣`. Jij hebt 6 HCP en vier harten: bied `1♥`. +- Partner opent `1♦`. Jij hebt 8 HCP, geen vierkaart hoog en vier ruiten: bied `2♦`. +- Partner opent `1♣`. Jij hebt 7 HCP, geen vierkaart ruiten/harten/schoppen en maar vier klaveren: bied `1SA`, niet `2♣`. + +### Het Vuilnisbakkenbod `1SA` + +`1SA` als bijbod na partners kleuropening is geen belofte van een mooie sans-atouthand. + +Het betekent meestal: + +- 6-9 HCP; +- geen steun voor partners kleur; +- geen eigen kleur die je op eenniveau kunt bieden; +- geen genoeg kracht voor een nieuwe kleur op tweeniveau. + +Voorbeeld: + +- `1♠ - 1SA`: antwoorder heeft vaak 6-9 HCP, minder dan drie schoppen en geen hand die sterk genoeg is om op tweeniveau een eigen kleur te bieden. + +## Openaars Herbieding En Tweede Bijbod + +Na het eerste antwoord beschrijft openaar zijn hand verder. + +Basisprioriteiten voor openaar: + +- Steun partners nieuwe hoge kleur met vierkaart steun. +- Herbied een eigen zeskaart. +- Bied SA met een evenwichtige hand. +- Toon een tweede kleur met een echt tweekleurenspel. +- Na partners verhoging: pas met minimum, invite met extra waarden, bied manche met genoeg gezamenlijke kracht. + +Basisprioriteiten voor antwoorder bij het tweede bijbod: + +- 6-9 HCP: houd het laag, geef preferentie of pas. +- 10-11 HCP: inviteer, vaak met `2SA` of een verhoging. +- 12+ HCP: zoek of bied de manche. +- Als drie echte kleuren zijn geboden en er is nog geen duidelijk eindcontract: gebruik vierde-kleur-forcing. + +Voorbeeld: + +```text +1♥ - 1♠ +2♦ - ? +``` + +Met 6-9 HCP geeft antwoorder vaak preferentie naar `2♥` als dat het minst misleidend is. Met 10-11 HCP kan `2SA` inviterend zijn. Met 12+ HCP en geen duidelijk contract kan `3♣` vierde-kleur-forcing zijn. + +## Stayman Na `1SA` + +`2♣` na `1SA` is Stayman. + +Betekenis: + +- vraagt openaar naar een vierkaart hoog; +- zoekt een 4-4 fit in harten of schoppen; +- wordt in de app gebruikt vanaf ongeveer 8 HCP met een vierkaart hoog. + +Antwoorden van openaar: + +| Antwoord | Betekenis | +| --- | --- | +| `2♦` | Geen vierkaart harten of schoppen. | +| `2♥` | Vierkaart harten. | +| `2♠` | Vierkaart schoppen. | + +Als openaar beide hoge vierkaarten heeft, toont de huidige regel eerst harten. + +Vervolg door antwoorder: + +- Met fit en 8-9 HCP: inviteer op drieniveau. +- Met fit en 10+ HCP: bied de hoge-kleurmanche. +- Zonder fit en 8-9 HCP: `2SA`. +- Zonder fit en 10+ HCP: `3SA`. + +Voorbeeld: + +```text +1SA - 2♣ +2♠ - 4♠ +``` + +Antwoorder vroeg met Stayman, openaar toonde een vierkaart schoppen, en antwoorder koos met genoeg kracht de manche in de gevonden fit. + +## Jacoby-Transfers Na `1SA` + +Jacoby-transfer betekent dat antwoorder de kleur onder zijn echte hoge kleur biedt. + +| Bod na `1SA` | Betekenis | +| --- | --- | +| `2♦` | Vraagt openaar `2♥` te bieden. Antwoorder toont minstens een vijfkaart harten. | +| `2♥` | Vraagt openaar `2♠` te bieden. Antwoorder toont minstens een vijfkaart schoppen. | + +Waarom: + +- de sterke `1SA`-hand wordt meestal leider; +- antwoorder kan met zwakke handen toch zijn lange hoge kleur laten spelen; +- met sterkere handen kan antwoorder daarna inviteren of de manche bieden. + +Voorbeelden: + +```text +1SA - 2♦ +2♥ - pas +``` + +Antwoorder heeft harten en is zwak genoeg om in `2♥` te stoppen. + +```text +1SA - 2♥ +2♠ - 3SA +``` + +Antwoorder heeft meestal precies vijf schoppen en manchekracht. Openaar mag met driekaart schoppen nog naar `4♠` corrigeren. + +```text +1SA - 2♦ +2♥ - 3♥ +``` + +Antwoorder heeft een zeskaart of langer in harten en inviterende kracht. + +Vervolgdetails zoals twee hoge kleuren na transfer worden wel door de engine herkend, maar horen niet bij de eerste uitleg. Ze kunnen in een latere les of developer-uitleg worden genoemd. + +## Na `2SA` + +Na een natuurlijke `2SA`-opening gebruikt de app dezelfde familie afspraken een niveau hoger: + +- `3♣` is Stayman. +- `3♦` vraagt `3♥`. +- `3♥` vraagt `3♠`. + +Omdat `2SA` al 20-22 HCP toont, is minder kracht bij antwoorder nodig om naar de manche te gaan. Dit is geen beginnerskern, maar lessen mogen het gebruiken zodra `1SA`-vervolgen bekend zijn. + +## Sterke `2♣` + +`2♣` is kunstmatig en sterk. + +Basis: + +- niet natuurlijk klaveren; +- ongeveer 20+ HCP, of een hand met zeer veel speelslagen; +- partner mag dit niet behandelen als een gewone lage-kleuropening. + +Antwoorden: + +- `2♦` is afwachtend, meestal 0-7 HCP. +- Een positief kleurantwoord toont ongeveer 8+ HCP en een goede vijfkaart met minstens twee tophonneurs. +- `2SA` kan een positief antwoord zonder geschikte kleur zijn. + +Na een `2SA`-herbieding door de `2♣`-openaar gelden Stayman en transfers alsof er een sterke SA-hand is getoond. + +Voor beginners wordt sterke `2♣` vooral als herkenpunt behandeld. De volledige vervolgstructuur wordt uitgesteld. + +## Vierde-Kleur-Forcing + +Vierde-kleur-forcing is mancheforcing in deze app. + +Het ontstaat wanneer het partnerschap al drie echte kleuren heeft geboden en antwoorder de vierde, nog niet geboden kleur biedt als kunstmatig vraagbod. + +Betekenis: + +- kunstmatig, niet per se lengte in de vierde kleur; +- vraagt openaar zijn hand verder te beschrijven; +- belooft genoeg kracht om minstens de manche te bereiken; +- hoort in uitleg als conventioneel en alertbaar te worden behandeld. + +Voorbeeld: + +```text +1♥ - 1♠ +2♦ - 3♣ +``` + +`3♣` is hier vierde-kleur-forcing. Het zegt niet: "ik wil klaveren spelen". Het vraagt openaar om extra informatie. + +Antwoorden van openaar, in gewone taal: + +- toon driekaart steun voor antwoorders eerste kleur als die er is; +- bied SA met een stop in de vierde kleur; +- herbied je openingskleur met extra lengte; +- herbied je tweede kleur als dat het beste beschikbare omschrijvende bod is. + +Daarna kiest antwoorder een manche. + +## `4SA` Azenvragen + +In deze cursus is `4SA` klassiek azenvragen wanneer er een troefkleur is afgesproken. + +Dit volgt de glossary-definitie: + +| Antwoord | Betekenis | +| --- | --- | +| `5♣` / `5K` | 0 of 4 azen. | +| `5♦` / `5R` | 1 aas. | +| `5♥` / `5H` | 2 azen. | +| `5♠` / `5S` | 3 azen. | + +Voorbeeld: + +```text +1SA - 2♥ +2♠ - 4SA +5♥ - 5♠ +``` + +Na de transfer is schoppen de afgesproken troefkleur. `4SA` vraagt azen. `5♥` toont twee azen. Als de vrager weet dat er samen twee azen ontbreken, zwaait hij af in `5♠`. + +Voor beginners: + +- Gebruik `4SA` alleen als azenvraag met een duidelijke troefkleur. +- Leg nog geen Roman Keycard Blackwood, herenvragen, controlebiedingen of kwantitatieve `4SA` uit. + +## Competitief Bieden + +De beginnerscursus hoeft competitief bieden niet vroeg te behandelen. Als een oefenhand of AI-suggestie toch competitie bevat, gebruikt de app deze beperkte afspraken. + +Huidige basis: + +- Een eenvoudig volgbod toont een goede vijfkaart of langer. +- Op eenniveau kan een volgbod vanaf ongeveer 8 HCP. +- Op tweeniveau vraagt een volgbod meestal 10+ HCP. +- Kwetsbaarheid kan de eisen verhogen. +- `1SA` als volgbod toont 15-17 HCP, evenwichtige verdeling en dekking in de kleur van de tegenpartij. +- Een informatiedoublet toont openingskracht, kortheid in de kleur van de tegenpartij en aansluiting in de ongeboden kleuren. +- Na partners informatiedoublet moet je bieden als de rechtertegenstander past; `1SA` na zo'n doublet belooft 6-9 HCP, SA-verdeling, dekking en geen betere ongeboden vierkaart. +- Tegen zwakke twee- en preemptieve openingen gebruikt de app eenvoudige volgboden, SA met dekking en informatiedoubletten. + +Voor beginners geldt: behandel competitie pas als het lesdoel dat vraagt. Normale openings- en antwoordlessen blijven ongestoord. + +## Bewust Uitgesteld + +Deze details worden bewust niet zwaar gemaakt in de beginnerscursus, ook als de engine er soms al een eenvoudige regel voor heeft: + +- volledige vervolgstructuur na sterke `2♣`; +- volledige antwoorden op zwakke twee- en preemptieve openingen; +- alle transfervervolgen met 5-4 of 5-5 in de hoge kleuren; +- slem bieden buiten klassiek `4SA` azenvragen; +- directe SA-slems op basis van gezamenlijke HCP; +- competitief bieden na meerdere biedrondes; +- kwetsbaarheidsafwegingen bij elk competitief bod; +- betekenis van alerts buiten Stayman, transfers, vierde-kleur-forcing en `4SA` azenvragen. + +Deze afspraken worden voorlopig niet als cursusstof gebruikt en mogen niet stilzwijgend in beginnerslessen opduiken: + +- negative doubles; +- supportdoubletten; +- responsive doubles; +- balancing; +- cue-bids; +- Michaels; +- Unusual NT; +- Lebensohl; +- strafpas- en cue-bid-vervolgen na partners informatiedoublet; +- Roman Keycard Blackwood en herenvragen; +- conventieprofielen of persoonlijke biedafspraken. + +## Didactische Checklist Voor Nieuwe Lessen + +Gebruik deze checklist bij nieuwe biedlessen of oefenhanden: + +- Noem alleen de afspraak die de speler nu nodig heeft. +- Houd normale gameplay compact; plaats extra uitleg in lesmodus, review, AI-suggesties of developer mode. +- Scheid het biedkaartje van de betekenis bij kunstmatige biedingen. +- Gebruik dezelfde puntengrenzen en betekenissen als dit document. +- Toon voorbeelden met `1SA`, `1♥`, `1♠`, `1♣` en `1♦` voordat competitief bieden wordt geintroduceerd. +- Voeg bij nieuwe biedregels ook uitleg en testfixtures toe. +- Maak claims niet sterker dan de engine kan waarmaken. + diff --git a/index.html b/index.html index cbf68e5..26f354d 100644 --- a/index.html +++ b/index.html @@ -93,6 +93,36 @@

Speel Vijfkaart Hoog met een AI-partner

Klik of druk op Enter om te spelen. +
Noord • Partner
@@ -114,13 +144,6 @@

Speel Vijfkaart Hoog met een AI-partner

-
Deel een nieuwe hand om te starten.
@@ -176,9 +199,9 @@

Speelgeschiedenis

-
-
+

Tot slot delen

Vier spelers krijgen elk 13 kaarten

Je speelt samen met de speler recht tegenover je. De twee andere spelers vormen het andere team.

@@ -202,7 +203,7 @@

Vier spelers krijgen elk 13 kaarten

-
+

Tafelkompas

Noord, Oost, Zuid en West

De tafel werkt als een kompas. Jij zit Zuid, je partner zit Noord, en Oost/West zijn de tegenstanders.

@@ -238,7 +239,7 @@

Klik op Zuid: jouw plek aan tafel.

-
+

Waarom speel je?

Het doel is genoeg slagen winnen

Noord/Zuid spelen samen tegen Oost/West. Na het bieden ligt er een contract: hoeveel slagen een paar denkt te kunnen maken, en in welke speelsoort.

@@ -260,7 +261,7 @@

Het doel is genoeg slagen winnen

-
+

De twee fasen

Eerst bieden, daarna spelen

Elke bridgehand heeft twee duidelijke fasen. De app laat eerst de bieding zien en daarna de kaartfase.

@@ -281,7 +282,7 @@

Eerst bieden, daarna spelen

Een bod is dus nog geen gespeelde kaart. Het is een afspraak over het doel van het spel.

-
+

Een slag

Vier kaarten vormen samen een slag

De speler die begint vraagt een kleur. Daarna speelt iedereen met de klok mee precies een kaart.

@@ -305,7 +306,7 @@

Vier kaarten vormen samen een slag

-
+

Speelsoorten

Je speelt met troef of zonder troef

Er zijn twee typen speelsoorten. Een kleurcontract heeft een troefkleur. SA, sans-atout of NT, betekent dat er geen troef is.

@@ -324,7 +325,7 @@

Je speelt met troef of zonder troef

Je mag alleen troeven als je de gevraagde kleur niet kunt bekennen.

-
+

Na de uitkomst

De leider speelt ook de kaarten van dummy

De leider is de speler die het contract speelt. De partner van de leider heet dummy en legt de kaarten open na de eerste kaart van het spelen.

@@ -348,14 +349,19 @@

De leider speelt ook de kaarten van dummy

-
- - +
+ +

+ Kaart 1 van 10 + Kleuren +

+
+ diff --git a/lesson-02-card-valuation.html b/lesson-02-card-valuation.html new file mode 100644 index 0000000..8f47f26 --- /dev/null +++ b/lesson-02-card-valuation.html @@ -0,0 +1,394 @@ + + + + + Les 2 - Kaarten waarderen + + + + + + + +
+
+ + + + Les 2 + Kaarten waarderen + + + +
+ +
+
+

Welkom aan tafel

+

Kaarten waarderen voordat je biedt

+

+ Vandaag geef je Zuid eerst een rustig handpaspoort: HCP, verdeling, langste kleur en mogelijke fitwaarde. + Je hoeft nog geen perfecte bieding te vinden; je leert zien hoeveel de hand waard is en waarom die waarde later kan veranderen. +

+
+
+ Aas 4 + Heer 3 + Vrouw 2 + Boer 1 +
+
+ + + + + +
+
+

Wat leer je vandaag?

+

Vijf dingen die elke hand sneller leesbaar maken

+
    +
  • Je telt HCP met Aas 4, Heer 3, Vrouw 2 en Boer 1.
  • +
  • Je herkent een Evenwichtige verdeling: 4-3-3-3, 4-4-3-2 en 5-3-3-2.
  • +
  • Je ziet het verschil tussen singleton, doubleton en renonce.
  • +
  • Je herkent een Fit als samen minstens acht kaarten in een kleur.
  • +
  • Je begrijpt waarom je eerst HCP telt en later pas gaat herwaarderen.
  • +
+
+ +
+ +
+
+

De situatie

+

Zuid is aan de beurt. Wat weet je al?

+

+ Je pakt je kaarten op. Voor je aan een Opening denkt, stel je drie vragen: hoeveel HCP heb ik, hoe is mijn verdeling, + en welke kleur is het langst? Pas daarna vraag je of de hand Openingskracht heeft. +

+
+
+
+

+ Deze hand heeft 15 HCP en een 4-3-3-3 verdeling. In Vijfkaart Hoog wijst dat naar 1SA: 15-17 HCP en evenwichtig. +

+
+
+ +
+

De theorie in kleine stappen

+

Van losse plaatjes naar een biedbare hand

+ +
+
+ 1 +
+

HCP telt alleen Aas, Heer, Vrouw en Boer

+

Kernzin: HCP is je eerste snelle krachtmeter.

+

+ HCP betekent honneurpunten. Aas telt 4, Heer 3, Vrouw 2 en Boer 1. De 10 is wel een Honneur, + maar telt niet mee als HCP. Een hand met Aas, Heer, Vrouw en Boer heeft dus 10 HCP. +

+

Glossary-termen: HCP, Honneur.

+
+

Probeer zelf: hoeveel HCP is Aas-Heer-Vrouw-Boer-10 samen?

+
+ + + +
+

Kies het totaal.

+
+
+
+ +
+ 2 +
+

Verdeling vertelt hoe de hand gebouwd is

+

Kernzin: dezelfde HCP kunnen in een rustige of juist grillige hand zitten.

+

+ Je verdeling is het aantal kaarten per kleur. 4-3-3-3, 4-4-3-2 en 5-3-3-2 heten een Evenwichtige verdeling. + Met zo'n hand past sans-atout vaker in beeld. Een 6-4-2-1 hand is onevenwichtiger en vraagt later meer kleurdenken. +

+

Glossary-termen: Evenwichtige verdeling, HCP.

+
+

Probeer zelf: welke verdeling is evenwichtig?

+
+ + + +
+

Kies de rustige SA-verdeling.

+
+
+
+ +
+ 3 +
+

Korte kleuren krijgen namen

+

Kernzin: hoe korter een zijkleur is, hoe meer die later kan gaan betekenen.

+

+ Een doubleton is precies twee kaarten in een kleur. Een singleton is precies een kaart. Een renonce is nul kaarten. + Voor de eerste telling leveren die korte kleuren nog geen gewone HCP op. Ze worden pas extra interessant zodra je een Fit hebt. +

+

Glossary-termen: Doubleton, Singleton, Renonce, Fit.

+
+

Probeer zelf: hoe heet nul kaarten in een kleur?

+
+ + + +
+

Kies de naam voor nul kaarten.

+
+
+
+ +
+ 4 +
+

Een fit maakt troefcontrole mogelijk

+

Kernzin: een Fit is samen minstens acht kaarten in een kleur.

+

+ Als partner vijf harten belooft en jij hebt drie harten, hebben jullie samen acht harten. Dat is een Fit. + Met een Fit kan die kleur troef worden. Dan zijn extra troeven en korte zijkleuren waardevoller, omdat je verliezers kunt aftroeven. +

+

Glossary-termen: Fit, Fitpunten, Singleton, Doubleton.

+
+

Probeer zelf: partner heeft vijf harten, jij hebt drie harten. Is dat een Fit?

+
+ + +
+

Tel de kaarten samen.

+
+
+
+ +
+ 5 +
+

Eerst tellen, daarna herwaarderen

+

Kernzin: HCP is het startpunt; Fitpunten komen pas met meer informatie.

+

+ Aan het begin weet je nog niet of partner jouw korte kleur kan benutten. Daarom tel je eerst HCP en herken je de basisverdeling. + Zodra de bieding een Fit laat zien, ga je herwaarderen. Dan kunnen singleton, doubleton, renonce en extra troeven meer waard worden. +

+

Glossary-termen: HCP, Fitpunten, Herwaarderen, Openingskracht.

+
+

Probeer zelf: wat tel je als eerste, voordat je weet of er een Fit is?

+
+ + +
+

Kies het startpunt.

+
+
+
+
+
+ +
+

Kijk mee aan tafel

+

Een handpaspoort voor Zuid

+
+
+
+

Wat zie je?

+
    +
  1. HCP: Aas schoppen 4, Heer schoppen 3, Vrouw harten 2, Heer ruiten 3, Vrouw ruiten 2, Boer klaveren 1. Samen 15.
  2. +
  3. Verdeling: 4 schoppen, 3 harten, 3 ruiten, 3 klaveren. Dat is 4-3-3-3 en dus evenwichtig.
  4. +
  5. Langste kleur: schoppen met vier kaarten, maar geen vijfkaart.
  6. +
  7. Eerste biedidee: 1SA is logisch in dit systeem, want 15-17 HCP en een Evenwichtige verdeling.
  8. +
+

+ Let op: je noemt nu nog geen Fitpunten. Die komen pas als partner later steun of een mogelijke Fit laat zien. +

+
+
+
+ +
+

Jij bent aan de beurt

+

Interactieve handwaarderingsvragen

+

+ Kijk steeds eerst naar de hand, kies je antwoord en lees daarna de feedback. De vragen wisselen HCP, verdeling, + langste kleur en mogelijke fitwaarde af. +

+
+
+ +
+

Oefenen met handen

+

Genereer handen met duidelijke criteria

+

+ Deze oefenknoppen maken telkens een nieuwe Zuid-hand die aan het criterium voldoet. De ids hieronder zijn voorstel-ids + voor latere vaste oefenhanden; de pagina gebruikt nu live gegenereerde handen om sneller te kunnen oefenen. +

+ +
+
+ + + + +
+ +
+
+
+ Kies links een criterium om een oefenhand te maken. +
+
+
+ + +
+ +
+

Veelgemaakte beginnersfouten

+

Valkuilen die bijna iedereen een keer maakt

+
+
+

De 10 als HCP tellen

+

Waarom begrijpelijk: de 10 is echt een Honneur. Betere vraag: telt deze honneur ook in de HCP-telling? Nee, de 10 telt 0.

+
+
+

Een 5-3-3-2 hand onevenwichtig noemen

+

Waarom begrijpelijk: er zit een vijfkaart in. Betere vraag: staat 5-3-3-2 op de lijst met evenwichtige verdelingen? Ja.

+
+
+

Te vroeg fitpunten bijtellen

+

Waarom begrijpelijk: korte kleuren zien er spannend uit. Betere vraag: weet ik al dat we een Fit hebben? Zo niet, eerst HCP tellen.

+
+
+

Lengte en kracht door elkaar halen

+

Waarom begrijpelijk: een lange kleur voelt sterk. Betere vraag: hoeveel HCP heb ik, en waar zitten die punten?

+
+
+
+ +
+

Mini-quiz

+

Zeven korte checks voordat je gaat racen

+
+
+ +
+
+

Eindchallenge

+

Puntenrace

+

+ Je krijgt 10 handen. Kies per hand het HCP-totaal en of de verdeling evenwichtig is. + Het gaat om snel en rustig herkennen, niet om perfecte biedkunst. +

+
+ +
+
+ Hand 0 van 10 + 75 sec + Score 0 +
+
+ Start de Puntenrace om de eerste hand te zien. +
+
+
+ Hoeveel HCP? +
+
+
+ Evenwichtige verdeling? +
+ + +
+
+
+
+ + +
+

Je score telt alleen wanneer HCP en verdeling allebei kloppen.

+
+
+ +
+

Samenvatting

+

Vijf zinnen om mee te nemen

+
    +
  1. HCP tel je met Aas 4, Heer 3, Vrouw 2 en Boer 1.
  2. +
  3. De 10 is wel een Honneur, maar telt niet mee als HCP.
  4. +
  5. 4-3-3-3, 4-4-3-2 en 5-3-3-2 zijn evenwichtige verdelingen.
  6. +
  7. Een Fit is samen minstens acht kaarten in een kleur, en daarna kunnen korte kleuren meer waard worden.
  8. +
  9. Tel eerst HCP, herken daarna verdeling en langste kleur, en ga pas herwaarderen wanneer de bieding meer informatie geeft.
  10. +
+
+ +
+ +

+ Kaart 1 van 10 + Leerdoelen +

+ +
+
+ + + + + diff --git a/scripts/app/hand-start.js b/scripts/app/hand-start.js index cc23c0d..b5bf689 100644 --- a/scripts/app/hand-start.js +++ b/scripts/app/hand-start.js @@ -8,6 +8,7 @@ const { seats } = runtime.constants; function startHand({ replay = false, seed = null, preserveBoard = false, skipFlow = false } = {}) { + actions.exitLessonMode?.(); const reuseCurrentDeal = replay && state.dealSeed && state.dealNumber > 0; const loadedSeed = actions.normalizeSeed(seed); if (!reuseCurrentDeal) { @@ -24,8 +25,9 @@ }); } - function startPracticeHand(handId, { preserveBoard = false, skipFlow = false, lesson = null } = {}) { + function startPracticeHand(handId, { preserveBoard = false, skipFlow = false, lesson = null, lessonContext = null } = {}) { if (!globalThis.PracticeHands) throw new Error("practice-hands/index.js must load before practice hands can be used"); + if (!lesson) actions.exitLessonMode?.(); const scenario = globalThis.PracticeHands.preparePracticeHand(handId); if (!preserveBoard || state.dealNumber === 0) state.dealNumber += 1; @@ -34,7 +36,7 @@ dealerIndex: seats.indexOf(scenario.dealer), vulnerability: scenario.vulnerability, hands: scenario.hands, - practice: practiceStateFromScenario(scenario, lesson), + practice: practiceStateFromScenario(scenario, lesson, lessonContext), skipFlow }); return scenario; @@ -52,6 +54,8 @@ practice })); state.lessonBoardAcknowledged = []; + state.lessonTableTaskDone = false; + state.lessonActionFeedback = null; if (timers.illegalActionFeedbackTimer) { window.clearTimeout(timers.illegalActionFeedbackTimer); timers.illegalActionFeedbackTimer = null; @@ -97,13 +101,27 @@ function replayHand() { if (state.practice?.id) { - startPracticeHand(state.practice.id, { preserveBoard: true }); + const lesson = state.practice.lessonId ? globalThis.BridgeLessons?.findLesson?.(state.practice.lessonId) || null : null; + const lessonContext = lesson ? lessonContextFromPractice() : null; + if (lesson) actions.enterLessonMode?.(); + startPracticeHand(state.practice.id, { preserveBoard: true, lesson, lessonContext }); return; } startHand({ replay: true }); } - function practiceStateFromScenario(scenario, lesson = null) { + function lessonContextFromPractice() { + return { + chapterId: state.practice?.lessonChapterId || null, + chapterTitle: state.practice?.lessonChapterTitle || "", + returnHref: state.practice?.lessonReturnHref || "", + tableTask: state.practice?.lessonTableTask || null, + boardGuidance: state.practice?.lessonBoardGuidance || [] + }; + } + + function practiceStateFromScenario(scenario, lesson = null, lessonContext = null) { + const context = lesson ? lessonContext || {} : {}; return { id: scenario.id, title: scenario.title, @@ -125,12 +143,17 @@ lessonFocus: lesson?.focus ? [...lesson.focus] : [], lessonIntro: lesson?.intro || "", lessonReviewFeedback: lesson?.reviewFeedback ? [...lesson.reviewFeedback] : [], - lessonBoardGuidance: lesson?.boardGuidance ? lesson.boardGuidance.map((step) => ({ ...step })) : [] + lessonChapterId: context.chapterId || null, + lessonChapterTitle: context.chapterTitle || "", + lessonReturnHref: context.returnHref || "", + lessonTableTask: context.tableTask ? { ...context.tableTask } : null, + lessonBoardGuidance: context.boardGuidance ? context.boardGuidance.map((step) => ({ ...step })) : (lesson?.boardGuidance ? lesson.boardGuidance.map((step) => ({ ...step })) : []) }; } Object.assign(actions, { clearDealAnimationTimer, + lessonContextFromPractice, practiceStateFromScenario, replayHand, resetScheduledFlow, diff --git a/scripts/app/runtime.js b/scripts/app/runtime.js index 749e207..573aa28 100644 --- a/scripts/app/runtime.js +++ b/scripts/app/runtime.js @@ -104,8 +104,11 @@ practice: null, feedbackStatus: null, illegalActionFeedback: null, + lessonActionFeedback: null, handSuitFocus: null, - lessonBoardAcknowledged: [] + lessonBoardAcknowledged: [], + lessonModeSettingsSnapshot: null, + lessonTableTaskDone: false }; } @@ -201,6 +204,7 @@ westLabel: document.querySelector("#west-label"), biddingTitle: document.querySelector("#bidding-title"), historyPanel: document.querySelector("#history-panel"), + lessonPanel: document.querySelector("#lesson-panel"), historyTitle: document.querySelector("#history-title"), reviewPanel: document.querySelector("#review-panel"), reviewTitle: document.querySelector("#review-title"), diff --git a/scripts/flow/auction-flow.js b/scripts/flow/auction-flow.js index 528316a..dfab19b 100644 --- a/scripts/flow/auction-flow.js +++ b/scripts/flow/auction-flow.js @@ -25,6 +25,8 @@ teamOf } = helpers; const enterContractReveal = (...args) => actions.enterContractReveal(...args); + const lessonBoardBlocksHumanBid = (...args) => actions.lessonBoardBlocksHumanBid?.(...args); + const maybeCompleteLessonTableTask = (...args) => actions.maybeCompleteLessonTableTask?.(...args); const prepareContractFromAuction = (...args) => actions.prepareContractFromAuction(...args); const renderAll = (...args) => render.renderAll(...args); const setStatus = (...args) => actions.setStatus(...args); @@ -32,6 +34,7 @@ function continueAuction() { if (state.phase !== "bidding") return; if (state.animateDeal) return; + if (actions.lessonTableTaskIsDone?.()) return; const seat = seatAt(state.turnIndex); renderAll(); if (auctionComplete()) { @@ -53,11 +56,13 @@ function continueAuction() { function makeBid(seat, bid, bidResult = null) { if (state.phase !== "bidding" || seat !== seatAt(state.turnIndex)) return; if (state.animateDeal) return; + if (lessonBoardBlocksHumanBid(seat)) return; const typedBid = normalizeBid(bid); if (!typedBid) return; if (isContractBid(typedBid) && !isBidHigher(typedBid, highestBid())) return; if (isDouble(typedBid) && !canDouble(seat)) return; if (isRedouble(typedBid) && !canRedouble(seat)) return; + if (!actions.validateLessonTableAction?.("bid", { seat, bid: typedBid })) return; const call = { seat, bid: typedBid, @@ -68,6 +73,7 @@ function makeBid(seat, bid, bidResult = null) { if (bidResult && !sameCall(bidResult.bid, typedBid)) call.recommendedBidResult = bidResult; Object.assign(state, BridgeStateTransitions.applyBidTransition(state, { ...call, seatCount: seats.length })); renderAll(); + if (maybeCompleteLessonTableTask("bid")) return; continueAuction(); } diff --git a/scripts/flow/play-flow.js b/scripts/flow/play-flow.js index 548c59f..bf32691 100644 --- a/scripts/flow/play-flow.js +++ b/scripts/flow/play-flow.js @@ -25,6 +25,7 @@ const ensurePlayPlan = (...args) => actions.ensurePlayPlan(...args); const finishHand = (...args) => actions.finishHand(...args); const lessonBoardBlocksHumanPlay = (...args) => actions.lessonBoardBlocksHumanPlay(...args); + const maybeCompleteLessonTableTask = (...args) => actions.maybeCompleteLessonTableTask?.(...args); const playPlanReferenceText = (...args) => actions.playPlanReferenceText(...args); const renderGuidance = (...args) => render.renderGuidance(...args); const renderHands = (...args) => render.renderHands(...args); @@ -86,6 +87,7 @@ function autoPlayCard(seat, card) { function continuePlay() { if (state.phase !== "playing") return; if (state.awaitingTrickAdvance) return; + if (actions.maybeCompleteLessonTableTask?.()) return; if (state.hands.South.length === 0 && state.currentTrick.length === 0) { finishHand(); return; @@ -477,9 +479,12 @@ function playCard(seat, cardId) { showIllegalCardFeedback(seat, card); return; } + if (!actions.validateLessonTableAction?.("card", { seat, card })) return; state.illegalActionFeedback = null; + state.lessonActionFeedback = null; state.handSuitFocus = null; renderIllegalActionFeedback(); + render.renderLessonPanel?.(); const animationSource = render.captureCardPlayAnimationSource(seat, cardId); const ruleResult = chooseCardPlayResult(seat); const explanation = explainCardPlay(seat, card, ruleResult); @@ -489,6 +494,7 @@ function playCard(seat, cardId) { renderHands(); renderPlayedCard(seat, card, { animationSource }); setStatus("played", { seat, card: cardText(card) }); + if (maybeCompleteLessonTableTask("card")) return; if (state.currentTrick.length === 4) { pauseCompletedTrick(); } else { diff --git a/scripts/learning/card-basics-page.js b/scripts/learning/card-basics-page.js index 02ff212..9fd7f49 100644 --- a/scripts/learning/card-basics-page.js +++ b/scripts/learning/card-basics-page.js @@ -5,10 +5,6 @@ const rankLabel = { T: "10", J: "J", Q: "Q", K: "K", A: "A" }; const suitSymbols = { C: "\u2663", D: "\u2666", H: "\u2665", S: "\u2660" }; const suitNames = { C: "klaveren", D: "ruiten", H: "harten", S: "schoppen" }; - const slides = [...document.querySelectorAll(".lesson-slide")]; - const progressButtons = [...document.querySelectorAll(".progress-step")]; - const previous = document.querySelector("#previous-step"); - const next = document.querySelector("#next-step"); const rankFeedback = document.querySelector("#rank-feedback"); const suitCountFeedback = document.querySelector("#suit-count-feedback"); const dealButton = document.querySelector("#deal-demo"); @@ -27,18 +23,12 @@ ]; const sampleDeal = buildSampleDeal(); let dealTimers = []; - let currentStep = 0; let windStep = 0; preserveTestHooksOnLessonLinks(); renderRanks(".compact-ranks", "S"); renderRanks(".full-ranks", "H"); renderWindStep(); - showStep(0); - - progressButtons.forEach((button) => { - button.addEventListener("click", () => showStep(Number(button.dataset.stepTarget || 0))); - }); document.querySelectorAll(".suit-count-card").forEach((button) => { button.addEventListener("click", () => { @@ -55,12 +45,6 @@ }); }); - previous?.addEventListener("click", () => showStep(currentStep - 1)); - next?.addEventListener("click", () => { - if (currentStep === slides.length - 1) showStep(0); - else showStep(currentStep + 1); - }); - document.querySelectorAll(".choice-card").forEach((button) => { button.addEventListener("click", () => { document.querySelectorAll(".choice-card").forEach((card) => card.classList.remove("is-correct", "is-missed")); @@ -80,19 +64,6 @@ button.addEventListener("click", () => chooseWindSeat(button)); }); - function showStep(step) { - currentStep = Math.max(0, Math.min(slides.length - 1, step)); - slides.forEach((slide, index) => { - slide.classList.toggle("is-active", index === currentStep); - }); - progressButtons.forEach((button, index) => { - if (index === currentStep) button.setAttribute("aria-current", "true"); - else button.removeAttribute("aria-current"); - }); - if (previous) previous.disabled = currentStep === 0; - if (next) next.textContent = currentStep === slides.length - 1 ? "Nog eens bekijken" : "Volgende"; - } - function renderRanks(selector, suit) { const row = document.querySelector(selector); if (!row) return; diff --git a/scripts/learning/hand-valuation-page.js b/scripts/learning/hand-valuation-page.js new file mode 100644 index 0000000..f5bad65 --- /dev/null +++ b/scripts/learning/hand-valuation-page.js @@ -0,0 +1,781 @@ +(function initHandValuationPage() { + "use strict"; + + const suits = ["S", "H", "D", "C"]; + const suitSymbols = { S: "\u2660", H: "\u2665", D: "\u2666", C: "\u2663" }; + const suitNames = { S: "schoppen", H: "harten", D: "ruiten", C: "klaveren" }; + const rankOrder = ["2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K", "A"]; + const descendingRanks = [...rankOrder].reverse(); + const rankLabel = { T: "10", J: "J", Q: "Q", K: "K", A: "A" }; + const hcpValue = { A: 4, K: 3, Q: 2, J: 1 }; + + const valuationQuestions = [ + { + kind: "hcp", + prompt: "Hoeveel HCP heeft deze hand?", + hand: "AS QS 7S KH 9H 4H QD 8D 3D JC 6C 5C 2C", + good: "Goed. Aas, Heer, Vrouw, Vrouw en Boer maken samen 12 HCP.", + wrong: "Tel alleen A, K, Q en J. De kleine kaarten tellen niet mee." + }, + { + kind: "hcp", + prompt: "Hoeveel HCP heeft deze hand? Let op de tienen.", + hand: "AS TS 8S KH TH 6H QD JD 4D TC 9C 7C 3C", + good: "Precies. De drie tienen zijn honneurs, maar leveren 0 HCP op.", + wrong: "De 10 is een Honneur, maar geen HCP. Tel A, K, Q en J." + }, + { + kind: "balanced", + prompt: "Is deze hand evenwichtig?", + hand: "AS 8S 5S 2S KH 9H 4H QD 6D 3D 8C 7C 5C", + good: "Ja. De verdeling is 4-3-3-3, dus evenwichtig.", + wrong: "Kijk naar het patroon: 4-3-3-3 staat op de evenwichtige lijst." + }, + { + kind: "balanced", + prompt: "Is deze hand evenwichtig?", + hand: "AS KS 9S 7S 6S 4S QH 8H 2H 7D 5D 3D 4C", + good: "Goed gezien. 6-3-3-1 is onevenwichtig en heeft een singleton.", + wrong: "Deze hand heeft zes schoppen en een singleton klaveren. Dat is geen SA-verdeling." + }, + { + kind: "longest", + prompt: "Welke kleur is het langst?", + hand: "KS 8S 4S AH QH 9H 7H 3H JD 6D 2D 8C 5C", + answerSuit: "H", + good: "Klopt. Harten heeft vijf kaarten en is de langste kleur.", + wrong: "Tel per kleur. Harten heeft hier vijf kaarten." + }, + { + kind: "longest", + prompt: "Welke kleur is het langst?", + hand: "QS 9S 8H 6H 3H AD KD 7D 5D 2D JC TC 4C", + answerSuit: "D", + good: "Ja. Ruiten heeft vijf kaarten en is de langste kleur.", + wrong: "Tel de ruiten nog eens: A, K, 7, 5 en 2." + }, + { + kind: "fitValue", + prompt: "Zou deze hand later meer waard worden met een hartenfit?", + hand: "AS 8S 6S 4S 2S KH 9H 5H QD 7D 6D 2D 3C", + answer: "yes", + good: "Ja. Met drie harten tegenover partners vijf harten is er een Fit, en de singleton klaveren wordt nuttig.", + wrong: "Partner met vijf harten plus jouw drie harten geeft een Fit. Dan is de singleton klaveren extra interessant." + }, + { + kind: "fitValue", + prompt: "Zou deze hand later meer waard worden met een hartenfit?", + hand: "AS QS 8S 7H 4H KD JD 6D 9C 8C 7C 5C 2C", + answer: "no", + good: "Goed. Met maar twee harten heb je tegenover vijf harten nog geen achtkaartfit.", + wrong: "Vijf harten bij partner plus twee bij jou is zeven. Dat is nog geen Fit." + }, + { + kind: "balanced", + prompt: "Is deze 5-3-3-2 hand evenwichtig?", + hand: "AS QS 8S 6S 3S KH 7H 2H JD 8D 4D 9C 5C", + good: "Ja. 5-3-3-2 is evenwichtig, ook al zit er een vijfkaart in.", + wrong: "5-3-3-2 hoort bij de drie evenwichtige verdelingen." + } + ]; + + const miniQuiz = [ + { + question: "Welke kaarten leveren HCP op?", + options: ["Aas, Heer, Vrouw en Boer", "Aas tot en met 10", "Alle honneurs evenveel"], + answer: "Aas, Heer, Vrouw en Boer", + feedback: "Juist. De 10 is wel een Honneur, maar telt 0 HCP." + }, + { + question: "Welke verdeling is evenwichtig?", + options: ["4-4-3-2", "6-4-2-1", "7-3-2-1"], + answer: "4-4-3-2", + feedback: "Klopt. 4-4-3-2 staat samen met 4-3-3-3 en 5-3-3-2 op de lijst." + }, + { + question: "Wat is een Fit?", + options: ["Samen minstens acht kaarten in een kleur", "Zelf precies vijf kaarten in een kleur", "Samen minstens acht HCP"], + answer: "Samen minstens acht kaarten in een kleur", + feedback: "Precies. Fit gaat over gezamenlijke lengte in een kleur." + }, + { + question: "Wanneer ga je herwaarderen met Fitpunten?", + options: ["Nadat een Fit waarschijnlijk is", "Voordat je HCP telt", "Alleen bij sans-atout"], + answer: "Nadat een Fit waarschijnlijk is", + feedback: "Ja. Eerst HCP, daarna pas Fitpunten wanneer de bieding een Fit laat zien." + }, + { + question: "Hoe heet precies een kaart in een kleur?", + options: ["Singleton", "Doubleton", "Renonce"], + answer: "Singleton", + feedback: "Goed. Een doubleton is twee kaarten; een renonce is nul." + }, + { + question: "Waarom is een Fit waardevol?", + options: ["Troef kan controle en aftroevers geven", "Elke kaart wordt automatisch HCP", "Je hoeft geen kleur meer te bekennen"], + answer: "Troef kan controle en aftroevers geven", + feedback: "Klopt. Met een troeffit kunnen korte kleuren en extra troeven meer werk doen." + }, + { + question: "Wat is de eerste vraag bij Openingskracht?", + options: ["Heb ik genoeg kracht om te openen?", "Welke kaart vind ik het mooist?", "Kan ik meteen slem bieden?"], + answer: "Heb ik genoeg kracht om te openen?", + feedback: "Precies. De eerste waardering is kracht plus verdeling, nog niet het hele eindcontract." + } + ]; + + const fallbackHands = { + exact12: "AS QS 7S KH 9H 4H QD 8D 3D JC 6C 5C 2C", + balanced1517: "AS KS 8S 3S QH 7H 4H KD QD 6D JC 9C 2C", + fitSingleton: "AS 8S 6S 4S 2S KH 9H 5H QD 7D 6D 2D 3C", + rule20Intro: "KS QS 9S 7S 5S AH JH 8H 6H 4H 7D 3D 6C" + }; + + const raceHandTexts = [ + "AS QS 7S KH 9H 4H QD 8D 3D JC 6C 5C 2C", + "AS TS 8S KH TH 6H QD JD 4D TC 9C 7C 3C", + "AS KS 9S 7S 6S 4S QH 8H 2H 7D 5D 3D 4C", + "KS 8S 4S AH QH 9H 7H 3H JD 6D 2D 8C 5C", + "AS QS 8S KH 7H 4H AD 8D 6D 3D JC 9C 2C", + "KS QS 9S 7S 5S AH JH 8H 6H 4H 7D 3D 6C", + "AS 8S 6S 4S KH 9H 5H QD 7D 6D 2D 3C 2C", + "QS 9S 3S 2S AH KH 8H 4H 2H AD TD 7D 5C", + "AS KS QS 3S 2S 3H 2H 4C 3C AD QD 4D 3D", + "AS KS 8S 3S QH 7H 4H KD QD 6D JC 9C 2C" + ].map(parseHand); + + let generatorSeed = 2102; + let raceTimerId = null; + const raceState = { + active: false, + index: 0, + score: 0, + timeLeft: 75, + selectedHcp: null, + selectedBalanced: null, + waitingNext: false + }; + + preserveTestHooksOnLessonLinks(); + renderSampleHands(); + initQuickChecks(); + renderValuationQuestions(); + renderMiniQuiz(); + initGenerator(); + initRace(); + + function renderSampleHands() { + document.querySelectorAll("[data-sample-hand]").forEach((target) => { + renderHand(target, parseHand(target.dataset.sampleHand || "")); + }); + } + + function initQuickChecks() { + document.querySelectorAll(".quick-check").forEach((check) => { + const feedback = check.querySelector(".quick-feedback"); + check.querySelectorAll("button").forEach((button) => { + button.addEventListener("click", () => { + check.querySelectorAll("button").forEach((candidate) => { + candidate.classList.remove("is-correct", "is-missed"); + }); + const correct = button.dataset.correct === "true"; + button.classList.add(correct ? "is-correct" : "is-missed"); + if (feedback) feedback.textContent = button.dataset.feedback || ""; + }); + }); + }); + } + + function renderValuationQuestions() { + const root = document.querySelector("#valuation-questions"); + if (!root) return; + root.innerHTML = ""; + + valuationQuestions.forEach((question, index) => { + const cards = parseHand(question.hand); + const item = document.createElement("article"); + item.className = "valuation-question"; + + const heading = document.createElement("p"); + heading.className = "question-heading"; + heading.textContent = `${index + 1}. ${question.prompt}`; + + const hand = document.createElement("div"); + hand.className = "question-hand"; + renderHand(hand, cards); + + const options = document.createElement("div"); + options.className = "question-options"; + + const facts = document.createElement("div"); + facts.className = "question-facts"; + + const feedback = document.createElement("p"); + feedback.className = "question-feedback"; + feedback.setAttribute("aria-live", "polite"); + feedback.textContent = "Kies je antwoord."; + + questionOptions(question, cards).forEach((option) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = option.label; + button.dataset.value = option.value; + button.addEventListener("click", () => { + handleQuestionAnswer({ question, cards, item, button, facts, feedback }); + }); + options.appendChild(button); + }); + + item.append(heading, hand, options, facts, feedback); + root.appendChild(item); + }); + } + + function handleQuestionAnswer({ question, cards, item, button, facts, feedback }) { + const correctValue = correctQuestionValue(question, cards); + const selectedValue = button.dataset.value || ""; + const correct = selectedValue === correctValue; + + item.querySelectorAll(".question-options button").forEach((candidate) => { + candidate.classList.remove("is-correct", "is-missed"); + if ((candidate.dataset.value || "") === correctValue) candidate.classList.add("is-correct"); + }); + if (!correct) button.classList.add("is-missed"); + + renderFactChips(facts, cards); + feedback.textContent = correct ? question.good : question.wrong; + } + + function questionOptions(question, cards) { + if (question.kind === "hcp") { + return hcpOptions(hcp(cards)).map((value) => ({ label: `${value} HCP`, value: String(value) })); + } + if (question.kind === "balanced") { + return [ + { label: "Ja, evenwichtig", value: "true" }, + { label: "Nee, onevenwichtig", value: "false" } + ]; + } + if (question.kind === "longest") { + return suits.map((suit) => ({ label: suitNames[suit], value: suit })); + } + return [ + { label: "Ja, later meer waard", value: "yes" }, + { label: "Nee, nog niet", value: "no" } + ]; + } + + function correctQuestionValue(question, cards) { + if (question.kind === "hcp") return String(hcp(cards)); + if (question.kind === "balanced") return String(isBalanced(cards)); + if (question.kind === "longest") return question.answerSuit || longestSuits(cards)[0] || ""; + return question.answer || ""; + } + + function renderMiniQuiz() { + const root = document.querySelector("#mini-quiz"); + if (!root) return; + root.innerHTML = ""; + + miniQuiz.forEach((question, index) => { + const item = document.createElement("article"); + item.className = "quiz-item"; + + const prompt = document.createElement("p"); + prompt.textContent = `${index + 1}. ${question.question}`; + + const options = document.createElement("div"); + options.className = "quiz-options"; + + const feedback = document.createElement("p"); + feedback.className = "quiz-feedback"; + feedback.setAttribute("aria-live", "polite"); + feedback.textContent = "Kies een antwoord."; + + question.options.forEach((option) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = option; + button.addEventListener("click", () => { + item.querySelectorAll(".quiz-options button").forEach((candidate) => { + candidate.classList.remove("is-correct", "is-missed"); + if (candidate.textContent === question.answer) candidate.classList.add("is-correct"); + }); + const correct = option === question.answer; + if (!correct) button.classList.add("is-missed"); + feedback.textContent = correct ? question.feedback : `Bijna. Het beste antwoord is: ${question.answer}.`; + }); + options.appendChild(button); + }); + + item.append(prompt, options, feedback); + root.appendChild(item); + }); + } + + function initGenerator() { + const buttons = [...document.querySelectorAll("[data-criterion]")]; + buttons.forEach((button) => { + button.setAttribute("aria-pressed", "false"); + button.addEventListener("click", () => { + buttons.forEach((candidate) => candidate.setAttribute("aria-pressed", String(candidate === button))); + renderGeneratedHand(button.dataset.criterion || "exact12"); + }); + }); + buttons[0]?.click(); + } + + function renderGeneratedHand(criterion) { + const handRoot = document.querySelector("#generated-hand"); + const factsRoot = document.querySelector("#generated-facts"); + if (!handRoot || !factsRoot) return; + + const cards = generateMatchingHand(criterion); + renderHand(handRoot, cards); + + const facts = generatedFacts(criterion, cards); + factsRoot.innerHTML = ""; + const intro = document.createElement("p"); + intro.textContent = facts.intro; + const list = document.createElement("ul"); + facts.items.forEach((text) => { + const item = document.createElement("li"); + item.textContent = text; + list.appendChild(item); + }); + factsRoot.append(intro, list); + } + + function generateMatchingHand(criterion) { + const matcher = criterionMatchers()[criterion] || criterionMatchers().exact12; + for (let attempt = 0; attempt < 5000; attempt += 1) { + const deck = shuffledDeck(); + const hand = sortCards(deck.slice(0, 13)); + if (matcher(hand)) return hand; + } + return parseHand(fallbackHands[criterion] || fallbackHands.exact12); + } + + function criterionMatchers() { + return { + exact12: (cards) => hcp(cards) === 12, + balanced1517: (cards) => { + const points = hcp(cards); + return points >= 15 && points <= 17 && isBalanced(cards); + }, + fitSingleton: (cards) => { + const counts = countSuits(cards); + const points = hcp(cards); + return points >= 8 && points <= 12 && counts.H >= 3 && ["S", "D", "C"].some((suit) => counts[suit] === 1); + }, + rule20Intro: (cards) => { + const points = hcp(cards); + const longest = Object.values(countSuits(cards)).sort((a, b) => b - a).slice(0, 2); + return points >= 10 && points <= 11 && points + longest[0] + longest[1] >= 20; + } + }; + } + + function generatedFacts(criterion, cards) { + const counts = countSuits(cards); + const points = hcp(cards); + const longest = Object.values(counts).sort((a, b) => b - a).slice(0, 2); + const short = shortSuits(cards); + const common = [ + `HCP: ${points}.`, + `Verdeling: ${distributionPattern(cards)} (${suitLengthsText(cards)}).`, + `Langste kleur: ${longestSuitText(cards)}.` + ]; + + if (criterion === "balanced1517") { + return { + intro: "Deze hand past bij de 1SA-denkvraag.", + items: [...common, "15-17 HCP plus een Evenwichtige verdeling maakt 1SA de eerste kandidaat in dit profiel."] + }; + } + if (criterion === "fitSingleton") { + return { + intro: "Deze hand is gemaakt om herwaarderen na een Fit te zien.", + items: [...common, `${short.length ? `Korte kleur: ${short.join(", ")}.` : "Geen korte zijkleur gevonden."}`, "Als partner vijf harten toont, worden jouw hartensteun en korte zijkleur waardevoller."] + }; + } + if (criterion === "rule20Intro") { + return { + intro: "Deze hand kijkt vooruit naar de Regel van 20.", + items: [...common, `Voorproefje: ${points} HCP + ${longest[0]} + ${longest[1]} kaarten in de twee langste kleuren = ${points + longest[0] + longest[1]}.`, "In deze les hoef je de volledige regel nog niet toe te passen; herken vooral dat lange kleuren iets kunnen toevoegen."] + }; + } + return { + intro: "Deze hand is een zuivere HCP-oefening.", + items: [...common, "Exact 12 HCP is een eerste signaal van mogelijke Openingskracht."] + }; + } + + function initRace() { + const start = document.querySelector("#race-start"); + const submit = document.querySelector("#race-submit"); + start?.addEventListener("click", startRace); + submit?.addEventListener("click", () => { + if (!raceState.active) return; + if (raceState.waitingNext) { + raceState.index += 1; + renderRaceHand(); + return; + } + submitRaceAnswer(); + }); + + document.querySelectorAll("[data-race-balanced]").forEach((button) => { + button.addEventListener("click", () => { + if (!raceState.active || raceState.waitingNext) return; + raceState.selectedBalanced = button.dataset.raceBalanced === "true"; + document.querySelectorAll("[data-race-balanced]").forEach((candidate) => { + candidate.setAttribute("aria-pressed", String(candidate === button)); + }); + syncRaceSubmit(); + }); + }); + } + + function startRace() { + clearInterval(raceTimerId); + raceState.active = true; + raceState.index = 0; + raceState.score = 0; + raceState.timeLeft = 75; + raceState.waitingNext = false; + document.querySelector("#race-start").textContent = "Opnieuw starten"; + raceTimerId = setInterval(() => { + raceState.timeLeft -= 1; + updateRaceStatus(); + if (raceState.timeLeft <= 0) finishRace("Tijd voorbij."); + }, 1000); + renderRaceHand(); + } + + function renderRaceHand() { + if (raceState.index >= raceHandTexts.length) { + finishRace("Race klaar."); + return; + } + + raceState.selectedHcp = null; + raceState.selectedBalanced = null; + raceState.waitingNext = false; + + const cards = raceHandTexts[raceState.index]; + const handRoot = document.querySelector("#race-hand"); + const hcpRoot = document.querySelector("#race-hcp-options"); + const submit = document.querySelector("#race-submit"); + const feedback = document.querySelector("#race-feedback"); + + if (handRoot) { + handRoot.className = "race-hand"; + renderHand(handRoot, cards); + } + + if (hcpRoot) { + hcpRoot.innerHTML = ""; + hcpOptions(hcp(cards)).forEach((value) => { + const button = document.createElement("button"); + button.type = "button"; + button.textContent = String(value); + button.dataset.raceHcp = String(value); + button.addEventListener("click", () => { + if (!raceState.active || raceState.waitingNext) return; + raceState.selectedHcp = value; + hcpRoot.querySelectorAll("button").forEach((candidate) => { + candidate.setAttribute("aria-pressed", String(candidate === button)); + }); + syncRaceSubmit(); + }); + hcpRoot.appendChild(button); + }); + } + + document.querySelectorAll("[data-race-balanced]").forEach((button) => { + button.disabled = false; + button.classList.remove("is-correct", "is-missed"); + button.setAttribute("aria-pressed", "false"); + }); + + if (submit) { + submit.textContent = "Controleer"; + submit.disabled = true; + } + if (feedback) feedback.textContent = "Kies HCP en verdeling voor deze hand."; + updateRaceStatus(); + } + + function submitRaceAnswer() { + const cards = raceHandTexts[raceState.index]; + const correctHcp = hcp(cards); + const correctBalanced = isBalanced(cards); + const correct = raceState.selectedHcp === correctHcp && raceState.selectedBalanced === correctBalanced; + const submit = document.querySelector("#race-submit"); + const feedback = document.querySelector("#race-feedback"); + + if (correct) raceState.score += 1; + raceState.waitingNext = true; + + document.querySelectorAll("[data-race-hcp]").forEach((button) => { + const value = Number(button.dataset.raceHcp || 0); + button.disabled = true; + button.classList.toggle("is-correct", value === correctHcp); + button.classList.toggle("is-missed", value === raceState.selectedHcp && value !== correctHcp); + }); + + document.querySelectorAll("[data-race-balanced]").forEach((button) => { + const value = button.dataset.raceBalanced === "true"; + button.disabled = true; + button.classList.toggle("is-correct", value === correctBalanced); + button.classList.toggle("is-missed", value === raceState.selectedBalanced && value !== correctBalanced); + }); + + if (raceState.index === raceHandTexts.length - 1) { + finishRace(`Race klaar. ${raceResultText(correct, correctHcp, correctBalanced)}`); + return; + } + + if (feedback) feedback.textContent = raceResultText(correct, correctHcp, correctBalanced); + if (submit) { + submit.textContent = "Volgende hand"; + submit.disabled = false; + } + updateRaceStatus(); + } + + function raceResultText(correct, correctHcp, correctBalanced) { + const balanceText = correctBalanced ? "evenwichtig" : "onevenwichtig"; + if (correct) return `Goed. ${correctHcp} HCP en ${balanceText}.`; + return `Bijna. Deze hand heeft ${correctHcp} HCP en is ${balanceText}.`; + } + + function syncRaceSubmit() { + const submit = document.querySelector("#race-submit"); + if (!submit) return; + submit.disabled = raceState.selectedHcp === null || raceState.selectedBalanced === null; + } + + function finishRace(prefix) { + clearInterval(raceTimerId); + raceTimerId = null; + if (prefix.startsWith("Race klaar.")) raceState.index = raceHandTexts.length; + raceState.active = false; + raceState.waitingNext = false; + const submit = document.querySelector("#race-submit"); + const feedback = document.querySelector("#race-feedback"); + if (submit) submit.disabled = true; + if (feedback) feedback.textContent = `${prefix} Je eindscore is ${raceState.score} van ${raceHandTexts.length}.`; + document.querySelector("#race-start").textContent = "Nog een race"; + updateRaceStatus(); + } + + function updateRaceStatus() { + const progress = document.querySelector("#race-progress"); + const timer = document.querySelector("#race-timer"); + const score = document.querySelector("#race-score"); + const shownIndex = raceState.active ? Math.min(raceState.index + 1, raceHandTexts.length) : Math.min(raceState.index, raceHandTexts.length); + if (progress) progress.textContent = `Hand ${shownIndex} van ${raceHandTexts.length}`; + if (timer) timer.textContent = `${Math.max(0, raceState.timeLeft)} sec`; + if (score) score.textContent = `Score ${raceState.score}`; + } + + function hcpOptions(correct) { + const values = new Set([correct, Math.max(0, correct - 2), Math.max(0, correct - 1), correct + 1]); + return [...values].sort((a, b) => a - b); + } + + function parseHand(text) { + return sortCards(String(text || "").trim().split(/\s+/).filter(Boolean).map(parseCard)); + } + + function parseCard(token) { + const suit = token.slice(-1).toUpperCase(); + const rawRank = token.slice(0, -1).toUpperCase(); + const rank = rawRank === "10" ? "T" : rawRank; + return { rank, suit, id: `${rank}${suit}` }; + } + + function renderHand(target, cards) { + if (!target) return; + target.innerHTML = ""; + const counts = countSuits(cards); + suits.forEach((suit) => { + const row = document.createElement("div"); + row.className = "hand-suit-row"; + + const label = document.createElement("span"); + label.className = "hand-suit-label"; + label.textContent = `${suitSymbols[suit]} ${suitNames[suit]} (${counts[suit]})`; + + const cardRow = document.createElement("div"); + cardRow.className = "hand-cards"; + const suitCards = cards.filter((card) => card.suit === suit); + if (!suitCards.length) { + const empty = document.createElement("span"); + empty.className = "empty-suit"; + empty.textContent = "geen"; + cardRow.appendChild(empty); + } else { + suitCards.forEach((card) => cardRow.appendChild(cardElement(card))); + } + + row.append(label, cardRow); + target.appendChild(row); + }); + } + + function cardElement(card) { + const cardEl = document.createElement("div"); + cardEl.className = "playing-card valuation-card"; + if (card.suit === "H" || card.suit === "D") cardEl.classList.add("red"); + const label = rankLabel[card.rank] || card.rank; + const symbol = suitSymbols[card.suit] || card.suit; + cardEl.innerHTML = ` +
${label}${symbol}
+
${symbol}
+
${label}${symbol}
+ `; + cardEl.setAttribute("aria-label", `${label} ${suitNames[card.suit] || card.suit}`); + return cardEl; + } + + function sortCards(cards) { + return [...cards].sort((a, b) => { + const suitDiff = suits.indexOf(a.suit) - suits.indexOf(b.suit); + if (suitDiff) return suitDiff; + return descendingRanks.indexOf(a.rank) - descendingRanks.indexOf(b.rank); + }); + } + + function createDeck() { + const deck = []; + suits.forEach((suit) => { + rankOrder.forEach((rank) => deck.push({ rank, suit, id: `${rank}${suit}` })); + }); + return deck; + } + + function shuffledDeck() { + const deck = createDeck(); + for (let index = deck.length - 1; index > 0; index -= 1) { + const swapIndex = Math.floor(nextRandom() * (index + 1)); + [deck[index], deck[swapIndex]] = [deck[swapIndex], deck[index]]; + } + return deck; + } + + function nextRandom() { + generatorSeed = (Math.imul(generatorSeed, 1664525) + 1013904223) >>> 0; + return generatorSeed / 4294967296; + } + + function hcp(cards) { + return cards.reduce((sum, card) => sum + (hcpValue[card.rank] || 0), 0); + } + + function countSuits(cards) { + return suits.reduce((counts, suit) => { + counts[suit] = cards.filter((card) => card.suit === suit).length; + return counts; + }, {}); + } + + function distributionPattern(cards) { + return Object.values(countSuits(cards)).sort((a, b) => b - a).join("-"); + } + + function isBalanced(cards) { + const pattern = distributionPattern(cards); + return pattern === "4-3-3-3" || pattern === "4-4-3-2" || pattern === "5-3-3-2"; + } + + function longestSuits(cards) { + const counts = countSuits(cards); + const max = Math.max(...Object.values(counts)); + return suits.filter((suit) => counts[suit] === max); + } + + function longestSuitText(cards) { + return longestSuits(cards).map((suit) => `${suitNames[suit]} (${countSuits(cards)[suit]})`).join(", "); + } + + function suitLengthsText(cards) { + const counts = countSuits(cards); + return suits.map((suit) => `${suitSymbols[suit]} ${counts[suit]}`).join(", "); + } + + function shortSuits(cards) { + const counts = countSuits(cards); + return suits + .filter((suit) => counts[suit] <= 2) + .map((suit) => { + const length = counts[suit]; + const label = length === 0 ? "renonce" : length === 1 ? "singleton" : "doubleton"; + return `${label} ${suitNames[suit]}`; + }); + } + + function renderFactChips(target, cards) { + if (!target) return; + target.innerHTML = ""; + [ + `${hcp(cards)} HCP`, + distributionPattern(cards), + isBalanced(cards) ? "evenwichtig" : "onevenwichtig", + `langste: ${longestSuitText(cards)}` + ].forEach((text) => { + const chip = document.createElement("span"); + chip.className = "fact-chip"; + chip.textContent = text; + target.appendChild(chip); + }); + } + + function preserveTestHooksOnLessonLinks() { + const params = new URLSearchParams(window.location.search || ""); + document.querySelectorAll('a[href^="lessons.html"], a[href^="index.html"]').forEach((link) => { + const href = new URL(link.getAttribute("href"), window.location.href); + if (href.pathname.endsWith("index.html")) { + applyTableLinkContext(href, params); + link.addEventListener("click", () => { + const nextHref = new URL(link.getAttribute("href"), window.location.href); + applyTableLinkContext(nextHref, params); + link.href = relativeHref(nextHref); + }); + } + if (params.has("testHooks")) href.searchParams.set("testHooks", "1"); + link.href = relativeHref(href); + }); + } + + function applyTableLinkContext(href, params) { + const lessonId = href.searchParams.get("lesson"); + const chapterId = href.searchParams.get("chapter") || chapterForHand(href.searchParams.get("hand")); + if (chapterId) href.searchParams.set("chapter", chapterId); + if (lessonId) href.searchParams.set("return", currentLessonReturnHref(params)); + if (params.has("testHooks")) href.searchParams.set("testHooks", "1"); + } + + function currentLessonReturnHref(params) { + const href = new URL(window.location.href); + if (params.has("testHooks")) href.searchParams.set("testHooks", "1"); + if (!href.hash) { + const activeCard = document.querySelector("[data-lesson-card].is-active-lesson-card") || document.querySelector("[data-lesson-card]:not([hidden])"); + if (activeCard?.id) href.hash = activeCard.id; + } + return relativeHref(href); + } + + function relativeHref(href) { + return `${href.pathname.split("/").pop()}${href.search}${href.hash}`; + } + + function chapterForHand(handId) { + return { + "one-nt-opening-001": "een-sa-opening-herkennen", + "opening-pass-001": "openingskracht-of-pas" + }[handId] || ""; + } +})(); diff --git a/scripts/learning/lesson-board-coach.js b/scripts/learning/lesson-board-coach.js index f087f71..917e1fa 100644 --- a/scripts/learning/lesson-board-coach.js +++ b/scripts/learning/lesson-board-coach.js @@ -2,7 +2,6 @@ "use strict"; const modules = root.BridgeAppModules = root.BridgeAppModules || {}; - const lessonBoardCoachLessonId = "les-01-wat-is-bridge"; modules.registerLessonBoardCoach = function registerLessonBoardCoach(runtime) { const { actions, constants, dom, els, helpers, render, state, transitions } = runtime; @@ -14,15 +13,18 @@ const seatAt = (...args) => helpers.seatAt(...args); const t = (...args) => helpers.t(...args); const advanceCompletedTrick = (...args) => actions.advanceCompletedTrick(...args); + const continueAuction = (...args) => actions.continueAuction(...args); const continuePlay = (...args) => actions.continuePlay(...args); const renderAll = (...args) => render.renderAll(...args); function renderLessonBanner() { if (!els.lessonBanner) return; - const step = activeLessonBoardStep(); + const done = lessonTableTaskIsDone(); + const step = done ? null : activeLessonBoardStep(); renderLessonBoardHighlights(step); - els.lessonBanner.classList.toggle("lesson-coach-card", Boolean(step)); - els.lessonBanner.hidden = !state.practice?.challenge && !step; + els.lessonBanner.classList.toggle("lesson-coach-card", Boolean(step || done)); + els.lessonBanner.classList.toggle("lesson-done-card", done); + els.lessonBanner.hidden = !state.practice?.challenge && !step && !done; els.lessonBanner.innerHTML = ""; if (els.lessonBanner.hidden) return; @@ -30,6 +32,11 @@ function renderLessonBanner() { const lessonPrefix = state.practice.lessonNumber ? `${t("lesson")} ${state.practice.lessonNumber}` : t("lesson"); label.textContent = state.practice.lessonTitle ? `${lessonPrefix}: ${state.practice.lessonTitle}` : lessonPrefix; + if (done) { + appendLessonDoneContent(els.lessonBanner, label.textContent, { compact: true }); + return; + } + if (step) { const stepIndex = lessonBoardSteps().findIndex((candidate) => candidate.id === step.id); const eyebrow = document.createElement("span"); @@ -54,15 +61,7 @@ function renderLessonBanner() { els.lessonBanner.append(eyebrow, head, body); if (step.gate !== "none") { - const action = document.createElement("button"); - action.type = "button"; - action.className = "lesson-coach-action"; - action.textContent = step.buttonLabel || "Verder"; - action.addEventListener("click", (event) => { - event.stopPropagation(); - acknowledgeLessonBoardStep(step); - }); - els.lessonBanner.appendChild(action); + appendLessonCoachAction(els.lessonBanner, step); } return; } @@ -72,12 +71,214 @@ function renderLessonBanner() { els.lessonBanner.append(label, challenge); } +function renderLessonPanel() { + if (!els.lessonPanel) return; + const active = isLessonModeActive() && state.phase !== "complete"; + els.lessonPanel.hidden = !active; + els.lessonPanel.innerHTML = ""; + if (!active) return; + + const done = lessonTableTaskIsDone(); + const step = done ? null : activeLessonBoardStep(); + const steps = lessonBoardSteps(); + const stepIndex = step ? steps.findIndex((candidate) => candidate.id === step.id) : -1; + + const eyebrow = document.createElement("p"); + eyebrow.className = "lesson-panel-eyebrow"; + const lessonLabel = state.practice.lessonNumber ? `Les ${state.practice.lessonNumber}` : "Les"; + const chapter = state.practice.lessonChapterTitle ? ` ${separatorDot} ${state.practice.lessonChapterTitle}` : ""; + eyebrow.textContent = `${lessonLabel}${chapter}`; + + const title = document.createElement("h2"); + title.textContent = state.practice.lessonTitle || "Les aan tafel"; + + const progress = document.createElement("div"); + progress.className = "lesson-panel-progress"; + const progressText = done + ? "Tafelmoment klaar" + : step && steps.length + ? `Stap ${stepIndex + 1} van ${steps.length}` + : "Aan tafel"; + progress.textContent = progressText; + + const mission = document.createElement("p"); + mission.className = "lesson-panel-mission"; + mission.appendChild(BridgeGlossary.linkifyText(state.practice.challenge || state.practice.testGoal || "Oefen deze situatie aan de tafel.")); + + els.lessonPanel.append(eyebrow, title, progress, mission); + + if (state.lessonActionFeedback) { + els.lessonPanel.appendChild(lessonActionFeedbackCard(state.lessonActionFeedback)); + } + + if (done) { + const card = document.createElement("section"); + card.className = "lesson-panel-card lesson-panel-done"; + appendLessonDoneContent(card, "", { compact: false }); + els.lessonPanel.appendChild(card); + return; + } + + if (step) { + const card = document.createElement("section"); + card.className = "lesson-panel-card"; + const badge = document.createElement("span"); + badge.className = "lesson-coach-badge"; + badge.textContent = step.badge; + const heading = document.createElement("h3"); + heading.textContent = step.title; + const body = document.createElement("p"); + body.appendChild(BridgeGlossary.linkifyText(step.body)); + card.append(badge, heading, body); + if (step.gate !== "none") { + appendLessonCoachAction(card, step); + } + els.lessonPanel.appendChild(card); + return; + } + + const idle = document.createElement("section"); + idle.className = "lesson-panel-card"; + const heading = document.createElement("h3"); + heading.textContent = "Kijk naar de tafel"; + const body = document.createElement("p"); + body.textContent = state.practice.lessonIntro || "Speel alleen het gevraagde lesmoment; daarna kun je terug naar de les."; + idle.append(heading, body); + els.lessonPanel.appendChild(idle); +} + +function lessonActionFeedbackCard(feedback) { + const card = document.createElement("section"); + card.className = "lesson-panel-card lesson-panel-retry"; + const badge = document.createElement("span"); + badge.className = "lesson-coach-badge"; + badge.textContent = "Probeer opnieuw"; + const heading = document.createElement("h3"); + heading.textContent = feedback.title || "Probeer nog eens"; + const body = document.createElement("p"); + body.appendChild(BridgeGlossary.linkifyText(feedback.body || "Deze keuze past nog niet bij de oefening.")); + card.append(badge, heading, body); + if (feedback.hint) { + const hint = document.createElement("p"); + hint.className = "lesson-feedback-hint"; + hint.appendChild(BridgeGlossary.linkifyText(feedback.hint)); + card.appendChild(hint); + } + return card; +} + +function appendLessonCoachAction(parent, step) { + const action = document.createElement("button"); + action.type = "button"; + action.className = "lesson-coach-action"; + action.textContent = step.buttonLabel || "Verder"; + action.addEventListener("click", (event) => { + event.stopPropagation(); + acknowledgeLessonBoardStep(step); + }); + parent.appendChild(action); +} + +function appendLessonDoneContent(parent, prefix, { compact }) { + const task = lessonTableTask(); + const eyebrow = document.createElement("span"); + eyebrow.className = "lesson-coach-eyebrow"; + eyebrow.textContent = prefix ? `${prefix} ${separatorDot} klaar` : "Klaar"; + + const title = document.createElement(compact ? "strong" : "h3"); + title.className = "lesson-coach-title"; + title.textContent = task?.doneTitle || "Tafelmoment klaar"; + + const body = document.createElement(compact ? "span" : "p"); + body.className = "lesson-coach-body"; + body.appendChild(BridgeGlossary.linkifyText(task?.doneBody || "Deze korte situatie is klaar. Ga terug naar de les om verder te leren.")); + + const actionsRow = document.createElement("div"); + actionsRow.className = "lesson-done-actions"; + actionsRow.appendChild(returnToLessonLink(task?.returnLabel || "Terug naar les")); + if (task?.retryLabel) { + const retry = document.createElement("button"); + retry.type = "button"; + retry.className = "lesson-done-retry"; + retry.textContent = task.retryLabel; + retry.addEventListener("click", (event) => { + event.stopPropagation(); + actions.replayHand(); + }); + actionsRow.appendChild(retry); + } + + parent.append(eyebrow, title, body, actionsRow); +} + +function returnToLessonLink(label) { + const link = document.createElement("a"); + link.className = "lesson-done-return"; + link.href = lessonReturnHref(); + link.textContent = label; + return link; +} + +function lessonReturnHref() { + return state.practice?.lessonReturnHref || actions.defaultLessonReturnHref?.(state.practice?.lessonId, state.practice?.lessonChapterId) || "lessons.html"; +} + +function isLessonModeActive() { + return actions.isLessonModeActive?.() || Boolean(state.practice?.lessonId); +} + +function lessonTableTask() { + return state.practice?.lessonTableTask || null; +} + +function lessonTableTaskIsDone() { + if (!isLessonModeActive() || !lessonTableTask()) return false; + if (state.lessonTableTaskDone) return true; + if (globalThis.BridgeLessons?.tableTaskCompleted?.(lessonTableTask(), state)) { + state.lessonTableTaskDone = true; + return true; + } + return false; +} + +function maybeCompleteLessonTableTask() { + const wasDone = Boolean(state.lessonTableTaskDone); + const done = lessonTableTaskIsDone(); + if (!done) return false; + if (!wasDone) actions.resetScheduledFlow?.(); + renderAll(); + return true; +} + +function validateLessonTableAction(type, action = {}) { + const task = lessonTableTask(); + if (!isLessonModeActive() || !task?.expectedAction || task.type !== type) { + clearLessonActionFeedback(); + return true; + } + const feedback = globalThis.BridgeLessons?.tableTaskActionFeedback?.(task, { ...action, type }); + if (!feedback) { + clearLessonActionFeedback(); + return true; + } + state.lessonActionFeedback = feedback; + state.illegalActionFeedback = `${feedback.title || "Probeer opnieuw"}: ${feedback.body || "Deze keuze past nog niet bij de oefening."}`; + renderAll(); + return false; +} + +function clearLessonActionFeedback() { + if (!state.lessonActionFeedback && !state.illegalActionFeedback) return; + state.lessonActionFeedback = null; + state.illegalActionFeedback = null; +} + function lessonBoardSteps() { - if (state.practice?.lessonId !== lessonBoardCoachLessonId) return []; - return Array.isArray(state.practice.lessonBoardGuidance) ? state.practice.lessonBoardGuidance : []; + return Array.isArray(state.practice?.lessonBoardGuidance) ? state.practice.lessonBoardGuidance : []; } function activeLessonBoardStep() { + if (lessonTableTaskIsDone()) return null; const steps = lessonBoardSteps(); if (!steps.length) return null; return steps.find((step) => lessonBoardStepReady(step) && !lessonBoardStepAcknowledged(step)) || null; @@ -85,11 +286,14 @@ function activeLessonBoardStep() { function lessonBoardStepAcknowledged(step) { if (step.gate === "none") return false; - return state.lessonBoardAcknowledged.includes(step.id); + return (state.lessonBoardAcknowledged || []).includes(step.id); } function lessonBoardStepReady(step) { if (step.id === "reviewResult") return state.phase === "complete"; + if (step.gate === "allowHumanBid") return state.phase === "bidding" && seatAt(state.turnIndex) === "South" && !state.animateDeal; + if (step.target === "auctionLog") return state.auction.length > 0; + if (step.target === "bidControls") return state.phase === "bidding" && !state.animateDeal; if (state.phase !== "playing" || !state.contract || !state.declarer || !state.dummy) return false; const openingLeadMade = openingLeadHasBeenMade(); const humanTurn = isHumanControlledSeat(seatAt(state.turnIndex)); @@ -101,6 +305,8 @@ function lessonBoardStepReady(step) { if (step.id === "followSuit") return openingLeadMade && humanTurn && !state.awaitingTrickAdvance && state.currentTrick.length > 0; if (step.id === "trumpMeaning") return openingLeadMade && humanTurn && !state.awaitingTrickAdvance && state.contract.strain !== "NT"; if (step.id === "trickWinner") return state.awaitingTrickAdvance && Boolean(state.pendingTrickWinner); + if (step.gate === "allowHumanPlay") return openingLeadMade && humanTurn && !state.awaitingTrickAdvance; + if (step.target === "review") return state.phase === "complete"; return false; } @@ -112,10 +318,22 @@ function acknowledgeLessonBoardStep(step) { advanceCompletedTrick(); return; } + if (step.gate === "allowHumanBid") { + continueAuction(); + return; + } continuePlay(); } +function lessonBoardBlocksHumanBid(seat) { + if (lessonTableTaskIsDone()) return true; + const step = activeLessonBoardStep(); + if (!step || step.gate !== "allowHumanBid") return false; + return seat === "South" && seatAt(state.turnIndex) === seat; +} + function lessonBoardBlocksHumanPlay(seat) { + if (lessonTableTaskIsDone()) return true; const step = activeLessonBoardStep(); if (!step || step.gate === "none") return false; if (state.phase !== "playing" || state.awaitingTrickAdvance) return false; @@ -123,6 +341,7 @@ function lessonBoardBlocksHumanPlay(seat) { } function blockingLessonBoardStep() { + if (lessonTableTaskIsDone()) return true; const step = activeLessonBoardStep(); return Boolean(step && step.gate !== "none"); } @@ -148,12 +367,18 @@ function clearLessonBoardHighlights() { "lesson-highlight-legalCards", "lesson-highlight-trumpCards", "lesson-highlight-trickWinner", - "lesson-highlight-review" + "lesson-highlight-review", + "lesson-highlight-bidControls", + "lesson-highlight-auctionLog", + "lesson-highlight-lessonPanel" ); [ els.contract, els.reviewPanel, els.trickArea, + els.bidControls, + els.auctionLog, + els.lessonPanel, ...Object.values(seatEls), ...Object.values(slotEls) ].forEach((element) => element?.classList?.remove("lesson-highlight-target")); @@ -171,6 +396,9 @@ function lessonBoardHighlightTargets(step) { if (step.target === "trumpCards") return [seatEls[state.declarer], seatEls[state.dummy]]; if (step.target === "trickWinner") return [slotEls[state.pendingTrickWinner]]; if (step.target === "review") return [els.reviewPanel]; + if (step.target === "bidControls") return [els.bidControls]; + if (step.target === "auctionLog") return [els.auctionLog]; + if (step.target === "lessonPanel") return [els.lessonPanel]; return []; } @@ -193,17 +421,22 @@ function lessonBoardHighlightCards(step) { activeLessonBoardStep, acknowledgeLessonBoardStep, blockingLessonBoardStep, + lessonBoardBlocksHumanBid, lessonBoardBlocksHumanPlay, lessonBoardHighlightCards, lessonBoardHighlightTargets, lessonBoardStepAcknowledged, lessonBoardStepReady, - lessonBoardSteps + lessonBoardSteps, + lessonTableTaskIsDone, + maybeCompleteLessonTableTask, + validateLessonTableAction }); Object.assign(render, { clearLessonBoardHighlights, renderLessonBanner, - renderLessonBoardHighlights + renderLessonBoardHighlights, + renderLessonPanel }); }; })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/learning/lesson-cards.js b/scripts/learning/lesson-cards.js new file mode 100644 index 0000000..6f2a3b6 --- /dev/null +++ b/scripts/learning/lesson-cards.js @@ -0,0 +1,146 @@ +(function initLessonCardsModule(global) { + const DEFAULTS = { + pageSelector: "[data-lesson-card-page]", + cardSelector: "[data-lesson-card]", + navSelector: "[data-lesson-card-nav] a[href^='#']", + previousSelector: "[data-lesson-previous]", + nextSelector: "[data-lesson-next]", + positionSelector: "[data-lesson-card-position]", + titleSelector: "[data-lesson-card-title]", + activeClass: "is-active-lesson-card" + }; + + function initAll(root = global.document) { + if (!root) return []; + return [...root.querySelectorAll(DEFAULTS.pageSelector)] + .map((page) => init(page)) + .filter(Boolean); + } + + function init(page, options = {}) { + if (!page || page.dataset.lessonCardsReady === "true") return null; + + const config = { ...DEFAULTS, ...options }; + const ownerDocument = page.ownerDocument || global.document; + const ownerWindow = ownerDocument?.defaultView || global; + const cards = [...page.querySelectorAll(config.cardSelector)]; + if (!cards.length) return null; + + const links = [...page.querySelectorAll(config.navSelector)]; + const previousButton = page.querySelector(config.previousSelector); + const nextButton = page.querySelector(config.nextSelector); + const position = page.querySelector(config.positionSelector); + const title = page.querySelector(config.titleSelector); + let activeIndex = indexForHash(ownerWindow?.location?.hash || ""); + + page.dataset.lessonCardsReady = "true"; + + links.forEach((link) => { + link.addEventListener("click", (event) => { + const targetIndex = indexForHash(link.getAttribute("href") || ""); + if (targetIndex < 0) return; + event.preventDefault(); + show(targetIndex, { updateHash: true, scroll: true }); + }); + }); + + previousButton?.addEventListener("click", () => { + show(activeIndex - 1, { updateHash: true, scroll: true }); + }); + + nextButton?.addEventListener("click", () => { + show(activeIndex + 1, { updateHash: true, scroll: true }); + }); + + ownerWindow?.addEventListener("hashchange", () => { + const targetIndex = indexForHash(ownerWindow.location.hash); + if (targetIndex >= 0) show(targetIndex, { updateHash: false, scroll: false }); + }); + + show(activeIndex >= 0 ? activeIndex : 0, { updateHash: Boolean(ownerWindow?.location?.hash), scroll: false }); + + return { + show, + getActiveIndex: () => activeIndex, + getActiveCard: () => cards[activeIndex] || null + }; + + function show(index, { updateHash = false, scroll = false } = {}) { + activeIndex = Math.max(0, Math.min(cards.length - 1, index)); + const activeCard = cards[activeIndex]; + + cards.forEach((card, cardIndex) => { + const isActive = cardIndex === activeIndex; + card.hidden = !isActive; + card.classList.toggle(config.activeClass, isActive); + }); + + const activeTitle = activeCard?.querySelector("h2")?.textContent?.trim() || "Leskaart"; + if (position) position.textContent = `Kaart ${activeIndex + 1} van ${cards.length}`; + if (title) title.textContent = activeTitle; + if (previousButton) previousButton.disabled = activeIndex <= 0; + if (nextButton) nextButton.disabled = activeIndex >= cards.length - 1; + + links.forEach((link) => { + const current = link.getAttribute("href") === `#${activeCard?.id}`; + if (current) link.setAttribute("aria-current", "true"); + else link.removeAttribute("aria-current"); + }); + + if (updateHash && activeCard?.id && ownerWindow?.history) { + const nextUrl = new URL(ownerWindow.location.href); + nextUrl.hash = activeCard.id; + ownerWindow.history.replaceState(null, "", nextUrl); + } + + if (scroll) { + activeCard?.scrollIntoView({ block: "start", behavior: "smooth" }); + } + + const LessonCardChangeEvent = ownerWindow?.CustomEvent || global.CustomEvent; + if (typeof LessonCardChangeEvent === "function") { + page.dispatchEvent(new LessonCardChangeEvent("lesson-card-change", { + detail: { + activeCard, + activeIndex, + totalCards: cards.length + } + })); + } + } + + function indexForHash(hash) { + const targetId = decodeHash(hash); + if (!targetId) return -1; + return cards.findIndex((card) => card.id === targetId); + } + } + + function decodeHash(hash) { + const text = String(hash || ""); + const hashIndex = text.indexOf("#"); + const raw = (hashIndex >= 0 ? text.slice(hashIndex + 1) : text).trim(); + if (!raw) return ""; + try { + return decodeURIComponent(raw); + } catch (_error) { + return raw; + } + } + + const api = { init, initAll }; + + if (typeof module !== "undefined" && module.exports) { + module.exports = api; + } + + global.BridgeLessonCards = api; + + if (global.document) { + if (global.document.readyState === "loading") { + global.document.addEventListener("DOMContentLoaded", () => initAll(global.document)); + } else { + initAll(global.document); + } + } +})(typeof globalThis !== "undefined" ? globalThis : window); diff --git a/scripts/learning/lesson-page.js b/scripts/learning/lesson-page.js index deaf356..70747f9 100644 --- a/scripts/learning/lesson-page.js +++ b/scripts/learning/lesson-page.js @@ -79,40 +79,32 @@ eyebrow.textContent = `Les ${lesson.number}`; const title = document.createElement("h2"); - title.textContent = "Samenvatting"; + title.textContent = lesson.title; - const intro = document.createElement("p"); - intro.className = "lesson-intro"; - intro.textContent = lesson.summary || lesson.intro || lesson.challenge; - - header.append(eyebrow, title, intro); + header.append(eyebrow, title); els.content.appendChild(header); - const lessonChapters = lesson.chapters?.length ? lesson.chapters : fallbackChapters(lesson); - els.content.appendChild(lessonSummaryEl(lesson, lessonChapters)); - els.content.appendChild(lessonStartEl(lesson, lessonChapters)); + els.content.appendChild(lessonGoalsEl(lesson)); + els.content.appendChild(lessonStartEl(lesson)); + scrollHashIntoView(); } - function lessonSummaryEl(lesson, chapters) { + function lessonGoalsEl(lesson) { const summary = document.createElement("section"); summary.className = "lesson-summary-panel"; const title = document.createElement("h3"); - title.textContent = "Wat zit erin?"; - - const copy = document.createElement("p"); - copy.className = "lesson-chapter-paragraph"; - copy.textContent = lesson.intro || "Begin de les en loop stap voor stap door de onderdelen heen."; - - summary.append(title, copy, focusEl(lesson.focus || [])); - - if (chapters.length) { - const chapterCopy = document.createElement("p"); - chapterCopy.className = "lesson-chapter-paragraph"; - chapterCopy.textContent = `De les bestaat uit ${chapters.length} korte onderdelen. Je opent ze met de knop hieronder.`; - summary.appendChild(chapterCopy); - } + title.textContent = "Leerdoelen"; + + const goals = document.createElement("ul"); + goals.className = "lesson-goal-list"; + lessonGoals(lesson).forEach((goal) => { + const item = document.createElement("li"); + item.textContent = goal; + goals.appendChild(item); + }); + summary.append(title, goals); return summary; } @@ -153,7 +145,7 @@ } if (chapter.handId) { - footer.appendChild(practiceLink(lesson, chapter.handId, "Oefenen")); + footer.appendChild(practiceLink(lesson, chapter.handId, "Oefenen", chapter)); } return footer.childElementCount ? footer : null; @@ -169,23 +161,26 @@ return link; } - function lessonStartEl(lesson, chapters) { - const finish = document.createElement("section"); + function lessonStartEl(lesson) { + const finish = document.createElement("div"); finish.className = "lesson-finish"; - const title = document.createElement("h3"); - title.textContent = lesson.pageHref ? "Begin de les" : "Aan tafel oefenen"; - - finish.appendChild(title); if (lesson.pageHref) { - finish.appendChild(chapterPageLink(lesson.pageHref, "Begin les")); + finish.appendChild(chapterPageLink(lesson.pageHref, "Start les")); + return finish; } - const handId = chapters.find((chapter) => chapter.handId)?.handId || lesson.handIds[0]; - finish.appendChild(practiceLink(lesson, handId, "Start oefening")); + finish.appendChild(practiceLink(lesson, lesson.handIds[0], "Start oefening")); return finish; } + function lessonGoals(lesson) { + if (lesson.learningGoals?.length) return lesson.learningGoals; + if (lesson.teachingPoints?.length) return lesson.teachingPoints; + if (lesson.summary) return [lesson.summary]; + return [lesson.challenge]; + } + function blockEl(block) { if (block.type === "list") { const list = document.createElement("ul"); @@ -243,18 +238,28 @@ return wrapper; } - function practiceLink(lesson, handId, label = "Start oefening") { + function practiceLink(lesson, handId, label = "Start oefening", chapter = null) { const link = document.createElement("a"); link.className = "lesson-practice-link"; const href = new URL("index.html", root.location.href); href.searchParams.set("lesson", lesson.id); href.searchParams.set("hand", handId); + if (chapter?.id) href.searchParams.set("chapter", chapter.id); + href.searchParams.set("return", lessonReturnHref(lesson, chapter)); if (params.has("testHooks")) href.searchParams.set("testHooks", "1"); link.href = href.pathname.split("/").pop() + href.search; link.textContent = label; return link; } + function lessonReturnHref(lesson, chapter = null) { + const href = new URL("lessons.html", root.location.href); + href.searchParams.set("lesson", lesson.id); + if (params.has("testHooks")) href.searchParams.set("testHooks", "1"); + if (chapter?.id) href.hash = chapter.id; + return `${href.pathname.split("/").pop()}${href.search}${href.hash}`; + } + function focusEl(labels) { const focus = document.createElement("div"); focus.className = "lesson-focus"; @@ -284,9 +289,18 @@ function updateUrlLesson(lessonId) { const next = new URL(root.location.href); next.searchParams.set("lesson", lessonId); + next.hash = ""; root.history.replaceState(null, "", next); } + function scrollHashIntoView() { + const id = decodeURIComponent((root.location.hash || "").replace(/^#/, "")); + if (!id) return; + window.setTimeout(() => { + document.getElementById(id)?.scrollIntoView({ block: "start" }); + }, 0); + } + function setupResponsiveRoute() { if (!els.routePanel || !root.matchMedia) return; const query = root.matchMedia("(max-width: 820px)"); diff --git a/scripts/learning/lesson-start.js b/scripts/learning/lesson-start.js index 457a53d..a70ea90 100644 --- a/scripts/learning/lesson-start.js +++ b/scripts/learning/lesson-start.js @@ -13,10 +13,12 @@ const setStatus = (...args) => actions.setStatus(...args); const startPracticeHand = (...args) => actions.startPracticeHand(...args); -function startLesson(lesson, handId) { +function startLesson(lesson, handId, options = {}) { if (!lesson?.id || !handId) return; + const lessonContext = lessonContextFromOptions(lesson, options); + enterLessonMode(); const startAtPlay = lesson.startMode === "play"; - const scenario = startPracticeHand(handId, { lesson, skipFlow: startAtPlay }); + const scenario = startPracticeHand(handId, { lesson, lessonContext, skipFlow: startAtPlay }); if (!startAtPlay) return scenario; const expected = scenario.expectedContract; @@ -30,7 +32,6 @@ function startLesson(lesson, handId) { seats })); state.phase = "playing"; - if (lesson.enableGuidance) state.guidanceMode = true; renderAll(); setStatus("lead", { leader: state.leader, declarer: state.declarer, dummy: state.dummy }); continuePlay(); @@ -46,11 +47,77 @@ function startLessonFromUrl() { const lesson = globalThis.BridgeLessons?.findLesson?.(lessonId); if (!lesson) return false; - startLesson(lesson, handId); + startLesson(lesson, handId, { + chapterId: params.get("chapter") || null, + returnHref: safeReturnHref(params.get("return"), lessonId, params.get("chapter")) + }); return true; } +function lessonContextFromOptions(lesson, options = {}) { + const chapter = options.chapterId ? globalThis.BridgeLessons?.findLessonChapter?.(lesson.id, options.chapterId) : null; + return { + chapterId: chapter?.id || options.chapterId || null, + chapterTitle: chapter?.title || "", + returnHref: options.returnHref || defaultReturnHref(lesson.id, chapter?.id || options.chapterId || null), + tableTask: chapter?.tableTask || lesson.tableTask || null, + boardGuidance: chapter?.boardGuidance || lesson.boardGuidance || [] + }; +} + +function enterLessonMode() { + if (!state.lessonModeSettingsSnapshot) { + state.lessonModeSettingsSnapshot = { + developerMode: state.developerMode, + guidanceMode: state.guidanceMode, + showPlayHistory: state.showPlayHistory + }; + } + state.developerMode = false; + state.guidanceMode = false; + state.showPlayHistory = false; + state.lessonTableTaskDone = false; +} + +function exitLessonMode() { + if (state.lessonModeSettingsSnapshot) { + state.developerMode = Boolean(state.lessonModeSettingsSnapshot.developerMode); + state.guidanceMode = Boolean(state.lessonModeSettingsSnapshot.guidanceMode); + state.showPlayHistory = Boolean(state.lessonModeSettingsSnapshot.showPlayHistory); + } + state.lessonModeSettingsSnapshot = null; + state.lessonTableTaskDone = false; +} + +function isLessonModeActive() { + return Boolean(state.practice?.lessonId); +} + +function defaultReturnHref(lessonId, chapterId = null) { + const href = new URL("lessons.html", globalThis.location?.href || "http://localhost/"); + href.searchParams.set("lesson", lessonId); + if (chapterId) href.hash = chapterId; + return `${href.pathname.split("/").pop()}${href.search}${href.hash}`; +} + +function safeReturnHref(value, lessonId, chapterId = null) { + if (!value) return defaultReturnHref(lessonId, chapterId); + try { + const href = new URL(value, globalThis.location?.href || "http://localhost/"); + const file = href.pathname.split("/").pop(); + if (file !== "lessons.html" && !/^lesson-\d+-.+\.html$/.test(file)) return defaultReturnHref(lessonId, chapterId); + return `${file}${href.search}${href.hash}`; + } catch { + return defaultReturnHref(lessonId, chapterId); + } +} + Object.assign(actions, { + defaultLessonReturnHref: defaultReturnHref, + enterLessonMode, + exitLessonMode, + isLessonModeActive, + lessonContextFromOptions, startLesson, startLessonFromUrl }); diff --git a/scripts/learning/lessons.js b/scripts/learning/lessons.js index c17ed44..e45b067 100644 --- a/scripts/learning/lessons.js +++ b/scripts/learning/lessons.js @@ -14,6 +14,13 @@ title: "Wat is bridge?", challenge: "Win slagen samen met partner en ontdek hoe bieden, spelen en dummy bij elkaar horen.", summary: "Je leert het doel van bridge, de twee fasen van een hand, slagen winnen, kleur bekennen, troef en sans-atout, leider en dummy.", + learningGoals: [ + "Je herkent Noord, Oost, Zuid en West en weet wie partners zijn.", + "Je begrijpt dat een slag uit vier kaarten bestaat.", + "Je weet dat het contract vertelt hoeveel slagen de leider moet maken.", + "Je ziet wat troef doet en wat sans-atout betekent.", + "Je weet wanneer dummy open komt en wie de kaarten van dummy speelt." + ], focus: ["Spelen", "Bieden", "Dummy"], handIds: ["draw-trumps-001"], pageHref: "lesson-01-cards.html", @@ -121,6 +128,22 @@ title: "Bekennen moet", summary: "Als de gevraagde kleur in je hand zit, moet je een kaart van die kleur spelen.", handId: "draw-trumps-001", + tableTask: { + type: "card", + completion: "northSouthCard", + expectedAction: { + type: "card", + seat: "North", + cardIds: ["5D"], + retryTitle: "Bijna", + retryBody: "Die kaart bekent wel ruiten, maar deze oefening zoekt de rustige lage ruiten. Probeer 5 ruiten.", + hint: "Kies 5 ruiten om laag te bekennen." + }, + doneTitle: "Kaart gekozen", + doneBody: "Je hebt aan tafel een kaart gespeeld terwijl de gevraagde kleur zichtbaar was. Dat is precies het lesmoment.", + returnLabel: "Terug naar les", + retryLabel: "Nog eens proberen" + }, blocks: [ { type: "paragraph", text: "Als iemand bijvoorbeeld harten vraagt en jij hebt harten, dan moet je harten spelen. Alleen als je die kleur niet hebt, mag je een andere kleur spelen." }, { type: "callout", text: "De oefening start meteen in het spelen, zodat je beurten, dummy en kleur bekennen in een echt bord ziet." } @@ -223,10 +246,114 @@ { id: "les-02-punten-en-handtypen", number: 2, - title: "Punten en handtypen", - challenge: "Tel de kracht van Zuid en herken waarom 1SA logisch kan zijn.", - focus: ["Bieden", "Punten"], - handIds: ["one-nt-opening-001"] + title: "Kaarten waarderen", + challenge: "Tel HCP, herken verdeling en ontdek wanneer een fit je hand later meer waard maakt.", + summary: "Je leert HCP tellen, basisverdelingen herkennen, evenwichtige en onevenwichtige handen onderscheiden en begrijpen waarom een fit waardevol is.", + learningGoals: [ + "Je telt HCP met Aas 4, Heer 3, Vrouw 2 en Boer 1.", + "Je herkent een evenwichtige verdeling.", + "Je ziet waarom lengte in een kleur belangrijk is.", + "Je begrijpt fit als samen minstens acht kaarten in een kleur.", + "Je maakt een eerste simpele keuze: pas, 1SA of een kleur openen." + ], + focus: ["Bieden", "HCP", "Fit"], + handIds: ["one-nt-opening-001", "opening-pass-001", "one-heart-opening-001"], + pageHref: "lesson-02-card-valuation.html", + intro: "Deze les is een handpaspoort voor Zuid: eerst HCP, dan verdeling, langste kleur en pas daarna herwaarderen zodra een fit in beeld komt.", + chapters: [ + { + id: "hcp-tellen", + title: "HCP tellen", + summary: "Aas telt 4, Heer 3, Vrouw 2, Boer 1; de 10 is wel een honneur maar telt niet mee.", + pageHref: "lesson-02-card-valuation.html", + blocks: [ + { type: "paragraph", text: "HCP is de eerste snelle krachtmeter voordat je gaat bieden." }, + { type: "paragraph", text: "De losse lespagina bevat interactieve handen en feedback per antwoord." } + ] + }, + { + id: "verdeling-en-fit", + title: "Verdeling en fit", + summary: "Je herkent 4-3-3-3, 4-4-3-2 en 5-3-3-2 als evenwichtig en ziet waarom korte kleuren later tellen.", + pageHref: "lesson-02-card-valuation.html", + blocks: [ + { type: "paragraph", text: "Een fit is samen minstens acht kaarten in een kleur." }, + { type: "callout", text: "Tel eerst HCP; herwaardeer pas wanneer een fit waarschijnlijk is." } + ] + }, + { + id: "een-sa-opening-herkennen", + title: "1SA-hand herkennen", + summary: "15-17 HCP met een evenwichtige verdeling maakt 1SA de eerste kandidaat.", + handId: "one-nt-opening-001", + tableTask: { + type: "bid", + completion: "southBid", + expectedAction: { + type: "bid", + seat: "South", + calls: ["1NT"], + retryTitle: "Nog niet", + retryBody: "Deze hand heeft 15 HCP en is evenwichtig. In deze les zoek je daarom de 1SA-opening.", + hint: "Kies 1SA." + }, + doneTitle: "Bod gedaan", + doneBody: "Zuid heeft de hand gewaardeerd en het eerste bod gekozen. Ga terug naar de les om dit handpaspoort naast de uitleg te leggen.", + returnLabel: "Terug naar les", + retryLabel: "Nog eens proberen" + }, + boardGuidance: [ + { + id: "valueThenBid", + title: "Waardeer eerst Zuid", + body: "Tel HCP, kijk of de verdeling evenwichtig is en kies daarna het openingsbod.", + badge: "Openingskeuze", + target: "bidControls", + buttonLabel: "Ik kies mijn bod", + gate: "allowHumanBid" + } + ], + blocks: [ + { type: "paragraph", text: "Start deze oefenhand en tel voor het eerste bod de HCP van Zuid." } + ] + }, + { + id: "openingskracht-of-pas", + title: "Openingskracht of pas", + summary: "Niet elke hand heeft genoeg kracht om te openen; passen kan de juiste actie zijn.", + handId: "opening-pass-001", + tableTask: { + type: "bid", + completion: "southBid", + expectedAction: { + type: "bid", + seat: "South", + calls: ["PASS"], + retryTitle: "Rustiger", + retryBody: "Zuid heeft te weinig openingskracht. In deze oefening is passen de bedoelde keuze.", + hint: "Kies Pas." + }, + doneTitle: "Keuze gemaakt", + doneBody: "Zuid heeft gekozen of deze hand genoeg openingskracht heeft. Terug in de les kun je de HCP en verdeling nog eens vergelijken.", + returnLabel: "Terug naar les", + retryLabel: "Nog eens proberen" + }, + boardGuidance: [ + { + id: "openingStrengthChoice", + title: "Openen of passen?", + body: "Kijk alleen naar Zuid: heeft deze hand genoeg kracht om te openen, of is passen rustiger?", + badge: "Openingskracht", + target: "bidControls", + buttonLabel: "Ik maak mijn keuze", + gate: "allowHumanBid" + } + ], + blocks: [ + { type: "paragraph", text: "Vergelijk deze hand met de HCP- en verdelingsvragen uit de les." } + ] + } + ] }, { id: "les-03-eerste-openingen", @@ -318,6 +445,104 @@ return allLessons().find((lesson) => lesson.id === id) || null; } + function findLessonChapter(lessonId, chapterId) { + const lesson = findLesson(lessonId); + if (!lesson || !chapterId) return null; + return lesson.chapters?.find((chapter) => chapter.id === chapterId) || null; + } + + function tableTaskCompleted(task, context = {}) { + if (!task) return false; + const plays = allPlayedCards(context); + const auction = Array.isArray(context.auction) ? context.auction : []; + if (task.type === "bid") { + if (task.completion === "southBid") return auction.some((call) => call.seat === "South"); + if (task.completion === "humanBid") return auction.some((call) => call.seat === "South"); + return auction.length > 0; + } + if (task.type === "card") { + if (task.completion === "northSouthCard" || task.completion === "humanCard") { + return plays.some((play) => play.seat === "North" || play.seat === "South"); + } + return plays.length > 0; + } + if (task.type === "trick") { + return Boolean(context.awaitingTrickAdvance || context.pendingTrickWinner || (context.trickHistory || []).length); + } + if (task.type === "review" || task.type === "hand") { + return context.phase === "complete"; + } + return false; + } + + function tableTaskActionFeedback(task, action = {}) { + const expected = task?.expectedAction; + if (!expected) return null; + if (expected.type && action.type && expected.type !== action.type) return null; + if (expected.seat && action.seat && expected.seat !== action.seat) return null; + + const ok = expected.type === "bid" + ? expectedBidMatches(expected, action.bid) + : expected.type === "card" + ? expectedCardMatches(expected, action.card) + : true; + if (ok) return null; + + return { + title: expected.retryTitle || "Probeer nog eens", + body: expected.retryBody || "Deze keuze is legaal, maar niet de bedoelde actie voor dit lesmoment. Probeer opnieuw.", + hint: expected.hint || "" + }; + } + + function expectedBidMatches(expected, bid) { + const codes = normalizeList(expected.calls || expected.call || expected.bid); + if (!codes.length) return true; + const bidCode = callCode(bid); + return codes.some((code) => normalizeCallCode(code) === bidCode); + } + + function expectedCardMatches(expected, card) { + if (!card) return false; + const cardIds = normalizeList(expected.cardIds || expected.cards || expected.cardId); + if (cardIds.length && !cardIds.includes(card.id)) return false; + const suits = normalizeList(expected.suits || expected.suit); + if (suits.length && !suits.includes(card.suit)) return false; + return Boolean(cardIds.length || suits.length); + } + + function normalizeList(value) { + if (!value) return []; + return (Array.isArray(value) ? value : [value]).map(String); + } + + function callCode(bid) { + if (!bid) return ""; + if (bid.type === "Pass") return "PASS"; + if (bid.type === "Double") return "X"; + if (bid.type === "Redouble") return "XX"; + if (bid.type === "Bid") return normalizeCallCode(`${bid.level}${bid.strain}`); + return normalizeCallCode(bid.call || bid.code || ""); + } + + function normalizeCallCode(value) { + return String(value || "") + .trim() + .toUpperCase() + .replace(/\s+/g, "") + .replace(/SA$/, "NT") + .replace(/^P$/, "PASS") + .replace(/^PAS$/, "PASS"); + } + + function allPlayedCards(context) { + const current = Array.isArray(context.currentTrick) ? context.currentTrick : []; + const historical = Array.isArray(context.trickHistory) + ? context.trickHistory.flatMap((trick) => Array.isArray(trick.cards) ? trick.cards : []) + : []; + return [...historical, ...current]; + } + function validateLessons(practiceApi = practiceHands) { const ids = new Set(); for (const lesson of lessons) { @@ -334,6 +559,7 @@ } validateLessonChapters(lesson, practiceApi); validateBoardGuidance(lesson); + validateTableTask(lesson.id, "lesson", lesson.tableTask); if (lesson.startMode === "play") { const scenario = practiceApi?.findPracticeHand?.(lesson.handIds[0]); if (!scenario?.expectedContract) throw new Error(`Lesson ${lesson.id} needs an expected contract for play start`); @@ -530,7 +756,7 @@ action.className = "lesson-start"; action.textContent = labels.startPractice || labels.start || "Start oefening"; action.addEventListener("click", () => { - startLessonFromHand({ lesson, handId: chapter.handId, startLesson, dialog }); + startLessonFromHand({ lesson, handId: chapter.handId, chapter, startLesson, dialog }); }); detail.appendChild(action); } @@ -539,8 +765,8 @@ back.focus(); } - function startLessonFromHand({ lesson, handId, startLesson, dialog }) { - startLesson?.(findLesson(lesson.id), handId); + function startLessonFromHand({ lesson, handId, chapter = null, startLesson, dialog }) { + startLesson?.(findLesson(lesson.id), handId, { chapterId: chapter?.id || null }); closeDialog(dialog); } @@ -640,8 +866,10 @@ chapters: (lesson.chapters || []).map(cloneChapter), miniQuiz: lesson.miniQuiz ? cloneQuiz(lesson.miniQuiz) : undefined, reviewFeedback: lesson.reviewFeedback ? [...lesson.reviewFeedback] : undefined, + tableTask: lesson.tableTask ? cloneTableTask(lesson.tableTask) : undefined, boardGuidance: lesson.boardGuidance ? lesson.boardGuidance.map(cloneBoardGuidanceStep) : undefined, - teachingPoints: lesson.teachingPoints ? [...lesson.teachingPoints] : undefined + teachingPoints: lesson.teachingPoints ? [...lesson.teachingPoints] : undefined, + learningGoals: lesson.learningGoals ? [...lesson.learningGoals] : undefined }; } @@ -652,10 +880,29 @@ ...block, items: block.items ? [...block.items] : undefined })), + tableTask: chapter.tableTask ? cloneTableTask(chapter.tableTask) : undefined, + boardGuidance: chapter.boardGuidance ? chapter.boardGuidance.map(cloneBoardGuidanceStep) : undefined, quiz: chapter.quiz ? cloneQuiz(chapter.quiz) : undefined }; } + function cloneTableTask(task) { + return { + ...task, + expectedAction: task.expectedAction ? cloneExpectedAction(task.expectedAction) : undefined + }; + } + + function cloneExpectedAction(action) { + return { + ...action, + calls: action.calls ? [...action.calls] : undefined, + cardIds: action.cardIds ? [...action.cardIds] : undefined, + cards: action.cards ? [...action.cards] : undefined, + suits: action.suits ? [...action.suits] : undefined + }; + } + function cloneQuiz(questions) { return questions.map((question) => ({ ...question, @@ -679,6 +926,8 @@ if (chapter.handId && !practiceApi?.findPracticeHand?.(chapter.handId)) { throw new Error(`Lesson ${lesson.id} chapter ${chapter.id} refers to unknown practice hand ${chapter.handId}`); } + validateTableTask(lesson.id, chapter.id, chapter.tableTask); + if (chapter.boardGuidance) validateBoardGuidance({ ...lesson, id: `${lesson.id} chapter ${chapter.id}`, boardGuidance: chapter.boardGuidance }); if (chapter.quiz) validateQuiz(lesson.id, chapter.id, chapter.quiz); } } @@ -697,8 +946,8 @@ if (!lesson.boardGuidance) return; if (!Array.isArray(lesson.boardGuidance)) throw new Error(`Lesson ${lesson.id} boardGuidance must be an array`); const ids = new Set(); - const validTargets = new Set(["contract", "openingLead", "dummy", "declarerAndDummy", "trickArea", "legalCards", "trumpCards", "trickWinner", "review"]); - const validGates = new Set(["releaseAutoPlay", "allowHumanPlay", "advanceTrick", "none"]); + const validTargets = new Set(["contract", "openingLead", "dummy", "declarerAndDummy", "trickArea", "legalCards", "trumpCards", "trickWinner", "review", "bidControls", "auctionLog", "lessonPanel"]); + const validGates = new Set(["releaseAutoPlay", "allowHumanPlay", "allowHumanBid", "advanceTrick", "none"]); lesson.boardGuidance.forEach((step, index) => { if (!step.id || ids.has(step.id)) throw new Error(`Lesson ${lesson.id} boardGuidance step ${index + 1} has an invalid id`); ids.add(step.id); @@ -709,11 +958,37 @@ }); } + function validateTableTask(lessonId, ownerId, task) { + if (!task) return; + const validTypes = new Set(["bid", "card", "trick", "review", "hand"]); + const validCompletions = new Set(["southBid", "humanBid", "northSouthCard", "humanCard", "trickWinnerShown", "reviewReached", "handComplete"]); + if (!validTypes.has(task.type)) throw new Error(`Lesson ${lessonId} ${ownerId} tableTask has unknown type ${task.type}`); + if (!validCompletions.has(task.completion)) throw new Error(`Lesson ${lessonId} ${ownerId} tableTask has unknown completion ${task.completion}`); + if (!task.doneTitle || !task.doneBody || !task.returnLabel) throw new Error(`Lesson ${lessonId} ${ownerId} tableTask is incomplete`); + validateExpectedAction(lessonId, ownerId, task); + } + + function validateExpectedAction(lessonId, ownerId, task) { + const expected = task.expectedAction; + if (!expected) return; + if (expected.type !== task.type) throw new Error(`Lesson ${lessonId} ${ownerId} expectedAction type must match tableTask type`); + if (expected.type === "bid" && !normalizeList(expected.calls || expected.call || expected.bid).length) { + throw new Error(`Lesson ${lessonId} ${ownerId} bid expectedAction needs calls`); + } + if (expected.type === "card" && !normalizeList(expected.cardIds || expected.cards || expected.cardId || expected.suits || expected.suit).length) { + throw new Error(`Lesson ${lessonId} ${ownerId} card expectedAction needs cardIds or suits`); + } + if (!expected.retryBody) throw new Error(`Lesson ${lessonId} ${ownerId} expectedAction needs retryBody`); + } + validateLessons(); return { allLessons, findLesson, + findLessonChapter, + tableTaskActionFeedback, + tableTaskCompleted, validateLessons, init }; diff --git a/scripts/render/render-app.js b/scripts/render/render-app.js index 3fdf473..768e2ae 100644 --- a/scripts/render/render-app.js +++ b/scripts/render/render-app.js @@ -134,6 +134,7 @@ render.renderBidControls(); render.renderPlayPlan(); render.renderHistory(); + render.renderLessonPanel?.(); render.renderPlayExplanations(); render.renderReview(); renderContract(); diff --git a/scripts/render/render-review.js b/scripts/render/render-review.js index 5cbc9fe..b030bbb 100644 --- a/scripts/render/render-review.js +++ b/scripts/render/render-review.js @@ -26,7 +26,9 @@ function renderHistory() { syncHistoryPanelState(); + if (els.lessonPanel) els.lessonPanel.hidden = !actions.isLessonModeActive?.() || state.phase === "complete"; els.history.innerHTML = ""; + if (actions.isLessonModeActive?.() && state.phase !== "complete") return; if (!state.showPlayHistory) return; if (!shouldShowLiveHistory()) { const inactive = document.createElement("div"); @@ -106,10 +108,12 @@ function renderReview() { function syncHistoryPanelState(complete = state.phase === "complete") { const hasVisiblePlayPlan = !els.playPlan.hidden; - const visible = !complete && (shouldShowLiveHistory() || shouldReserveHistorySlot() || hasVisiblePlayPlan); + const hasLessonPanel = actions.isLessonModeActive?.() && !complete; + const visible = !complete && (hasLessonPanel || shouldShowLiveHistory() || shouldReserveHistorySlot() || hasVisiblePlayPlan); els.historyPanel.hidden = !visible; - els.historyPanel.classList.toggle("is-inactive", visible && !shouldShowLiveHistory()); - els.historyPanel.classList.toggle("is-empty-reserved", visible && !state.showPlayHistory && !hasVisiblePlayPlan); + els.historyPanel.classList.toggle("is-lesson-mode", Boolean(hasLessonPanel)); + els.historyPanel.classList.toggle("is-inactive", visible && !hasLessonPanel && !shouldShowLiveHistory()); + els.historyPanel.classList.toggle("is-empty-reserved", visible && !hasLessonPanel && !state.showPlayHistory && !hasVisiblePlayPlan); } function shouldShowLiveHistory() { diff --git a/scripts/state/seed.js b/scripts/state/seed.js index 4fc17fb..c48d6f2 100644 --- a/scripts/state/seed.js +++ b/scripts/state/seed.js @@ -168,6 +168,7 @@ function startSituationSeed(seed) { : null; const hands = scenario ? scenario.hands : dealHands(state.dealSeed); const lesson = scenario && situation.e ? globalThis.BridgeLessons?.findLesson?.(String(situation.e)) || null : null; + if (lesson) actions.enterLessonMode?.(); const practice = scenario ? practiceStateFromScenario(scenario, lesson) : null; startPreparedHand({ diff --git a/scripts/state/state-transitions.js b/scripts/state/state-transitions.js index dabfdc0..468cad8 100644 --- a/scripts/state/state-transitions.js +++ b/scripts/state/state-transitions.js @@ -37,6 +37,7 @@ scoreOverviewDismissed: false, feedbackStatus: null, illegalActionFeedback: null, + lessonActionFeedback: null, pendingStop: false, pendingAlert: false }; @@ -104,7 +105,8 @@ }, currentTrick: [...state.currentTrick, { seat, card, ruleId: ruleResult?.ruleId || null }], playExplanations: [...state.playExplanations, ...playExplanation], - illegalActionFeedback: null + illegalActionFeedback: null, + lessonActionFeedback: null }; } diff --git a/scripts/ui/menu.js b/scripts/ui/menu.js index 3cbdee3..2295b90 100644 --- a/scripts/ui/menu.js +++ b/scripts/ui/menu.js @@ -23,16 +23,28 @@ function initSettingsControls() { els.developerMode.addEventListener("change", () => { + if (actions.isLessonModeActive?.()) { + render.renderAll(); + return; + } state.developerMode = els.developerMode.checked; actions.saveSettings(); render.renderAll(); }); els.guidanceMode.addEventListener("change", () => { + if (actions.isLessonModeActive?.()) { + render.renderAll(); + return; + } state.guidanceMode = els.guidanceMode.checked; actions.saveSettings(); render.renderAll(); }); els.playHistoryMode.addEventListener("change", () => { + if (actions.isLessonModeActive?.()) { + render.renderAll(); + return; + } state.showPlayHistory = els.playHistoryMode.checked; actions.saveSettings(); render.renderAll(); @@ -62,10 +74,13 @@ els.settingsSummary.title = "Menu"; els.developerModeLabel.textContent = helpers.t("developerMode"); els.developerMode.checked = state.developerMode; + els.developerMode.disabled = Boolean(actions.isLessonModeActive?.()); els.guidanceModeLabel.textContent = helpers.t("guidanceMode"); els.guidanceMode.checked = state.guidanceMode; + els.guidanceMode.disabled = Boolean(actions.isLessonModeActive?.()); els.playHistoryModeLabel.textContent = helpers.t("playHistoryMode"); els.playHistoryMode.checked = state.showPlayHistory; + els.playHistoryMode.disabled = Boolean(actions.isLessonModeActive?.()); setSettingInfo(els.playHistoryModeDescription, helpers.t("playHistoryModeHelp")); setSettingInfo(els.guidanceModeDescription, helpers.t("guidanceModeHelp")); setSettingInfo(els.developerModeDescription, helpers.t("developerModeHelp")); diff --git a/styles/auction-responsive.css b/styles/auction-responsive.css index 0dac9a5..74b03df 100644 --- a/styles/auction-responsive.css +++ b/styles/auction-responsive.css @@ -21,7 +21,7 @@ --mobile-bidbox-width: clamp(236px, calc(100vw - 132px), 300px); --mobile-bidbox-row-width: clamp(202px, calc(100vw - 185px), 300px); --mobile-bidbox-half-width: clamp(101px, calc(50vw - 95px), 150px); - --mobile-bidbox-side-gap: 4px; + --mobile-bidbox-side-gap: 0px; --mobile-bidbox-center-y: calc(50% - 59px); --mobile-bidbox-top: calc(var(--mobile-bidbox-center-y) - 189px); --mobile-bidbox-bottom: calc(var(--mobile-bidbox-center-y) + 189px); diff --git a/styles/lesson-card-basics.css b/styles/lesson-card-basics.css index 5894d22..b68db08 100644 --- a/styles/lesson-card-basics.css +++ b/styles/lesson-card-basics.css @@ -175,14 +175,18 @@ .progress-step { min-height: 54px; + border: 1px solid var(--line); + border-radius: 8px; display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 10px; align-items: center; + padding: 0 12px; text-align: left; background: rgba(255, 255, 255, 0.045); color: var(--ink); font-weight: 760; + text-decoration: none; } .progress-step[aria-current="true"] { @@ -212,14 +216,19 @@ } .lesson-slide { - display: none; -} - -.lesson-slide.is-active { display: grid; align-content: start; gap: 16px; +} + +.lesson-slide[hidden] { + display: none; +} + +.lesson-slide.is-active-lesson-card { animation: slide-in 260ms ease both; + box-shadow: none; + min-height: 0; } .lesson-slide h2 { @@ -755,7 +764,7 @@ } @media (prefers-reduced-motion: reduce) { - .lesson-slide.is-active, + .lesson-slide.is-active-lesson-card, .lesson-rank-card.playing-card, .deal-card { animation: none; diff --git a/styles/lesson-cards.css b/styles/lesson-cards.css new file mode 100644 index 0000000..3afa916 --- /dev/null +++ b/styles/lesson-cards.css @@ -0,0 +1,107 @@ +.lesson-anchor-nav, +.lesson-card-controls { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; +} + +.lesson-anchor-nav { + margin: 0 0 16px; +} + +.lesson-anchor-nav a { + min-height: 38px; + border: 1px solid rgba(247, 244, 232, 0.18); + border-radius: 8px; + display: inline-grid; + place-items: center; + background: rgba(255, 255, 255, 0.045); + color: rgba(247, 244, 232, 0.88); + padding: 0 12px; + font-weight: 760; + text-decoration: none; +} + +.lesson-anchor-nav a[aria-current="true"] { + border-color: rgba(224, 194, 95, 0.78); + background: rgba(224, 194, 95, 0.14); + color: var(--ink); +} + +.lesson-card-controls { + justify-content: space-between; + margin: 14px 0 0; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 8px; + background: rgba(16, 30, 24, 0.96); + padding: 10px; + box-shadow: 0 14px 28px rgba(0, 0, 0, 0.22); +} + +.lesson-card-nav { + min-width: 132px; + border-color: rgba(224, 194, 95, 0.54); + background: rgba(224, 194, 95, 0.13); + font-weight: 950; +} + +.lesson-card-nav:disabled { + opacity: 0.44; +} + +.lesson-card-status { + display: grid; + gap: 2px; + margin: 0; + color: var(--muted); + text-align: center; + font-size: 0.84rem; + font-weight: 760; +} + +.lesson-card-status strong { + color: var(--ink); + font-size: 1rem; +} + +[data-lesson-card].is-active-lesson-card { + min-height: min(720px, calc(100vh - 210px)); + align-content: start; + box-shadow: 0 24px 60px rgba(0, 0, 0, 0.24); + animation: lesson-card-in 180ms ease both; +} + +@media (max-width: 620px) { + .lesson-anchor-nav, + .lesson-card-controls { + display: grid; + width: 100%; + } + + .lesson-card-controls { + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); + } + + .lesson-card-status { + grid-column: 1 / -1; + order: -1; + } + + .lesson-anchor-nav a, + .lesson-card-nav { + width: 100%; + } +} + +@keyframes lesson-card-in { + from { + opacity: 0; + transform: translateY(8px); + } + + to { + opacity: 1; + transform: translateY(0); + } +} diff --git a/styles/lesson-hand-valuation.css b/styles/lesson-hand-valuation.css new file mode 100644 index 0000000..e902f38 --- /dev/null +++ b/styles/lesson-hand-valuation.css @@ -0,0 +1,657 @@ +.valuation-lesson-body { + min-height: 100vh; + background: + linear-gradient(135deg, rgba(18, 43, 33, 0.96), rgba(12, 18, 16, 0.98)), + var(--bg); +} + +.valuation-lesson-shell { + width: min(1180px, calc(100vw - 32px)); + margin: 0 auto; + padding: 18px 0 36px; +} + +.valuation-topbar, +.valuation-brand, +.valuation-toplinks, +.practice-links, +.race-actions { + display: flex; + align-items: center; +} + +.valuation-topbar { + justify-content: space-between; + gap: 16px; + margin-bottom: 18px; +} + +.valuation-brand, +.valuation-toplinks a, +.practice-links a { + color: var(--ink); + text-decoration: none; +} + +.valuation-brand { + gap: 12px; + min-width: 0; +} + +.valuation-logo { + width: 52px; + height: 52px; + object-fit: contain; + mix-blend-mode: screen; +} + +.valuation-brand strong { + display: block; + font-size: 1.25rem; + line-height: 1.12; +} + +.valuation-kicker { + display: block; + margin: 0 0 5px; + color: var(--accent-2); + font-size: 0.76rem; + font-weight: 900; + letter-spacing: 0; + text-transform: uppercase; +} + +.valuation-toplinks, +.practice-links, +.race-actions { + flex-wrap: wrap; + gap: 8px; +} + +.valuation-toplinks a, +.practice-links a { + min-height: 38px; + border: 1px solid rgba(247, 244, 232, 0.18); + border-radius: 8px; + display: inline-grid; + place-items: center; + background: rgba(255, 255, 255, 0.045); + color: rgba(247, 244, 232, 0.88); + padding: 0 12px; + font-weight: 760; +} + +.valuation-toplinks a:last-child, +.practice-links a:first-child { + border-color: rgba(224, 194, 95, 0.5); + background: rgba(224, 194, 95, 0.14); + color: var(--ink); +} + +.valuation-hero, +.valuation-section, +.race-panel { + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 8px; +} + +.valuation-hero { + overflow: hidden; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(260px, 0.34fr); + gap: 22px; + align-items: center; + margin-bottom: 14px; + padding: clamp(20px, 4vw, 38px); + background: + linear-gradient(135deg, rgba(224, 194, 95, 0.13), rgba(96, 197, 185, 0.08)), + rgba(31, 52, 43, 0.82); +} + +.valuation-hero h1 { + max-width: 780px; + color: var(--ink); + font-size: clamp(2rem, 4vw, 3.6rem); +} + +.valuation-hero p:not(.valuation-kicker), +.section-intro, +.valuation-section > p, +.theory-step p, +.example-notes li, +.mistake-grid p, +.summary-section li, +.generated-facts, +.race-feedback { + color: rgba(247, 244, 232, 0.86); + font-size: 1rem; + font-weight: 560; + line-height: 1.55; +} + +.valuation-hero p:not(.valuation-kicker) { + max-width: 720px; + margin: 12px 0 0; + font-size: 1.05rem; +} + +.hcp-meter { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; +} + +.score-card { + min-height: 112px; + border: 2px solid rgba(176, 125, 46, 0.9); + border-radius: 8px; + display: grid; + place-items: center; + background: linear-gradient(135deg, #fffefa, #dad9d2); + color: #151515; + font-size: 0.9rem; + font-weight: 900; + text-align: center; + box-shadow: 0 14px 26px rgba(0, 0, 0, 0.22); +} + +.score-card strong { + display: block; + color: #124230; + font-size: 2.6rem; + line-height: 1; +} + +.valuation-section { + display: grid; + gap: 16px; + margin-top: 0; + background: rgba(22, 29, 25, 0.82); + padding: clamp(18px, 4vw, 32px); +} + +.valuation-grid-section, +.situation-section, +.table-example-grid, +.criteria-layout, +.race-section { + grid-template-columns: minmax(0, 1fr) minmax(280px, 0.52fr); + align-items: start; +} + +.valuation-section h2 { + max-width: 820px; + color: var(--ink); + font-size: clamp(1.5rem, 3vw, 2.45rem); +} + +.valuation-section h3 { + margin: 0; + color: var(--ink); + font-size: 1.12rem; + line-height: 1.25; +} + +.lesson-goal-list, +.example-notes ol, +.summary-section ol { + margin: 0; + padding-left: 22px; +} + +.lesson-goal-list li { + margin: 8px 0; + color: rgba(247, 244, 232, 0.88); + font-weight: 620; + line-height: 1.45; +} + +.term-panel, +.sample-hand-card, +.example-notes, +.generated-hand-panel, +.mistake-grid article, +.valuation-question, +.quiz-item, +.theory-step { + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.045); +} + +.term-panel { + display: grid; + gap: 12px; + padding: 14px; +} + +.term-cloud { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.term-cloud span, +.fact-chip { + border: 1px solid rgba(96, 197, 185, 0.36); + border-radius: 999px; + background: rgba(96, 197, 185, 0.1); + color: rgba(247, 244, 232, 0.88); + padding: 5px 9px; + font-size: 0.76rem; + font-weight: 760; +} + +.sample-hand-card, +.example-notes, +.generated-hand-panel { + display: grid; + gap: 12px; + padding: 14px; +} + +.lesson-callout, +.quick-feedback, +.question-feedback, +.quiz-feedback { + border-left: 3px solid var(--accent-2); + border-radius: 0 8px 8px 0; + background: rgba(20, 55, 45, 0.72); + color: rgba(247, 244, 232, 0.88); + padding: 10px 12px; + font-weight: 620; + line-height: 1.45; +} + +.theory-steps { + display: grid; + gap: 12px; +} + +.theory-step { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 14px; + padding: 14px; +} + +.lesson-step-number { + width: 34px; + height: 34px; + border: 1px solid rgba(96, 197, 185, 0.48); + border-radius: 50%; + display: grid; + place-items: center; + color: var(--accent-2); + font-size: 0.86rem; + font-weight: 950; +} + +.kernel { + margin: 6px 0 0; + color: var(--accent); + font-weight: 840; +} + +.step-terms { + margin: 8px 0 0; + color: var(--muted); + font-size: 0.9rem; + font-weight: 720; +} + +.quick-check { + display: grid; + gap: 10px; + margin-top: 12px; +} + +.quick-options, +.question-options, +.quiz-options, +.race-options { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.quick-options button, +.question-options button, +.quiz-options button, +.race-options button { + min-height: 38px; + font-weight: 900; +} + +.quick-options button.is-correct, +.question-options button.is-correct, +.quiz-options button.is-correct, +.race-options button.is-correct { + border-color: rgba(85, 213, 126, 0.82); + background: rgba(85, 213, 126, 0.18); +} + +.quick-options button.is-missed, +.question-options button.is-missed, +.quiz-options button.is-missed, +.race-options button.is-missed { + border-color: rgba(231, 97, 97, 0.72); + background: rgba(231, 97, 97, 0.14); +} + +.sample-hand, +.generated-hand, +.race-hand-placeholder { + display: grid; + gap: 8px; +} + +.hand-suit-row { + display: grid; + grid-template-columns: 88px minmax(0, 1fr); + gap: 8px; + align-items: center; +} + +.hand-suit-label { + color: var(--muted); + font-size: 0.86rem; + font-weight: 850; +} + +.hand-cards { + min-height: 74px; + display: flex; + flex-wrap: wrap; + gap: 5px; + align-items: center; +} + +.valuation-card.playing-card { + width: 48px; + height: 68px; + padding: 5px; + border-radius: 7px; + font-size: 0.68rem; + box-shadow: 0 8px 14px rgba(0, 0, 0, 0.2); +} + +.valuation-card.playing-card .playing-card-suit { + font-size: 1.35rem; +} + +.valuation-card.playing-card .playing-card-mini { + font-size: 0.68rem; +} + +.empty-suit { + color: var(--muted); + font-size: 0.9rem; + font-style: italic; +} + +.valuation-question-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.valuation-question, +.quiz-item { + display: grid; + gap: 12px; + padding: 14px; +} + +.question-heading, +.quiz-item p { + margin: 0; + color: var(--ink); + font-weight: 820; + line-height: 1.35; +} + +.question-facts { + display: flex; + flex-wrap: wrap; + gap: 6px; + min-height: 30px; +} + +.criteria-list { + display: grid; + gap: 9px; +} + +.criteria-list button { + min-height: 74px; + display: grid; + gap: 5px; + align-content: center; + text-align: left; + background: rgba(255, 255, 255, 0.045); +} + +.criteria-list button[aria-pressed="true"] { + border-color: rgba(224, 194, 95, 0.78); + background: rgba(224, 194, 95, 0.12); +} + +.criteria-list strong { + color: var(--ink); + font-size: 0.86rem; +} + +.criteria-list span { + color: var(--muted); + font-size: 0.88rem; + font-weight: 620; + line-height: 1.35; +} + +.generated-facts { + display: grid; + gap: 8px; +} + +.generated-facts ul { + margin: 0; + padding-left: 20px; +} + +.generated-facts li { + margin: 4px 0; +} + +.practice-links { + padding-top: 2px; +} + +.mistake-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.mistake-grid article { + padding: 14px; +} + +.mistake-grid p { + margin: 8px 0 0; +} + +.mini-quiz { + display: grid; + gap: 10px; +} + +.race-panel { + display: grid; + gap: 14px; + background: + linear-gradient(135deg, rgba(224, 194, 95, 0.1), rgba(96, 197, 185, 0.08)), + rgba(18, 30, 25, 0.86); + padding: 14px; +} + +.race-status { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 8px; +} + +.race-status span { + border: 1px solid rgba(96, 197, 185, 0.3); + border-radius: 8px; + background: rgba(96, 197, 185, 0.09); + color: var(--ink); + padding: 8px; + font-size: 0.86rem; + font-weight: 900; + text-align: center; +} + +.race-hand-placeholder { + min-height: 316px; + align-content: center; + color: var(--muted); + font-weight: 720; +} + +.race-answer-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 12px; +} + +.race-answer-grid fieldset { + min-width: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + display: grid; + gap: 10px; + margin: 0; + padding: 12px; +} + +.race-answer-grid legend { + color: var(--muted); + font-size: 0.82rem; + font-weight: 900; +} + +.race-options button[aria-pressed="true"] { + border-color: rgba(224, 194, 95, 0.78); + background: rgba(224, 194, 95, 0.12); +} + +.race-feedback { + min-height: 26px; + margin: 0; +} + +.summary-section ol { + display: grid; + gap: 8px; +} + +@media (max-width: 900px) { + .valuation-hero, + .valuation-grid-section, + .situation-section, + .table-example-grid, + .criteria-layout, + .race-section, + .valuation-question-grid { + grid-template-columns: 1fr; + } + + .hcp-meter { + grid-template-columns: repeat(4, minmax(0, 1fr)); + } + + .score-card { + min-height: 94px; + } + + .race-panel { + max-width: 100%; + } +} + +@media (max-width: 620px) { + .valuation-lesson-shell { + width: min(100vw - 20px, 1180px); + padding-top: 10px; + } + + .valuation-topbar { + align-items: flex-start; + flex-direction: column; + } + + .valuation-logo { + width: 44px; + height: 44px; + } + + .valuation-brand strong { + font-size: 1.06rem; + } + + .valuation-toplinks, + .practice-links, + .quick-options, + .question-options, + .quiz-options, + .race-options, + .race-actions { + display: grid; + width: 100%; + } + + .valuation-toplinks a, + .practice-links a, + .quick-options button, + .question-options button, + .quiz-options button, + .race-options button, + .race-actions button { + width: 100%; + } + + .hcp-meter, + .mistake-grid, + .race-status, + .race-answer-grid { + grid-template-columns: 1fr; + } + + .theory-step { + grid-template-columns: 1fr; + } + + .lesson-step-number { + width: 32px; + height: 32px; + } + + .hand-suit-row { + grid-template-columns: 1fr; + } + + .hand-cards { + min-height: 56px; + } + + .valuation-card.playing-card { + width: 42px; + height: 58px; + font-size: 0.58rem; + } + + .valuation-card.playing-card .playing-card-suit { + font-size: 1.08rem; + } +} diff --git a/styles/lessons.css b/styles/lessons.css index 8c41115..9c494bc 100644 --- a/styles/lessons.css +++ b/styles/lessons.css @@ -338,6 +338,20 @@ font-size: 1.22rem; } +.lesson-goal-list { + display: grid; + gap: 8px; + margin: 0; + padding-left: 20px; +} + +.lesson-goal-list li { + color: rgba(247, 244, 232, 0.86); + font-size: 1rem; + font-weight: 560; + line-height: 1.5; +} + .lesson-chapter { display: grid; grid-template-columns: auto minmax(0, 1fr); @@ -432,12 +446,6 @@ padding: 16px; } -.lesson-finish h3 { - margin: 0; - color: var(--ink); - font-size: 1.18rem; -} - .lesson-finish p { margin: 0; color: rgba(247, 244, 232, 0.82); diff --git a/styles/table.css b/styles/table.css index ce0bcf8..99eef8f 100644 --- a/styles/table.css +++ b/styles/table.css @@ -740,6 +740,145 @@ font-weight: 950; } +.lesson-done-card { + border-color: rgba(85, 213, 126, 0.58); + border-left-color: rgba(85, 213, 126, 0.9); + background: + linear-gradient(135deg, rgba(85, 213, 126, 0.14), rgba(96, 197, 185, 0.1)), + rgba(20, 38, 30, 0.96); +} + +.lesson-done-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; +} + +.lesson-done-return, +.lesson-done-retry { + min-height: 36px; + border: 1px solid rgba(224, 194, 95, 0.58); + border-radius: 8px; + padding: 8px 11px; + display: inline-flex; + align-items: center; + justify-content: center; + color: var(--ink); + font-size: 0.84rem; + font-weight: 950; + line-height: 1.1; + text-decoration: none; +} + +.lesson-done-return { + background: rgba(224, 194, 95, 0.24); +} + +.lesson-done-retry { + background: rgba(255, 255, 255, 0.055); +} + +.lesson-done-return:hover, +.lesson-done-return:focus-visible, +.lesson-done-retry:hover, +.lesson-done-retry:focus-visible { + border-color: rgba(224, 194, 95, 0.88); + transform: translateY(-1px); +} + +.history-panel.is-lesson-mode { + grid-template-rows: minmax(0, 1fr); +} + +.history-panel.is-lesson-mode > .panel-head, +.history-panel.is-lesson-mode > .history, +.history-panel.is-lesson-mode > .play-plan-panel, +.history-panel.is-lesson-mode > .play-explanations { + display: none; +} + +.lesson-panel { + display: grid; + align-content: start; + gap: 12px; + min-width: 0; + color: rgba(247, 244, 232, 0.86); +} + +.lesson-panel[hidden] { + display: none; +} + +.lesson-panel-eyebrow { + margin: 0; + color: var(--accent-2); + font-size: 0.72rem; + font-weight: 950; + text-transform: uppercase; +} + +.lesson-panel h2 { + margin: 0; + color: var(--ink); + font-size: 1.15rem; + line-height: 1.16; +} + +.lesson-panel-progress { + width: fit-content; + border: 1px solid rgba(96, 197, 185, 0.42); + border-radius: 999px; + background: rgba(96, 197, 185, 0.12); + color: rgba(247, 244, 232, 0.9); + padding: 4px 9px; + font-size: 0.76rem; + font-weight: 900; +} + +.lesson-panel-mission, +.lesson-panel-card p { + margin: 0; + color: rgba(247, 244, 232, 0.84); + font-size: 0.88rem; + font-weight: 620; + line-height: 1.45; +} + +.lesson-panel-card { + border: 1px solid rgba(224, 194, 95, 0.34); + border-left: 4px solid var(--accent); + border-radius: 0 8px 8px 0; + background: rgba(255, 255, 255, 0.055); + padding: 12px; + display: grid; + gap: 8px; +} + +.lesson-panel-card h3 { + margin: 0; + color: var(--ink); + font-size: 1rem; + line-height: 1.2; +} + +.lesson-panel-done { + border-color: rgba(85, 213, 126, 0.42); + border-left-color: rgba(85, 213, 126, 0.9); + background: rgba(85, 213, 126, 0.1); +} + +.lesson-panel-retry { + border-color: rgba(255, 189, 89, 0.48); + border-left-color: rgba(255, 189, 89, 0.92); + background: rgba(255, 189, 89, 0.1); +} + +.lesson-feedback-hint { + border-top: 1px solid rgba(255, 255, 255, 0.08); + padding-top: 8px; +} + .table-area.has-lesson-highlight .hand, .table-area.has-lesson-highlight .trick-area, .table-area.has-lesson-highlight .contract { diff --git a/tests/browser/smoke.spec.js b/tests/browser/smoke.spec.js index 4376e40..c67c7ff 100644 --- a/tests/browser/smoke.spec.js +++ b/tests/browser/smoke.spec.js @@ -1426,12 +1426,15 @@ test("opens lesson picker and starts a quiet challenge", async ({ page }) => { await expect(page.locator("#lesson-page-title")).toContainText("Wat is bridge?"); await expect(page.locator("#lesson-route-panel")).toContainText("Leerroute"); await expect(page.locator(".lesson-route-button")).toHaveCount(12); - await expect(page.locator("#lesson-content")).toContainText("Samenvatting"); - await expect(page.locator("#lesson-content")).toContainText("doel van bridge"); - await expect(page.locator("#lesson-content")).toContainText("Begin les"); - await expect(page.locator("#lesson-content")).not.toContainText("Hoofdstukken"); - - await page.locator(".lesson-finish .lesson-chapter-link", { hasText: "Begin les" }).click(); + await expect(page.locator("#lesson-content")).toContainText("Leerdoelen"); + await expect(page.locator("#lesson-content")).toContainText("Je herkent Noord, Oost, Zuid en West"); + await expect(page.locator("#lesson-content")).toContainText("Start les"); + await expect(page.locator("#lesson-content .lesson-chapter")).toHaveCount(0); + await expect(page.locator("#lesson-content .lesson-finish h3")).toHaveCount(0); + await expect(page.locator("#lesson-content .lesson-finish .lesson-chapter-link", { hasText: "Start les" })).toHaveCount(1); + await expect(page.locator("#lesson-content")).not.toContainText("De kaarten van de spelers"); + + await page.locator(".lesson-finish .lesson-chapter-link", { hasText: "Start les" }).click(); await expect(page).toHaveURL(/lesson-01-cards\.html\?testHooks=1/); await expect(page.locator("#card-lesson-title")).toContainText("Van kaarten naar een hele bridgehand"); await expect(page.locator(".progress-step")).toHaveCount(10); @@ -1440,7 +1443,7 @@ test("opens lesson picker and starts a quiet challenge", async ({ page }) => { await expect(page.locator(".suit-tile")).toHaveCount(4); await expect(page.locator(".suit-tile", { hasText: "Harten" })).toBeVisible(); await page.locator("#next-step").click(); - await expect(page.locator(".lesson-slide.is-active")).toContainText("Elke kleur heeft 13 kaarten"); + await expect(page.locator(".lesson-slide.is-active-lesson-card")).toContainText("Elke kleur heeft 13 kaarten"); await expect(page.locator(".compact-ranks .lesson-rank-card")).toHaveCount(13); await page.locator(".suit-count-card", { hasText: "♥" }).click(); await expect(page.locator("#suit-count-feedback")).toContainText("hartenkaarten"); @@ -1449,10 +1452,10 @@ test("opens lesson picker and starts a quiet challenge", async ({ page }) => { await page.locator(".choice-card", { hasText: "A" }).click(); await expect(page.locator("#rank-feedback")).toContainText("De aas is de hoogste kaart"); await page.locator("#next-step").click(); - await expect(page.locator(".lesson-slide.is-active")).toContainText("speler recht tegenover je"); - await expect(page.locator(".lesson-slide.is-active")).toContainText("Partner"); - await expect(page.locator(".lesson-slide.is-active")).not.toContainText("Noord"); - await expect(page.locator(".lesson-slide.is-active")).not.toContainText("Zuid"); + await expect(page.locator(".lesson-slide.is-active-lesson-card")).toContainText("speler recht tegenover je"); + await expect(page.locator(".lesson-slide.is-active-lesson-card")).toContainText("Partner"); + await expect(page.locator(".lesson-slide.is-active-lesson-card")).not.toContainText("Noord"); + await expect(page.locator(".lesson-slide.is-active-lesson-card")).not.toContainText("Zuid"); await page.locator("#deal-demo").click(); await expect(page.locator(".table-demo")).toHaveClass(/is-dealt/); await expect(page.locator(".deal-card")).toHaveCount(52); @@ -1466,7 +1469,7 @@ test("opens lesson picker and starts a quiet challenge", async ({ page }) => { await expect(page.locator('[data-seat-count="West"]')).toHaveText("13"); await expect(page.locator("#deal-status")).toContainText("Iedere speler heeft nu 13 kaarten"); await page.locator("#next-step").click(); - await expect(page.locator(".lesson-slide.is-active")).toContainText("Noord, Oost, Zuid en West"); + await expect(page.locator(".lesson-slide.is-active-lesson-card")).toContainText("Noord, Oost, Zuid en West"); await page.locator(".wind-mini-seat", { hasText: "Zuid" }).click(); await expect(page.locator("#wind-feedback")).toContainText("Jij speelt vanuit Zuid"); await page.locator(".wind-mini-seat", { hasText: "Noord" }).click(); @@ -1478,8 +1481,8 @@ test("opens lesson picker and starts a quiet challenge", async ({ page }) => { await page.goto("/lessons.html?testHooks=1"); await expect(page).toHaveURL(/lessons\.html\?testHooks=1/); - await page.locator(".lesson-finish .lesson-practice-link", { hasText: "Start oefening" }).click(); - await expect(page).toHaveURL(/index\.html\?lesson=les-01-wat-is-bridge&hand=draw-trumps-001&testHooks=1/); + await page.goto("/index.html?lesson=les-01-wat-is-bridge&hand=draw-trumps-001&testHooks=1"); + await expect(page).toHaveURL(/index\.html\?lesson=les-01-wat-is-bridge&hand=draw-trumps-001.*testHooks=1/); await expect(page.locator("#lesson-banner")).toContainText("Je speelt 4 schoppen"); await expect(page.locator("#lesson-banner .lesson-coach-action")).toHaveText("Start aan tafel"); await expect(page.locator("#trick-area .card.played")).toHaveCount(0); @@ -1609,12 +1612,159 @@ test("opens lesson picker and starts a quiet challenge", async ({ page }) => { await expect(page.locator("#review-summary")).toBeEmpty(); }); +test("lesson summaries stay compact and lesson 2 starts from one action", async ({ page }) => { + await openFreshApp(page); + + await page.goto("/lessons.html?lesson=les-02-punten-en-handtypen&testHooks=1#een-sa-opening-herkennen"); + await expect(page.locator("#lesson-page-title")).toContainText("Kaarten waarderen"); + await expect(page.locator("#lesson-content")).toContainText("Leerdoelen"); + await expect(page.locator("#lesson-content")).toContainText("Je telt HCP met Aas 4"); + await expect(page.locator("#lesson-content .lesson-chapter")).toHaveCount(0); + await expect(page.locator("#lesson-content")).not.toContainText("1SA-hand herkennen"); + await page.locator(".lesson-finish .lesson-chapter-link", { hasText: "Start les" }).click(); + await expect(page).toHaveURL(/lesson-02-card-valuation\.html\?testHooks=1/); +}); + +test("lesson table task rejects a wrong but legal card before retry", async ({ page }) => { + await openFreshApp(page); + + await page.evaluate(() => { + const app = window.BridgeAppTestHooks; + const lesson = window.BridgeLessons.findLesson("les-01-wat-is-bridge"); + app.startLesson(lesson, "draw-trumps-001", { chapterId: "bekennen-moet" }); + const state = app.getState(); + app.setState({ + animateDeal: false, + currentTrick: [{ seat: "West", card: app.makeCard("TD") }], + hands: { + ...state.hands, + West: state.hands.West.filter((card) => card.id !== "TD") + }, + turnIndex: 0, + lessonBoardAcknowledged: [ + "contractIntro", + "openingLeadIntro", + "dummyReveal", + "declarerControlsDummy", + "trickMeaning", + "followSuit", + "trumpMeaning" + ] + }); + app.renderAll(); + }); + + await expect(page.locator("#lesson-panel")).toContainText("Bekennen moet"); + await page.evaluate(() => window.BridgeAppTestHooks.playCard("North", "QD")); + await expect(page.locator("#table-feedback")).toContainText("Probeer 5 ruiten"); + await expect(page.locator("#lesson-panel")).toContainText("Probeer opnieuw"); + expect(await page.evaluate(() => window.BridgeAppTestHooks.getState().currentTrick.map((play) => `${play.seat}:${play.card.id}`))).toEqual(["West:TD"]); + + await page.evaluate(() => window.BridgeAppTestHooks.playCard("North", "5D")); + await expect(page.locator("#table-feedback")).toBeHidden(); + await expect(page.locator("#lesson-panel")).toContainText("Kaart gekozen"); + expect(await page.evaluate(() => window.BridgeAppTestHooks.getState().currentTrick.map((play) => `${play.seat}:${play.card.id}`))).toEqual(["West:TD", "North:5D"]); +}); + +test("completes a short lesson table task and returns to the same chapter", async ({ page }) => { + await openFreshApp(page); + + await page.evaluate(() => { + localStorage.setItem("bridge-app-settings", JSON.stringify({ + developerMode: true, + guidanceMode: true, + showPlayHistory: true + })); + }); + + await page.goto("/lesson-02-card-valuation.html?testHooks=1#handgenerator"); + await expect(page.locator("#lesson-card-title")).toContainText("Genereer handen"); + await page.locator(".practice-links a", { hasText: "Oefen 1SA-opening" }).click(); + + await expect(page).toHaveURL(/index\.html\?lesson=les-02-punten-en-handtypen&hand=one-nt-opening-001&chapter=een-sa-opening-herkennen/); + await expect(page.locator("#history-panel")).toHaveClass(/is-lesson-mode/); + await expect(page.locator("#lesson-panel")).toBeVisible(); + await expect(page.locator("#lesson-panel")).toContainText("1SA-hand herkennen"); + await expect(page.locator("#lesson-banner")).toContainText("Waardeer eerst Zuid"); + await expect(page.locator("#bid-controls")).toHaveClass(/active-bid-box/); + await expect(page.locator("#bid-controls")).toHaveClass(/lesson-highlight-target/); + + const settingsDuringLesson = await page.evaluate(() => { + const state = window.BridgeAppTestHooks.getState(); + return { + developerMode: state.developerMode, + guidanceMode: state.guidanceMode, + showPlayHistory: state.showPlayHistory, + stored: JSON.parse(localStorage.getItem("bridge-app-settings")) + }; + }); + expect(settingsDuringLesson).toEqual({ + developerMode: false, + guidanceMode: false, + showPlayHistory: false, + stored: { + developerMode: true, + guidanceMode: true, + showPlayHistory: true + } + }); + + await page.locator("#bid-controls button.bid", { hasText: "1NT" }).click(); + expect(await page.evaluate(() => window.BridgeAppTestHooks.getState().auction.length)).toBe(0); + + await page.locator("#lesson-panel .lesson-coach-action", { hasText: "Ik kies mijn bod" }).click(); + await page.locator("#bid-controls button.pass", { hasText: "Pas" }).click(); + await expect(page.locator("#lesson-panel")).toContainText("Nog niet"); + await expect(page.locator("#table-feedback")).toContainText("1SA-opening"); + expect(await page.evaluate(() => window.BridgeAppTestHooks.getState().auction.length)).toBe(0); + + await page.locator("#bid-controls button.bid", { hasText: "1NT" }).click(); + await expect(page.locator("#lesson-panel")).toContainText("Bod gedaan"); + await expect(page.locator("#lesson-banner")).toContainText("Bod gedaan"); + await expect(page.locator("#lesson-panel .lesson-done-return")).toHaveText("Terug naar les"); + + const doneState = await page.evaluate(() => { + const state = window.BridgeAppTestHooks.getState(); + return { + auctionLength: state.auction.length, + done: state.lessonTableTaskDone, + phase: state.phase + }; + }); + expect(doneState).toEqual({ auctionLength: 1, done: true, phase: "bidding" }); + + await page.locator("#lesson-panel .lesson-done-return").click(); + await expect(page).toHaveURL(/lesson-02-card-valuation\.html\?testHooks=1#handgenerator/); + await expect(page.locator("#handgenerator")).toBeVisible(); +}); + +test("returns from a standalone lesson card to the same lesson point", async ({ page }) => { + await openFreshApp(page); + + await page.goto("/lesson-02-card-valuation.html?testHooks=1#handgenerator"); + await expect(page.locator("#lesson-card-title")).toContainText("Genereer handen"); + await expect(page.locator("#handgenerator")).toBeVisible(); + + await page.locator(".practice-links a", { hasText: "Oefen 1SA-opening" }).click(); + await expect(page).toHaveURL(/index\.html\?lesson=les-02-punten-en-handtypen&hand=one-nt-opening-001&chapter=een-sa-opening-herkennen/); + await expect(page.locator("#lesson-panel")).toContainText("1SA-hand herkennen"); + await expect(page.locator("#bid-controls")).toHaveClass(/active-bid-box/); + + await page.locator("#lesson-panel .lesson-coach-action", { hasText: "Ik kies mijn bod" }).click(); + await page.locator("#bid-controls button.bid", { hasText: "1NT" }).click(); + await expect(page.locator("#lesson-panel .lesson-done-return")).toHaveText("Terug naar les"); + + await page.locator("#lesson-panel .lesson-done-return").click(); + await expect(page).toHaveURL(/lesson-02-card-valuation\.html\?testHooks=1#handgenerator/); + await expect(page.locator("#lesson-card-title")).toContainText("Genereer handen"); + await expect(page.locator("#handgenerator")).toBeVisible(); +}); + test("submits structured lesson metadata in feedback payload", async ({ page }) => { await openFreshApp(page); - await clickMenuButton(page, "#open-lessons"); - await page.locator(".lesson-finish .lesson-practice-link", { hasText: "Start oefening" }).click(); - await expect(page).toHaveURL(/index\.html\?lesson=les-01-wat-is-bridge&hand=draw-trumps-001&testHooks=1/); + await page.goto("/index.html?lesson=les-01-wat-is-bridge&hand=draw-trumps-001&testHooks=1"); + await expect(page).toHaveURL(/index\.html\?lesson=les-01-wat-is-bridge&hand=draw-trumps-001.*testHooks=1/); await page.locator("#lesson-banner .lesson-coach-action", { hasText: "Start aan tafel" }).click(); await page.locator("#lesson-banner .lesson-coach-action", { hasText: "Laat West uitkomen" }).click(); await expect(page.locator("#trick-area .card.played")).toHaveCount(1); diff --git a/tests/unit/lessons.test.js b/tests/unit/lessons.test.js index e762649..625302b 100644 --- a/tests/unit/lessons.test.js +++ b/tests/unit/lessons.test.js @@ -15,6 +15,17 @@ test("lesson catalog is valid and points at existing practice hands", () => { assert.ok(lessons.allLessons()[0].chapters.find((chapter) => chapter.id === "leider-en-dummy")); assert.ok(lessons.allLessons()[0].chapters.find((chapter) => chapter.id === "spelverloop")); assert.equal(lessons.allLessons()[0].chapters.find((chapter) => chapter.id === "bekennen-moet").handId, "draw-trumps-001"); + assert.equal(lessons.allLessons()[0].learningGoals.length, 5); + const lessonTwo = lessons.allLessons()[1]; + assert.equal(lessonTwo.title, "Kaarten waarderen"); + assert.equal(lessonTwo.pageHref, "lesson-02-card-valuation.html"); + assert.equal(lessonTwo.learningGoals.length, 5); + assert.deepEqual(lessonTwo.handIds, ["one-nt-opening-001", "opening-pass-001", "one-heart-opening-001"]); + assert.ok(lessonTwo.chapters.find((chapter) => chapter.id === "hcp-tellen")); + assert.equal(lessonTwo.chapters.find((chapter) => chapter.id === "een-sa-opening-herkennen").handId, "one-nt-opening-001"); + assert.equal(lessonTwo.chapters.find((chapter) => chapter.id === "een-sa-opening-herkennen").tableTask.type, "bid"); + assert.deepEqual(lessonTwo.chapters.find((chapter) => chapter.id === "een-sa-opening-herkennen").tableTask.expectedAction.calls, ["1NT"]); + assert.equal(lessonTwo.chapters.find((chapter) => chapter.id === "een-sa-opening-herkennen").boardGuidance[0].target, "bidControls"); assert.deepEqual( lessons.allLessons()[0].boardGuidance.map((step) => step.id), [ @@ -44,6 +55,10 @@ test("lesson catalog is valid and points at existing practice hands", () => { assert.ok(chapter.title, `${lesson.id} chapter ${chapter.id} must have a title`); assert.ok(chapter.summary, `${lesson.id} chapter ${chapter.id} must have a summary`); if (chapter.handId) assert.ok(practiceHands.findPracticeHand(chapter.handId), `${lesson.id} chapter ${chapter.id} refers to ${chapter.handId}`); + if (chapter.tableTask) { + assert.ok(chapter.tableTask.doneTitle, `${lesson.id} chapter ${chapter.id} table task must have done title`); + assert.ok(chapter.tableTask.returnLabel, `${lesson.id} chapter ${chapter.id} table task must have return label`); + } }); (lesson.boardGuidance || []).forEach((step) => { assert.ok(step.id, `${lesson.id} board step must have an id`); @@ -54,3 +69,53 @@ test("lesson catalog is valid and points at existing practice hands", () => { }); } }); + +test("lesson table task completion detects one-bid and one-card situations", () => { + const bidTask = lessons.findLessonChapter("les-02-punten-en-handtypen", "een-sa-opening-herkennen").tableTask; + assert.equal(lessons.tableTaskCompleted(bidTask, { phase: "bidding", auction: [] }), false); + assert.equal(lessons.tableTaskCompleted(bidTask, { + phase: "bidding", + auction: [{ seat: "South", bid: { type: "Bid", level: 1, strain: "NT" } }] + }), true); + + const cardTask = lessons.findLessonChapter("les-01-wat-is-bridge", "bekennen-moet").tableTask; + assert.equal(lessons.tableTaskCompleted(cardTask, { + phase: "playing", + currentTrick: [{ seat: "West", card: { id: "TD" } }], + trickHistory: [] + }), false); + assert.equal(lessons.tableTaskCompleted(cardTask, { + phase: "playing", + currentTrick: [ + { seat: "West", card: { id: "TD" } }, + { seat: "North", card: { id: "5D" } } + ], + trickHistory: [] + }), true); +}); + +test("lesson expected actions give retry feedback for wrong bids and cards", () => { + const bidTask = lessons.findLessonChapter("les-02-punten-en-handtypen", "een-sa-opening-herkennen").tableTask; + assert.equal(lessons.tableTaskActionFeedback(bidTask, { + type: "bid", + seat: "South", + bid: { type: "Bid", level: 1, strain: "NT" } + }), null); + assert.match(lessons.tableTaskActionFeedback(bidTask, { + type: "bid", + seat: "South", + bid: { type: "Pass" } + }).body, /1SA-opening/); + + const cardTask = lessons.findLessonChapter("les-01-wat-is-bridge", "bekennen-moet").tableTask; + assert.equal(lessons.tableTaskActionFeedback(cardTask, { + type: "card", + seat: "North", + card: { id: "5D", suit: "D" } + }), null); + assert.match(lessons.tableTaskActionFeedback(cardTask, { + type: "card", + seat: "North", + card: { id: "QD", suit: "D" } + }).body, /5 ruiten/); +});