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..85c5fe6 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`. @@ -82,7 +82,7 @@ Enige bron van waarheid voor open product-, bied- en speelwerk. Opgeschoond na c ### Lessen en oefenmodus -- Werk lessen 2-12 later net zo rijk uit als les 1, met interactieve vragen, gerichte oefenstart en betrouwbare reviewfeedback per lesdoel. +- Werk lessen 3-12 later net zo rijk uit als les 1 en 2, met interactieve vragen, gerichte oefenstart en betrouwbare reviewfeedback per lesdoel. - Voeg ongedaan maken/herhalen toe voor de leermodus, minstens voor de meest recente kaart. - Voeg keuze-feedback toe in lessen, niet in de rustige basisgame, en alleen wanneer de engine de uitleg betrouwbaar kan onderbouwen. diff --git a/docs/architecture.md b/docs/architecture.md index 0aee228..c53149d 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -24,7 +24,7 @@ Dit document beschrijft de actuele architectuur en gewenste groeirichting van Br ## Hoog-overzicht ```text -index.html / lessons.html / lesson-01-cards.html +index.html / lessons.html / lesson-01-cards.html / lesson-02-card-valuation.html +-- laadt CSS en browser scripts in vaste volgorde +-- index bevat DOM-structuur voor tafel, bieding, dialogs en review +-- lessons bevat de rustige lespagina en start oefenhanden via index queryparameters @@ -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, gedeelde leskaartnavigatie, 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 @@ -222,6 +234,7 @@ Doel: - `styles.css` blijft de compatibility/aggregator entrypoint voor bestaande laadvolgorde. - `styles/` bevat domeingerichte CSS: base, layout, table, auction, dialogs, review en responsive gedrag. - `styles/card.css` bevat de gedeelde visuele kaartbasis; gameplay voegt daar `.card`-gedrag aan toe, lessen gebruiken eigen lesson-classes bovenop `.playing-card`. +- `styles/lesson-cards.css` bevat de gedeelde kaartnavigatie voor losse lespagina's; les-specifieke stylesheets voegen alleen inhoudelijke layout en oefenvormen toe. - Component-specifieke responsive regels mogen naast het domeinbestand staan; `styles/auction-responsive.css` is eigenaar van biedtafel-, bidbox- en auction-log-responsiveness. Richtlijn: @@ -232,12 +245,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 +267,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 +334,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 +343,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 +377,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/lesformat.md b/docs/lesformat.md new file mode 100644 index 0000000..cf105bc --- /dev/null +++ b/docs/lesformat.md @@ -0,0 +1,974 @@ +# Lesformat Bridgetafel + +Status: herbruikbaar inhoudelijk lesformat +Scope: de 12 lessen uit `docs/lessenplan.md`, oefenhanden, quizzen en toekomstige lesfeedback + +Dit format is de standaardvorm voor alle Bridgetafel-lessen, tenzij een les aantoonbaar beter werkt met een andere setup. Het format is inhoudelijk bedoeld: het helpt lessen schrijven die later netjes passen in `scripts/learning/lessons.js`, oefenhanden in `practice-hands/` en glossary-linking via `scripts/learning/glossary.js`. + +Gebruik `docs/vijfkaart-hoog-systeem.md` als systeembron. Lescopy mag simpeler zijn dan de engine, maar mag geen andere biedafspraken aanleren. + +## Ontwerpprincipes + +- Schrijf voor online leren: korte stukken, vaak klikken, meteen feedback. +- Laat de speler steeds iets doen: kiezen, tellen, aanwijzen, voorspellen of proberen aan tafel. +- Gebruik glossary-termen letterlijk wanneer ze in `glossary.js` staan. +- Houd normale gameplay compact; uitgebreide uitleg hoort in de les, review, AI-suggesties of developer mode. +- Maak feedback vriendelijk en concreet: wat zag je, waarom is dat logisch, wat probeer je nu? +- Geef geen harde foutcorrectie als de engine of oefenhand het niet betrouwbaar kan onderbouwen. +- Gebruik `1SA` in lescopy; map naar `1NT` waar code of oefenhanden dat nodig hebben. +- Gebruik de bridgetafel voor korte, concrete lesmomenten: meestal 1 bod, 1 kaart, 1 slagmoment of 1 reviewcheck. +- Laat terugkeer naar de les altijd expliciet zijn via `Terug naar les`; geen automatische redirect na een tafelsituatie. + +## Vaste lesopbouw + +Elke les gebruikt deze onderdelen in deze volgorde: + +1. Welkom aan tafel - korte, leuke introductie. +2. Wat leer je vandaag? - 3 tot 5 concrete leerdoelen. +3. De situatie - een herkenbare bridgevraag. +4. De theorie in kleine stappen - maximaal 5 korte blokken. +5. Kijk mee aan tafel - een uitgewerkt bied- of speelvoorbeeld. +6. Jij bent aan de beurt - interactieve vraag met feedback. +7. Oefenen met handen - automatisch genereerbare handen met criteria. +8. Veelgemaakte beginnersfouten - 3 tot 5 valkuilen. +9. Mini-quiz - minimaal 6 vragen. +10. Eindchallenge - leuke opdracht op het bestaande bridge-bord. +11. Samenvatting - 5 kernzinnen. + +Deze opbouw hoort bij de losse lespagina zelf. Het algemene lessenoverzicht toont niet al deze onderdelen. + +## Lessenoverzicht + +Op `lessons.html` is de leskaart een rustige keuzehulp, geen inhoudsopgave. Per geselecteerde les staat daar alleen: + +- de titel; +- `Leerdoelen`; +- een enkele knop `Start les`. + +Richtlijnen: + +- Toon op het overzicht geen losse hoofdstukken, onderdelen, quizzen, oefenhanden of tafelmomenten. +- Maak onderdelen op het overzicht niet apart openklikbaar. +- Gebruik geen extra kop boven de startknop als de knop zelf al `Start les` zegt. +- De losse lespagina mag daarna wel kaartnavigatie, oefeningen, quizzen en tafellussen tonen. +- `lesson.summary` mag bestaan als interne korte belofte of fallback, maar het overzicht gebruikt primair `learningGoals`. + +## Kaartnavigatie voor losse lespagina's + +Nieuwe losse lespagina's gebruiken de gedeelde kaartstructuur uit `styles/lesson-cards.css` en `scripts/learning/lesson-cards.js`. Daarmee ziet de cursist steeds een leskaart tegelijk en staat de `Vorige`/`Volgende`-balk onder de actieve kaart. + +Minimaal HTML-contract: + +```html + + +
+ + +
+

Wat leer je vandaag?

+
+ +
+

De theorie in kleine stappen

+
+ +
+ +

+ + +

+ +
+
+ + +``` + +Richtlijnen: + +- Elke kaart heeft een uniek `id`, een `h2` en `data-lesson-card`. +- De ankerlinks in `data-lesson-card-nav` verwijzen naar die kaart-ids. +- De navigatiebalk staat na de leskaarten, dus onderaan de actieve kaart. +- Les-specifieke CSS mag extra layoutclasses toevoegen, maar de kaartnavigatie blijft in de gedeelde CSS. +- Les-specifieke JavaScript regelt alleen oefeningen, quizzen of handgeneratie; kaartnavigatie blijft in `lesson-cards.js`. + +## Lesmetadata + +Vul dit bovenaan elke les intern in, ook als niet alles zichtbaar wordt voor de speler. + +```text +Lesnummer: +Titel: +Korte belofte: +Hoofdfocus: Bieden / Spelen / Score / Review / Tegenspel +Voorkennis: +Nieuwe glossary-termen: +Herhaalde glossary-termen: +Biedprofiel: fiveCardHigh +Primaire oefenhanden: +Benodigde engine-uitleg: ruleId, planAction of reviewtekst +Wanneer feedback hard mag zijn: +Wanneer feedback voorzichtig moet blijven: +Hoofdstukken met tafelsituatie: +Return-doel per hoofdstuk: +``` + +## Hoofdstukken en korte tafelsituaties + +Een leshoofdstuk mag de bestaande bridgetafel gebruiken als korte oefenlus. De speler leest of beantwoordt iets in de les, klikt op oefenen, doet aan tafel precies het kernmoment, ziet een klaar-kaart en kiest daarna zelf `Terug naar les`. + +Gebruik dit voor: + +- 1 bod kiezen, bijvoorbeeld openen met `1SA` of passen; +- 1 kaart spelen, bijvoorbeeld kleur bekennen of een uitkomst kiezen; +- 1 slagmoment bekijken, bijvoorbeeld Dummy reveal of Slagwinnaar; +- 1 reviewmoment controleren, bijvoorbeeld Contract, resultaat of score; +- alleen uitzonderlijk een volledige hand uitspelen. + +Schrijf elke tafelsituatie als hoofdstuktaak: + +```yaml +chapter: + id: een-sa-opening-herkennen + title: 1SA-hand herkennen + handId: one-nt-opening-001 + tableTask: + type: bid + completion: southBid + doneTitle: Bod gedaan + doneBody: Je hebt het openingsbod gekozen. Ga terug naar de les om de keuze te vergelijken. + returnLabel: Terug naar les + retryLabel: Nog eens proberen + boardGuidance: + - id: valueThenBid + target: bidControls + gate: allowHumanBid + badge: Openingskeuze + title: Waardeer eerst Zuid + body: Tel HCP, kijk of de verdeling evenwichtig is en kies daarna het openingsbod. + buttonLabel: Ik kies mijn bod +``` + +`chapter.id` is het anker waarnaar de speler terugkeert. Een startlink naar de tafel bevat daarom minimaal `lesson`, `hand`, `chapter` en een `return`-URL naar de oorspronkelijke lesplek, bijvoorbeeld `lessons.html?lesson=...#chapter-id` of een losse `lesson-...html#stap`. + +### `tableTask` + +`tableTask` beschrijft wanneer een korte tafelsituatie klaar is en wat de speler daarna ziet. + +Velden: + +- `type`: `bid`, `card`, `trick`, `review` of `hand`. +- `completion`: concrete klaarvoorwaarde, bijvoorbeeld `southBid`, `northSouthCard`, `trickWinnerShown`, `reviewReached` of `handComplete`. +- `doneTitle`: korte titel op de klaar-kaart. +- `doneBody`: 1 tot 2 zinnen die het lesmoment afronden. +- `returnLabel`: meestal `Terug naar les`. +- `retryLabel`: optioneel; gebruik dit alleen wanneer opnieuw proberen didactisch zinvol is. + +Richtlijnen: + +- Kies de kleinste completion die het lesdoel bewijst. +- Bij `bid` is meestal 1 bod door Zuid genoeg. +- Bij `card` is meestal 1 kaart door Zuid of Dummy genoeg. +- Bij `trick` stopt de situatie zodra de Slagwinnaar zichtbaar is. +- Bij `review` stopt de situatie zodra het relevante reviewpaneel is bereikt. +- Bij `hand` speelt de speler bewust de hele hand uit; gebruik dit spaarzaam. +- Na completion hoort gewone gameplay geblokkeerd of gedempt te blijven totdat de speler `Terug naar les` of `Nog eens proberen` kiest. + +### `boardGuidance` + +`boardGuidance` is de coachlaag op de tafel. Gebruik die voor spotlight en alleen voor noodzakelijke flowblokkades. + +Ondersteunde targets: + +- `contract` +- `bidControls` +- `auctionLog` +- `dummy` +- `legalCards` +- `trumpCards` +- `trickArea` +- `trickWinner` +- `review` +- `lessonPanel` + +Ondersteunde gates: + +- `none` - alleen spotlight/uitleg, geen blokkade. +- `allowHumanBid` - blokkeert het bod totdat de speler de coachstap bevestigt. +- `allowHumanPlay` - blokkeert de kaart totdat de speler de coachstap bevestigt. +- `advanceTrick` - laat de speler bewust naar de volgende slag gaan. + +Gebruik gates alleen bij kernmomenten. Als de speler al veilig verder kan zonder uitleg te missen, gebruik `gate: none`. + +### Tijdelijke lesinstellingen + +In lesmodus zijn rustige tafelinstellingen tijdelijk leidend: + +- `showPlayHistory = false`; +- `guidanceMode = false`; +- `developerMode = false`. + +Deze waarden mogen opgeslagen voorkeuren niet overschrijven. Lescopy mag er dus niet van uitgaan dat developer-uitleg, AI-suggesties of speelgeschiedenis zichtbaar blijven tijdens een korte tafelsituatie. + +## 1. Welkom aan tafel + +Doel: de speler ontspannen binnenhalen en nieuwsgierig maken. + +Richtlijn: + +- 2 tot 4 korte zinnen. +- Noem een herkenbaar tafelmoment, niet meteen een theoriehoofdstuk. +- Een klein grapje mag, maar de bridgevraag moet helder blijven. +- Eindig met een zachte actie: "Kijk eerst eens naar Zuid" of "We gaan samen tellen." + +Voorbeeldvorm: + +```text +Welkom aan tafel. Vandaag draait alles om [leskern]. +Je hoeft nog niet alles perfect te zien; je zoekt alleen het eerste goede spoor. +Aan het einde kun je [concreet resultaat] aan een echte hand herkennen. +``` + +## 2. Wat leer je vandaag? + +Doel: de les belooft concreet gedrag, geen abstract hoofdstuk. + +Richtlijn: + +- 3 tot 5 leerdoelen. +- Begin elk leerdoel met een werkwoord: herken, tel, kies, verklaar, speel, vergelijk. +- Koppel minimaal 2 leerdoelen aan glossary-termen. +- Laat elk leerdoel terugkomen in oefening, quiz of eindchallenge. + +Voorbeeldvorm: + +```text +- Je herkent [glossary-term] in een echte bieding. +- Je kiest tussen [optie A] en [optie B] met de afspraak uit Vijfkaart-Hoog. +- Je legt in een zin uit waarom [bod/kaart] logisch is. +- Je probeert dit op het bridge-bord met een vaste oefenhand. +``` + +## 3. De situatie + +Doel: een herkenbare bridgevraag zetten voordat de theorie begint. + +Richtlijn: + +- 1 concrete vraag aan Zuid, Noord/Zuid of de leider. +- Gebruik echte tafelcontext: kwetsbaarheid, dealer, biedverloop, dummy of slagpositie alleen als dat nodig is. +- Laat de speler eerst voorspellen voordat de uitleg antwoord geeft. +- Houd de vraag geschikt voor beginners: een beste eerste stap is genoeg. + +Voorbeeldvorm: + +```text +Zuid is aan de beurt. Partner heeft [bod/actie] gedaan. +Je hebt [kracht/verdeling/speelpositie]. +Wat is nu je eerste vraag aan jezelf? +``` + +Goede situatievragen: + +- "Open je met 1SA of met een kleur?" +- "Hebben Noord/Zuid samen een Fit?" +- "Welke kleur wordt je Werkkleur in Sans-atout?" +- "Wie komt uit, en wanneer verschijnt Dummy?" + +## 4. De theorie in kleine stappen + +Doel: maximaal 5 korte theorieblokken die elk maar een idee dragen. + +Elke theorieblok gebruikt dit vaste mini-format: + +```text +### Stap [nummer] - [korte titel] + +Kernzin: +[Een zin die de speler mag onthouden.] + +Uitleg: +[3 tot 6 korte zinnen. Maximaal een nieuw idee.] + +Glossary-termen: +[2 tot 5 termen uit glossary.js, letterlijk gespeld.] + +Probeer zelf: +[Een kleine tel-, kies-, wijs-aan- of voorspelvraag.] + +Directe feedback: +- Als goed: [waarom dit klopt]. +- Als bijna: [welk detail ontbreekt]. +- Als mis: [waar opnieuw naar kijken]. +``` + +Regels voor theorieblokken: + +- Maximaal 5 blokken per les. +- Maximaal 90 woorden uitleg per blok. +- Minstens 1 "Probeer zelf"-moment na elke 2 blokken; liever in elk blok. +- Gebruik glossary-termen waar passend, vooral in titels, kernzinnen, feedback en quiz. +- Leg kunstmatige biedingen altijd uit als betekenis, niet alleen als kaartje. Voorbeeld: `2R` na `1SA` kan Jacoby-transfer naar harten zijn. +- Herhaal oude begrippen kort wanneer ze nodig zijn: "We kennen Fit al: samen minstens acht kaarten in een kleur." + +Voorbeeld van een theorieblok: + +```text +### Stap 2 - Stayman vraagt naar een hoge kleur + +Kernzin: +Stayman is een vraag naar een vierkaart harten of schoppen. + +Uitleg: +Na een 1SA-opening weet partner al veel: 15-17 HCP en een Evenwichtige verdeling. Met Stayman zoekt responder een 4-4 Fit in de Hoge kleuren. Het bod 2K betekent dan niet: "ik wil klaveren spelen", maar: "partner, heb jij een vierkaart hoog?" + +Glossary-termen: +Stayman, Sans-atout, HCP, Evenwichtige verdeling, Fit, Hoge kleuren, Conventioneel bod + +Probeer zelf: +Je hebt 8 HCP en vier harten na partners 1SA. Vraag je met Stayman? + +Directe feedback: +- Als goed: Ja, je zoekt eerst of er een hoge-kleurfit is. +- Als bijna: Let op: vierkaart hoog is precies waarom Stayman bestaat. +- Als mis: Kijk opnieuw naar de combinatie 1SA, HCP en vierkaart hoog. +``` + +## 5. Kijk mee aan tafel + +Doel: een uitgewerkt voorbeeld waarin de speler ziet hoe de theorie aan tafel klinkt. + +Kies per les een biedvoorbeeld of speelvoorbeeld. Gebruik beide alleen als dat de les niet te lang maakt. + +Format voor een biedvoorbeeld: + +```text +Situatie: +[Dealer, kwetsbaarheid indien relevant, Zuid-hand of Noord/Zuid-context.] + +Biedverloop: +1. [Bod] - [korte betekenis] +2. [Bod] - [korte betekenis] +3. [Eindcontract of tussenconclusie] + +Waarom dit werkt: +[Korte uitleg met glossary-termen.] + +Let op: +[Een beginnerverwarring, bijvoorbeeld biedkaartje versus betekenis.] +``` + +Format voor een speelvoorbeeld: + +```text +Situatie: +[Contract, leider, dummy, uitkomst.] + +Kijkstappen: +1. Wat is het Contract? +2. Wie is Leider en wie is Dummy? +3. Wat is Troef of is het Sans-atout? +4. Wat is het eerste plan: Troef trekken, Werkkleur kiezen, Stopper bewaren, Aftroeven of iets anders? +5. Welke kaart probeer je nu? + +Waarom dit werkt: +[Korte uitleg met glossary-termen en eventueel planAction.] +``` + +Feedbackregel: de tekst zegt niet "dit is altijd goed", maar "in deze situatie is dit logisch omdat ...". + +## 6. Jij bent aan de beurt + +Doel: de speler maakt zelf een keuze en krijgt directe feedback. + +Richtlijn: + +- 1 hoofdvraag per lesonderdeel. +- 2 tot 4 opties. +- Elke optie krijgt eigen feedback. +- De juiste optie krijgt een korte bevestiging plus reden. +- Foute opties leggen uit welk detail je over het hoofd ziet. +- Bied bij twijfel een tweede poging aan in plaats van meteen door te gaan. + +Invulvorm: + +```text +Vraag: +[Wat bied/speel/kies je nu?] + +Opties: +A. [optie] +B. [optie] +C. [optie] + +Feedback: +A. [correct/bijna/mis + uitleg] +B. [correct/bijna/mis + uitleg] +C. [correct/bijna/mis + uitleg] + +Glossary-termen in feedback: +[termen] +``` + +Geschikte interacties: + +- Kies het bod. +- Kies de kaart. +- Wijs Leider, Dummy, Troef of Uitkomst aan. +- Tel HCP. +- Tel benodigde slagen bij het Contract. +- Kies tussen Troef trekken, Aftroeven, Werkkleur ontwikkelen of Stopper bewaren. + +## 7. Oefenen met handen + +Doel: elke les heeft oefenhanden die vastgelegd of automatisch genereerbaar zijn. + +Elke les bevat minimaal 4 handen of handcriteria: + +- 1 instaphand: precies het lesdoel, weinig ruis. +- 1 variatiehand: hetzelfde doel met een kleine draai. +- 1 herkenningshand: speler moet kiezen of de regel wel of niet past. +- 1 challengehand: lesdoel in een echte tafelcontext met review. + +Gebruik bestaande oefenhand-id's waar mogelijk. Ontbrekende handen krijgen een voorstel-id en criteria, zonder te doen alsof ze al bestaan. + +Criteriaformat: + +```yaml +- id: lesson-[nn]-[slug]-001 + status: bestaand | voorstel + lesdoel: "..." + fase: bidding | play | review | score + startmodus: auction | play | complete + spelerstaak: "Wat moet Zuid/de leider/de tegenspeler doen?" + tableTask: + type: bid | card | trick | review | hand + completion: southBid | northSouthCard | trickWinnerShown | reviewReached | handComplete + doneTitle: "" + doneBody: "" + returnLabel: "Terug naar les" + retryLabel: "" + systeemafspraak: "fiveCardHigh" + vaste_context: + dealer: any | N | E | S | W + kwetsbaarheid: any | none | NS | EW | both + biedverloop_prefix: [] + contract: "" + leider: "" + uitkomst: "" + handcriteria: + zuid: + hcp: "" + verdeling: "" + vereiste_kaarten: [] + verboden_patronen: [] + noord: + hcp: "" + verdeling: "" + vereiste_kaarten: [] + oost_west: + constraints: [] + verwachte_actie: + bieding: "" + kaart: "" + planactie: "" + uitleg_moet_noemen: + glossary: [] + ruleIds: [] + waarschuwing: "" + acceptatiecheck: + - "De legale actie is beschikbaar." + - "De AI-suggestie of review noemt dezelfde reden als de les." + - "De speler kan na afloop zien waarom het resultaat klopt." +``` + +Voorbeeldcriteria voor een biedhand: + +```yaml +- id: lesson-07-stayman-find-fit-001 + status: voorstel + lesdoel: "Stayman gebruiken na 1SA om een hoge-kleurfit te zoeken." + fase: bidding + startmodus: auction + spelerstaak: "Zuid kiest het antwoord op partners 1SA-opening." + systeemafspraak: "fiveCardHigh" + vaste_context: + dealer: N + kwetsbaarheid: any + biedverloop_prefix: ["N:1SA"] + handcriteria: + zuid: + hcp: "8-9" + verdeling: "minstens een vierkaart harten of schoppen, geen vijfkaart hoog" + noord: + hcp: "15-17" + verdeling: "Evenwichtige verdeling" + verwachte_actie: + bieding: "2K als Stayman" + uitleg_moet_noemen: + glossary: ["Stayman", "Sans-atout", "Fit", "Hoge kleuren", "Conventioneel bod"] + acceptatiecheck: + - "2K wordt uitgelegd als Stayman, niet als natuurlijk klaveren." + - "De vervolgactie zoekt een Fit in harten of schoppen." + - "Na het gevraagde bod verschijnt een klaar-kaart met Terug naar les." +``` + +Voorbeeldcriteria voor een speelhand: + +```yaml +- id: lesson-08-draw-trumps-generated-001 + status: voorstel + lesdoel: "Als Leider eerst Troef trekken wanneer er geen urgente Aftroever is." + fase: play + startmodus: play + spelerstaak: "Zuid maakt een eerste speelplan na Dummy reveal." + vaste_context: + contract: "4S door Zuid" + leider: S + uitkomst: "veilig zijkleurkaartje door West" + handcriteria: + noord_zuid: + fit: "minstens 8 schoppen samen" + losers: "zichtbare Verliezers, maar geen directe noodzaak om eerst af te troeven" + oost_west: + constraints: ["tegenpartij heeft 3 tot 5 troeven samen"] + verwachte_actie: + planactie: "Troef trekken" + uitleg_moet_noemen: + glossary: ["Leider", "Dummy", "Troef", "Troef trekken", "Verliezers"] + acceptatiecheck: + - "Het speelplanpaneel adviseert Troef trekken." + - "Review maakt duidelijk waarom troefcontrole nuttig was." +``` + +## 8. Veelgemaakte beginnersfouten + +Doel: valkuilen benoemen zonder de speler af te straffen. + +Richtlijn: + +- 3 tot 5 valkuilen. +- Elke valkuil bevat: fout, waarom begrijpelijk, betere vraag, korte retry. +- Koppel valkuilen aan echte lesfeedback of quizdistractors. + +Invulvorm: + +```text +1. Valkuil: [wat doet een beginner?] + Waarom begrijpelijk: [welke gedachte zit erachter?] + Betere vraag: [waar moet de speler eerst naar kijken?] + Feedbackzin: [vriendelijke zin in de app] +``` + +Voorbeelden: + +- "1SA als Vuilnisbakkenbod verwarren met een sterke Sans-atout-hand." +- "Bij Jacoby-transfer naar het biedkaartje kijken en vergeten wat het betekent." +- "Te snel Troef trekken terwijl Dummy eerst een korte kleur kan Aftroeven." +- "Een Volgbod doen met alleen punten, maar zonder goede kleur." + +## 9. Mini-quiz + +Doel: minimaal 6 korte vragen die begrip checken voordat de eindchallenge begint. + +Richtlijn: + +- Minimaal 6 vragen. +- Mix herkenning, toepassing en kleine uitleg. +- Maximaal 4 antwoordopties. +- Elke vraag krijgt feedback voor goed en fout. +- Minimaal 3 vragen gebruiken glossary-termen letterlijk. +- Minimaal 1 vraag gebruikt een mini-biedverloop of mini-speelpositie. +- Minimaal 1 vraag vraagt om "waarom", maar het antwoord blijft kort. + +Quizformat: + +```text +Vraag [nummer]: +[vraag] + +Type: +meerkeuze | juist/onjuist | volgorde | wijs aan | korte uitleg + +Opties: +- [optie] +- [optie] +- [optie] + +Correct: +[antwoord] + +Feedback goed: +[bevestiging + reden] + +Feedback fout: +[hint + correcte anker] + +Glossary-termen: +[termen] +``` + +Vragenmix per les: + +1. Begrip: "Wat betekent [term]?" +2. Herkenning: "Welke hand past bij [regel]?" +3. Toepassing: "Wat bied of speel je?" +4. Grensgeval: "Waarom is dit net geen [regel]?" +5. Tafelritme: "Wie is aan de beurt of wie wordt Leider?" +6. Uitleg: "Maak de zin af: ik kies dit omdat ..." + +## 10. Eindchallenge + +Doel: de les eindigt met een kleine missie op het bestaande bridge-bord. + +Richtlijn: + +- Gebruik een bestaande oefenhand of voorstel-id uit deze les. +- Geef een leuk doel, geen examenstress. +- Laat de speler voor de eerste actie voorspellen. +- Laat na afloop review of lesfeedback drie dingen teruggeven: keuze, resultaat, lesdoel. +- Maak succes haalbaar: de challenge mag over de juiste beslissing gaan, niet alleen over perfecte score. + +Invulvorm: + +```text +Challenge: +[speelse opdrachtnaam] + +Start: +[handId, startmodus, hoofdstuk-id, tableTask en eventuele boardGuidance] + +Missie: +[wat moet de speler proberen?] + +Succescheck: +- [eerste juiste keuze] +- [lesdoel zichtbaar in spel of review] +- [klaar-kaart verschijnt na de afgesproken completion] +- [Terug naar les opent hetzelfde hoofdstuk] +- [speler kan in een zin uitleggen waarom] + +Reviewzin: +[korte afronding met glossary-termen] +``` + +Voorbeelden: + +- "Openingsbingo: vind in vier handen pas, 1SA, een hoge-kleuropening en een lage-kleuropening." +- "Geheime boodschap: noteer bij elk Conventioneel bod wat het kaartje zegt en wat het betekent." +- "Speelplan in vijf woorden: kies na Dummy reveal je eerste planactie." + +## 11. Samenvatting + +Doel: precies 5 kernzinnen die de speler mag onthouden op de losse lespagina. Dit is niet de compacte kaart op `lessons.html`. + +Richtlijn: + +- 5 zinnen, geen bullets met bijzinnen die stiekem paragrafen worden. +- Elke zin is zelfstandig en concreet. +- Gebruik glossary-termen letterlijk waar passend. +- Herhaal de belangrijkste grens: wanneer geldt de regel wel, wanneer niet? +- Laat de laatste zin vooruitwijzen naar oefenen of de volgende les. + +Invulvorm: + +```text +1. [Kernzin over het hoofdbegrip.] +2. [Kernzin over de keuze aan tafel.] +3. [Kernzin over een belangrijke uitzondering of grens.] +4. [Kernzin over feedback/review.] +5. [Kernzin die de speler meeneemt naar de volgende hand.] +``` + +## Glossary-termen per les + +Gebruik deze termbank als startpunt. Voeg alleen termen toe als ze in `glossary.js` staan of bewust als toekomstige glossary-term zijn gemarkeerd. + +| Les | Primaire glossary-termen | +| --- | --- | +| 1 | Slag, Contract, Troef, Sans-atout, Leider, Dummy, Uitkomst, Kleur bekennen | +| 2 | HCP, Honneur, Evenwichtige verdeling, Fit, Fitpunten, Opening, Openingskracht, Regel van 20 | +| 3 | Opening, Openaar, Openingskracht, Hoge kleuren, Lage kleuren, Sans-atout, Evenwichtige verdeling, Regel van 20 | +| 4 | Bijbod, Fit, Vuilnisbakkenbod, Hoge kleuren, Lage kleuren, Forcing, Minimum | +| 5 | Herbieding, Minimum, Maximum, Invite, Openaar, Bijbod, Fit, Manche | +| 6 | Fit, Fitpunten, Manche, Hoge kleuren, Invite, Troef trekken, Contractpunten | +| 7 | Sans-atout, Stayman, Jacoby-transfer, Conventioneel bod, Hoge kleuren, Fit, Leider | +| 8 | Leider, Dummy, Verliezers, Troef trekken, Aftroeven, Afgooien, Entree | +| 9 | Sans-atout, Werkkleur, Stopper, Ophouden, Entree, Vrijspelen, Lengteslagen, Gevaarlijke hand | +| 10 | Tegenspelers, Uitkomst, Serie, Honneur, Vrijspelen | +| 11 | Volgbod, Informatiedoublet, Doublet, Biedplicht, Kwetsbaarheid, Openingskracht, Hoge kleuren, Lage kleuren | +| 12 | Zwakke twee, Vierde-kleur-forcing, Forcing, Azenvragen, Slem, Kleinslem, Grootslem, Manche, Conventioneel bod | + +Let op: "Speelplan", "Signaleren", "tweede hand laag" en "derde hand hoog" zijn inhoudelijk nuttig, maar staan niet allemaal als losse glossary-termen. Markeer ze bij implementatie als gewone lescopy of voeg later expliciete glossary-items toe. + +## Compleet invulsjabloon + +Kopieer dit blok voor een nieuwe lesuitwerking. + +```markdown +# Les [nummer] - [titel] + +## Welkom aan tafel + +[2 tot 4 korte zinnen.] + +## Wat leer je vandaag? + +- [Leerdoel 1.] +- [Leerdoel 2.] +- [Leerdoel 3.] +- [Optioneel leerdoel 4.] +- [Optioneel leerdoel 5.] + +## De situatie + +[Een herkenbare bridgevraag aan Zuid, Noord/Zuid, Leider of Tegenspelers.] + +## De theorie in kleine stappen + +### Stap 1 - [titel] + +Kernzin: [...] + +Uitleg: [...] + +Glossary-termen: [...] + +Probeer zelf: [...] + +Directe feedback: +- Als goed: [...] +- Als bijna: [...] +- Als mis: [...] + +### Stap 2 - [titel] + +Kernzin: [...] + +Uitleg: [...] + +Glossary-termen: [...] + +Probeer zelf: [...] + +Directe feedback: +- Als goed: [...] +- Als bijna: [...] +- Als mis: [...] + +### Stap 3 - [titel] + +Kernzin: [...] + +Uitleg: [...] + +Glossary-termen: [...] + +Probeer zelf: [...] + +Directe feedback: +- Als goed: [...] +- Als bijna: [...] +- Als mis: [...] + +### Stap 4 - [titel] + +Kernzin: [...] + +Uitleg: [...] + +Glossary-termen: [...] + +Probeer zelf: [...] + +Directe feedback: +- Als goed: [...] +- Als bijna: [...] +- Als mis: [...] + +### Stap 5 - [titel] + +Kernzin: [...] + +Uitleg: [...] + +Glossary-termen: [...] + +Probeer zelf: [...] + +Directe feedback: +- Als goed: [...] +- Als bijna: [...] +- Als mis: [...] + +## Kijk mee aan tafel + +Situatie: [...] + +Bied- of speelverloop: +1. [...] +2. [...] +3. [...] + +Waarom dit werkt: [...] + +Let op: [...] + +## Jij bent aan de beurt + +Vraag: [...] + +Opties: +A. [...] +B. [...] +C. [...] + +Feedback: +A. [...] +B. [...] +C. [...] + +## Oefenen met handen + +### Hand 1 - Instap + +- id: +- status: bestaand | voorstel +- lesdoel: +- tableTask: +- criteria: +- verwachte actie: +- uitleg moet noemen: + +### Hand 2 - Variatie + +- id: +- status: bestaand | voorstel +- lesdoel: +- tableTask: +- criteria: +- verwachte actie: +- uitleg moet noemen: + +### Hand 3 - Herkenning + +- id: +- status: bestaand | voorstel +- lesdoel: +- tableTask: +- criteria: +- verwachte actie: +- uitleg moet noemen: + +### Hand 4 - Challenge + +- id: +- status: bestaand | voorstel +- lesdoel: +- tableTask: +- criteria: +- verwachte actie: +- uitleg moet noemen: + +## Veelgemaakte beginnersfouten + +1. Valkuil: [...] + Betere vraag: [...] + Feedbackzin: [...] + +2. Valkuil: [...] + Betere vraag: [...] + Feedbackzin: [...] + +3. Valkuil: [...] + Betere vraag: [...] + Feedbackzin: [...] + +## Mini-quiz + +1. Vraag: [...] + Opties: [...] + Correct: [...] + Feedback goed: [...] + Feedback fout: [...] + +2. Vraag: [...] + Opties: [...] + Correct: [...] + Feedback goed: [...] + Feedback fout: [...] + +3. Vraag: [...] + Opties: [...] + Correct: [...] + Feedback goed: [...] + Feedback fout: [...] + +4. Vraag: [...] + Opties: [...] + Correct: [...] + Feedback goed: [...] + Feedback fout: [...] + +5. Vraag: [...] + Opties: [...] + Correct: [...] + Feedback goed: [...] + Feedback fout: [...] + +6. Vraag: [...] + Opties: [...] + Correct: [...] + Feedback goed: [...] + Feedback fout: [...] + +## Eindchallenge + +Challenge: [...] + +Start: [...] + +Missie: [...] + +Succescheck: +- [...] +- [...] +- [...] +- [Klaar-kaart verschijnt op de tafel.] +- [Terug naar les keert terug naar hetzelfde hoofdstuk.] + +Reviewzin: [...] + +## Samenvatting + +1. [...] +2. [...] +3. [...] +4. [...] +5. [...] +``` + +## Acceptatiecheck per les + +Een les is formatklaar wanneer dit allemaal klopt: + +- Alle verplichte onderdelen staan erin. +- De theorie heeft maximaal 5 blokken. +- Elk theorieblok heeft passende glossary-termen of bewust geen jargon. +- De les gebruikt de biedafspraken uit `docs/vijfkaart-hoog-systeem.md`. +- Kunstmatige biedingen scheiden biedkaartje en betekenis. +- Er is minimaal 1 echte interactievraag met feedback per les. +- De mini-quiz heeft minimaal 6 vragen. +- Er zijn minimaal 4 oefenhanden of handcriteria. +- De eindchallenge start op het bestaande bridge-bord. +- De leskaart op `lessons.html` toont alleen titel, leerdoelen en een enkele startknop. +- Elke bridge-bordstart vanuit een hoofdstuk heeft `chapter`, `return` en waar nodig een compacte `tableTask`. +- Elke korte tafelsituatie heeft een duidelijke completion, klaar-kaart en expliciete `Terug naar les`. +- `boardGuidance` blokkeert alleen kernmomenten; spotlights zonder noodzakelijke actie gebruiken `gate: none`. +- De samenvatting bestaat uit precies 5 kernzinnen. +- Feedback is alleen hard waar de engine of oefenhand dat betrouwbaar ondersteunt. diff --git a/docs/lessenplan.md b/docs/lessenplan.md index d31be08..2766105 100644 --- a/docs/lessenplan.md +++ b/docs/lessenplan.md @@ -1,510 +1,903 @@ # Lessenplan Bridgetafel -Status: voorstel -Scope: `bridge-app` lesmodus en oefenhanden +Status: inhoudelijke cursusstructuur +Scope: `bridge-app` lesmodus, oefenhanden en toekomstige lesfeedback -Dit lessenplan vertaalt de huidige 12-delige lesroute naar een praktische product- en implementatierichting. Het doel is niet om een boek te vervangen, maar om beginners met korte uitleg, vaste oefenhanden en betrouwbare feedback steeds zelfstandiger bridge te laten spelen. +Dit lessenplan beschrijft een cursus van 12 online bridgelessen voor beginners tot lichtgevorderden. De cursus leert bridge via Vijfkaart-Hoog, in de geest van NBB/Barry's Vijfkaart Hoog en `Start met Bridge 1 & 2`, maar blijft zo geschreven dat andere conventieprofielen later naast dit eerste profiel kunnen bestaan. -## Doelgroep +Het doel is niet om een boek droog na te vertellen. De app moet voelen als een rustige oefentafel met een vriendelijke coach naast je: korte uitleg, herkenbare situaties, "wat zou jij bieden?"-momenten, oefenen op het bestaande bridge-bord en daarna terugkijken waarom iets werkte. -Primaire doelgroep: +## Systeembron -- Beginners die de app gebruiken om bridge stap voor stap te leren. -- Testers die nog niet alle bridgebegrippen kennen. -- Later: spelers die Vijfkaart Hoog / Start met Bridge 1 & 2 willen oefenen. +`docs/vijfkaart-hoog-systeem.md` is het cursuscontract voor biedafspraken. Dit lessenplan mag afspraken eenvoudiger uitleggen dan de engine redeneert, maar mag geen ander systeem aanleren. -Voor deze doelgroep geldt: +Belangrijke cursusafspraken uit dat document: -- Minder tekst tijdens normaal spelen is beter. -- Lesmodus mag meer uitleg geven, maar per stap kort. -- Feedback moet alleen stellig zijn wanneer de engine het betrouwbaar weet. -- Herhaalbaarheid is belangrijk: elke les moet vaste oefenhanden kunnen starten. +- `1SA` in lescopy is dezelfde bieding als `1NT` in code en oefenhanden. +- 1SA gaat voor bij 15-17 HCP en een evenwichtige verdeling, ook als de hand een vijfkaart hoog in een 5-3-3-2-verdeling heeft. +- 1 harten en 1 schoppen beloven minstens een vijfkaart, behalve dat een langere lage kleur soms eerst geopend wordt. +- 1 ruiten belooft meestal minstens een vierkaart ruiten. +- 1 klaveren kan een vangnetopening zijn en dus korter zijn dan beginners verwachten. +- Na een gevonden Fit mag de cursus over Fitpunten spreken in plaats van alleen HCP. +- Kunstmatige biedingen moeten altijd als betekenis worden uitgelegd, niet alleen als biedkaartje: Stayman, Jacoby-transfer, Vierde-kleur-forcing en Azenvragen zijn daar de eerste voorbeelden van. -## Didactische principes +## Glossary-afspraak -1. Eerst tafelritme, dan regels. - De speler moet eerst begrijpen wie aan de beurt is, wat een slag is, wanneer dummy verschijnt en hoe een hand eindigt. +`scripts/learning/glossary.js` is de vaste bron voor jargon. Lescopy gebruikt daarom bij voorkeur exact dezelfde termen als de glossary, zodat de bestaande glossary-functionaliteit woorden kan herkennen en klikbaar kan maken wanneer lescopy via `BridgeGlossary.linkifyText` wordt weergegeven. -2. Een les heeft een concreet mini-doel. - Bijvoorbeeld: “herken 15-17 gebalanceerd” of “bekennen moet”. Geen brede hoofdstukken zonder actie. +Belangrijke vaste termen in deze cursus: -3. Theorie wordt gekoppeld aan een oefenhand. - Elke les moet minstens een oefenhand hebben die het lesdoel zichtbaar maakt. +- HCP +- Fit +- Manche +- Stayman +- Jacoby-transfer +- Vuilnisbakkenbod +- Vierde-kleur-forcing +- Informatiedoublet +- Zwakke twee +- Regel van 20 +- Azenvragen +- Slem +- Troef +- Leider +- Dummy +- Uitkomst +- Stopper +- Entree +- Werkkleur -4. Feedback is sober en betrouwbaar. - Geen harde foutmelding wanneer de bied- of speelengine onzeker is. Dan liever: “De app zou hier X overwegen omdat...” +Richtlijn voor implementatie: -5. Herhaling zit in review. - Na de hand moet de speler kunnen teruglezen: contract, bieding, slagen, score en het lespunt. +- Gebruik glossary-termen letterlijk in titels, kernbegrippen, theorieblokken, quizfeedback en reviewtekst. +- Vermijd nieuwe synoniemen als de glossary al een term heeft. +- Gebruik "1SA" in lescopy voor sans-atout, maar map in engine/UI waar nodig naar de bestaande `1NT`-call. +- Biedingen mogen in UI-copy als `1C`, `1D`, `1H`, `1S` of als "1 klaveren", "1 ruiten", "1 harten", "1 schoppen" worden getoond. Houd de uitleg begrijpelijker dan de notatie. -6. De basisgame blijft rustig. - Uitgebreide uitleg hoort in `lessons.html`, AI-suggesties, review, woordenlijst of developer mode. +## Didactische lijn -## Lesformat +De cursus bouwt in kleine stappen op: -Elke les krijgt bij voorkeur deze structuur: +1. Eerst tafelritme: slagen, contract, troef, leider en Dummy. +2. Dan handwaardering: HCP, verdeling, Fit en simpele biedkeuzes. +3. Daarna Vijfkaart-Hoog-openingen volgens het cursuscontract: eerst kracht en speciale openingen, dan pas natuurlijke kleuren. +4. Daarna antwoorden en herbiedingen. +5. Pas daarna Manche, Sans-atout-conventies, speelplan, tegenspel en competitie. +6. Sterkere afspraken komen pas in les 12 als kennismaking, niet als examen. -```text -1. Instapvraag / mini-uitleg -2. Kernbegrip in maximaal 3 korte punten -3. Miniquiz of herkenvraag -4. Start oefenhand -5. Reviewfeedback gekoppeld aan het lesdoel -6. Optioneel: tweede oefenhand voor herhaling -``` +Nieuwe begrippen worden pas gebruikt nadat ze in dezelfde of een eerdere les zijn uitgelegd. Als een latere les teruggrijpt op een begrip, mag de tekst kort herhalen: "We kennen Fit al: samen minstens acht kaarten in een kleur." -Velden in `scripts/learning/lessons.js`: +## Standaard lesformat -- `id` - stabiele les-id. -- `number` - routevolgorde. -- `title` - korte titel. -- `challenge` - wat de speler gaat proberen. -- `focus` - labels voor routekaart. -- `handIds` - oefenhanden die bij de les horen. -- `chapters` - korte hoofdstukken met blocks, quiz en oefenlinks. -- `reviewFeedback` - lesgerichte feedback na afloop. -- `teachingPoints` - kernpunten die in de review terugkomen. -- `startMode` - bijvoorbeeld `play` als de les direct bij het spelen start. +Elke les bestaat uit: -## Route-overzicht +- titel; +- leerdoelen; +- kernbegrippen; +- korte theorieblokken; +- interactieve biedvoorbeelden; +- interactieve speelvoorbeelden op het bestaande bridge-bord; +- korte tafelsituaties vanuit een hoofdstuk, meestal 1 bod, 1 kaart, 1 slagmoment of 1 reviewcheck; +- minimaal 6 oefenvragen; +- minimaal 4 oefenhanden; +- korte leuke eindopdracht. -### Fase 1 - Tafelritme en kaartspel +Oefenhanden hieronder gebruiken bestaande ids waar mogelijk. Ontbrekende situaties krijgen voorstel-ids. Die ids zijn nog geen implementatiebelofte; ze geven aan welke vaste handen later in `practice-hands/` nuttig zijn. -Doel: de speler kan een hand volgen zonder biedtheorie te hoeven begrijpen. +### Lesoverzicht in de app -#### Les 1: Wat is bridge? +Het overzicht op `lessons.html` blijft bewust compact. Per geselecteerde les toont het alleen: -Huidige status: rijk uitgewerkt. +- de titel; +- de leerdoelen; +- een enkele knop `Start les`. -Leerdoelen: +Noem daar niet elk hoofdstuk, lesonderdeel, oefenmoment of quizblok apart, en maak die onderdelen daar ook niet los openklikbaar. De speler kiest eerst een les; de losse lespagina zelf mag daarna met kaartnavigatie, oefeningen en tafellussen door de onderdelen lopen. -- Vier spelers en partners herkennen. -- Begrijpen wat een slag is. -- Dummy herkennen na de uitkomst. -- Bekennen toepassen. +## Lesmodus met korte tafelsituaties -Oefenhand: +De lesmodus gebruikt de bestaande bridgetafel niet als losse volledige game, maar als korte oefenlus binnen een hoofdstuk. De speler start vanuit een concreet hoofdstuk, doet aan tafel het gevraagde kernmoment, ziet een klaar-kaart en keert met `Terug naar les` terug naar hetzelfde hoofdstuk. -- `draw-trumps-001` +Standaard flow: -Acceptatie: +1. Het hoofdstuk legt de situatie kort uit. +2. De oefenlink opent `index.html` met `lesson`, `hand`, `chapter` en een `return`-URL naar de oorspronkelijke lesplek, bijvoorbeeld `lessons.html?lesson=...#chapter-id` of een losse `lesson-...html#stap`. +3. De tafel toont het gewone bord, met het lespaneel op de plek van de speelgeschiedenis. +4. De coachkaart/spotlight wijst het relevante onderdeel aan, bijvoorbeeld biedbox, legale kaarten, Dummy, slaggebied of review. +5. De speler doet de afgesproken actie: meestal 1 bod of 1 kaart. +6. Zodra de `tableTask` klaar is, verschijnt een afrondingskaart met `Terug naar les` en optioneel `Nog eens proberen`. +7. Gewone gameplay blijft daarna geblokkeerd of gedempt totdat de speler een van die knoppen kiest. -- Speler kan zeggen wie Zuid, Noord, Oost en West zijn. -- Speler begrijpt dat dummy pas na de uitkomst open komt. -- Speler ziet waarom niet elke kaart legaal speelbaar is. +Gebruik deze korte tafelrondes om les en bord aan elkaar te knopen zonder de beginner uit het hoofdstuk te trekken. Een volledige hand uitspelen blijft mogelijk, maar is een bewuste uitzondering voor lessen die juist een hele speel- of reviewflow nodig hebben. -Volgende verbetering: +### Metadata-contract -- Houd deze les als kwaliteitslat voor lessen 2-12. +Elke hoofdstukstart naar de tafel hoort genoeg context mee te geven om terug te keren: -#### Les 2: Punten en handtypen +- `lesson`: les-id. +- `hand`: oefenhand-id. +- `chapter`: hoofdstuk-id en anker. +- `return`: doel-URL naar hetzelfde leshoofdstuk of dezelfde stap. -Huidige status: compact route-item. +Hoofdstukken met een korte tafelronde gebruiken `tableTask`: -Leerdoelen: +- `type`: `bid`, `card`, `trick`, `review` of `hand`. +- `completion`: bijvoorbeeld `southBid`, `northSouthCard`, `trickWinnerShown`, `reviewReached` of `handComplete`. +- `doneTitle`, `doneBody`, `returnLabel` en optioneel `retryLabel`. -- Honneurpunten tellen: A=4, H=3, V=2, B=1. -- Gebalanceerde hand herkennen. -- Begrijpen waarom 1SA een begrensde opening is. +Hoofdstukken kunnen daarnaast `boardGuidance` gebruiken voor spotlight en flow: -Oefenhand: +- targets: `contract`, `bidControls`, `auctionLog`, `dummy`, `legalCards`, `trumpCards`, `trickArea`, `trickWinner`, `review` en `lessonPanel`; +- gates: `none`, `allowHumanBid`, `allowHumanPlay` en `advanceTrick`. -- `one-nt-opening-001` +Blokkeer alleen kernmomenten. De tafel moet blijven voelen als bridge spelen, niet als een formulier invullen. -Benodigde content: +### Rustige lesinstellingen -- Mini-uitleg honneurpunten. -- Herkenvraag: “Hoeveel punten heeft Zuid?” -- Herkenvraag: “Is deze hand gebalanceerd?” -- Oefening waarin Zuid of AI 1SA opent. +Tijdens lesmodus gelden tijdelijke instellingen: -Reviewfeedback: +- `showPlayHistory = false`; +- `guidanceMode = false`; +- `developerMode = false`. -- Puntenaantal van Zuid. -- Waarom 1SA logisch was of waarom een kleur beter was. +De app bewaart deze tijdelijke waarden niet in `localStorage`; na verlaten van lesmodus keren opgeslagen voorkeuren terug. Lescopy en tests moeten daarom uitgaan van een rustig lespaneel, niet van developer-uitleg of AI-suggestiepanelen. -Acceptatie: +### Voorbeelden per huidige les -- Speler kan een simpele 15-17 SA-hand herkennen. +- Les 1 gebruikt `draw-trumps-001` als korte speelronde: de speler speelt een kaart in de bekennen-situatie en krijgt daarna `Terug naar les`. +- Les 2 gebruikt `one-nt-opening-001` als korte biedronde: Zuid kiest het openingsbod, ziet `Bod gedaan` en keert terug naar het hoofdstuk over 1SA herkennen. +- Les 2 gebruikt `opening-pass-001` op dezelfde manier voor openingskracht versus pas. -### Fase 2 - Eerste biedbeslissingen +Voor toekomstige lessen is de voorkeurskeuze steeds de kleinste tafelactie die het lesdoel zichtbaar maakt: bij Stayman 1 conventioneel bod, bij uitkomst 1 kaart, bij speelplan 1 planactie na Dummy reveal, bij score 1 reviewcheck. -Doel: de speler begrijpt de eerste Vijfkaart Hoog-keuzes zonder competitief bieden. +## Cursusoverzicht -#### Les 3: Eerste openingen +1. Wat is bridge? Slagen, contract, troef, Leider en Dummy. +2. Kaarten waarderen: HCP, verdeling, Fit en eerste biedlogica. +3. Openen in Vijfkaart-Hoog: 1SA, 1 klaveren, 1 ruiten, 1 harten, 1 schoppen. +4. Antwoorden op een Opening: steun, nieuwe kleur, 1SA-Vuilnisbakkenbod. +5. Herbiedingen van Openaar en responder: Minimum, Maximum, Invite. +6. De hoge-kleurenfit vinden en naar Manche bieden. +7. Sans-atout: 1SA-opening, Stayman en Jacoby-transfer. +8. Speelplan als Leider: Troef trekken, Verliezers tellen, Aftroeven. +9. Sans-atout spelen: Werkkleur, Stopper, Ophouden en Entree. +10. Tegenspel: Uitkomst, signaleren op beginnersniveau, Vrijspelen. +11. Competitief bieden: Volgbod, Informatiedoublet, Kwetsbaarheid. +12. Sterkere afspraken: Zwakke twee, Vierde-kleur-forcing, Azenvragen en Slemintro. -Leerdoelen: +--- -- Openen vanaf voldoende kracht. -- Vijfkaart hoog prioriteit geven. -- Verschil tussen 1 harten, 1 schoppen, 1SA en lage-kleur opening globaal zien. +## Les 1 - Wat is bridge? -Oefenhand: +Slagen, Contract, Troef, Leider en Dummy. -- `one-heart-opening-001` +### Leerdoelen -Benodigde content: +- 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. -- “Heb ik genoeg om te openen?” -- “Heb ik een vijfkaart hoog?” -- Miniquiz met 3 handen: pas, 1H/1S, 1SA. +### Kernbegrippen -Reviewfeedback: +Slag, Contract, Troef, Sans-atout, Leider, Dummy, Uitkomst, Kleur bekennen. -- Regel die de opening koos. -- Korte uitleg van punten + lengte. +### Korte theorieblokken -Acceptatie: +- De tafel als toneel: jij zit Zuid, partner Noord, de tegenstanders Oost/West. Jullie spelen niet tegen de app, maar samen met partner tegen het andere paar. +- Een Slag is een rondje van vier kaarten. De hoogste kaart van de gevraagde kleur wint, behalve wanneer Troef wordt gespeeld. +- Het Contract is de belofte van de bieding. Bij 4 schoppen moet de Leider tien slagen maken, omdat zes slagen de basis zijn en het niveau daar bovenop komt. +- Dummy is de open partnerhand van de Leider. Dummy komt pas na de Uitkomst open, alsof het doek opengaat na de eerste scene. -- Speler begrijpt waarom een vijfkaart hoog vaak eerst komt. +### Interactieve biedvoorbeelden -#### Les 4: Fit zoeken na 1 hoog +- Herken het eindcontract: de app toont `1S - pas - 2S - pas - 4S - pas - pas - pas`. Vraag: "Wat is het Contract en welke kleur is Troef?" +- Contract naar slagen: toon 1SA, 2 harten, 3SA en 4 schoppen. Laat de speler het aantal benodigde slagen kiezen. +- Wie wordt Leider? Toon een korte bieding waarin Noord/Zuid het Contract winnen. Laat de speler aanwijzen wie de speelsoort als eerste bood. -Leerdoelen: +### Interactieve speelvoorbeelden op het bridge-bord -- Partnersteun herkennen. -- Driekaart steun na 1 hoog begrijpen. -- Simpele verhoging versus manche-interesse globaal onderscheiden. +- Start `draw-trumps-001` direct in de speelfase. Begeleid de speler langs Uitkomst, Dummy reveal, Kleur bekennen en Slagwinnaar. +- Pauzeer na een complete Slag en vraag: "Welke kaart won deze Slag, en wie begint nu?" +- Laat een simpele Troef-slag zien: iemand kan niet bekennen en mag troeven. Vraag of Troef de Slag wint. -Oefenhand: +### Oefenvragen -- `response-raise-after-1s-001` +1. Hoeveel kaarten speelt iedere speler in een Slag? +2. Wanneer komt Dummy open? +3. Wie speelt de kaarten van Dummy? +4. Wat betekent Troef? +5. Hoeveel slagen moet je maken in 3SA? +6. Wat moet je doen als je de gevraagde kleur nog hebt? -Benodigde content: +### Oefenhanden -- Wat is een fit? -- Waarom 8+ kaarten samen prettig is. -- Oefenvraag: “Partner opent 1S, jij hebt 3 schoppens. Is er steun?” +- `draw-trumps-001` - bestaand: Dummy, Troef en Kleur bekennen. +- `lesson-01-follow-suit-001` - voorstel: alleen legaliteit en bekennen oefenen. +- `lesson-01-trump-wins-001` - voorstel: Troef wint wanneer de gevraagde kleur ontbreekt. +- `lesson-01-contract-reading-001` - voorstel: contractdoel en Leider herkennen. -Reviewfeedback: +### Eindopdracht -- Aantal troeven samen. -- Waarom verhogen rustiger is dan een nieuwe kleur bieden. +Geef na een bord een mini-commentaar alsof je sportverslaggever bent: "Het Contract was ..., Dummy kwam open na ..., en de Slag werd gewonnen door ..." -Acceptatie: +--- -- Speler kan steun na 1 hoog herkennen. +## Les 2 - Kaarten waarderen -#### Les 5: Zonder fit: nieuwe kleur of SA +HCP, verdeling, Fit en eerste biedlogica. -Leerdoelen: +### Leerdoelen -- Geen fit met partners hoge kleur herkennen. -- Eigen biedbare kleur tonen. -- SA overwegen bij gebalanceerde hand zonder fit. +- Je telt HCP met Aas 4, Heer 3, Vrouw 2, 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. -Oefenhand: +### Kernbegrippen -- `response-new-suit-after-1h-001` +HCP, Honneur, Evenwichtige verdeling, Fit, Fitpunten, Opening, Openingskracht, Regel van 20. -Benodigde content: +### Korte theorieblokken -- Beslisboom: steun? eigen kleur? SA? -- Uitleg dat een nieuwe kleur forcing/zoekend kan zijn, afhankelijk van systeemniveau. -- Korte quiz met “wel/geen fit”. +- HCP is de snelle krachtmeter. Aas is 4, Heer 3, Vrouw 2, Boer 1. Een hand met twee azen en een vrouw heeft dus 10 HCP. +- Verdeling is het landschap van je hand. Een 5-3-3-2-hand voelt anders dan een 6-4-2-1-hand, zelfs met hetzelfde aantal HCP. +- Fit betekent dat jij en partner samen minstens acht kaarten in een kleur hebben. Een Fit is fijn, omdat Troef dan vaker controle geeft. +- Bij 15-17 HCP en een evenwichtige verdeling is 1SA in dit systeem de eerste kandidaat. Dat geldt ook als de evenwichtige hand een vijfkaart hoog heeft. +- De Regel van 20 is een eerste blik op lichte openingen: HCP plus de lengtes van je twee langste kleuren. Dit is nog geen vrijbrief, maar een nuttige beginnerslamp. -Reviewfeedback: +### Interactieve biedvoorbeelden -- Waarom nieuwe kleur tonen beter was dan partner direct steunen. +- Tel en kies: Zuid heeft 15 HCP en een evenwichtige verdeling. Vraag: "Open je 1SA of een kleur?" +- Vijfkaart maar toch SA: Zuid heeft 16 HCP, vijf schoppen en 5-3-3-2. Vraag waarom 1SA volgens deze app voorgaat. +- Regel van 20: toon een hand met 11 HCP en twee lange kleuren. Laat de speler HCP + lengtes optellen. +- Fit-detective: partner toont vijf harten, jij hebt drie harten. Vraag: "Hebben jullie samen een Fit?" -Acceptatie: +### Interactieve speelvoorbeelden op het bridge-bord -- Speler begrijpt dat “geen fit” niet automatisch passen betekent. +- Start `one-nt-opening-001` en laat de speler voor het bieden eerst HCP tellen. +- Gebruik een bord met een duidelijke Fit en laat na Dummy reveal zien dat extra Troef controle geeft. +- Laat in review zien hoeveel HCP Noord/Zuid samen ongeveer hadden en of dat paste bij het Contract. -#### Les 6: Openingen in lage kleuren +### Oefenvragen -Leerdoelen: +1. Hoeveel HCP telt Aas-Heer-Vrouw-Boer samen? +2. Welke verdeling is evenwichtiger: 5-3-3-2 of 6-4-2-1? +3. Wat is een Fit? +4. Je hebt 12 HCP. Is dat meestal genoeg om te openen? +5. Wat tel je op bij de Regel van 20? +6. Waarom kan een lange kleur extra waarde hebben? +7. Waarom kan 1SA soms voorgaan boven een vijfkaart hoog? -- Lage-kleur opening als startpunt zien, niet als definitieve troefkeuze. -- Na 1K/1R zoeken naar hoge kleuren. -- Vierkaart hoog als antwoord herkennen. +### Oefenhanden -Oefenhand: +- `one-nt-opening-001` - bestaand: 15-17 HCP en evenwichtige verdeling. +- `opening-pass-001` - bestaand: te weinig Openingskracht. +- `one-heart-opening-001` - bestaand: HCP plus vijfkaart hoog. +- `lesson-02-rule-of-20-001` - voorstel: lichte Opening via Regel van 20. -- `minor-opening-find-major-001` +### Eindopdracht -Benodigde content: +Maak een "handpaspoort" van Zuid: HCP, verdeling, langste kleur, en je eerste biedidee in een zin. -- “Lage kleur opent vaak de bieding, maar hoge kleuren blijven belangrijk.” -- Herkenvraag: welke hoge kleur kan responder tonen? +--- -Reviewfeedback: +## Les 3 - Openen in Vijfkaart-Hoog -- Waarom responder een hoge kleur toont in plaats van de lage kleur te steunen. +1SA, 1 klaveren, 1 ruiten, 1 harten en 1 schoppen. -Acceptatie: +### Leerdoelen -- Speler ziet dat een lage-kleur opening niet meteen betekent: we spelen klaveren/ruiten. +- Je kent de basisvolgorde voor een Opening in Vijfkaart-Hoog. +- Je opent 1SA met 15-17 HCP en een evenwichtige verdeling. +- Je opent 1 harten of 1 schoppen met een vijfkaart hoog en openingskracht, behalve wanneer 1SA of een langere lage kleur volgens het systeem voorgaat. +- Je gebruikt 1 ruiten als normale lage-kleuropening met ruitenlengte. +- Je begrijpt dat 1 klaveren een vangnetopening kan zijn en dus niet altijd een lange klaverenkleur belooft. -### Fase 3 - Contract, score en speelplan +### Kernbegrippen -Doel: de speler koppelt bieden aan contractdoel en leert planmatig spelen. +Opening, Openaar, Openingskracht, Hoge kleuren, Lage kleuren, Sans-atout, Evenwichtige verdeling, Regel van 20. -#### Les 7: Contractdoelen en score +### Korte theorieblokken -Leerdoelen: +- Vijfkaart-Hoog geeft veel aandacht aan harten en schoppen. Hoge kleuren scoren prettig, want 4 harten en 4 schoppen zijn Manche. +- 1SA is precieser dan veel beginners denken: 15-17 HCP en evenwichtige verdeling. In deze app gaat 1SA voor, ook met een 5-3-3-2-hand en een vijfkaart hoog. +- 1 harten en 1 schoppen beloven minstens vijf kaarten. Open de langste kleur; met twee vijfkaarten open je de hoogste van de twee. +- Een langere lage kleur kan voorgaan boven een vijfkaart hoog. Met vijf schoppen en zes klaveren open je in dit cursuscontract dus 1 klaveren. +- 1 ruiten belooft meestal minstens vier ruiten. 1 klaveren kan kort zijn en is soms de vangnetopening als niets anders past. +- Met lichte handen kijk je voorzichtig naar de Regel van 20. De app moet dit niet als universele waarheid verkopen, maar als Vijfkaart-Hoog-afspraak binnen dit profiel. -- Contractniveau lezen. -- Begrijpen hoeveel slagen nodig zijn. -- Manchebonus herkennen, vooral kwetsbaar. +### Interactieve biedvoorbeelden -Oefenhand: +- Sorteer de openingen: vier handen verschijnen. De speler kiest uit pas, 1SA, 1 harten, 1 schoppen, 1 klaveren of 1 ruiten. +- "De vijfkaart hoog, tenzij...": toon 13 HCP met vijf schoppen en vier harten. Vraag: "Welke hoge kleur open je?" +- "1SA gaat voor": toon 16 HCP met vijf schoppen en 5-3-3-2. Vraag waarom 1SA hier de systeemopening is. +- "Langste kleur niet verstoppen": toon 12 HCP met vijf schoppen en zes klaveren. Vraag waarom 1 klaveren in beeld komt. -- `game-bonus-vulnerable-001` +### Interactieve speelvoorbeelden op het bridge-bord -Benodigde content: +- Start `one-heart-opening-001`; laat de speler eerst de opening kiezen en daarna zien welk Contract eruit kan groeien. +- Start `one-spade-opening-001`; vergelijk het met de hartenhand: dezelfde logica, andere hoge kleur. +- Start `minor-opening-find-major-001`; laat zien dat een lage-kleur Opening later alsnog een hoge kleur kan vinden. -- “4H betekent 10 slagen.” -- Waarom kwetsbaar gemaakte manche 620 kan scoren. -- Miniquiz: hoeveel slagen nodig voor 2H, 3SA, 4H? +### Oefenvragen -Reviewfeedback: +1. Welke kleuren heten Hoge kleuren? +2. Wanneer past 1SA als Opening? +3. Wat belooft 1 harten of 1 schoppen minimaal? +4. Waarom kan 1 klaveren kort zijn? +5. Wat doe je met 10 HCP en geen lange kleur? +6. Waarom kan een langere lage kleur soms voorgaan boven een vijfkaart hoog? +7. Wat check je eerst: genoeg kracht, speciale opening, of favoriete kleur? Leg je volgorde uit. -- Contractdoel. -- Gehaalde slagen. -- Scoreopbouw. +### Oefenhanden -Acceptatie: +- `one-nt-opening-001` - bestaand: 1SA-opening. +- `one-heart-opening-001` - bestaand: 1 harten met vijfkaart, geen 1SA-prioriteit. +- `one-spade-opening-001` - bestaand: 1 schoppen met vijfkaart, geen 1SA-prioriteit. +- `minor-opening-find-major-001` - bestaand: lage-kleur Opening als startpunt. -- Speler kan contractniveau naar benodigde slagen vertalen. +### Eindopdracht -#### Les 8: Spelen als leider: maak een plan +Speel "openingsbingo": vind in vier oefenhanden minstens een keer pas, 1SA, een hoge-kleur Opening en een lage-kleur Opening. -Leerdoelen: +--- -- Winners/losers globaal herkennen. -- Troeven trekken wanneer dat veilig en logisch is. -- Speelplanpaneel gebruiken zonder te verdrinken in details. +## Les 4 - Antwoorden op een Opening -Oefenhand: +Steun, nieuwe kleur en 1SA-Vuilnisbakkenbod. -- `draw-trumps-001` +### Leerdoelen -Benodigde content: +- Je herkent wanneer je partner kunt steunen. +- Je weet dat steun in een hoge kleur vaak met drie kaarten al genoeg is. +- Je toont een eigen nieuwe kleur wanneer steun ontbreekt en je hand daarvoor geschikt is. +- Je gebruikt het 1SA-Vuilnisbakkenbod als rustige opvang voor handen zonder steun en zonder goed eenhoogtebod. +- Je ziet dat passen na partners Opening niet de automatische veilige keuze is. -- “Stop even na dummy: wat is troef, wat kan misgaan?” -- Troeven trekken als basisidee. -- Miniquiz: wanneer is troeven trekken vaak goed? +### Kernbegrippen -Reviewfeedback: +Bijbod, Fit, Vuilnisbakkenbod, Hoge kleuren, Lage kleuren, Forcing, Minimum. -- Welke planregel actief was. -- Of kaartadvies het zichtbare plan volgde. +### Korte theorieblokken -Acceptatie: +- Na partners Opening ben jij responder. Je eerste vraag is vriendelijk praktisch: "Kan ik partner steunen?" +- Na 1 harten of 1 schoppen is driekaart steun vaak genoeg voor een Fit, omdat partner minstens vijf kaarten belooft. +- Na 1 ruiten is steun meestal vanaf vier ruiten logisch. Na 1 klaveren vraagt steun vaker om vijf klaveren, omdat 1 klaveren kort kan zijn. +- Een nieuwe kleur vertelt partner: "Hier heb ik ook iets." In beginnerslessen houden we dat kort en concreet. +- Het 1SA-Vuilnisbakkenbod klinkt onaardig, maar is heel nuttig: 6-9 punten, geen steun, geen betere nieuwe kleur op eenhoogte. De vuilnisbak is hier gewoon netjes gesorteerd. -- Speler gebruikt het speelplanpaneel als hulp, niet als ruis. +### Interactieve biedvoorbeelden -#### Les 9: Dummy en tempo +- Partner opent 1 schoppen, jij hebt drie schoppen en 8 punten. Vraag: "2 schoppen of 1SA?" +- Partner opent 1 harten, jij hebt vijf schoppen en geen hartensteun. Vraag: "Toon je schoppen?" +- Partner opent 1 ruiten, jij hebt 7 punten, geen vierkaart hoog en geen ruitensteun. Vraag of 1SA-Vuilnisbakkenbod past. +- Partner opent 1 klaveren, jij hebt vier klaveren en 7 punten. Vraag waarom 1SA soms eerlijker is dan klaveren steunen. -Leerdoelen: +### Interactieve speelvoorbeelden op het bridge-bord -- Dummy als bron van slagen/entrees zien. -- Een verliezer weggooien op een winnaar. -- Timing begrijpen: kans eerst gebruiken voordat de tegenpartij aan slag komt. +- Start `response-raise-after-1s-001`; laat de speler de driekaart steun vinden en later de Troef-fit zien. +- Start `response-new-suit-after-1h-001`; laat zien dat het Contract soms anders eindigt dan partners eerste kleur. +- Gebruik een voorstelhand voor 1SA-Vuilnisbakkenbod en laat in review zien waarom dit bod niet per se een prachtige SA-hand belooft. -Oefenhand: +### Oefenvragen -- `discard-loser-on-winner-001` +1. Partner opent 1 schoppen. Hoeveel schoppen heb je meestal nodig voor steun? +2. Wat betekent Fit? +3. Wanneer denk je aan een nieuwe kleur als antwoord? +4. Wat is het 1SA-Vuilnisbakkenbod? +5. Belooft 1SA-Vuilnisbakkenbod altijd goede Stoppers in alle kleuren? +6. Waarom steun je 1 klaveren voorzichtiger dan 1 harten of 1 schoppen? +7. Waarom is direct passen met 7 punten na partners Opening vaak te voorzichtig? -Benodigde content: +### Oefenhanden -- Voorbeeld: verliezer in hand weg op hoge kaart in dummy. -- Herkenvraag: welke verliezer kan verdwijnen? +- `response-raise-after-1s-001` - bestaand: steun na 1 schoppen. +- `response-new-suit-after-1h-001` - bestaand: nieuwe kleur na 1 harten. +- `minor-opening-find-major-001` - bestaand: antwoord in hoge kleur na lage-kleur Opening. +- `lesson-04-trash-1nt-response-001` - voorstel: 1SA-Vuilnisbakkenbod. -Reviewfeedback: +### Eindopdracht -- Welke discard-kans er was. -- Waarom timing belangrijk was. +Speel "partnerpost": schrijf na je antwoordbod een ansichtkaart aan partner met precies een zin: "Ik bied ..., want ik heb ..." -Acceptatie: +--- -- Speler ziet dummy niet alleen als extra kaarten, maar als planonderdeel. +## Les 5 - Herbiedingen van Openaar en responder -### Fase 4 - Tegenspel en conventies +Minimum, Maximum en Invite. -Doel: speler leert basisverdediging en de eerste conventionele SA-vervolgen. +### Leerdoelen -#### Les 10: Basis tegenspel +- Je herkent dat het eerste bod nog niet het hele verhaal vertelt. +- Je begrijpt Minimum en Maximum als onder- en bovenkant van je beloofde range. +- Je gebruikt een Invite om partner te vragen door te gaan met een Maximum. +- Je ziet hoe Openaar en responder samen laag kunnen stoppen of naar Manche kunnen groeien. +- Je houdt biedingen uitlegbaar: kracht, verdeling en Fit blijven de drie ankers. -Leerdoelen: +### Kernbegrippen -- Uitkomen uit een honneurserie. -- Tweede hand laag / derde hand hoog als basisidee herkennen. -- Partner helpen met veilige, uitlegbare regels. +Herbieding, Minimum, Maximum, Invite, Openaar, Bijbod, Fit, Manche. -Oefenhand: +### Korte theorieblokken -- `lead-sequence-001` +- De eerste biedronde is een begroeting, geen autobiografie. De Herbieding vertelt meer: minimum, extra lengte, steun of Sans-atout. +- Met een Minimum rem je af. Met een Maximum mag je extra interesse tonen. +- Een Invite is een beleefde vraag: "Partner, als jij aan de bovenkant zit, gaan we naar Manche; anders blijven we lager." +- Openaar steunt partners nieuwe hoge kleur met vierkaart steun, herbiedt een eigen zeskaart, biedt Sans-atout met een evenwichtige hand of toont een tweede kleur met een echt tweekleurenspel. +- Beginners hoeven nog niet elke route te kennen. Ze moeten vooral leren luisteren naar wat partner al beloofd heeft. -Benodigde content: +### Interactieve biedvoorbeelden -- “Tegen kleurcontract: start liever uit een serie dan onder een losse honneur.” -- Miniquiz: welke kaart uit HVB? +- `1H - 1S - 2H`: vraag wat Openaar extra vertelt. Antwoord: meestal langere harten en geen sterke sprong. +- `1S - 2S - 3S`: vraag of 3S een eindbod of Invite is. +- Responder heeft 10-11 punten na partners 1SA-herbieding. Vraag: "pas, invite of Manche?" -Reviewfeedback: +### Interactieve speelvoorbeelden op het bridge-bord -- Uitkomstregel die de app koos. -- Waarom die uitkomst partner helpt. +- Start een fit-hand waarin de bieding op 2 hoog stopt; laat zien dat laag stoppen soms volwassen bridge is. +- Start een invite-hand en laat het eindcontract afhangen van partners Minimum of Maximum. +- Speel een bord uit en laat in review de biedladder zien: Opening, antwoord, Herbieding, eindcontract. -Acceptatie: +### Oefenvragen -- Speler begrijpt minstens een eenvoudige veilige uitkomstregel. +1. Wat betekent Minimum in de bieding? +2. Wat betekent Maximum? +3. Wat probeert een Invite te bereiken? +4. Partner nodigt uit en jij hebt een Maximum. Wat doe je meestal? +5. Partner nodigt uit en jij hebt een Minimum. Waarom mag je passen? +6. Wat kan Openaar met vierkaart steun voor responders hoge kleur doen? +7. Waarom is een Herbieding vaak informatiever dan het eerste bod? -#### Les 11: 1SA-vervolgen: Stayman en Jacoby +### Oefenhanden -Leerdoelen: +- `response-raise-after-1s-001` - bestaand: simpele verhoging als basis. +- `lesson-05-opener-minimum-rebid-001` - voorstel: Openaar remt af met Minimum. +- `lesson-05-opener-maximum-accepts-001` - voorstel: Maximum accepteert Invite. +- `lesson-05-responder-invite-001` - voorstel: responder nodigt uit na fit. -- Stayman als vraag naar vierkaart hoog herkennen. -- Jacoby-transfer als overdracht naar vijfkaart hoog herkennen. -- Contractbetekenis scheiden van biedbetekenis: 2R kan harten betekenen. +### Eindopdracht -Oefenhand: +Speel "thermostaat bieden": zet na elke bieding de meter op koud, lauw of warm. Koud is stoppen, warm is Manche zoeken. -- `stayman-after-1nt-001` +--- -Benodigde extra oefenhanden: +## Les 6 - De hoge-kleurenfit vinden en naar Manche bieden -- Een Jacoby-transfer naar harten. -- Een Jacoby-transfer naar schoppen. -- Een Stayman-hand met wel/geen fit. +Fit in harten of schoppen, en wanneer 4 hoog dichtbij komt. -Benodigde content: +### Leerdoelen -- Twee tegels: “vraag” vs “overdracht”. -- Korte waarschuwing: het bod zegt niet altijd letterlijk de kleur. -- Miniquiz: wat betekent 2K na 1SA? Wat betekent 2R na 1SA? +- Je zoekt actief naar een Fit in harten of schoppen. +- Je begrijpt waarom hoge-kleurmanches vaak aantrekkelijk zijn. +- Je telt gezamenlijke kracht globaal met HCP en Fitpunten. +- Je weet dat 4 harten en 4 schoppen meestal Manche zijn. +- Je onderscheidt stoppen op 2 hoog, inviteren op 3 hoog en bieden naar 4 hoog. -Reviewfeedback: +### Kernbegrippen -- Welke conventie gebruikt werd. -- Wat het bod betekent, niet alleen welk contract geboden werd. +Fit, Fitpunten, Manche, Hoge kleuren, Invite, Troef trekken, Contractpunten. -Acceptatie: +### Korte theorieblokken -- Speler kan uitleggen dat sommige biedingen afspraken zijn. +- Een hoge-kleurenfit is een beetje als goede wandelschoenen: de route wordt niet automatisch makkelijk, maar je hebt grip. +- Met samen ongeveer 25 punten en een Fit kijk je vaak naar Manche in 4 harten of 4 schoppen. +- Extra Troef en korte kleuren kunnen na een Fit meer waard worden. Daarom bestaan Fitpunten. +- Na 1 harten of 1 schoppen is de beginnersladder: 2 hoog met 6-9 Fitpunten, 3 hoog als Invite met ongeveer 10-11 Fitpunten, 4 hoog met ongeveer 12+ Fitpunten. +- Bied niet meteen de berg op. Soms is 2 hoog genoeg, soms nodig je uit, soms ga je naar 4 hoog. -### Fase 5 - Integratie en zelfstandigheid +### Interactieve biedvoorbeelden -Doel: speler speelt hele spellen en gebruikt review om zelf te leren. +- Partner opent 1 schoppen, jij hebt 6-9 punten en drie schoppen. Vraag: "2S, 3S of 4S?" +- Partner opent 1 harten, jij hebt 12 punten en vier harten. Vraag waarom 4 harten in beeld komt. +- Partner opent 1 schoppen, jij hebt steun maar ook een singleton. Laat de speler Fitpunten herwaarderen. -#### Les 12: Reviewles: hele spellen +### Interactieve speelvoorbeelden op het bridge-bord -Leerdoelen: +- Start `game-bonus-vulnerable-001`; laat zien hoe een Manchecontract een concreet slagendoel wordt. +- Start `draw-trumps-001`; koppel de gevonden Fit aan Troef trekken. +- Start een voorstelhand waarin te hoog bieden faalt; laat review rustig tonen dat 3 hoog genoeg was. -- Hele hand spelen zonder stap-voor-stap instructie. -- Review gebruiken om bieding, slagen en score terug te lezen. -- Herhaalcode/feedback gebruiken wanneer iets onduidelijk is. +### Oefenvragen -Oefenhand: +1. Waarom zijn harten en schoppen belangrijk in Vijfkaart-Hoog? +2. Hoeveel kaarten samen heb je nodig voor een Fit? +3. Welk contract is een Manche: 2 schoppen, 3 schoppen of 4 schoppen? +4. Wat is het verschil tussen 2S en 3S na partners 1S-opening? +5. Wanneer ga je met een Fit extra naar korte kleuren kijken? +6. Hoeveel Fitpunten passen ongeveer bij direct 4 hoog na partners 1 hoog? +7. Waarom is Troef trekken vaak nuttig in een hoge-kleurcontract? -- `game-bonus-vulnerable-001` +### Oefenhanden -Benodigde content: +- `response-raise-after-1s-001` - bestaand: Fit vinden. +- `game-bonus-vulnerable-001` - bestaand: Manchebonus zichtbaar. +- `draw-trumps-001` - bestaand: Troef trekken na Fit. +- `lesson-06-major-fit-invite-001` - voorstel: invite naar 3 hoog. -- Checklist voor na een bord: - - Wat was het contract? - - Wie was leider? - - Hoeveel slagen waren nodig? - - Hoeveel slagen zijn gemaakt? - - Welke bieding of kaart was het meest leerzaam? +### Eindopdracht -Reviewfeedback: +Geef je partnerschap een teamnaam nadat je een hoge-kleurenfit vindt. Bonuspunt als de naam iets zegt over Troef. -- Geen lange nieuwe theorie; vooral terugwijzen naar reviewonderdelen. +--- -Acceptatie: +## Les 7 - Sans-atout -- Speler kan na afloop zelfstandig de review lezen en een concrete vraag/feedback formuleren. +1SA-opening, Stayman en Jacoby-transfer. -## Implementatievolgorde +### Leerdoelen -Aanbevolen volgorde, klein en testbaar: +- Je herkent de 1SA-opening: 15-17 HCP en evenwichtige verdeling. +- Je gebruikt Stayman om een vierkaart hoge kleur te vragen. +- Je gebruikt Jacoby-transfer met minstens een vijfkaart hoog. +- Je scheidt het biedkaartje van de betekenis: 2 ruiten kan harten betekenen. +- Je begrijpt waarom de sterke 1SA-hand vaak Leider blijft. -1. Maak les 2 rijk zoals les 1: hoofdstukken, quiz, reviewfeedback. -2. Daarna lessen 3-6 rijk maken, want die vormen de eerste biedbasis. -3. Daarna lessen 7-9, omdat score en speelplan zichtbaar veel beginnerwaarde geven. -4. Daarna les 10 en 11, met extra oefenhanden waar de huidige catalogus nog dun is. -5. Sluit af met les 12 als integratieles en testerchecklist. +### Kernbegrippen -Niet alles tegelijk omzetten. Per les: +Sans-atout, 1SA, Stayman, Jacoby-transfer, Conventioneel bod, Hoge kleuren, Fit, Leider. -- update `scripts/learning/lessons.js`; -- voeg ontbrekende oefenhand toe in `practice-hands/catalog/`; -- voeg/werk unit tests bij; -- draai `npm run test:unit`; -- draai browser smoke als de lesson page of startflow verandert. +### Korte theorieblokken -## Benodigde nieuwe oefenhanden +- 1SA is een nette visitekaart: "15-17 HCP, evenwichtig." Partner weet daardoor meteen veel. +- Stayman is de vraag: "Partner, heb jij een vierkaart harten of schoppen?" In deze app gebruik je Stayman meestal vanaf ongeveer 8 HCP met een vierkaart hoog. +- Jacoby-transfer is de overdracht: met vijf harten bied je 2 ruiten, zodat partner 2 harten biedt; met vijf schoppen bied je 2 harten, zodat partner 2 schoppen biedt. Het bod is dus Conventioneel. +- Het slimme bij transfers: de sterke SA-hand wordt vaak Leider, en haar kaarten blijven verborgen voor de tegenpartij. +- Na een Jacoby-transfer kan responder zwak passen, inviteren of met Manchekracht verder bieden. De eerste les hierover houdt het bij de herkenbare hoofdroutes. -Waarschijnlijk nuttig: +### Interactieve biedvoorbeelden -- Les 2: extra 12-14 gebalanceerd, zodat speler ziet waarom niet 1SA openen. -- Les 3: pas-hand met te weinig punten; 1S-opening naast 1H-opening. -- Les 4: 3-kaart steun en 4-kaart steun na 1 hoog. -- Les 5: geen fit maar eigen vier/vijfkaart. -- Les 6: lage-kleur opening met responder vierkaart hoog. -- Les 9: tweede discard/tempo-situatie. -- Les 11: Jacoby-transfer naar harten en schoppen; Stayman met misfit. -- Les 12: een neutraal volledig bord zonder speciale truc, voor eindtoets. +- Na 1SA heb je 8 punten en 4-4 in de hoge kleuren. Vraag: "Gebruik je Stayman?" +- Na 1SA heb je vijf harten. Vraag: "Welk Jacoby-transfer-bod gebruik je?" +- Na 1SA heb je geen vierkaart hoog en genoeg voor Manche. Vraag: "3SA of eerst Stayman?" -## Feedbackniveaus +### Interactieve speelvoorbeelden op het bridge-bord -### Tijdens normale game +- Start `stayman-after-1nt-001`; laat de speler zien dat 2 klaveren een vraag is, geen klaverencontract. +- Start `transfer-to-hearts-001`; laat de bieding eindigen in harten en wijs aan wie Leider wordt. +- Start `transfer-to-spades-001`; vergelijk de overdracht naar schoppen met de hartenroute. -- Minimaal. -- Alleen turn, legaliteit, suggestie indien AI-suggesties aan staan. +### Oefenvragen -### Tijdens les +1. Welke HCP-range hoort bij een gewone 1SA-opening? +2. Wat vraagt Stayman? +3. Wat belooft Jacoby-transfer meestal in de hoge kleur? +4. Wat betekent 2 ruiten na partners 1SA in de transferafspraak? +5. Wat betekent 2 harten na partners 1SA in de transferafspraak? +6. Waarom is het vaak prettig dat de 1SA-openaar Leider wordt? +7. Wat is een Conventioneel bod? -- Korte missie bovenin. -- Gerichte hints alleen voor het lesdoel. -- Geen algemene bridgecollege-tekst naast de tafel. +### Oefenhanden -### Na afloop in review +- `one-nt-opening-001` - bestaand: 1SA-opening. +- `stayman-after-1nt-001` - bestaand: Stayman. +- `transfer-to-hearts-001` - bestaand: Jacoby-transfer naar harten. +- `transfer-to-spades-001` - bestaand: Jacoby-transfer naar schoppen. -- Contract en score. -- Lespunten. -- Eventuele biedfeedback. -- Kaartfeedback alleen als de regel betrouwbaar is. +### Eindopdracht -### Developer mode +Speel "geheime boodschap": noteer bij elk conventioneel bod wat het kaartje zegt en wat het echt betekent. -- Technische regel-id's, heuristieken en enginekeuzes. -- Geschikt voor CP/Tungsten om gedrag te debuggen. +--- -## Test- en acceptatiecriteria +## Les 8 - Speelplan als Leider -Per les: +Troef trekken, Verliezers tellen en Aftroeven. -- Les staat in `BridgeLessons.allLessons()` met unieke id. -- `validateLessons(practiceHands)` blijft groen. -- Alle `handIds` bestaan. -- Hoofdstukken hebben titel, summary en geldige quizopties. -- Oefenlink opent de juiste hand. -- Review toont lespunten na afloop. -- Geen harde feedbackclaim zonder enginebewijs. +### Leerdoelen -Voor de hele route: +- Je stopt na Dummy reveal om een speelplan te maken. +- Je telt Verliezers in een kleurcontract op beginnersniveau. +- Je herkent wanneer Troef trekken logisch is. +- Je ziet wanneer Aftroeven in Dummy juist eerst moet gebeuren. +- Je gebruikt het speelplanpaneel als suggestie, niet als magisch orakel. -- `npm run test:unit` groen. -- Browser smoke groen als lesson navigation of startflow wijzigt. -- Beginner kan les 1-3 doorlopen zonder uitleg buiten de app. +### Kernbegrippen -## Productnotities +Leider, Dummy, Verliezers, Troef trekken, Aftroeven, Afgooien, Entree, Speelplan. -- Les 1 is de kwaliteitslat: rustig, concreet, hoofdstukken + oefening. -- Lessen 2-12 mogen eerst compact blijven, maar elke uitbreiding moet dezelfde structuur volgen. -- De woordenlijst moet begrippen uit lessen ondersteunen, niet vervangen. -- Lesfeedback moet later kunnen groeien naar foutgerichte feedback, maar nu vooral enginegedrag en lesdoel uitleggen. -- Het lessenplan moet meegroeien met echte testerfeedback; concrete afhakers zijn belangrijker dan theoretische volledigheid. +### Korte theorieblokken -## Aanbevolen volgende stap +- Na de Uitkomst komt Dummy open. Dat is het moment voor een korte pauze: "Wat is Troef? Welke Verliezers zie ik? Waar zitten mijn kansen?" +- Troef trekken voorkomt vaak dat tegenstanders jouw hoge kaarten aftroeven. +- Soms moet je juist eerst Aftroeven in Dummy, voordat je alle Troef weghaalt. Timing is het verschil tussen plan en paniek. +- Een goede Leider hoeft niet alles te zien. Begin met een klein plan voor de eerstvolgende paar slagen. -Werk les 2 volledig uit als sjabloon voor de rest: +### Interactieve biedvoorbeelden -- hoofdstukken voor honneurpunten, handverdeling en 1SA; -- miniquiz over punten tellen; -- reviewfeedback voor `one-nt-opening-001`; -- eventueel een extra oefenhand met te weinig punten voor 1SA; -- unit test dat les 2 hoofdstukken en quiz bevat. +- Toon een bieding naar 4 schoppen. Vraag: "Wat is Troef en hoeveel slagen zijn nodig?" +- Laat twee mogelijke Contracten zien: 3SA of 4 harten met Fit. Vraag welk contract waarschijnlijk makkelijker speelt. +- Na een Fit-bieding vraagt de app: "Wat verwacht je straks als eerste te controleren: Troef of Sans-atout-stoppers?" + +### Interactieve speelvoorbeelden op het bridge-bord + +- Start `draw-trumps-001`; volg het zichtbare plan om eerst Troef te trekken. +- Start `short-trump-ruff-001`; laat zien wanneer Dummy een korte kleur kan Aftroeven. +- Start `discard-loser-on-winner-001`; laat een Verliezer verdwijnen op een winnaar. + +### Oefenvragen + +1. Wanneer maak je als Leider je eerste echte speelplan? +2. Wat betekent Verliezers tellen? +3. Waarom trek je vaak Troef? +4. Wanneer kan Aftroeven in Dummy nuttig zijn? +5. Wat is een Entree? +6. Waarom moet je soms een planactie doen voordat je Troef trekt? + +### Oefenhanden + +- `draw-trumps-001` - bestaand: Troef trekken. +- `short-trump-ruff-001` - bestaand: korte Troef in Dummy benutten. +- `long-side-suit-ruff-001` - bestaand: lange zijkleur en Aftroeven. +- `discard-loser-on-winner-001` - bestaand: Afgooien van een Verliezer. + +### Eindopdracht + +Noem je speelplan in maximaal vijf woorden, bijvoorbeeld "eerst Troef, dan klaveren". Als het langer wordt, is het nog geen plan maar een roman. + +--- + +## Les 9 - Sans-atout spelen + +Werkkleur, Stopper, Ophouden en Entrees. + +### Leerdoelen + +- Je kiest een Werkkleur om extra slagen te ontwikkelen. +- Je begrijpt Stopper als rem op een gevaarlijke tegenkleur. +- Je gebruikt Ophouden om communicatie bij de tegenpartij te verstoren. +- Je plant Entrees naar de hand met vrije kaarten. +- Je ziet dat Sans-atout vaak draait om tempo: wie is eerder klaar? + +### Kernbegrippen + +Sans-atout, Werkkleur, Stopper, Ophouden, Entree, Vrijspelen, Lengteslagen, Gevaarlijke hand. + +### Korte theorieblokken + +- In Sans-atout is er geen Troef om je te redden. Je wint door hoge kaarten, Lengteslagen en goede timing. +- Een Werkkleur is de kleur waar je extra slagen uit wilt persen. Meestal is dat een lange kleur met honneurs. +- Een Stopper voorkomt dat de tegenpartij meteen een hele kleur opruimt. +- Ophouden voelt tegennatuurlijk: je kunt nemen, maar doet het nog niet. Soms verbreek je zo de lijn tussen de tegenstanders. + +### Interactieve biedvoorbeelden + +- Na 1SA - 3SA vraagt de app: "Waarom kiezen we Sans-atout in plaats van een kleur?" +- Toon een hand met geen hoge-kleurfit maar genoeg HCP. Vraag: "3SA of zoeken naar 4 harten?" +- Laat een bieding naar 1SA zien en vraag welke informatie de 1SA-bieder al gaf: HCP en verdeling. + +### Interactieve speelvoorbeelden op het bridge-bord + +- Start `notrump-develop-long-suit-001`; laat de speler de Werkkleur kiezen. +- Start `notrump-unblock-long-suit-001`; laat zien hoe een Blokkade kan ontstaan. +- Gebruik een voorstelhand met Ophouden: de speler kiest of hij de eerste of tweede Slag neemt. + +### Oefenvragen + +1. Wat ontbreekt er in Sans-atout? +2. Wat is een Werkkleur? +3. Waarom zijn Entrees belangrijk? +4. Wat doet een Stopper? +5. Wanneer kan Ophouden slim zijn? +6. Waarom kan een lange kleur later extra slagen opleveren? + +### Oefenhanden + +- `notrump-develop-long-suit-001` - bestaand: Werkkleur vrijspelen. +- `notrump-unblock-long-suit-001` - bestaand: Blokkade en Entree. +- `one-nt-opening-001` - bestaand: 1SA als contractbasis. +- `lesson-09-hold-up-001` - voorstel: Ophouden met Stopper. + +### Eindopdracht + +Kies een Werkkleur en geef hem een bijnaam, zoals "de ruitenmachine". Daarna moet je uitleggen hoe die machine slagen gaat maken. + +--- + +## Les 10 - Tegenspel + +Uitkomst, signaleren op beginnersniveau en Vrijspelen. + +### Leerdoelen + +- Je kiest een veilige Uitkomst op beginnersniveau. +- Je herkent een Serie en waarom uitkomen uit een Serie partner helpt. +- Je gebruikt simpele principes: tweede hand laag, derde hand hoog. +- Je ziet dat tegenspelers ook kleuren kunnen Vrijspelen. +- Je signaleert eenvoudig: aanmoedigen of liever niet, zonder ingewikkelde afspraken. + +### Kernbegrippen + +Tegenspelers, Uitkomst, Serie, Honneur, Tweede hand laag, Derde hand hoog, Vrijspelen, Signaleren. + +Let op: "tweede hand laag", "derde hand hoog" en "signaleren" staan nog niet allemaal als afzonderlijke glossary-termen. Gebruik ze wel consistent, maar overweeg later glossary-items toe te voegen zodra de lescopy wordt geimplementeerd. + +### Korte theorieblokken + +- Als tegenspeler ben je geen toeschouwer. Jij en partner proberen het Contract te verslaan. +- Uitkomen uit een Serie, zoals Heer-Vrouw-Boer, is vaak vriendelijk voor partner: je vertelt iets en geeft minder snel een slag weg. +- Tweede hand laag en derde hand hoog zijn beginnersregels, geen natuurwetten. Ze geven houvast wanneer je nog geen beter plan ziet. +- Vrijspelen kan ook voor de verdediging: hoge kaarten eruit werken zodat lagere kaarten later winnen. + +### Interactieve biedvoorbeelden + +- Toon het eindcontract 4 harten door Zuid. Vraag: "Wie komt uit?" +- Laat de bieding zien waarin de leider schoppen heeft geboden. Vraag welke kleur de verdediging misschien liever niet opent. +- Toon een Contract in Sans-atout en vraag waarom een lange kleur als aanvalskleur aantrekkelijk kan zijn. + +### Interactieve speelvoorbeelden op het bridge-bord + +- Start `lead-sequence-001`; de speler kiest de Uitkomst uit een Serie. +- Start `lead-avoid-unsupported-honor-001`; laat zien waarom onder een losse Honneur riskant kan zijn. +- Start `defense-unblock-honor-001`; laat een eenvoudige partnerhulp zien. + +### Oefenvragen + +1. Wie komt uit tegen een Contract? +2. Waarom is een Serie prettig om uit te komen? +3. Wat betekent derde hand hoog? +4. Wanneer is tweede hand laag vaak logisch? +5. Kunnen Tegenspelers ook een kleur Vrijspelen? +6. Waarom moet signaleren op beginnersniveau simpel blijven? + +### Oefenhanden + +- `lead-sequence-001` - bestaand: Uitkomst uit Serie. +- `lead-singleton-001` - bestaand: korte kleur als Uitkomst-idee. +- `lead-avoid-unsupported-honor-001` - bestaand: geen onnodig riskante Honneur. +- `defense-unblock-honor-001` - bestaand: partner helpen door te deblokkeren. + +### Eindopdracht + +Speel "partnerfluisteraar": kies een kaart en zeg in een zin welke boodschap partner daar hopelijk uit haalt. + +--- + +## Les 11 - Competitief bieden + +Volgbod, Informatiedoublet en Kwetsbaarheid. + +### Leerdoelen + +- Je begrijpt dat de tegenpartij soms opent voordat jij aan de beurt bent. +- Je herkent een Volgbod als natuurlijk bod met eigen kleur. +- Je herkent een Informatiedoublet als vraag aan partner, niet als straf. +- Je ziet dat Kwetsbaarheid invloed heeft op risico. +- Je leert dat competitief bieden klein en voorzichtig begint. + +### Kernbegrippen + +Volgbod, Informatiedoublet, Doublet, Biedplicht, Kwetsbaarheid, Openingskracht, Hoge kleuren, Lage kleuren. + +### Korte theorieblokken + +- Competitief bieden begint zodra beide paren meedoen. De tafel wordt drukker, dus de afspraken moeten juist simpeler en scherper zijn. +- Een Volgbod zegt: "Ik heb een eigen kleur die ik wil noemen." In deze app is dat meestal een goede vijfkaart of langer: op eenniveau vanaf ongeveer 8 HCP, op tweeniveau meestal 10+ HCP. +- Een 1SA-Volgbod is juist geen Vuilnisbakkenbod: het toont 15-17 HCP, evenwichtige verdeling en dekking in de kleur van de tegenpartij. +- Een Informatiedoublet zegt meestal: "Partner, ik heb Openingskracht, kortheid in hun kleur en steun voor de ongeboden kleuren. Kies jij maar." Dat is iets anders dan straf. +- Kwetsbaarheid is het waarschuwingslampje van de score. Down gaan kan duurder zijn, maar een gemaakte Manche levert ook meer op. + +### Interactieve biedvoorbeelden + +- Rechts opent 1 ruiten, Zuid heeft vijf schoppen en genoeg kracht. Vraag: "Is 1 schoppen als Volgbod logisch?" +- Rechts opent 1 harten, Zuid heeft 16 HCP, evenwichtige verdeling en harten gedekt. Vraag waarom 1SA als Volgbod iets heel anders belooft dan het 1SA-Vuilnisbakkenbod. +- Links opent 1 klaveren, partner doubleert, rechts past. Vraag waarom Zuid meestal moet bieden: Biedplicht. +- Toon dezelfde hand kwetsbaar en niet-kwetsbaar. Vraag of de speler even enthousiast blijft over een dun Volgbod. + +### Interactieve speelvoorbeelden op het bridge-bord + +- Start `simple-overcall-001`; laat het Volgbod leiden tot een speelbaar kleurcontract. +- Start `takeout-double-response-forced-001`; laat de speler na partners Informatiedoublet een kleur kiezen. +- Start `takeout-double-response-notrump-001`; laat zien dat Sans-atout alleen past met de juiste hand en dekking. + +### Oefenvragen + +1. Wat is een Volgbod? +2. Wat vraagt een Informatiedoublet aan partner? +3. Waarom is een Informatiedoublet niet hetzelfde als een strafdoublet? +4. Wat betekent Biedplicht na partners Informatiedoublet? +5. Wat verandert Kwetsbaarheid aan je risico? +6. Wat belooft 1SA als Volgbod? +7. Waarom moet een competitief bod extra uitlegbaar zijn? + +### Oefenhanden + +- `simple-overcall-001` - bestaand: natuurlijk Volgbod. +- `takeout-double-response-forced-001` - bestaand: verplichte kleur na Informatiedoublet. +- `takeout-double-response-notrump-001` - bestaand: 1SA na Informatiedoublet. +- `takeout-double-response-game-001` - bestaand: sterke reactie na Informatiedoublet. + +### Eindopdracht + +Doe de "drukke tafel"-challenge: na elke bieding zeg je hardop van welk paar het bod is en of het natuurlijk, vraagstellend of voorzichtig is. + +--- + +## Les 12 - Sterkere afspraken + +Zwakke twee, Vierde-kleur-forcing, Azenvragen en Slemintro. + +### Leerdoelen + +- Je maakt kennis met Zwakke twee als preemptieve Opening. +- Je begrijpt Vierde-kleur-forcing als kunstmatige vraag, niet als echte kleurbelofte. +- Je ziet Azenvragen als stap richting Slem, niet als standaard trucje. +- Je onderscheidt Manche, Kleinslem en Grootslem. +- Je leert wanneer je sterker bieden rustig moet onderzoeken en wanneer je moet afzwaaien. + +### Kernbegrippen + +Zwakke twee, Preemptief bod, Vierde-kleur-forcing, Forcing, Azenvragen, Slem, Kleinslem, Grootslem, Manche, Conventioneel bod. + +### Korte theorieblokken + +- Zwakke twee is een opening van 2 ruiten, 2 harten of 2 schoppen met een goede zeskaart en beperkte kracht, meestal 6-10 HCP. Het doel is tegelijk beschrijven en de tegenpartij ruimte afpakken. +- Vierde-kleur-forcing ontstaat nadat het partnerschap al drie echte kleuren heeft geboden. De vierde kleur is dan een kunstmatige vraag; in deze app is het Mancheforcing. +- Azenvragen is in deze cursus klassiek 4SA met een afgesproken Troefkleur. Het gaat pas over Slem als er al genoeg gezamenlijke kracht en controle lijkt te zijn. +- Slem is feestelijk, maar duur als je te optimistisch bent. Kleinslem vraagt twaalf slagen; Grootslem alle dertien. + +### Interactieve biedvoorbeelden + +- Openingskeuze: Zuid heeft zes schoppen en weinig HCP. Vraag of Zwakke twee in aanmerking komt. +- Biedverloop met drie kleuren geboden: toon de vierde kleur en vraag waarom Vierde-kleur-forcing geen natuurlijke kleur hoeft te beloven. +- Na sterke fit en veel HCP vraagt de speler met 4SA naar azen. Laat zien hoe Azenvragen naar 5-niveau kan leiden en waarom afzwaaien soms slim is. +- Toon de klassieke antwoorden op 4SA: 5 klaveren is 0 of 4 azen, 5 ruiten is 1 aas, 5 harten is 2 azen en 5 schoppen is 3 azen. + +### Interactieve speelvoorbeelden op het bridge-bord + +- Start `small-slam-after-2nt-001`; laat zien hoeveel slagen Kleinslem vraagt. +- Start `blackwood-after-2nt-transfer-001`; laat Azenvragen en afzwaaien zien in review. +- Gebruik een voorstelhand voor Zwakke twee; laat zien hoe een lange Troef-kleur in het spelen werkt, maar ook kwetsbaar kan zijn. + +### Oefenvragen + +1. Wat belooft Zwakke twee in grote lijnen? +2. Waarom heet een Preemptief bod storend? +3. Wat vraagt Vierde-kleur-forcing? +4. Wanneer is een bod Forcing? +5. Hoeveel slagen vraagt Kleinslem? +6. Wat vraagt 4SA in deze cursus wanneer er een Troefkleur is afgesproken? +7. Waarom gebruik je Azenvragen niet zomaar zonder plan? + +### Oefenhanden + +- `small-slam-after-2nt-001` - bestaand: Kleinslem in Sans-atout. +- `blackwood-after-2nt-transfer-001` - bestaand: Azenvragen en afzwaaien. +- `lesson-12-weak-two-001` - voorstel: Zwakke twee openen. +- `lesson-12-fourth-suit-forcing-001` - voorstel: Vierde-kleur-forcing als vraagbod. + +### Eindopdracht + +Speel de "slemthermometer": geef na de bieding een temperatuur van 1 tot 5. 1 is "stoppen met thee", 5 is "Slem onderzoeken". + +--- + +## Implementatienotities + +Deze cursusstructuur is inhoudelijk. Implementatie kan per les klein gebeuren: + +1. Werk een les uit in `scripts/learning/lessons.js` met korte hoofdstukken en quiz. +2. Voeg alleen ontbrekende oefenhanden toe die echt nodig zijn voor die les. +3. Houd de kaart op `lessons.html` compact: titel, leerdoelen en een knop `Start les`. +4. Koppel tafeloefeningen vanuit hoofdstukken met `chapter`, `return`, een compacte `tableTask` en alleen noodzakelijke `boardGuidance`. +5. Kies voor korte tafelsituaties bij voorkeur 1 bod, 1 kaart, 1 slagmoment of 1 reviewcheck; gebruik `type: hand` alleen als de les echt een volledige hand vraagt. +6. Houd normale gameplay rustig; uitgebreide tekst hoort in lesmodus, review, AI-suggesties of developer mode. +7. Laat lesfeedback alleen hard corrigeren als de engine de regel betrouwbaar kan onderbouwen. +8. Draai voor lesdata minimaal `npm run test:unit`; draai browser smoke wanneer lesson navigation, startflow, `tableTask` completion of bordbegeleiding wijzigt. + +## Acceptatiecriteria voor de hele cursus + +- Alle 12 lessen hebben een duidelijke opbouw en gebruiken geen jargon voordat het is uitgelegd. +- Het lesoverzicht blijft compact: titel, leerdoelen en een enkele `Start les`-knop per geselecteerde les. +- Alle kernbegrippen gebruiken de spelling uit `glossary.js`. +- Alle biedafspraken volgen `docs/vijfkaart-hoog-systeem.md`. +- Elke les bevat minimaal 6 oefenvragen en minimaal 4 oefenhanden of voorstelhanden. +- Elke les bevat minstens een biedmoment en een speelvoorbeeld op het bestaande bridge-bord. +- Elke hoofdstukstart naar het bridge-bord keert met `Terug naar les` terug naar hetzelfde hoofdstuk. +- Korte tafelsituaties hebben een duidelijke `tableTask` completion en blokkeren of dempen de gewone flow na afronding. +- Lesmodus overschrijft opgeslagen instellingen niet; developer mode, AI-suggesties en speelgeschiedenis blijven tijdelijk uit tijdens de tafelsituatie. +- De toon blijft vriendelijk, helder en speels. +- De cursus ondersteunt de huidige Vijfkaart-Hoog-afspraken zonder te doen alsof ze universeel zijn. +- De route eindigt met lichtgevorderde herkenning, niet met de claim dat spelers alle conventies zelfstandig beheersen. 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..dd84fda 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.
@@ -167,6 +190,7 @@ -
-
+

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/rules/bidding/systems/five-card-high/explanations-nl.js b/rules/bidding/systems/five-card-high/explanations-nl.js index e375bb9..a729ff8 100644 --- a/rules/bidding/systems/five-card-high/explanations-nl.js +++ b/rules/bidding/systems/five-card-high/explanations-nl.js @@ -1,6 +1,34 @@ (function initFiveCardHighBidExplanationsNl(root) { "use strict"; + const bridgeRules = root.BridgeRules || null; + + function isPass(bid) { + return bridgeRules?.isPass ? bridgeRules.isPass(bid) : root.isPass(bid); + } + + function isDouble(bid) { + return bridgeRules?.isDouble ? bridgeRules.isDouble(bid) : root.isDouble(bid); + } + + function isRedouble(bid) { + return bridgeRules?.isRedouble ? bridgeRules.isRedouble(bid) : root.isRedouble(bid); + } + + function bidEquals(bid, level, strain) { + return bridgeRules?.isContractBid ? bridgeRules.isContractBid(bid) && bid.level === level && bid.strain === strain : root.bidEquals(bid, level, strain); + } + + function suitName(suit) { + return root.BridgeTextNl?.suits?.[suit] || root.suitName?.(suit) || suit; + } + + function t(key, args = {}) { + if (typeof root.t === "function") return root.t(key, args); + const template = root.BridgeTextNl?.[key] || key; + return Object.entries(args).reduce((message, [name, value]) => message.replaceAll(`{${name}}`, value), template); + } + function explainBidChoiceResult(result) { const ruleName = bidRuleName(result); if (isPass(result.bid)) return explainPassChoiceResult(ruleName, result); diff --git a/scripts/app.js b/scripts/app.js index aa09d1e..3f320b4 100644 --- a/scripts/app.js +++ b/scripts/app.js @@ -1,1287 +1,52 @@ -const seats = ["North", "East", "South", "West"]; -const handSuitOrder = ["S", "H", "C", "D"]; -const biddingBoxStrains = ["NT", "S", "H", "D", "C"]; -const suitSymbols = { C: "\u2663", D: "\u2666", H: "\u2665", S: "\u2660", NT: "NT" }; -const separatorDot = "\u00b7"; -const roleSeparator = "\u2022"; -const rankOrder = ["2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K", "A"]; -const rankLabel = { T: "10", J: "J", Q: "Q", K: "K", A: "A" }; -const settingsStorageKey = "bridge-app-settings"; -const bridgeRules = globalThis.BridgeRules; -if (!bridgeRules) throw new Error("bridge-rules.js must load before app.js"); -const stateTransitions = globalThis.BridgeStateTransitions; -if (!stateTransitions) throw new Error("state-transitions.js must load before app.js"); -const defaultBiddingSystem = bridgeRules.biddingSystems.fiveCardHigh; -const seatEls = { - North: document.querySelector("#north-hand"), - East: document.querySelector("#east-hand"), - South: document.querySelector("#south-hand"), - West: document.querySelector("#west-hand") -}; -const slotEls = { - North: document.querySelector(".trick-north"), - East: document.querySelector(".trick-east"), - South: document.querySelector(".trick-south"), - West: document.querySelector(".trick-west") -}; -const seatAuctionEls = { - North: document.querySelector("#north-auction-calls"), - East: document.querySelector("#east-auction-calls"), - South: document.querySelector("#south-auction-calls"), - West: document.querySelector("#west-auction-calls") -}; - -const state = { - hands: {}, - phase: "idle", - dealerIndex: 0, - turnIndex: 0, - auction: [], - contract: null, - declarer: null, - dummy: null, - leader: null, - currentTrick: [], - awaitingTrickAdvance: false, - trickAdvanceArmed: false, - pendingTrickWinner: null, - tricks: { NS: 0, EW: 0 }, - trickHistory: [], - reviewTrickCursor: null, - playExplanations: [], - playPlan: null, - playPlanKey: null, - originalHands: {}, - dealNumber: 0, - vulnerability: "none", - animateDeal: false, - developerMode: false, - guidanceMode: false, - showPlayHistory: false, - showAdvancedBidControls: false, - biddingSystemId: defaultBiddingSystem.id, - biddingAgreements: { ...defaultBiddingSystem.conventionDefaults }, - pendingStop: false, - pendingAlert: false, - status: { key: "chooseAndDeal", args: {} }, - finalScore: null, - dealSeed: null, - seedMessage: null, - practice: null, - feedbackStatus: null, - illegalActionFeedback: null, - handSuitFocus: null, - lessonBoardAcknowledged: [] -}; - -let illegalActionFeedbackTimer = null; -let flowGeneration = 0; -let dealAnimationTimer = null; -let dealAnimationHandsLocked = false; -let dealAnimationHandRenderSnapshot = null; - -const els = { - appShell: document.querySelector(".app-shell"), - title: document.querySelector("#app-title"), - heading: document.querySelector("#app-heading"), - playMode: document.querySelector("#play-mode"), - appMenu: document.querySelector(".app-menu"), - settingsSummary: document.querySelector("#settings-summary"), - developerOnlyElements: document.querySelectorAll("[data-developer-only]"), - developerMode: document.querySelector("#developer-mode"), - developerModeLabel: document.querySelector("#developer-mode-label"), - developerModeDescription: document.querySelector("#developer-mode-description"), - guidanceMode: document.querySelector("#guidance-mode"), - guidanceModeLabel: document.querySelector("#guidance-mode-label"), - guidanceModeDescription: document.querySelector("#guidance-mode-description"), - playHistoryMode: document.querySelector("#play-history-mode"), - playHistoryModeLabel: document.querySelector("#play-history-mode-label"), - playHistoryModeDescription: document.querySelector("#play-history-mode-description"), - hintButton: document.querySelector("#hint-button"), - openFeedback: document.querySelector("#open-feedback"), - openLessons: document.querySelector("#open-lessons"), - lessonsDialog: document.querySelector("#lessons-dialog"), - closeLessons: document.querySelector("#close-lessons"), - lessonsEyebrow: document.querySelector("#lessons-eyebrow"), - lessonsTitle: document.querySelector("#lessons-title"), - lessonsIntro: document.querySelector("#lessons-intro"), - lessonsList: document.querySelector("#lessons-list"), - openGlossary: document.querySelector("#open-glossary"), - glossaryDialog: document.querySelector("#glossary-dialog"), - closeGlossary: document.querySelector("#close-glossary"), - glossaryTitle: document.querySelector("#glossary-title"), - glossarySearch: document.querySelector("#glossary-search"), - glossaryList: document.querySelector("#glossary-list"), - glossaryTerm: document.querySelector("#glossary-term"), - glossaryDefinition: document.querySelector("#glossary-definition"), - openScoreTable: document.querySelector("#open-score-table"), - scoreTableDialog: document.querySelector("#score-table-dialog"), - closeScoreTable: document.querySelector("#close-score-table"), - newHand: document.querySelector("#new-hand"), - sameHand: document.querySelector("#same-hand"), - replayPanel: document.querySelector("#replay-panel"), - replayTitle: document.querySelector("#replay-title"), - replayNewHand: document.querySelector("#replay-new-hand"), - replaySameHand: document.querySelector("#replay-same-hand"), - quickReview: document.querySelector("#quick-review"), - seedLabel: document.querySelector("#seed-label"), - seedInput: document.querySelector("#seed-input"), - loadSeed: document.querySelector("#load-seed"), - copySeed: document.querySelector("#copy-seed"), - seedDescription: document.querySelector("#seed-description"), - tableArea: document.querySelector(".table-area"), - contractReveal: document.querySelector("#contract-reveal"), - contractRevealBid: document.querySelector("#contract-reveal-bid"), - contractRevealMeta: document.querySelector("#contract-reveal-meta"), - contractRevealLead: document.querySelector("#contract-reveal-lead"), - sidePanel: document.querySelector(".side-panel"), - auctionPanel: document.querySelector(".auction-panel"), - mobileBiddingSlot: document.querySelector("#mobile-bidding-slot"), - northLabel: document.querySelector("#north-label"), - eastLabel: document.querySelector("#east-label"), - southLabel: document.querySelector("#south-label"), - westLabel: document.querySelector("#west-label"), - biddingTitle: document.querySelector("#bidding-title"), - historyPanel: document.querySelector("#history-panel"), - historyTitle: document.querySelector("#history-title"), - reviewPanel: document.querySelector("#review-panel"), - reviewTitle: document.querySelector("#review-title"), - reviewResult: document.querySelector("#review-result"), - reviewSummary: document.querySelector("#review-summary"), - reviewTricks: document.querySelector("#review-tricks"), - feedbackDialog: document.querySelector("#feedback-dialog"), - feedbackTitle: document.querySelector("#feedback-title"), - feedbackState: document.querySelector("#feedback-state"), - feedbackTypeLabel: document.querySelector("#feedback-type-label"), - feedbackType: document.querySelector("#feedback-type"), - feedbackMessageLabel: document.querySelector("#feedback-message-label"), - feedbackMessage: document.querySelector("#feedback-message"), - feedbackDetailField: document.querySelector("#feedback-detail-field"), - feedbackDetailLabel: document.querySelector("#feedback-detail-label"), - feedbackDetail: document.querySelector("#feedback-detail"), - copyFeedback: document.querySelector("#copy-feedback"), - mailFeedback: document.querySelector("#mail-feedback"), - closeFeedback: document.querySelector("#close-feedback"), - feedbackDescription: document.querySelector("#feedback-description"), - bidControlsTitle: document.querySelector("#bid-controls-title"), - bidControls: document.querySelector("#bid-controls"), - bidExplanations: document.querySelector("#bid-explanations"), - playPlan: document.querySelector("#play-plan-panel"), - playExplanations: document.querySelector("#play-explanations"), - auctionLog: document.querySelector("#auction-log"), - dealerBadge: document.querySelector("#dealer-badge"), - contract: document.querySelector("#contract"), - trickArea: document.querySelector("#trick-area"), - status: document.querySelector("#status"), - guidancePanel: document.querySelector("#guidance-panel"), - dummyNotice: document.querySelector("#dummy-notice"), - lessonBanner: document.querySelector("#lesson-banner"), - tableFeedback: document.querySelector("#table-feedback"), - trickAdvanceHint: document.querySelector("#trick-advance-hint"), - scoreline: document.querySelector("#scoreline"), - history: document.querySelector("#history"), - trickCount: document.querySelector("#trick-count") -}; - -const mobileLayoutQuery = globalThis.matchMedia?.("(max-width: 760px)") || null; -const mobileBiddingLayoutQuery = mobileLayoutQuery; -const stableSidebarLayoutQuery = globalThis.matchMedia?.("(min-width: 1180px)") || null; - -loadSavedSettings(); - -els.newHand.addEventListener("click", startHand); -els.sameHand.addEventListener("click", replayHand); -els.replayNewHand.addEventListener("click", startHand); -els.replaySameHand.addEventListener("click", replayHand); -els.quickReview.addEventListener("click", jumpToTrickOverview); -BridgeGlossary.init({ - dialog: els.glossaryDialog, - openButton: els.openGlossary, - closeButton: els.closeGlossary, - searchInput: els.glossarySearch, - list: els.glossaryList, - term: els.glossaryTerm, - definition: els.glossaryDefinition -}); -els.openLessons?.addEventListener("click", () => { - closeAppMenu(); -}); -if (els.openLessons && new URLSearchParams(globalThis.location?.search || "").has("testHooks")) { - els.openLessons.href = "lessons.html?testHooks=1"; -} -els.openScoreTable.addEventListener("click", openScoreTableDialog); -els.closeScoreTable.addEventListener("click", closeScoreTableDialog); -els.loadSeed.addEventListener("click", loadSeedFromInput); -els.copySeed.addEventListener("click", copyCurrentSeed); -els.openFeedback.addEventListener("click", openFeedbackDialog); -els.closeFeedback.addEventListener("click", closeFeedbackDialog); -els.copyFeedback.addEventListener("click", copyFeedbackReport); -els.mailFeedback.addEventListener("click", submitFeedbackReport); -els.feedbackType.addEventListener("change", updateFeedbackQuestions); -els.feedbackDialog.addEventListener("click", (event) => { - if (event.target === els.feedbackDialog) closeFeedbackDialog(); -}); -els.settingsSummary?.addEventListener("click", (event) => { - event.stopPropagation(); - toggleAppMenu(); -}); -els.appMenu?.querySelector(".menu-actions")?.addEventListener("click", (event) => { - if (event.target.closest("button, a")) closeAppMenu(); -}); -els.appMenu?.addEventListener("click", (event) => event.stopPropagation()); -els.scoreTableDialog.addEventListener("click", (event) => { - if (event.target === els.scoreTableDialog) closeScoreTableDialog(); -}); -document.addEventListener("click", () => closeAppMenu()); -document.documentElement.lang = "nl"; -els.developerMode.addEventListener("change", () => { - state.developerMode = els.developerMode.checked; - saveSettings(); - renderAll(); -}); -els.guidanceMode.addEventListener("change", () => { - state.guidanceMode = els.guidanceMode.checked; - saveSettings(); - renderAll(); -}); -els.playHistoryMode.addEventListener("change", () => { - state.showPlayHistory = els.playHistoryMode.checked; - saveSettings(); - renderAll(); -}); -els.contractReveal?.addEventListener("click", (event) => { - event.stopPropagation(); - startPlayFromContractReveal(); -}); -els.tableArea.addEventListener("click", (event) => { - if (clearHandSuitFocusFromOutsideClick(event.target)) return; - if (state.phase === "contract-reveal") { - if (isControlTarget(event.target)) return; - startPlayFromContractReveal(); - return; - } - if (blockingLessonBoardStep()) return; - if (state.awaitingTrickAdvance && state.trickAdvanceArmed) advanceCompletedTrick(); -}); -document.addEventListener("keydown", (event) => { - if (event.key === "Escape") closeAppMenu(); - if (isControlTarget(event.target)) return; - if (event.key === "Enter" && state.phase === "contract-reveal") { - event.preventDefault(); - startPlayFromContractReveal(); - return; - } - if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && moveReviewTrickCursor(event.key === "ArrowRight" ? 1 : -1)) { - event.preventDefault(); - return; - } - if (event.key === "Enter" && blockingLessonBoardStep()) { - event.preventDefault(); - return; - } - if (event.key === "Enter" && state.awaitingTrickAdvance && state.trickAdvanceArmed) { - event.preventDefault(); - advanceCompletedTrick(); - } -}); - -const rerenderResponsiveLayout = () => renderAll(); -[mobileBiddingLayoutQuery, stableSidebarLayoutQuery].filter(Boolean).forEach((query) => { - if (typeof query.addEventListener === "function") { - query.addEventListener("change", rerenderResponsiveLayout); - } else if (typeof query.addListener === "function") { - query.addListener(rerenderResponsiveLayout); - } -}); - -function isControlTarget(target) { - return target?.closest?.("a, button, input, select, textarea, summary, details"); -} - -function startHand({ replay = false, seed = null, preserveBoard = false, skipFlow = false } = {}) { - const reuseCurrentDeal = replay && state.dealSeed && state.dealNumber > 0; - const loadedSeed = normalizeSeed(seed); - if (!reuseCurrentDeal) { - if (!preserveBoard || state.dealNumber === 0) state.dealNumber += 1; - state.dealSeed = loadedSeed || createDealSeed(); - } - startPreparedHand({ - dealerIndex: dealerIndexForDeal(state.dealNumber), - vulnerability: vulnerabilityForDeal(state.dealNumber), - hands: dealHands(state.dealSeed), - practice: null, - clearSeedMessage: !loadedSeed, - skipFlow - }); -} - -function startPracticeHand(handId, { preserveBoard = false, skipFlow = false, lesson = null } = {}) { - if (!globalThis.PracticeHands) throw new Error("practice-hands/index.js must load before practice hands can be used"); - const scenario = globalThis.PracticeHands.preparePracticeHand(handId); - if (!preserveBoard || state.dealNumber === 0) state.dealNumber += 1; - - state.dealSeed = scenario.id; - startPreparedHand({ - dealerIndex: seats.indexOf(scenario.dealer), - vulnerability: scenario.vulnerability, - hands: scenario.hands, - practice: practiceStateFromScenario(scenario, lesson), - skipFlow - }); - return scenario; -} - -function startLesson(lesson, handId) { - if (!lesson?.id || !handId) return; - const startAtPlay = lesson.startMode === "play"; - const scenario = startPracticeHand(handId, { lesson, skipFlow: startAtPlay }); - if (!startAtPlay) return scenario; - - const expected = scenario.expectedContract; - if (!expected) return scenario; - const contract = globalThis.PracticeHands.contractFromText(expected.contract); - state.phase = "playing"; - state.contract = contract; - state.declarer = expected.declarer; - state.dummy = partnerOf(state.declarer); - state.leader = leftOf(state.declarer); - state.turnIndex = seats.indexOf(state.leader); - if (lesson.enableGuidance) state.guidanceMode = true; - renderAll(); - setStatus("lead", { leader: state.leader, declarer: state.declarer, dummy: state.dummy }); - continuePlay(); - return scenario; -} - -function startLessonFromUrl() { - const params = new URLSearchParams(globalThis.location?.search || ""); - const lessonId = params.get("lesson"); - const handId = params.get("hand"); - if (!lessonId || !handId) return false; - - const lesson = globalThis.BridgeLessons?.findLesson?.(lessonId); - if (!lesson) return false; - - startLesson(lesson, handId); - return true; -} - -function startPreparedHand({ dealerIndex, vulnerability, hands, practice = null, clearSeedMessage = false, skipFlow = false }) { - resetScheduledFlow(); - clearDealAnimationTimer(); - dealAnimationHandsLocked = false; - Object.assign(state, stateTransitions.startPreparedHandTransition({ - dealerIndex, - vulnerability, - hands, - originalHands: cloneHands(hands), - practice - })); - state.lessonBoardAcknowledged = []; - if (illegalActionFeedbackTimer) { - window.clearTimeout(illegalActionFeedbackTimer); - illegalActionFeedbackTimer = null; - } - state.handSuitFocus = null; - if (clearSeedMessage) state.seedMessage = null; - clearTrickSlots(); - if (skipFlow) { - state.animateDeal = false; - dealAnimationHandsLocked = false; - setStatus("opensAuction", { seat: seatAt(state.turnIndex) }); - return; - } - renderAll(); - scheduleDealAnimationEnd(); - setStatus("dealing"); -} - -function resetScheduledFlow() { - flowGeneration += 1; -} - -function scheduleDealAnimationEnd() { - clearDealAnimationTimer(); - const dealAnimationMs = prefersReducedMotion() ? 0 : 1400; - dealAnimationTimer = window.setTimeout(() => { - state.animateDeal = false; - dealAnimationHandsLocked = false; - dealAnimationHandRenderSnapshot = null; - dealAnimationTimer = null; - renderHands(); - setStatus("opensAuction", { seat: seatAt(state.turnIndex) }); - continueAuction(); - }, dealAnimationMs); -} - -function clearDealAnimationTimer() { - if (dealAnimationTimer) window.clearTimeout(dealAnimationTimer); - dealAnimationTimer = null; - dealAnimationHandsLocked = false; - dealAnimationHandRenderSnapshot = null; -} - -function replayHand() { - if (state.practice?.id) { - startPracticeHand(state.practice.id, { preserveBoard: true }); - return; - } - startHand({ replay: true }); -} - -function practiceStateFromScenario(scenario, lesson = null) { - return { - id: scenario.id, - title: scenario.title, - level: scenario.level, - focus: [...(scenario.focus || [])], - systemId: scenario.systemId, - expectedAuction: scenario.expectedAuction || [], - expectedContract: scenario.expectedContract || null, - expectedPlayPlan: scenario.expectedPlayPlan || null, - expectedScore: scenario.expectedScore || null, - teachingPoints: lesson?.teachingPoints ? [...lesson.teachingPoints] : (scenario.teachingPoints || []), - explanationKeys: scenario.explanationKeys || [], - testGoal: scenario.testGoal || "", - lessonId: lesson?.id || null, - lessonNumber: lesson?.number || null, - lessonTitle: lesson?.title || "", - challenge: lesson?.challenge || "", - lessonStartMode: lesson?.startMode || "", - lessonFocus: lesson?.focus ? [...lesson.focus] : [], - lessonIntro: lesson?.intro || "", - lessonReviewFeedback: lesson?.reviewFeedback ? [...lesson.reviewFeedback] : [], - lessonBoardGuidance: lesson?.boardGuidance ? lesson.boardGuidance.map((step) => ({ ...step })) : [] - }; -} - -function jumpToTrickOverview() { - for (let attempt = 0; attempt < 12; attempt++) { - startHand({ skipFlow: true }); - autoCompleteAuction(); - if (state.phase !== "playing") continue; - autoCompletePlay(); - break; - } - renderAll(); - window.setTimeout(scrollReviewToTricks, 0); -} - -function scrollReviewToTricks() { - if (!els.reviewTricks || els.reviewPanel.hidden) return; - els.reviewPanel.scrollTop = Math.max(0, els.reviewTricks.offsetTop - els.reviewPanel.offsetTop); -} - -function moveReviewTrickCursor(delta) { - if (!state.developerMode || state.phase !== "complete" || !state.trickHistory.length || els.reviewPanel.hidden) return false; - const maxIndex = state.trickHistory.length - 1; - const current = Number.isInteger(state.reviewTrickCursor) ? state.reviewTrickCursor : (delta > 0 ? -1 : state.trickHistory.length); - state.reviewTrickCursor = Math.max(0, Math.min(maxIndex, current + delta)); - renderAll(); - window.setTimeout(scrollSelectedReviewTrickIntoView, 0); - return true; -} - -function ensureReviewTrickCursor() { - if (!state.developerMode || state.phase !== "complete" || !state.trickHistory.length) { - state.reviewTrickCursor = null; - return null; - } - const maxIndex = state.trickHistory.length - 1; - if (!Number.isInteger(state.reviewTrickCursor)) state.reviewTrickCursor = 0; - state.reviewTrickCursor = Math.max(0, Math.min(maxIndex, state.reviewTrickCursor)); - return state.trickHistory[state.reviewTrickCursor]?.number || null; -} - -function scrollSelectedReviewTrickIntoView() { - if (!els.reviewPanel || els.reviewPanel.hidden) return; - const selectedRow = els.reviewPanel.querySelector(".review-trick-row.is-review-selected"); - selectedRow?.scrollIntoView({ block: "nearest" }); -} - -function dealHands(seed = state.dealSeed) { - return bridgeRules.dealHands(bridgeRules.randomFromSeed(seed)); -} - -function cloneHands(hands) { - return Object.fromEntries(seats.map((seat) => [seat, hands[seat].map((card) => ({ ...card }))])); -} - -function vulnerabilityForDeal(dealNumber) { - return bridgeRules.vulnerabilityForDeal(dealNumber); -} - -function dealerIndexForDeal(dealNumber) { - return bridgeRules.dealerIndexForDeal(dealNumber); -} - -function vulnerabilityName(vulnerability = state.vulnerability) { - return { - none: t("vulnerabilityNone"), - NS: t("vulnerabilityNS"), - EW: t("vulnerabilityEW"), - both: t("vulnerabilityBoth") - }[vulnerability]; -} - -function isTeamVulnerable(team, vulnerability = state.vulnerability) { - return bridgeRules.isTeamVulnerable(team, vulnerability); -} - -function isSeatVulnerable(seat, vulnerability = state.vulnerability) { - return isTeamVulnerable(bridgeRules.teamOf(seat), vulnerability); -} - -function compareCards(a, b) { - return bridgeRules.compareCards(a, b); -} - -function renderAll() { - applyStaticText(); - renderResponsiveLayoutState(); - renderScoreTable(); - renderTurnFocus(); - renderTrickSlotFocus(); - if (!shouldSkipHandRenderForDealAnimation()) renderHands(); - renderAuction(); - renderBidControls(); - renderPlayPlan(); - renderHistory(); - renderPlayExplanations(); - renderReview(); - renderContract(); - renderGuidance(); - renderLessonBanner(); - renderContractReveal(); - renderFeedbackStatus(); - renderIllegalActionFeedback(); - renderReplayPanel(); - els.dealerBadge.textContent = `${t("board")} ${state.dealNumber} ${separatorDot} ${t("dealer")}: ${seatName(seatAt(state.dealerIndex))}`; - els.trickCount.textContent = `${state.tricks.NS + state.tricks.EW} ${t("tricks")}`; - els.scoreline.textContent = state.finalScore - ? `${t("bridgeScore")} ${state.finalScore.scoreText} ${separatorDot} ${t("vulnerability")}: ${vulnerabilityName()}` - : `${t("northSouth")} ${state.tricks.NS} ${separatorDot} ${t("eastWest")} ${state.tricks.EW} ${separatorDot} ${t("vulnerability")}: ${vulnerabilityName()}`; - renderHint(); - renderTrickAdvanceHint(); - renderSeedControls(); - renderStatus(); -} - -function renderResponsiveLayoutState() { - const isBidding = state.phase === "bidding"; - const isContractReveal = state.phase === "contract-reveal"; - const auctionReady = isBidding && !state.animateDeal; - const useTableBiddingLayout = isBidding; - const useMobileBiddingLayout = Boolean(useTableBiddingLayout && mobileBiddingLayoutQuery?.matches); - const useStableSidebarLayout = Boolean(stableSidebarLayoutQuery?.matches); - els.appShell?.classList.toggle("is-bidding", isBidding); - els.appShell?.classList.toggle("is-playing", state.phase === "playing"); - els.appShell?.classList.toggle("is-contract-reveal", isContractReveal); - els.appShell?.classList.toggle("is-table-bidding", useTableBiddingLayout); - els.appShell?.classList.toggle("is-mobile-bidding", useMobileBiddingLayout); - els.appShell?.classList.toggle("has-stable-sidebars", useStableSidebarLayout); - - if (!els.auctionPanel || !els.sidePanel || !els.mobileBiddingSlot) return; - - if (useTableBiddingLayout) { - if (els.bidControls.parentElement !== els.mobileBiddingSlot) { - els.mobileBiddingSlot.appendChild(els.bidControls); - } - els.mobileBiddingSlot.setAttribute("aria-hidden", "false"); - return; - } - - if (els.bidControls.parentElement === els.mobileBiddingSlot) { - els.bidControlsTitle.insertAdjacentElement("afterend", els.bidControls); - } - els.mobileBiddingSlot.setAttribute("aria-hidden", "true"); -} - -function isMobileLayout() { - return Boolean(mobileLayoutQuery?.matches); -} - -function usesStableSidebarLayout() { - return Boolean(stableSidebarLayoutQuery?.matches); -} - -function focusHandSuit(seat, suit) { - if (!isMobileLayout() || !seat || !suit) return false; - state.handSuitFocus = { seat, suit }; - renderHands(); - return true; -} - -function clearHandSuitFocus() { - if (!state.handSuitFocus) return false; - state.handSuitFocus = null; - renderHands(); - return true; -} - -function clearHandSuitFocusFromOutsideClick(target) { - if (!state.handSuitFocus || !isMobileLayout()) return false; - const focusedHand = seatEls[state.handSuitFocus.seat]; - if (focusedHand?.contains(target)) return false; - clearHandSuitFocus(); - return false; -} - -function shouldSkipHandRenderForDealAnimation() { - if (!state.animateDeal || !dealAnimationHandsLocked || !dealAnimationHandRenderSnapshot) return false; - const current = dealAnimationHandRenderState(); - return ( - current.phase === dealAnimationHandRenderSnapshot.phase && - current.hands === dealAnimationHandRenderSnapshot.hands && - current.developerMode === dealAnimationHandRenderSnapshot.developerMode && - current.guidanceMode === dealAnimationHandRenderSnapshot.guidanceMode && - current.declarer === dealAnimationHandRenderSnapshot.declarer && - current.dummy === dealAnimationHandRenderSnapshot.dummy && - current.turnIndex === dealAnimationHandRenderSnapshot.turnIndex && - current.awaitingTrickAdvance === dealAnimationHandRenderSnapshot.awaitingTrickAdvance && - current.currentTrickLength === dealAnimationHandRenderSnapshot.currentTrickLength && - current.trickHistoryLength === dealAnimationHandRenderSnapshot.trickHistoryLength - ); -} - -function lockDealAnimationHands() { - if (!state.animateDeal) return; - dealAnimationHandsLocked = true; - dealAnimationHandRenderSnapshot = dealAnimationHandRenderState(); -} - -function dealAnimationHandRenderState() { - const playing = state.phase === "playing"; - return { - phase: state.phase, - hands: state.phase === "complete" ? state.originalHands : state.hands, - developerMode: state.developerMode, - guidanceMode: state.guidanceMode, - declarer: state.declarer, - dummy: state.dummy, - turnIndex: playing ? state.turnIndex : null, - awaitingTrickAdvance: playing ? state.awaitingTrickAdvance : false, - currentTrickLength: playing ? state.currentTrick.length : 0, - trickHistoryLength: playing ? state.trickHistory.length : 0 - }; -} - -function prefersReducedMotion() { - return Boolean(globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches); -} - -function toggleAppMenu() { - const isOpen = !els.appMenu?.classList.contains("is-open"); - renderAppMenu(isOpen); -} - -function closeAppMenu() { - renderAppMenu(false); -} - -function renderAppMenu(isOpen) { - if (!els.appMenu || !els.settingsSummary) return; - els.appMenu.classList.toggle("is-open", isOpen); - els.settingsSummary.setAttribute("aria-expanded", String(isOpen)); - const panel = els.appMenu.querySelector(".app-menu-panel"); - if (panel) panel.hidden = !isOpen; -} - -function renderReplayPanel() { - els.replayPanel.hidden = state.phase !== "complete"; -} - -function renderTurnFocus() { - const focusClasses = seats.map((seat) => `turn-focus-${seat.toLowerCase()}`); - els.tableArea.classList.remove(...focusClasses); - if (state.phase === "bidding" && seatAt(state.turnIndex) === "South") { - els.tableArea.classList.add("turn-focus-south"); - return; - } - if (state.phase !== "playing" || state.awaitingTrickAdvance) return; - els.tableArea.classList.add(`turn-focus-${seatAt(state.turnIndex).toLowerCase()}`); -} - -function renderTrickSlotFocus() { - Object.values(slotEls).forEach((slot) => { - slot.classList.remove("active-trick-slot", "pending-trick-winner"); - }); - if (state.phase === "playing" && state.awaitingTrickAdvance && state.pendingTrickWinner) { - slotEls[state.pendingTrickWinner]?.classList.add("pending-trick-winner"); - return; - } - if (state.phase !== "playing" || state.awaitingTrickAdvance) return; - slotEls[seatAt(state.turnIndex)]?.classList.add("active-trick-slot"); -} - -function applyStaticText() { - document.title = t("title"); - els.title.textContent = t("title"); - els.heading.textContent = t("heading"); - if (els.playMode) els.playMode.textContent = t("playMode"); - els.settingsSummary.setAttribute("aria-label", "Menu"); - els.settingsSummary.title = "Menu"; - els.openFeedback.textContent = t("openFeedback"); - els.openLessons.textContent = t("openLessons"); - els.lessonsEyebrow.textContent = t("lessonsEyebrow"); - els.lessonsTitle.textContent = t("lessonsTitle"); - els.lessonsIntro.textContent = t("lessonsIntro"); - els.closeLessons.setAttribute("aria-label", t("closeLessons")); - els.openGlossary.textContent = t("openGlossary"); - els.openScoreTable.textContent = t("openScoreTable"); - els.developerModeLabel.textContent = t("developerMode"); - els.developerMode.checked = state.developerMode; - els.guidanceModeLabel.textContent = t("guidanceMode"); - els.guidanceMode.checked = state.guidanceMode; - els.playHistoryModeLabel.textContent = t("playHistoryMode"); - els.playHistoryMode.checked = state.showPlayHistory; - setSettingInfo(els.playHistoryModeDescription, t("playHistoryModeHelp")); - setSettingInfo(els.guidanceModeDescription, t("guidanceModeHelp")); - setSettingInfo(els.developerModeDescription, t("developerModeHelp")); - els.newHand.textContent = t("newHand"); - els.sameHand.textContent = t("sameHand"); - els.replayTitle.textContent = t("replayTitle"); - els.replayNewHand.textContent = t("newHand"); - els.replaySameHand.textContent = t("sameHand"); - els.quickReview.textContent = t("quickReview"); - els.developerOnlyElements.forEach((element) => { - element.hidden = !state.developerMode; - }); - els.seedLabel.textContent = t("seed"); - els.loadSeed.textContent = t("loadSeed"); - els.copySeed.textContent = t("copySeed"); - renderSeatLabel(els.northLabel, "North", t("partner")); - renderSeatLabel(els.eastLabel, "East"); - renderSeatLabel(els.southLabel, "South", t("you")); - renderSeatLabel(els.westLabel, "West"); - els.biddingTitle.textContent = t("bidding"); - els.historyTitle.textContent = t("history"); - els.reviewTitle.textContent = t("review"); - els.feedbackTitle.textContent = t("feedbackTitle"); - els.feedbackTypeLabel.textContent = t("feedbackTypeLabel"); - els.feedbackMessageLabel.textContent = t("feedbackMessageLabel"); - els.feedbackMessage.placeholder = t("feedbackMessagePlaceholder"); - els.feedbackDetailLabel.textContent = t("feedbackDetailLabel"); - els.copyFeedback.textContent = t("copyFeedback"); - els.mailFeedback.textContent = t("mailFeedback"); - els.closeFeedback.setAttribute("aria-label", t("closeFeedback")); - els.feedbackDescription.textContent = t("feedbackHelp"); - els.closeScoreTable.setAttribute("aria-label", t("closeScoreTable")); - els.closeGlossary.setAttribute("aria-label", t("closeGlossary")); - els.glossaryTitle.textContent = t("glossaryTitle"); - Object.entries(t("feedbackTypes")).forEach(([value, label]) => { - const option = els.feedbackType.querySelector(`[value="${value}"]`); - if (option) option.textContent = label; - }); - updateFeedbackQuestions(); - els.hintButton.setAttribute("aria-label", t("hint")); -} - -function setSettingInfo(element, tooltip) { - if (!element) return; - element.textContent = "i"; - element.dataset.tooltip = tooltip; - element.title = tooltip; - element.setAttribute("aria-label", tooltip); -} - -function openScoreTableDialog() { - if (typeof els.scoreTableDialog.showModal === "function") { - els.scoreTableDialog.showModal(); - } else { - els.scoreTableDialog.setAttribute("open", ""); - } - els.closeScoreTable.focus(); -} - -function closeScoreTableDialog() { - if (typeof els.scoreTableDialog.close === "function") { - els.scoreTableDialog.close(); - } else { - els.scoreTableDialog.removeAttribute("open"); - } -} - -function openFeedbackDialog() { - state.feedbackStatus = null; - closeAppMenu(); - updateFeedbackQuestions(); - renderFeedbackStatus(); - if (typeof els.feedbackDialog.showModal === "function") { - els.feedbackDialog.showModal(); - } else { - els.feedbackDialog.setAttribute("open", ""); - } - els.feedbackMessage.focus(); -} - -function updateFeedbackQuestions() { - const prompts = t("feedbackPrompts") || {}; - const prompt = prompts[els.feedbackType.value] || prompts.confusion || {}; - els.feedbackMessageLabel.textContent = prompt.primaryLabel || t("feedbackMessageLabel"); - els.feedbackMessage.placeholder = prompt.primaryPlaceholder || t("feedbackMessagePlaceholder"); - const hasDetailQuestion = Boolean(prompt.secondaryLabel); - els.feedbackDetailField.hidden = !hasDetailQuestion; - els.feedbackDetailLabel.textContent = prompt.secondaryLabel || t("feedbackDetailLabel"); - els.feedbackDetail.placeholder = prompt.secondaryPlaceholder || ""; - if (!hasDetailQuestion) els.feedbackDetail.value = ""; -} - -function closeFeedbackDialog() { - if (typeof els.feedbackDialog.close === "function") { - els.feedbackDialog.close(); - } else { - els.feedbackDialog.removeAttribute("open"); - } -} - -function renderGuidance() { - els.guidancePanel.hidden = true; - els.guidancePanel.innerHTML = ""; - if (!state.guidanceMode || state.awaitingTrickAdvance) return; - if (blockingLessonBoardStep()) return; - - const guidance = currentGuidance(); - if (!guidance) return; - - const title = document.createElement("strong"); - title.textContent = `${guidance.label}: ${guidance.action}`; - const reason = document.createElement("span"); - reason.appendChild(BridgeGlossary.linkifyText(guidance.reason)); - els.guidancePanel.append(title, reason); - els.guidancePanel.hidden = false; -} - -function renderSeatLabel(label, seat, role = "") { - if (!label) return; - let name = label.querySelector(".seat-label-name"); - if (!name || name.parentElement !== label) { - name = document.createElement("span"); - name.className = "seat-label-name"; - label.replaceChildren(name); - } - - const nextName = seatName(seat); - if (name.textContent !== nextName) name.textContent = nextName; - name.classList.toggle("is-vulnerable-team", isSeatVulnerable(seat)); - - const roleText = role ? ` ${roleSeparator} ${role}` : ""; - const roleNode = Array.from(label.childNodes).find((node) => node.nodeType === Node.TEXT_NODE); - Array.from(label.childNodes).forEach((node) => { - if (node !== name && node !== roleNode) node.remove(); - }); - - if (!roleText) { - roleNode?.remove(); - return; - } - - if (roleNode) { - if (roleNode.textContent !== roleText) roleNode.textContent = roleText; - if (roleNode.previousSibling !== name) name.after(roleNode); - return; - } - - name.after(document.createTextNode(roleText)); -} - -function currentGuidance() { - if (state.phase === "bidding" && !state.animateDeal && seatAt(state.turnIndex) === "South") return biddingGuidance(); - if (state.phase === "playing" && isHumanControlledSeat(seatAt(state.turnIndex))) return cardGuidance(); - return null; -} - -function biddingGuidance() { - const result = chooseRecommendedBidResult("South"); - return { - label: t("recommendedBid"), - action: formatCall(result.bid), - reason: recommendedBidReason(result, "South") - }; -} - -function cardGuidance() { - const seat = seatAt(state.turnIndex); - const result = chooseCardPlayResult(seat); - if (!result?.card) return null; - return { - label: t("recommendedCard"), - action: cardText(result.card), - reason: `${explainCardPlayResult(result)}${playPlanReferenceText(result)}` - }; -} - -function formatCall(call) { - if (isPass(call)) return t("pass"); - if (isDouble(call)) return t("double"); - if (isRedouble(call)) return t("redouble"); - return formatBid(call); -} - -function sameCall(left, right) { - return bridgeRules.sameCall(left, right); -} - -function renderHint() { - els.hintButton.dataset.hint = currentHint(); -} - -function renderIllegalActionFeedback() { - if (!els.tableFeedback) return; - els.tableFeedback.hidden = !state.illegalActionFeedback; - els.tableFeedback.textContent = state.illegalActionFeedback || ""; -} - -function renderTrickAdvanceHint() { - els.trickAdvanceHint.hidden = !state.awaitingTrickAdvance; - if (!state.awaitingTrickAdvance) { - els.trickAdvanceHint.textContent = ""; - return; - } - const winner = state.pendingTrickWinner; - const number = state.trickHistory.length + 1; - const winnerText = winner ? `${seatName(winner)} wint slag ${number}. ` : ""; - els.trickAdvanceHint.textContent = `${winnerText}Klik ergens of druk op Enter voor de volgende slag.`; -} - -function currentHint() { - if (state.phase === "idle") return "Deel een nieuwe hand om te starten."; - if (state.phase === "bidding") return biddingHint(); - if (state.phase === "contract-reveal") return "Het contract is bekend. Klik op de tafel of druk op Enter om het spel te starten."; - if (state.phase === "playing") return playingHint(); - if (state.phase === "complete") { - return state.developerMode - ? "Bekijk het handoverzicht. In Developermodus zie je ook bied- en speeluitleg." - : "Bekijk het handoverzicht om het biedverloop en de slagen terug te zien. Zet Developermodus aan voor extra uitleg."; - } - return "Zet Developermodus aan om meer uitleg over biedingen en speelkeuzes te zien."; -} - -function biddingHint() { - if (state.animateDeal) return "Wacht tot de kaarten gedeeld zijn; daarna begint het bieden."; - if (seatAt(state.turnIndex) === "South") { - if (highestBid()) return "Je mag alleen hoger bieden dan het huidige hoogste bod. Pas betekent dat je nu geen bod doet."; - return "Open alleen met genoeg kracht of een duidelijke verdeling. 1SA toont meestal een gebalanceerde hand."; - } - if (highestBid()?.strain === "NT") return "Na 1SA zoek je eerst een hoge-kleurfit: 2K Stayman met een vierkaart hoog, 2R/2H transfer met een vijfkaart hoog."; - return "Als partner jouw kleur steunt, hebben jullie waarschijnlijk een fit."; -} - -function playingHint() { - const seat = seatAt(state.turnIndex); - if (!openingLeadHasBeenMade()) return "Dummy wordt pas zichtbaar na de uitkomst."; - if (isHumanControlledSeat(seat)) { - if (state.currentTrick.length) { - const leadSuit = state.currentTrick[0].card.suit; - const canFollow = state.hands[seat].some((card) => card.suit === leadSuit); - if (canFollow) return `Je moet ${suitName(leadSuit)} bekennen als je kunt.`; - return "Je kunt niet bekennen; je mag afgooien of troeven."; - } - if (seat === state.declarer || seat === state.dummy) return "Als leider maak je eerst een plan: tel verliezers of vaste slagen voordat je speelt."; - return "Als partner de slag al wint, is laag spelen vaak verstandig."; - } - if (state.currentTrick.length) return "De hoogste kaart in de gevraagde kleur wint, tenzij iemand troeft."; - if (state.contract.strain !== "NT") return "Troef wint van elke andere kleur."; - return "In sans-atout wint de hoogste kaart in de gevraagde kleur."; -} - -function renderContract() { - if (state.finalScore?.passOut) { - els.contract.textContent = t("passedOut"); - return; - } - if (!state.contract) { - els.contract.textContent = state.phase === "bidding" ? t("auctionInProgress") : t("dealToStart"); - return; - } - els.contract.textContent = `${formatBid(state.contract)} ${t("by")} ${seatName(state.declarer)}`; -} - -function finishHand() { - state.phase = "complete"; - recomputeFinalScore(); - const needed = state.contract.level + 6; - const made = state.finalScore.made; - const resultKey = made >= needed ? "made" : "down"; - const resultArgs = made >= needed ? { over: made - needed } : { under: needed - made }; - setStatus("contractResult", { contract: formatBid(state.contract), declarer: state.declarer, resultKey, resultArgs }); - renderAll(); -} - -function recomputeFinalScore() { - if (!state.contract || !state.declarer) return; - const declaringTeam = teamOf(state.declarer); - const defenders = declaringTeam === "NS" ? "EW" : "NS"; - const made = state.tricks[declaringTeam]; - state.finalScore = calculateBridgeScore({ - contract: state.contract, - declarer: state.declarer, - tricksMade: made, - vulnerability: state.vulnerability, - }); - state.finalScore.made = made; - state.finalScore.defenders = state.tricks[defenders]; -} - -function calculateBridgeScore({ contract, declarer, tricksMade, vulnerability }) { - return bridgeRules.calculateBridgeScore({ contract, declarer, tricksMade, vulnerability }); -} - -function contractTrickPoints(contract) { - return bridgeRules.contractTrickPoints(contract); -} - -function overtrickPoints(contract, overtricks, vulnerable, multiplier) { - return bridgeRules.overtrickPoints(contract, overtricks, vulnerable, multiplier); -} - -function downScore(undertricks, vulnerable, multiplier) { - return bridgeRules.downScore(undertricks, vulnerable, multiplier); -} - -function legalCards(seat) { - return bridgeRules.legalCards(state.hands[seat], state.currentTrick); -} - -function isLegalCard(seat, card) { - return legalCards(seat).some((legal) => legal.id === card.id); -} - -function currentWinningPlay() { - const trump = state.contract.strain === "NT" ? null : state.contract.strain; - return bridgeRules.currentWinningPlay(state.currentTrick, trump); -} - -function beats(card, best, leadSuit, trump) { - return bridgeRules.beats(card, best, leadSuit, trump); -} - -function highestBidCall() { - return bridgeRules.highestBidCall(state.auction); -} - -function highestBid() { - return bridgeRules.highestBid(state.auction); -} - -function isBidHigher(bid, current) { - return bridgeRules.isBidHigher(bid, current); -} - -function isPass(bid) { - return bridgeRules.isPass(bid); -} - -function isDouble(bid) { - return bridgeRules.isDouble(bid); -} - -function isRedouble(bid) { - return bridgeRules.isRedouble(bid); -} - -function isContractBid(bid) { - return bridgeRules.isContractBid(bid); -} - -function normalizeBid(bid) { - return bridgeRules.normalizeBid(bid); -} - -function formatBid(bid) { - const base = `${bid.level}${suitSymbols[bid.strain]}`; - if (bid.redoubled) return `${base} xx`; - if (bid.doubled) return `${base} x`; - return base; -} - -function cardText(card) { - return `${rankLabel[card.rank] || card.rank}${suitSymbols[card.suit]}`; -} - -function seatAt(index) { - return seats[index % 4]; -} - -function teamOf(seat) { - return bridgeRules.teamOf(seat); -} - -function partnerOf(seat) { - return seat === "North" ? "South" : seat === "South" ? "North" : seat === "East" ? "West" : "East"; -} - -function leftOf(seat) { - return seats[(seats.indexOf(seat) + 1) % 4]; -} - -function setStatus(key, args = {}) { - state.status = { key, args }; - renderStatus(); -} - -function renderStatus() { - els.status.textContent = t(state.status.key, localizeArgs(state.status.args)); - renderDummyNotice(); -} - -function renderDummyNotice() { - els.dummyNotice.hidden = true; - els.dummyNotice.textContent = ""; - if ( - state.phase !== "playing" || - !state.declarer || - !state.dummy || - !openingLeadHasBeenMade() || - state.trickHistory.length > 0 - ) { - return; - } - const key = teamOf(state.declarer) === "NS" ? "dummyNoticeDeclaring" : "dummyNoticeDefending"; - els.dummyNotice.textContent = t(key, { - declarer: seatName(state.declarer), - dummy: seatName(state.dummy) - }); - els.dummyNotice.hidden = false; -} - -function localizeArgs(args) { - const localized = { ...args }; - ["seat", "leader", "declarer", "dummy"].forEach((key) => { - if (localized[key]) localized[key] = seatName(localized[key]); - }); - if (localized.resultKey) { - localized.result = t(localized.resultKey, localized.resultArgs || {}); - } - return localized; -} - -function t(key, args = {}) { - const template = text[key] || key; - return Object.entries(args).reduce((text, [name, value]) => text.replaceAll(`{${name}}`, value), template); -} - -function seatName(seat) { - return text.seats[seat] || seat; -} - -function suitName(suit) { - return text.suits[suit] || suit; -} - -const BridgeApp = { - state, - els, - rules: bridgeRules, - actions: { - startHand, - startPracticeHand, - startLesson, - autoCompleteAuction, - enterContractReveal, - startPlayFromContractReveal, - autoCompletePlay, - continuePlay, - playCard, - makeBid, - replayHand, - jumpToTrickOverview, - createSituationSeed - }, - render: { - renderAll, - renderHands, - renderAuction, - renderBidControls, - renderPlayPlan, - renderHistory, - renderReview, - renderScoreTable, - renderPlayedCard, - clearTrickSlots - }, - helpers: { - seatAt, - seatName, - suitName, - cardText, - legalCards, - chooseCard, - autoPlayCard, - chooseCardPlayResult, - chooseRecommendedBidResult, - currentRecommendedCard, - sameCall - } -}; - -globalThis.BridgeApp = BridgeApp; -globalThis.BridgeAppContext = BridgeApp; - -if (new URLSearchParams(globalThis.location?.search || "").has("testHooks")) { - globalThis.BridgeAppTestHooks = createBridgeAppTestHooks(); -} - -function createBridgeAppTestHooks() { - const setState = (nextState) => { - const patch = typeof nextState === "function" ? nextState(state) : nextState; - if (!patch || typeof patch !== "object") return getState(); - Object.assign(state, patch); - return getState(); - }; - - const setDeveloperMode = (enabled) => { - state.developerMode = Boolean(enabled); - renderAll(); - return getState(); - }; - - const setGuidanceMode = (enabled) => { - state.guidanceMode = Boolean(enabled); - renderAll(); - return getState(); - }; - - return { - app: BridgeApp, - rules: bridgeRules, - startHand, - startPracticeHand, - startLesson, - autoCompleteAuction, - enterContractReveal, - startPlayFromContractReveal, - autoCompletePlay, - renderAll, - continuePlay, - playCard, - makeBid, - setState, - getState, - makeCard, - clearTrickSlots, - renderPlayedCard, - setDeveloperMode, - setGuidanceMode, - loadSeedFromInput, - createSituationSeed, - chooseRecommendedBidResult, - chooseCardPlayResult, - chooseCard, - autoPlayCard, - legalCards, - seatAt, - sameCall, - getEls: () => els - }; -} - -function getState() { - return JSON.parse(JSON.stringify(state)); -} - -function makeCard(id) { - return { id, rank: id.slice(0, -1), suit: id.slice(-1) }; -} - -if (!startLessonFromUrl()) startHand(); +(function initBridgeApp(root) { + "use strict"; + + const runtimeFactory = root.BridgeAppRuntime; + if (!runtimeFactory) throw new Error("scripts/app/runtime.js must load before app.js"); + + const runtime = runtimeFactory.create({ + rules: root.BridgeRules, + transitions: root.BridgeStateTransitions, + reviewPlayback: root.BridgeReviewPlayback, + text: root.BridgeTextNl + }); + + const modules = root.BridgeAppModules || {}; + [ + ["registerHelpers", modules.registerHelpers], + ["registerSettings", modules.registerSettings], + ["registerMenu", modules.registerMenu], + ["registerDialogs", modules.registerDialogs], + ["registerBidExplanations", modules.registerBidExplanations], + ["registerScoreTable", modules.registerScoreTable], + ["registerPlayPlanRenderer", modules.registerPlayPlanRenderer], + ["registerContractRevealRenderer", modules.registerContractRevealRenderer], + ["registerHandsRenderer", modules.registerHandsRenderer], + ["registerCardAnimation", modules.registerCardAnimation], + ["registerAuctionRenderer", modules.registerAuctionRenderer], + ["registerReviewRenderer", modules.registerReviewRenderer], + ["registerContractRevealFlow", modules.registerContractRevealFlow], + ["registerAuctionFlow", modules.registerAuctionFlow], + ["registerHandFinishFlow", modules.registerHandFinishFlow], + ["registerPlayFlow", modules.registerPlayFlow], + ["registerSeed", modules.registerSeed], + ["registerHandStart", modules.registerHandStart], + ["registerLessonBoardCoach", modules.registerLessonBoardCoach], + ["registerLessonStart", modules.registerLessonStart], + ["registerFeedback", modules.registerFeedback], + ["registerAppRenderer", modules.registerAppRenderer], + ["registerBootstrap", modules.registerBootstrap] + ].forEach(([name, register]) => { + if (typeof register !== "function") throw new Error(`${name} must load before app.js`); + register(runtime); + }); + + runtime.bootstrap.init(); + + if (typeof modules.registerPublicApi !== "function") { + throw new Error("registerPublicApi must load before app.js"); + } + modules.registerPublicApi(runtime); + + if (!runtime.actions.startLessonFromUrl()) runtime.actions.startHand(); +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/app/bootstrap.js b/scripts/app/bootstrap.js new file mode 100644 index 0000000..872e2d4 --- /dev/null +++ b/scripts/app/bootstrap.js @@ -0,0 +1,110 @@ +(function registerBridgeBootstrap(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerBootstrap = function registerBootstrap(runtime) { + const { actions, els, media, render, state } = runtime; + + function initGlobalAppEvents() { + document.documentElement.lang = "nl"; + + els.newHand?.addEventListener("click", actions.startHand); + els.sameHand?.addEventListener("click", actions.replayHand); + els.replayNewHand?.addEventListener("click", actions.startHand); + els.replaySameHand?.addEventListener("click", actions.replayHand); + els.replayScoreHelp?.addEventListener("click", actions.toggleReplayScoreExplanation); + els.replayClose?.addEventListener("click", actions.dismissScoreOverview); + els.replayPanel?.addEventListener("click", (event) => { + if (event.target === els.replayPanel) actions.dismissScoreOverview(event); + }); + els.quickReview?.addEventListener("click", actions.jumpToTrickOverview); + + const glossary = typeof BridgeGlossary !== "undefined" ? BridgeGlossary : root.BridgeGlossary; + glossary?.init?.({ + dialog: els.glossaryDialog, + openButton: els.openGlossary, + closeButton: els.closeGlossary, + searchInput: els.glossarySearch, + list: els.glossaryList, + term: els.glossaryTerm, + definition: els.glossaryDefinition + }); + + els.openLessons?.addEventListener("click", () => actions.closeAppMenu()); + if (els.openLessons && new URLSearchParams(root.location?.search || "").has("testHooks")) { + els.openLessons.href = "lessons.html?testHooks=1"; + } + + els.openScoreTable?.addEventListener("click", actions.openScoreTableDialog); + els.closeScoreTable?.addEventListener("click", actions.closeScoreTableDialog); + els.loadSeed?.addEventListener("click", actions.loadSeedFromInput); + els.copySeed?.addEventListener("click", actions.copyCurrentSeed); + els.scoreTableDialog?.addEventListener("click", (event) => { + if (event.target === els.scoreTableDialog) actions.closeScoreTableDialog(); + }); + + els.contractReveal?.addEventListener("click", (event) => { + event.stopPropagation(); + actions.startPlayFromContractReveal(); + }); + els.tableArea?.addEventListener("click", (event) => { + if (actions.clearHandSuitFocusFromOutsideClick(event.target)) return; + if (state.phase === "contract-reveal") { + if (isControlTarget(event.target)) return; + actions.startPlayFromContractReveal(); + return; + } + if (actions.blockingLessonBoardStep()) return; + if (state.awaitingTrickAdvance && state.trickAdvanceArmed) actions.advanceCompletedTrick(); + }); + + document.addEventListener("keydown", (event) => { + if ((event.key === "ArrowLeft" || event.key === "ArrowRight") && !isTextEntryTarget(event.target) && actions.moveReviewCursor(event.key === "ArrowRight" ? 1 : -1)) { + event.preventDefault(); + return; + } + if (isControlTarget(event.target)) return; + if (event.key === "Enter" && state.phase === "contract-reveal") { + event.preventDefault(); + actions.startPlayFromContractReveal(); + return; + } + if (event.key === "Enter" && actions.blockingLessonBoardStep()) { + event.preventDefault(); + return; + } + if (event.key === "Enter" && state.awaitingTrickAdvance && state.trickAdvanceArmed) { + event.preventDefault(); + actions.advanceCompletedTrick(); + } + }); + } + + function initResponsiveLayoutListeners() { + const rerenderResponsiveLayout = () => render.renderAll(); + [media.mobileBiddingLayoutQuery, media.stableSidebarLayoutQuery].filter(Boolean).forEach((query) => { + if (typeof query.addEventListener === "function") { + query.addEventListener("change", rerenderResponsiveLayout); + } else if (typeof query.addListener === "function") { + query.addListener(rerenderResponsiveLayout); + } + }); + } + + function isControlTarget(target) { + return target?.closest?.("a, button, input, select, textarea, summary, details"); + } + + function isTextEntryTarget(target) { + return target?.closest?.("input, select, textarea"); + } + + runtime.bootstrap.steps.unshift(() => actions.loadSavedSettings()); + runtime.bootstrap.steps.push(initGlobalAppEvents, initResponsiveLayoutListeners); + Object.assign(actions, { + isControlTarget, + isTextEntryTarget + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/app/hand-start.js b/scripts/app/hand-start.js new file mode 100644 index 0000000..b5bf689 --- /dev/null +++ b/scripts/app/hand-start.js @@ -0,0 +1,166 @@ +(function registerBridgeHandStart(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerHandStart = function registerHandStart(runtime) { + const { actions, helpers, render, state, timers, transitions } = runtime; + 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) { + if (!preserveBoard || state.dealNumber === 0) state.dealNumber += 1; + state.dealSeed = loadedSeed || actions.createDealSeed(); + } + startPreparedHand({ + dealerIndex: helpers.dealerIndexForDeal(state.dealNumber), + vulnerability: helpers.vulnerabilityForDeal(state.dealNumber), + hands: helpers.dealHands(state.dealSeed), + practice: null, + clearSeedMessage: !loadedSeed, + skipFlow + }); + } + + 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; + + state.dealSeed = scenario.id; + startPreparedHand({ + dealerIndex: seats.indexOf(scenario.dealer), + vulnerability: scenario.vulnerability, + hands: scenario.hands, + practice: practiceStateFromScenario(scenario, lesson, lessonContext), + skipFlow + }); + return scenario; + } + + function startPreparedHand({ dealerIndex, vulnerability, hands, practice = null, clearSeedMessage = false, skipFlow = false }) { + resetScheduledFlow(); + clearDealAnimationTimer(); + timers.dealAnimationHandsLocked = false; + Object.assign(state, transitions.startPreparedHandTransition({ + dealerIndex, + vulnerability, + hands, + originalHands: helpers.cloneHands(hands), + practice + })); + state.lessonBoardAcknowledged = []; + state.lessonTableTaskDone = false; + state.lessonActionFeedback = null; + if (timers.illegalActionFeedbackTimer) { + window.clearTimeout(timers.illegalActionFeedbackTimer); + timers.illegalActionFeedbackTimer = null; + } + state.handSuitFocus = null; + if (clearSeedMessage) state.seedMessage = null; + render.clearTrickSlots(); + if (skipFlow) { + state.animateDeal = false; + timers.dealAnimationHandsLocked = false; + actions.setStatus("opensAuction", { seat: helpers.seatAt(state.turnIndex) }); + return; + } + render.renderAll(); + scheduleDealAnimationEnd(); + actions.setStatus("dealing"); + } + + function resetScheduledFlow() { + timers.flowGeneration += 1; + } + + function scheduleDealAnimationEnd() { + clearDealAnimationTimer(); + const dealAnimationMs = actions.prefersReducedMotion() ? 0 : 1400; + timers.dealAnimationTimer = window.setTimeout(() => { + state.animateDeal = false; + timers.dealAnimationHandsLocked = false; + timers.dealAnimationHandRenderSnapshot = null; + timers.dealAnimationTimer = null; + render.renderHands(); + actions.setStatus("opensAuction", { seat: helpers.seatAt(state.turnIndex) }); + actions.continueAuction(); + }, dealAnimationMs); + } + + function clearDealAnimationTimer() { + if (timers.dealAnimationTimer) window.clearTimeout(timers.dealAnimationTimer); + timers.dealAnimationTimer = null; + timers.dealAnimationHandsLocked = false; + timers.dealAnimationHandRenderSnapshot = null; + } + + function replayHand() { + if (state.practice?.id) { + 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 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, + level: scenario.level, + focus: [...(scenario.focus || [])], + systemId: scenario.systemId, + expectedAuction: scenario.expectedAuction || [], + expectedContract: scenario.expectedContract || null, + expectedPlayPlan: scenario.expectedPlayPlan || null, + expectedScore: scenario.expectedScore || null, + teachingPoints: lesson?.teachingPoints ? [...lesson.teachingPoints] : (scenario.teachingPoints || []), + explanationKeys: scenario.explanationKeys || [], + testGoal: scenario.testGoal || "", + lessonId: lesson?.id || null, + lessonNumber: lesson?.number || null, + lessonTitle: lesson?.title || "", + challenge: lesson?.challenge || "", + lessonStartMode: lesson?.startMode || "", + lessonFocus: lesson?.focus ? [...lesson.focus] : [], + lessonIntro: lesson?.intro || "", + lessonReviewFeedback: lesson?.reviewFeedback ? [...lesson.reviewFeedback] : [], + 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, + scheduleDealAnimationEnd, + startHand, + startPracticeHand, + startPreparedHand + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/app/helpers.js b/scripts/app/helpers.js new file mode 100644 index 0000000..870974a --- /dev/null +++ b/scripts/app/helpers.js @@ -0,0 +1,209 @@ +(function registerBridgeAppHelpers(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerHelpers = function registerHelpers(runtime) { + const { constants, helpers, rules, state, text } = runtime; + const { rankLabel, seats, suitSymbols } = constants; + + function dealHands(seed = state.dealSeed) { + return rules.dealHands(rules.randomFromSeed(seed)); + } + + function cloneHands(hands) { + return Object.fromEntries(seats.map((seat) => [seat, hands[seat].map((card) => ({ ...card }))])); + } + + function vulnerabilityForDeal(dealNumber) { + return rules.vulnerabilityForDeal(dealNumber); + } + + function dealerIndexForDeal(dealNumber) { + return rules.dealerIndexForDeal(dealNumber); + } + + function vulnerabilityName(vulnerability = state.vulnerability) { + return { + none: t("vulnerabilityNone"), + NS: t("vulnerabilityNS"), + EW: t("vulnerabilityEW"), + both: t("vulnerabilityBoth") + }[vulnerability]; + } + + function isTeamVulnerable(team, vulnerability = state.vulnerability) { + return rules.isTeamVulnerable(team, vulnerability); + } + + function isSeatVulnerable(seat, vulnerability = state.vulnerability) { + return isTeamVulnerable(rules.teamOf(seat), vulnerability); + } + + function compareCards(a, b) { + return rules.compareCards(a, b); + } + + function calculateBridgeScore({ contract, declarer, tricksMade, vulnerability }) { + return rules.calculateBridgeScore({ contract, declarer, tricksMade, vulnerability }); + } + + function contractTrickPoints(contract) { + return rules.contractTrickPoints(contract); + } + + function overtrickPoints(contract, overtricks, vulnerable, multiplier) { + return rules.overtrickPoints(contract, overtricks, vulnerable, multiplier); + } + + function downScore(undertricks, vulnerable, multiplier) { + return rules.downScore(undertricks, vulnerable, multiplier); + } + + function legalCards(seat) { + return rules.legalCards(state.hands[seat], state.currentTrick); + } + + function isLegalCard(seat, card) { + return legalCards(seat).some((legal) => legal.id === card.id); + } + + function currentWinningPlay() { + const trump = state.contract.strain === "NT" ? null : state.contract.strain; + return rules.currentWinningPlay(state.currentTrick, trump); + } + + function beats(card, best, leadSuit, trump) { + return rules.beats(card, best, leadSuit, trump); + } + + function highestBidCall() { + return rules.highestBidCall(state.auction); + } + + function highestBid() { + return rules.highestBid(state.auction); + } + + function isBidHigher(bid, current) { + return rules.isBidHigher(bid, current); + } + + function isPass(bid) { + return rules.isPass(bid); + } + + function isDouble(bid) { + return rules.isDouble(bid); + } + + function isRedouble(bid) { + return rules.isRedouble(bid); + } + + function isContractBid(bid) { + return rules.isContractBid(bid); + } + + function normalizeBid(bid) { + return rules.normalizeBid(bid); + } + + function formatBid(bid) { + const base = `${bid.level}${suitSymbols[bid.strain]}`; + if (bid.redoubled) return `${base} xx`; + if (bid.doubled) return `${base} x`; + return base; + } + + function formatCall(call) { + if (isPass(call)) return t("pass"); + if (isDouble(call)) return t("double"); + if (isRedouble(call)) return t("redouble"); + return formatBid(call); + } + + function cardText(card) { + return `${rankLabel[card.rank] || card.rank}${suitSymbols[card.suit]}`; + } + + function seatAt(index) { + return seats[index % 4]; + } + + function teamOf(seat) { + return rules.teamOf(seat); + } + + function partnerOf(seat) { + return seat === "North" ? "South" : seat === "South" ? "North" : seat === "East" ? "West" : "East"; + } + + function leftOf(seat) { + return seats[(seats.indexOf(seat) + 1) % 4]; + } + + function localizeArgs(args) { + const localized = { ...args }; + ["seat", "leader", "declarer", "dummy"].forEach((key) => { + if (localized[key]) localized[key] = seatName(localized[key]); + }); + if (localized.resultKey) { + localized.result = t(localized.resultKey, localized.resultArgs || {}); + } + return localized; + } + + function t(key, args = {}) { + const template = text[key] || key; + return Object.entries(args).reduce((message, [name, value]) => message.replaceAll(`{${name}}`, value), template); + } + + function seatName(seat) { + return text.seats[seat] || seat; + } + + function suitName(suit) { + return text.suits[suit] || suit; + } + + Object.assign(helpers, { + beats, + calculateBridgeScore, + cardText, + cloneHands, + compareCards, + contractTrickPoints, + currentWinningPlay, + dealerIndexForDeal, + dealHands, + downScore, + formatBid, + formatCall, + highestBid, + highestBidCall, + isBidHigher, + isContractBid, + isDouble, + isLegalCard, + isPass, + isRedouble, + isSeatVulnerable, + isTeamVulnerable, + leftOf, + legalCards, + localizeArgs, + normalizeBid, + overtrickPoints, + partnerOf, + sameCall: rules.sameCall, + seatAt, + seatName, + suitName, + t, + teamOf, + vulnerabilityForDeal, + vulnerabilityName + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/app/public-api.js b/scripts/app/public-api.js new file mode 100644 index 0000000..fab43be --- /dev/null +++ b/scripts/app/public-api.js @@ -0,0 +1,125 @@ +(function registerBridgePublicApi(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerPublicApi = function registerPublicApi(runtime) { + const { actions, els, helpers, render, rules, state } = runtime; + + const BridgeApp = { + state, + els, + rules, + actions: { + startHand: actions.startHand, + startPracticeHand: actions.startPracticeHand, + startLesson: actions.startLesson, + autoCompleteAuction: actions.autoCompleteAuction, + enterContractReveal: actions.enterContractReveal, + startPlayFromContractReveal: actions.startPlayFromContractReveal, + autoCompletePlay: actions.autoCompletePlay, + continuePlay: actions.continuePlay, + playCard: actions.playCard, + makeBid: actions.makeBid, + replayHand: actions.replayHand, + jumpToTrickOverview: actions.jumpToTrickOverview, + createSituationSeed: actions.createSituationSeed + }, + render: { + renderAll: render.renderAll, + renderHands: render.renderHands, + renderAuction: render.renderAuction, + renderBidControls: render.renderBidControls, + renderPlayPlan: render.renderPlayPlan, + renderHistory: render.renderHistory, + renderReview: render.renderReview, + renderScoreTable: render.renderScoreTable, + renderPlayedCard: render.renderPlayedCard, + clearTrickSlots: render.clearTrickSlots + }, + helpers: { + seatAt: helpers.seatAt, + seatName: helpers.seatName, + suitName: helpers.suitName, + cardText: helpers.cardText, + legalCards: helpers.legalCards, + chooseCard: actions.chooseCard, + autoPlayCard: actions.autoPlayCard, + chooseCardPlayResult: actions.chooseCardPlayResult, + chooseRecommendedBidResult: actions.chooseRecommendedBidResult, + currentRecommendedCard: actions.currentRecommendedCard, + sameCall: helpers.sameCall + } + }; + + root.BridgeApp = BridgeApp; + root.BridgeAppContext = BridgeApp; + runtime.publicApi = BridgeApp; + + if (new URLSearchParams(root.location?.search || "").has("testHooks")) { + root.BridgeAppTestHooks = createBridgeAppTestHooks(BridgeApp); + } + + function createBridgeAppTestHooks(app) { + const setState = (nextState) => { + const patch = typeof nextState === "function" ? nextState(state) : nextState; + if (!patch || typeof patch !== "object") return getState(); + Object.assign(state, patch); + return getState(); + }; + + const setDeveloperMode = (enabled) => { + state.developerMode = Boolean(enabled); + render.renderAll(); + return getState(); + }; + + const setGuidanceMode = (enabled) => { + state.guidanceMode = Boolean(enabled); + render.renderAll(); + return getState(); + }; + + return { + app, + rules, + startHand: actions.startHand, + startPracticeHand: actions.startPracticeHand, + startLesson: actions.startLesson, + autoCompleteAuction: actions.autoCompleteAuction, + enterContractReveal: actions.enterContractReveal, + startPlayFromContractReveal: actions.startPlayFromContractReveal, + autoCompletePlay: actions.autoCompletePlay, + renderAll: render.renderAll, + continuePlay: actions.continuePlay, + playCard: actions.playCard, + makeBid: actions.makeBid, + setState, + getState, + makeCard, + clearTrickSlots: render.clearTrickSlots, + renderPlayedCard: render.renderPlayedCard, + setDeveloperMode, + setGuidanceMode, + loadSeedFromInput: actions.loadSeedFromInput, + createSituationSeed: actions.createSituationSeed, + chooseRecommendedBidResult: actions.chooseRecommendedBidResult, + chooseCardPlayResult: actions.chooseCardPlayResult, + chooseCard: actions.chooseCard, + autoPlayCard: actions.autoPlayCard, + legalCards: helpers.legalCards, + seatAt: helpers.seatAt, + sameCall: helpers.sameCall, + getEls: () => els + }; + } + + function getState() { + return JSON.parse(JSON.stringify(state)); + } + + function makeCard(id) { + return { id, rank: id.slice(0, -1), suit: id.slice(-1) }; + } + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/app/runtime.js b/scripts/app/runtime.js new file mode 100644 index 0000000..573aa28 --- /dev/null +++ b/scripts/app/runtime.js @@ -0,0 +1,252 @@ +(function initBridgeAppRuntime(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + function create({ rules, transitions, reviewPlayback, text }) { + if (!rules) throw new Error("bridge-rules.js must load before app.js"); + if (!transitions) throw new Error("state-transitions.js must load before app.js"); + if (!reviewPlayback) throw new Error("review-playback.js must load before app.js"); + if (!text) throw new Error("scripts/copy/text-nl.js must load before app runtime"); + + const constants = createConstants(rules); + const dom = createDomRefs(); + const runtime = { + actions: {}, + bootstrap: { + steps: [], + init() { + this.steps.forEach((step) => step()); + } + }, + constants, + dom, + els: dom.els, + helpers: {}, + media: { + mobileLayoutQuery: root.matchMedia?.("(max-width: 760px)") || null, + mobileBiddingLayoutQuery: null, + stableSidebarLayoutQuery: root.matchMedia?.("(min-width: 1180px)") || null + }, + render: {}, + reviewPlayback, + rules, + state: createInitialState(constants.defaultBiddingSystem), + text, + timers: { + illegalActionFeedbackTimer: null, + flowGeneration: 0, + dealAnimationTimer: null, + dealAnimationHandsLocked: false, + dealAnimationHandRenderSnapshot: null + }, + transitions + }; + runtime.media.mobileBiddingLayoutQuery = runtime.media.mobileLayoutQuery; + return runtime; + } + + function createConstants(rules) { + const defaultBiddingSystem = rules.biddingSystems.fiveCardHigh; + return { + biddingBoxStrains: ["NT", "S", "H", "D", "C"], + defaultBiddingSystem, + handSuitOrder: ["S", "H", "C", "D"], + rankLabel: { T: "10", J: "J", Q: "Q", K: "K", A: "A" }, + rankOrder: ["2", "3", "4", "5", "6", "7", "8", "9", "T", "J", "Q", "K", "A"], + roleSeparator: "\u2022", + seats: ["North", "East", "South", "West"], + separatorDot: "\u00b7", + suitSymbols: { C: "\u2663", D: "\u2666", H: "\u2665", S: "\u2660", NT: "NT" } + }; + } + + function createInitialState(defaultBiddingSystem) { + return { + hands: {}, + phase: "idle", + dealerIndex: 0, + turnIndex: 0, + auction: [], + contract: null, + declarer: null, + dummy: null, + leader: null, + currentTrick: [], + awaitingTrickAdvance: false, + trickAdvanceArmed: false, + trickClearAnimating: false, + pendingTrickWinner: null, + tricks: { NS: 0, EW: 0 }, + trickHistory: [], + reviewTrickCursor: null, + reviewCursor: null, + playExplanations: [], + playPlan: null, + playPlanKey: null, + originalHands: {}, + dealNumber: 0, + vulnerability: "none", + animateDeal: false, + developerMode: false, + guidanceMode: false, + showPlayHistory: false, + showAdvancedBidControls: false, + biddingSystemId: defaultBiddingSystem.id, + biddingAgreements: { ...defaultBiddingSystem.conventionDefaults }, + pendingStop: false, + pendingAlert: false, + status: { key: "chooseAndDeal", args: {} }, + finalScore: null, + scoreOverviewDismissed: false, + dealSeed: null, + seedMessage: null, + practice: null, + feedbackStatus: null, + illegalActionFeedback: null, + lessonActionFeedback: null, + handSuitFocus: null, + lessonBoardAcknowledged: [], + lessonModeSettingsSnapshot: null, + lessonTableTaskDone: false + }; + } + + function createDomRefs() { + const seatEls = { + North: document.querySelector("#north-hand"), + East: document.querySelector("#east-hand"), + South: document.querySelector("#south-hand"), + West: document.querySelector("#west-hand") + }; + const slotEls = { + North: document.querySelector(".trick-north"), + East: document.querySelector(".trick-east"), + South: document.querySelector(".trick-south"), + West: document.querySelector(".trick-west") + }; + const seatAuctionEls = { + North: document.querySelector("#north-auction-calls"), + East: document.querySelector("#east-auction-calls"), + South: document.querySelector("#south-auction-calls"), + West: document.querySelector("#west-auction-calls") + }; + return { + seatAuctionEls, + seatEls, + slotEls, + els: { + appShell: document.querySelector(".app-shell"), + title: document.querySelector("#app-title"), + heading: document.querySelector("#app-heading"), + playMode: document.querySelector("#play-mode"), + appMenu: document.querySelector(".app-menu"), + settingsSummary: document.querySelector("#settings-summary"), + developerOnlyElements: document.querySelectorAll("[data-developer-only]"), + developerMode: document.querySelector("#developer-mode"), + developerModeLabel: document.querySelector("#developer-mode-label"), + developerModeDescription: document.querySelector("#developer-mode-description"), + guidanceMode: document.querySelector("#guidance-mode"), + guidanceModeLabel: document.querySelector("#guidance-mode-label"), + guidanceModeDescription: document.querySelector("#guidance-mode-description"), + playHistoryMode: document.querySelector("#play-history-mode"), + playHistoryModeLabel: document.querySelector("#play-history-mode-label"), + playHistoryModeDescription: document.querySelector("#play-history-mode-description"), + hintButton: document.querySelector("#hint-button"), + openFeedback: document.querySelector("#open-feedback"), + openLessons: document.querySelector("#open-lessons"), + lessonsDialog: document.querySelector("#lessons-dialog"), + closeLessons: document.querySelector("#close-lessons"), + lessonsEyebrow: document.querySelector("#lessons-eyebrow"), + lessonsTitle: document.querySelector("#lessons-title"), + lessonsIntro: document.querySelector("#lessons-intro"), + lessonsList: document.querySelector("#lessons-list"), + openGlossary: document.querySelector("#open-glossary"), + glossaryDialog: document.querySelector("#glossary-dialog"), + closeGlossary: document.querySelector("#close-glossary"), + glossaryTitle: document.querySelector("#glossary-title"), + glossarySearch: document.querySelector("#glossary-search"), + glossaryList: document.querySelector("#glossary-list"), + glossaryTerm: document.querySelector("#glossary-term"), + glossaryDefinition: document.querySelector("#glossary-definition"), + openScoreTable: document.querySelector("#open-score-table"), + scoreTableDialog: document.querySelector("#score-table-dialog"), + closeScoreTable: document.querySelector("#close-score-table"), + newHand: document.querySelector("#new-hand"), + sameHand: document.querySelector("#same-hand"), + replayPanel: document.querySelector("#replay-panel"), + replayTitle: document.querySelector("#replay-title"), + replayContract: document.querySelector("#replay-contract"), + replayResult: document.querySelector("#replay-result"), + replayScore: document.querySelector("#replay-score"), + replayScoreHelp: document.querySelector("#replay-score-help"), + replayScoreExplanation: document.querySelector("#replay-score-explanation"), + replayClose: document.querySelector("#replay-close"), + replayNewHand: document.querySelector("#replay-new-hand"), + replaySameHand: document.querySelector("#replay-same-hand"), + quickReview: document.querySelector("#quick-review"), + seedLabel: document.querySelector("#seed-label"), + seedInput: document.querySelector("#seed-input"), + loadSeed: document.querySelector("#load-seed"), + copySeed: document.querySelector("#copy-seed"), + seedDescription: document.querySelector("#seed-description"), + tableArea: document.querySelector(".table-area"), + contractReveal: document.querySelector("#contract-reveal"), + contractRevealBid: document.querySelector("#contract-reveal-bid"), + contractRevealMeta: document.querySelector("#contract-reveal-meta"), + contractRevealLead: document.querySelector("#contract-reveal-lead"), + sidePanel: document.querySelector(".side-panel"), + auctionPanel: document.querySelector(".auction-panel"), + mobileBiddingSlot: document.querySelector("#mobile-bidding-slot"), + northLabel: document.querySelector("#north-label"), + eastLabel: document.querySelector("#east-label"), + southLabel: document.querySelector("#south-label"), + 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"), + reviewResult: document.querySelector("#review-result"), + reviewSummary: document.querySelector("#review-summary"), + reviewTricks: document.querySelector("#review-tricks"), + feedbackDialog: document.querySelector("#feedback-dialog"), + feedbackTitle: document.querySelector("#feedback-title"), + feedbackState: document.querySelector("#feedback-state"), + feedbackTypeLabel: document.querySelector("#feedback-type-label"), + feedbackType: document.querySelector("#feedback-type"), + feedbackMessageLabel: document.querySelector("#feedback-message-label"), + feedbackMessage: document.querySelector("#feedback-message"), + feedbackDetailField: document.querySelector("#feedback-detail-field"), + feedbackDetailLabel: document.querySelector("#feedback-detail-label"), + feedbackDetail: document.querySelector("#feedback-detail"), + copyFeedback: document.querySelector("#copy-feedback"), + mailFeedback: document.querySelector("#mail-feedback"), + closeFeedback: document.querySelector("#close-feedback"), + feedbackDescription: document.querySelector("#feedback-description"), + bidControlsTitle: document.querySelector("#bid-controls-title"), + bidControls: document.querySelector("#bid-controls"), + bidExplanations: document.querySelector("#bid-explanations"), + playPlan: document.querySelector("#play-plan-panel"), + playExplanations: document.querySelector("#play-explanations"), + auctionLog: document.querySelector("#auction-log"), + dealerBadge: document.querySelector("#dealer-badge"), + contract: document.querySelector("#contract"), + trickArea: document.querySelector("#trick-area"), + status: document.querySelector("#status"), + guidancePanel: document.querySelector("#guidance-panel"), + dummyNotice: document.querySelector("#dummy-notice"), + lessonBanner: document.querySelector("#lesson-banner"), + tableFeedback: document.querySelector("#table-feedback"), + trickAdvanceHint: document.querySelector("#trick-advance-hint"), + scoreline: document.querySelector("#scoreline"), + history: document.querySelector("#history"), + trickCount: document.querySelector("#trick-count") + } + }; + } + + root.BridgeAppRuntime = { create }; + modules._order = modules._order || []; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/copy/text-nl.js b/scripts/copy/text-nl.js index 2ed7f64..706d063 100644 --- a/scripts/copy/text-nl.js +++ b/scripts/copy/text-nl.js @@ -17,7 +17,7 @@ const text = { guidanceMode: "AI-suggesties", playHistoryMode: "Speelgeschiedenis", bidExplanations: "Bieduitleg", - replayTitle: "Speel opnieuw", + replayTitle: "Scoreoverzicht", newHand: "Nieuwe hand", sameHand: "Zelfde hand", quickReview: "Test slagenoverzicht", @@ -100,7 +100,7 @@ const text = { bidBox: "Biedbox", bidBoxYourTurn: "Biedbox · jij bent aan zet", history: "Speelgeschiedenis", - review: "Handoverzicht", + review: "Scoreoverzicht", openingLead: "Eerste kaart", finalContract: "Contract", passedOut: "Rondpas", @@ -121,7 +121,7 @@ const text = { auction: "Biedverloop", hands: "Handen", trickOverview: "Slagenoverzicht", - reviewTrickKeyboardHelp: "Developermodus: gebruik \u2190 en \u2192 om door de slagen te lopen.", + reviewTrickKeyboardHelp: "Klik op een slagnummer of gebruik \u2190/\u2192 om kaart voor kaart terug te kijken.", trickLegendLead: "Uit", trickLegendNSWin: "Noord/Zuid wint", trickLegendEWWin: "Oost/West wint", @@ -191,3 +191,5 @@ const text = { seats: { North: "Noord", East: "Oost", South: "Zuid", West: "West" }, suits: { C: "klaveren", D: "ruiten", H: "harten", S: "schoppen", NT: "sans-atout" } }; + +globalThis.BridgeTextNl = text; diff --git a/scripts/feedback/controller.js b/scripts/feedback/controller.js new file mode 100644 index 0000000..ed15ad1 --- /dev/null +++ b/scripts/feedback/controller.js @@ -0,0 +1,264 @@ +(function registerBridgeFeedback(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerFeedback = function registerFeedback(runtime) { + const { actions, els, helpers, render, state } = runtime; + +function initFeedbackDialog() { + els.openFeedback.addEventListener("click", openFeedbackDialog); + els.closeFeedback.addEventListener("click", closeFeedbackDialog); + els.copyFeedback.addEventListener("click", copyFeedbackReport); + els.mailFeedback.addEventListener("click", submitFeedbackReport); + els.feedbackType.addEventListener("change", updateFeedbackQuestions); + els.feedbackDialog.addEventListener("click", (event) => { + if (event.target === els.feedbackDialog) closeFeedbackDialog(); + }); +} + +function applyFeedbackStaticText() { + els.feedbackTitle.textContent = helpers.t("feedbackTitle"); + els.feedbackTypeLabel.textContent = helpers.t("feedbackTypeLabel"); + els.feedbackMessageLabel.textContent = helpers.t("feedbackMessageLabel"); + els.feedbackMessage.placeholder = helpers.t("feedbackMessagePlaceholder"); + els.feedbackDetailLabel.textContent = helpers.t("feedbackDetailLabel"); + els.copyFeedback.textContent = helpers.t("copyFeedback"); + els.mailFeedback.textContent = helpers.t("mailFeedback"); + els.closeFeedback.setAttribute("aria-label", helpers.t("closeFeedback")); + els.feedbackDescription.textContent = helpers.t("feedbackHelp"); + Object.entries(helpers.t("feedbackTypes")).forEach(([value, label]) => { + const option = els.feedbackType.querySelector(`[value="${value}"]`); + if (option) option.textContent = label; + }); + updateFeedbackQuestions(); +} + +function openFeedbackDialog() { + state.feedbackStatus = null; + actions.closeAppMenu(); + updateFeedbackQuestions(); + renderFeedbackStatus(); + if (typeof els.feedbackDialog.showModal === "function") { + els.feedbackDialog.showModal(); + } else { + els.feedbackDialog.setAttribute("open", ""); + } + els.feedbackMessage.focus(); +} + +function updateFeedbackQuestions() { + const prompts = helpers.t("feedbackPrompts") || {}; + const prompt = prompts[els.feedbackType.value] || prompts.confusion || {}; + els.feedbackMessageLabel.textContent = prompt.primaryLabel || helpers.t("feedbackMessageLabel"); + els.feedbackMessage.placeholder = prompt.primaryPlaceholder || helpers.t("feedbackMessagePlaceholder"); + const hasDetailQuestion = Boolean(prompt.secondaryLabel); + els.feedbackDetailField.hidden = !hasDetailQuestion; + els.feedbackDetailLabel.textContent = prompt.secondaryLabel || helpers.t("feedbackDetailLabel"); + els.feedbackDetail.placeholder = prompt.secondaryPlaceholder || ""; + if (!hasDetailQuestion) els.feedbackDetail.value = ""; +} + +function closeFeedbackDialog() { + if (typeof els.feedbackDialog.close === "function") { + els.feedbackDialog.close(); + } else { + els.feedbackDialog.removeAttribute("open"); + } +} + +async function copyFeedbackReport() { + try { + await actions.copyText(buildFeedbackReport()); + state.feedbackStatus = helpers.t("feedbackCopied"); + } catch { + state.feedbackStatus = helpers.t("feedbackCopyFailed"); + } + renderFeedbackStatus(); +} + +async function submitFeedbackReport(event) { + event?.preventDefault(); + const endpoint = feedbackSubmitEndpoint(); + if (!endpoint) { + state.feedbackStatus = helpers.t("feedbackSubmitMissingEndpoint"); + renderFeedbackStatus(); + return; + } + + setFeedbackSubmitting(true); + state.feedbackStatus = helpers.t("feedbackSubmitSending"); + renderFeedbackStatus(); + + try { + await sendFeedbackReport(endpoint, buildFeedbackPayload()); + state.feedbackStatus = helpers.t("feedbackSubmitSent"); + resetFeedbackForm(); + } catch { + state.feedbackStatus = helpers.t("feedbackSubmitFailed"); + } finally { + setFeedbackSubmitting(false); + } + renderFeedbackStatus(); +} + +function feedbackSubmitEndpoint() { + return String(globalThis.BridgeFeedbackConfig?.endpoint || "").trim(); +} + +function setFeedbackSubmitting(isSubmitting) { + if (!els.mailFeedback) return; + els.mailFeedback.disabled = isSubmitting; + els.mailFeedback.textContent = isSubmitting ? helpers.t("feedbackSubmitting") : helpers.t("mailFeedback"); +} + +function resetFeedbackForm() { + els.feedbackType.value = "confusion"; + els.feedbackMessage.value = ""; + els.feedbackDetail.value = ""; + updateFeedbackQuestions(); +} + +async function sendFeedbackReport(endpoint, payload) { + await fetch(endpoint, { + method: "POST", + mode: "no-cors", + body: JSON.stringify(payload) + }); +} + +function buildFeedbackPayload() { + const feedbackTypes = helpers.t("feedbackTypes"); + const type = els.feedbackType.value; + const typeLabel = feedbackTypes[type] || type; + const repeatCode = actions.currentRepeatCode() || ""; + const answers = currentFeedbackAnswers(); + return { + source: "bridge-app", + type, + typeLabel, + message: answers.message, + feedbackQuestion: answers.primaryLabel, + feedbackAnswer: answers.primary, + feedbackDetailQuestion: answers.secondaryLabel, + feedbackDetail: answers.secondary, + report: buildFeedbackReport(), + repeatCode, + phase: state.phase, + phaseLabel: phaseName(state.phase), + dealNumber: state.dealNumber || "", + vulnerability: state.vulnerability || "", + vulnerabilityLabel: helpers.vulnerabilityName(), + contract: feedbackContractText(), + declarer: state.declarer || "", + turnSeat: currentFeedbackTurnSeat(), + dummyVisible: feedbackDummyVisibility(), + lessonId: state.practice?.lessonId || "", + practiceHandId: state.practice?.id || "", + startMode: currentFeedbackStartMode(), + pageUrl: globalThis.location?.href || "", + userAgent: globalThis.navigator?.userAgent || "", + language: globalThis.navigator?.language || "", + createdAt: new Date().toISOString() + }; +} + +function renderFeedbackStatus() { + els.feedbackState.textContent = state.feedbackStatus || ""; +} + +function buildFeedbackReport() { + const feedbackTypes = helpers.t("feedbackTypes"); + const type = feedbackTypes[els.feedbackType.value] || els.feedbackType.value; + const answers = currentFeedbackAnswers(); + const answerLines = [ + answers.primaryLabel, + answers.primary || helpers.t("feedbackNoMessage") + ]; + if (answers.secondaryLabel) { + answerLines.push("", answers.secondaryLabel, answers.secondary || helpers.t("feedbackAnswerMissing")); + } + return [ + "## Feedback", + "", + `Type: ${type}`, + ...answerLines, + "", + `${helpers.t("feedbackSituationSeed")}: ${actions.currentRepeatCode() || helpers.t("none")}` + ].join("\n").trimEnd(); +} + +function currentFeedbackAnswers() { + const prompts = helpers.t("feedbackPrompts") || {}; + const prompt = prompts[els.feedbackType.value] || prompts.confusion || {}; + const primaryLabel = prompt.primaryLabel || helpers.t("feedbackMessageLabel"); + const secondaryLabel = prompt.secondaryLabel || ""; + const primary = els.feedbackMessage.value.trim(); + const secondary = secondaryLabel ? els.feedbackDetail.value.trim() : ""; + const message = secondaryLabel + ? [ + `${primaryLabel} ${primary || helpers.t("feedbackNoMessage")}`, + `${secondaryLabel} ${secondary || helpers.t("feedbackAnswerMissing")}` + ].join("\n") + : primary; + return { + primaryLabel, + primary, + secondaryLabel, + secondary, + message + }; +} + +function feedbackContractText() { + if (state.finalScore?.passOut) return helpers.t("passedOut"); + if (!state.contract) return ""; + return `${helpers.formatBid(state.contract)} ${helpers.t("by")} ${helpers.seatName(state.declarer)}`; +} + +function phaseName(phase) { + return { + idle: "Start", + bidding: "Bieden", + "contract-reveal": "Contract tonen", + playing: "Spelen", + complete: helpers.t("review") + }[phase] || phase; +} + +function currentFeedbackTurnSeat() { + if (!["bidding", "contract-reveal", "playing"].includes(state.phase)) return ""; + return helpers.seatAt(state.turnIndex); +} + +function feedbackDummyVisibility() { + if (!state.contract || !state.dummy) return "N.v.t."; + if (state.phase === "complete") return "Ja"; + if (state.phase === "contract-reveal") return "Nee"; + if (state.phase !== "playing") return "N.v.t."; + return actions.openingLeadHasBeenMade() ? "Ja" : "Nee"; +} + +function currentFeedbackStartMode() { + if (state.practice?.lessonStartMode === "play") return "direct-play"; + if (state.practice?.lessonStartMode) return String(state.practice.lessonStartMode); + if (state.phase === "playing" && state.contract && !state.auction.length) return "direct-play"; + if (state.phase === "bidding" || state.phase === "contract-reveal") return "auction"; + return ""; +} + + runtime.bootstrap.steps.push(initFeedbackDialog); + Object.assign(actions, { + buildFeedbackPayload, + buildFeedbackReport, + closeFeedbackDialog, + copyFeedbackReport, + openFeedbackDialog, + submitFeedbackReport + }); + Object.assign(render, { + applyFeedbackStaticText, + renderFeedbackStatus + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/flow/auction-flow.js b/scripts/flow/auction-flow.js index 52a56e8..dfab19b 100644 --- a/scripts/flow/auction-flow.js +++ b/scripts/flow/auction-flow.js @@ -1,6 +1,40 @@ +(function registerBridgeAuctionFlow(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerAuctionFlow = function registerAuctionFlow(runtime) { + const { actions, constants, helpers, render, rules, state, timers, transitions } = runtime; + const { seats, separatorDot } = constants; + const bridgeRules = rules; + const BridgeStateTransitions = transitions; + const { + calculateBridgeScore, + formatBid, + highestBid, + highestBidCall, + isBidHigher, + isContractBid, + isDouble, + isPass, + isRedouble, + normalizeBid, + sameCall, + seatAt, + t, + 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); + function continueAuction() { if (state.phase !== "bidding") return; if (state.animateDeal) return; + if (actions.lessonTableTaskIsDone?.()) return; const seat = seatAt(state.turnIndex); renderAll(); if (auctionComplete()) { @@ -11,9 +45,9 @@ function continueAuction() { setStatus("yourCall"); return; } - const scheduledFlowGeneration = flowGeneration; + const scheduledFlowGeneration = timers.flowGeneration; window.setTimeout(() => { - if (scheduledFlowGeneration !== flowGeneration) return; + if (scheduledFlowGeneration !== timers.flowGeneration) return; const bidResult = chooseBidResult(seat); makeBid(seat, bidResult.bid, bidResult); }, 560); @@ -22,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, @@ -35,11 +71,9 @@ function makeBid(seat, bid, bidResult = null) { }; if (bidResult && sameCall(bidResult.bid, typedBid)) call.bidResult = bidResult; if (bidResult && !sameCall(bidResult.bid, typedBid)) call.recommendedBidResult = bidResult; - state.auction.push(call); - state.pendingStop = false; - state.pendingAlert = false; - state.turnIndex = (state.turnIndex + 1) % 4; + Object.assign(state, BridgeStateTransitions.applyBidTransition(state, { ...call, seatCount: seats.length })); renderAll(); + if (maybeCompleteLessonTableTask("bid")) return; continueAuction(); } @@ -99,17 +133,11 @@ function finishAuction() { } function finishPassedOutHand() { - state.phase = "complete"; - state.contract = null; - state.declarer = null; - state.dummy = null; - state.leader = null; - state.finalScore = calculateBridgeScore({ - contract: null - }); - state.finalScore.scoreText = `${t("northSouth")} 0 ${separatorDot} ${t("eastWest")} 0`; - state.finalScore.made = 0; - state.finalScore.defenders = 0; + const finalScore = calculateBridgeScore({ contract: null }); + finalScore.scoreText = `${t("northSouth")} 0 ${separatorDot} ${t("eastWest")} 0`; + finalScore.made = 0; + finalScore.defenders = 0; + Object.assign(state, BridgeStateTransitions.finishPassedOutAuctionTransition(state, { finalScore })); setStatus("fourPasses"); renderAll(); } @@ -119,14 +147,14 @@ function autoCompleteAuction({ revealContract = false } = {}) { while (state.phase === "bidding" && !auctionComplete() && callCount < 80) { const seat = seatAt(state.turnIndex); const bidResult = legalAutoBidResult(seat); - state.auction.push({ + Object.assign(state, BridgeStateTransitions.applyBidTransition(state, { seat, bid: normalizeBid(bidResult.bid), bidResult, stop: false, - alert: false - }); - state.turnIndex = (state.turnIndex + 1) % 4; + alert: false, + seatCount: seats.length + })); callCount += 1; } if (state.phase !== "bidding" || !auctionComplete()) return; @@ -164,3 +192,24 @@ function legalAutoBidResult(seat) { function findDeclarer(contract) { return bridgeRules.findDeclarer(state.auction, contract); } + + Object.assign(actions, { + auctionComplete, + autoCompleteAuction, + bidEquals, + canDouble, + canRedouble, + chooseBid, + chooseBidResult, + chooseRecommendedBid, + chooseRecommendedBidResult, + continueAuction, + findDeclarer, + finishAuction, + finishPassedOutHand, + legalAutoBid, + legalAutoBidResult, + makeBid + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/flow/contract-reveal-flow.js b/scripts/flow/contract-reveal-flow.js index 017b373..8614f46 100644 --- a/scripts/flow/contract-reveal-flow.js +++ b/scripts/flow/contract-reveal-flow.js @@ -1,21 +1,30 @@ +(function registerBridgeContractRevealFlow(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerContractRevealFlow = function registerContractRevealFlow(runtime) { + const { actions, constants, els, helpers, render, state, transitions } = runtime; + const { seats } = constants; + const findDeclarer = (...args) => actions.findDeclarer(...args); + function prepareContractFromAuction(bid) { - state.contract = bid; - state.declarer = findDeclarer(bid); - state.dummy = partnerOf(state.declarer); - state.leader = leftOf(state.declarer); - state.turnIndex = seats.indexOf(state.leader); - state.currentTrick = []; - state.awaitingTrickAdvance = false; - state.trickAdvanceArmed = false; - state.pendingTrickWinner = null; + const declarer = findDeclarer(bid); + Object.assign(state, transitions.finishAuctionTransition(state, { + contract: bid, + declarer, + dummy: helpers.partnerOf(declarer), + leader: helpers.leftOf(declarer), + seats + })); } function enterContractReveal() { - const patch = BridgeStateTransitions.enterContractRevealTransition(state); + const patch = transitions.enterContractRevealTransition(state); if (!patch) return false; Object.assign(state, patch); - setStatus("contractReady", { contract: formatBid(state.contract), declarer: state.declarer }); - renderAll(); + actions.setStatus("contractReady", { contract: helpers.formatBid(state.contract), declarer: state.declarer }); + render.renderAll(); focusContractReveal(); return true; } @@ -26,11 +35,20 @@ function focusContractReveal() { } function startPlayFromContractReveal() { - const patch = BridgeStateTransitions.startPlayFromContractRevealTransition(state); + const patch = transitions.startPlayFromContractRevealTransition(state); if (!patch) return false; Object.assign(state, patch); - setStatus("lead", { leader: state.leader, declarer: state.declarer, dummy: state.dummy }); - renderAll(); - continuePlay(); + actions.setStatus("lead", { leader: state.leader, declarer: state.declarer, dummy: state.dummy }); + render.renderAll(); + actions.continuePlay(); return true; } + + Object.assign(actions, { + enterContractReveal, + focusContractReveal, + prepareContractFromAuction, + startPlayFromContractReveal + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/flow/hand-finish-flow.js b/scripts/flow/hand-finish-flow.js new file mode 100644 index 0000000..615ce9e --- /dev/null +++ b/scripts/flow/hand-finish-flow.js @@ -0,0 +1,48 @@ +(function registerBridgeHandFinishFlow(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerHandFinishFlow = function registerHandFinishFlow(runtime) { + const { actions, helpers, render, state, transitions } = runtime; + + function finishHand() { + const finalScore = finalScoreForCurrentContract(); + if (!finalScore) return; + Object.assign(state, transitions.finishHandTransition(state, { finalScore })); + const needed = state.contract.level + 6; + const made = state.finalScore.made; + const resultKey = made >= needed ? "made" : "down"; + const resultArgs = made >= needed ? { over: made - needed } : { under: needed - made }; + actions.setStatus("contractResult", { contract: helpers.formatBid(state.contract), declarer: state.declarer, resultKey, resultArgs }); + render.renderAll(); + } + + function recomputeFinalScore() { + const finalScore = finalScoreForCurrentContract(); + if (finalScore) state.finalScore = finalScore; + } + + function finalScoreForCurrentContract() { + if (!state.contract || !state.declarer) return null; + const declaringTeam = helpers.teamOf(state.declarer); + const defenders = declaringTeam === "NS" ? "EW" : "NS"; + const made = state.tricks[declaringTeam]; + const finalScore = helpers.calculateBridgeScore({ + contract: state.contract, + declarer: state.declarer, + tricksMade: made, + vulnerability: state.vulnerability + }); + finalScore.made = made; + finalScore.defenders = state.tricks[defenders]; + return finalScore; + } + + Object.assign(actions, { + finalScoreForCurrentContract, + finishHand, + recomputeFinalScore + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/flow/play-flow.js b/scripts/flow/play-flow.js index 711ced6..bf32691 100644 --- a/scripts/flow/play-flow.js +++ b/scripts/flow/play-flow.js @@ -1,3 +1,42 @@ +(function registerBridgePlayFlow(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerPlayFlow = function registerPlayFlow(runtime) { + const { actions, constants, dom, els, helpers, render, rules, state, timers, transitions } = runtime; + const { rankLabel, seats, suitSymbols } = constants; + const { slotEls } = dom; + const bridgeRules = rules; + const BridgeStateTransitions = transitions; + const { + cardText, + currentWinningPlay, + isLegalCard, + legalCards, + partnerOf, + seatAt, + seatName, + suitName, + t, + teamOf + } = helpers; + const blockingLessonBoardStep = (...args) => actions.blockingLessonBoardStep(...args); + 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); + const renderIllegalActionFeedback = (...args) => render.renderIllegalActionFeedback(...args); + const renderLessonBanner = (...args) => render.renderLessonBanner(...args); + const renderPlayPlan = (...args) => render.renderPlayPlan(...args); + const renderTrickAdvanceHint = (...args) => render.renderTrickAdvanceHint(...args); + const renderTrickSlotFocus = (...args) => render.renderTrickSlotFocus(...args); + const renderAll = (...args) => render.renderAll(...args); + const setStatus = (...args) => actions.setStatus(...args); + function autoCompletePlay() { let playCount = 0; while (state.phase === "playing" && playCount < 60) { @@ -48,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; @@ -63,13 +103,17 @@ function continuePlay() { setStatus("yourPlay"); return; } - const scheduledFlowGeneration = flowGeneration; + const scheduledFlowGeneration = timers.flowGeneration; window.setTimeout(() => { - if (scheduledFlowGeneration !== flowGeneration) return; + if (scheduledFlowGeneration !== timers.flowGeneration) return; const card = chooseCard(seat); if (!card) return; playCard(seat, card.id); - }, seat === state.dummy ? 480 : 680); + }, firstResponseAfterOpeningLead() ? 900 : seat === state.dummy ? 480 : 680); +} + +function firstResponseAfterOpeningLead() { + return state.trickHistory.length === 0 && state.currentTrick.length === 1; } function chooseCard(seat) { @@ -403,11 +447,11 @@ function confidenceName(confidence) { function showIllegalCardFeedback(seat, card) { state.illegalActionFeedback = illegalCardFeedbackText(seat, card); renderIllegalActionFeedback(); - if (illegalActionFeedbackTimer) window.clearTimeout(illegalActionFeedbackTimer); - illegalActionFeedbackTimer = window.setTimeout(() => { + if (timers.illegalActionFeedbackTimer) window.clearTimeout(timers.illegalActionFeedbackTimer); + timers.illegalActionFeedbackTimer = window.setTimeout(() => { state.illegalActionFeedback = null; renderIllegalActionFeedback(); - illegalActionFeedbackTimer = null; + timers.illegalActionFeedbackTimer = null; }, 2200); } @@ -435,10 +479,13 @@ 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(); - const animationSource = captureCardPlayAnimationSource(seat, cardId); + render.renderLessonPanel?.(); + const animationSource = render.captureCardPlayAnimationSource(seat, cardId); const ruleResult = chooseCardPlayResult(seat); const explanation = explainCardPlay(seat, card, ruleResult); Object.assign(state, BridgeStateTransitions.applyCardPlayTransition(state, { seat, card, cardId, ruleResult, explanation })); @@ -447,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 { @@ -457,14 +505,17 @@ function playCard(seat, cardId) { function renderPlayedCard(seat, card, options = {}) { slotEls[seat].innerHTML = ""; - const cardEl = createCardEl(card, true); + const cardEl = render.createCardEl(card, true); cardEl.classList.add("played"); + if (options.reviewPlayback) cardEl.classList.add("review-playback-card"); slotEls[seat].appendChild(cardEl); - animateCardPlayToSlot({ - source: options.animationSource, - targetEl: cardEl, - card - }); + if (options.animate !== false) { + render.animateCardPlayToSlot({ + source: options.animationSource, + targetEl: cardEl, + card + }); + } } function pauseCompletedTrick() { @@ -477,25 +528,39 @@ function pauseCompletedTrick() { renderGuidance(); renderLessonBanner(); renderTrickAdvanceHint(); - const scheduledFlowGeneration = flowGeneration; + const scheduledFlowGeneration = timers.flowGeneration; window.setTimeout(() => { - if (scheduledFlowGeneration !== flowGeneration) return; + if (scheduledFlowGeneration !== timers.flowGeneration) return; state.trickAdvanceArmed = true; }, 0); } function advanceCompletedTrick() { if (!state.awaitingTrickAdvance || !state.currentTrick.length) return; + if (state.trickClearAnimating) return; const winner = state.pendingTrickWinner || currentWinningPlay().seat; - Object.assign(state, BridgeStateTransitions.advanceCompletedTrickTransition(state, { + const finishAdvance = () => { + state.trickClearAnimating = false; + Object.assign(state, BridgeStateTransitions.advanceCompletedTrickTransition(state, { + winner, + winningTeam: teamOf(winner), + seats + })); + clearTrickSlots(); + setStatus("winsTrick", { seat: winner, number: state.trickHistory.length }); + renderAll(); + continuePlay(); + }; + + state.trickClearAnimating = true; + state.trickAdvanceArmed = false; + const animated = render.animateCompletedTrickToWinner({ + trickArea: els.trickArea, + slotEls, winner, - winningTeam: teamOf(winner), - seats - })); - clearTrickSlots(); - setStatus("winsTrick", { seat: winner, number: state.trickHistory.length }); - renderAll(); - continuePlay(); + onComplete: finishAdvance + }); + if (!animated) finishAdvance(); } function clearTrickSlots() { @@ -503,3 +568,29 @@ function clearTrickSlots() { slot.innerHTML = ""; }); } + + Object.assign(actions, { + advanceCompletedTrick, + autoCompletePlay, + autoPlayCard, + chooseCard, + chooseCardPlayResult, + confidenceName, + continuePlay, + currentRecommendedCard, + explainCardPlay, + explainCardPlayResult, + illegalCardFeedbackText, + isHumanControlledSeat, + isSeatVisible, + openingLeadHasBeenMade, + playCard, + pauseCompletedTrick, + showIllegalCardFeedback + }); + Object.assign(render, { + clearTrickSlots, + renderPlayedCard + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/learning/bid-explanations.js b/scripts/learning/bid-explanations.js index d95071a..30748e2 100644 --- a/scripts/learning/bid-explanations.js +++ b/scripts/learning/bid-explanations.js @@ -1,3 +1,11 @@ +(function registerBridgeBidExplanations(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerBidExplanations = function registerBidExplanations(runtime) { + const { actions, helpers, state } = runtime; + function bidExplanationSystemForResult(result = null) { const systemId = result?.system || "fiveCardHigh"; if (systemId === "fiveCardHigh") return globalThis.FiveCardHighBidExplanationsNl || null; @@ -20,43 +28,43 @@ function explainBid(call, index) { function explainBidDeviation(call, index) { const recommended = call.recommendedBidResult; - const actor = call.seat === "South" ? "Je bod" : `${seatName(call.seat)}s bod`; - return `${actor} wijkt af van de biedheuristiek. De heuristiek stelde ${formatCall(recommended.bid)} voor: ${explainBidChoiceResult(recommended)} Gekozen bod: ${explainBidFallback(call, index)}`; + const actor = call.seat === "South" ? "Je bod" : `${helpers.seatName(call.seat)}s bod`; + return `${actor} wijkt af van de biedheuristiek. De heuristiek stelde ${helpers.formatCall(recommended.bid)} voor: ${explainBidChoiceResult(recommended)} Gekozen bod: ${explainBidFallback(call, index)}`; } function explainBidFallback(call, index) { - if (isPass(call.bid)) return t("bidExplanationPass"); - if (isDouble(call.bid)) return t("bidExplanationDouble"); - if (isRedouble(call.bid)) return t("bidExplanationRedouble"); + if (helpers.isPass(call.bid)) return helpers.t("bidExplanationPass"); + if (helpers.isDouble(call.bid)) return helpers.t("bidExplanationDouble"); + if (helpers.isRedouble(call.bid)) return helpers.t("bidExplanationRedouble"); const context = auctionContextAt(index); const detail = bidMeaning(call.bid, context); - return t(detail.key, { detail: detail.text }); + return helpers.t(detail.key, { detail: detail.text }); } function bidResultMatchesCall(result, call) { - return sameCall(result?.bid, call); + return helpers.sameCall(result?.bid, call); } function explainBidChoiceResult(result) { const system = bidExplanationSystemForResult(result); if (system?.explainBidChoiceResult) return system.explainBidChoiceResult(result); - return t("bidExplanationContinuation", { detail: result?.reason || "Geen bieduitleg beschikbaar voor dit biedsysteem." }); + return helpers.t("bidExplanationContinuation", { detail: result?.reason || "Geen bieduitleg beschikbaar voor dit biedsysteem." }); } function bidMeaning(bid, context) { const system = currentBidExplanationSystem(); if (system?.bidMeaning) return system.bidMeaning(bid, context); - return { key: "bidExplanationContinuation", text: bid?.strain === "NT" ? "natuurlijk sans-atout vervolg." : `natuurlijk vervolg in ${suitName(bid?.strain)}.` }; + return { key: "bidExplanationContinuation", text: bid?.strain === "NT" ? "natuurlijk sans-atout vervolg." : `natuurlijk vervolg in ${helpers.suitName(bid?.strain)}.` }; } function recommendedBidReason(resultOrBid, seat) { if (resultOrBid?.bid && resultOrBid.ruleId) return explainBidChoiceResult(resultOrBid); const bid = resultOrBid; - if (isPass(bid)) return t("bidExplanationPass"); - if (isDouble(bid)) return t("bidExplanationDouble"); - if (isRedouble(bid)) return t("bidExplanationRedouble"); + if (helpers.isPass(bid)) return helpers.t("bidExplanationPass"); + if (helpers.isDouble(bid)) return helpers.t("bidExplanationDouble"); + if (helpers.isRedouble(bid)) return helpers.t("bidExplanationRedouble"); const detail = bidMeaning(bid, auctionContextForCall(seat)); - return t(detail.key, { detail: detail.text }); + return helpers.t(detail.key, { detail: detail.text }); } function auctionContextForCall(seat) { @@ -71,12 +79,21 @@ function auctionContextAt(index) { } function auctionContextFromPreviousCalls(previous, seat) { - const partnershipCalls = previous.filter((prior) => teamOf(prior.seat) === teamOf(seat) && isContractBid(prior.bid)); - const opponentCalls = previous.filter((prior) => teamOf(prior.seat) !== teamOf(seat) && isContractBid(prior.bid)); + const partnershipCalls = previous.filter((prior) => helpers.teamOf(prior.seat) === helpers.teamOf(seat) && helpers.isContractBid(prior.bid)); + const opponentCalls = previous.filter((prior) => helpers.teamOf(prior.seat) !== helpers.teamOf(seat) && helpers.isContractBid(prior.bid)); return { partnershipCalls, opponentCalls, openingBid: partnershipCalls[0]?.bid || null, - lastPartnerBid: [...previous].reverse().find((prior) => prior.seat === partnerOf(seat) && isContractBid(prior.bid))?.bid || null + lastPartnerBid: [...previous].reverse().find((prior) => prior.seat === helpers.partnerOf(seat) && helpers.isContractBid(prior.bid))?.bid || null }; } + + Object.assign(actions, { + bidMeaning, + explainBid, + explainBidChoiceResult, + recommendedBidReason + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); 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 c26b4bc..917e1fa 100644 --- a/scripts/learning/lesson-board-coach.js +++ b/scripts/learning/lesson-board-coach.js @@ -1,11 +1,30 @@ -const lessonBoardCoachLessonId = "les-01-wat-is-bridge"; +(function registerBridgeLessonBoardCoach(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerLessonBoardCoach = function registerLessonBoardCoach(runtime) { + const { actions, constants, dom, els, helpers, render, state, transitions } = runtime; + const { separatorDot } = constants; + const { seatEls, slotEls } = dom; + const legalCards = (...args) => helpers.legalCards(...args); + const openingLeadHasBeenMade = (...args) => actions.openingLeadHasBeenMade(...args); + const isHumanControlledSeat = (...args) => actions.isHumanControlledSeat(...args); + 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; @@ -13,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"); @@ -37,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; } @@ -55,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; @@ -68,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)); @@ -84,21 +305,35 @@ 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; } function acknowledgeLessonBoardStep(step) { if (!step || step.gate === "none") return; - state.lessonBoardAcknowledged = BridgeStateTransitions.acknowledgeLessonBoardStepTransition(state, step); + state.lessonBoardAcknowledged = transitions.acknowledgeLessonBoardStepTransition(state, step); renderAll(); if (step.gate === "advanceTrick") { 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; @@ -106,6 +341,7 @@ function lessonBoardBlocksHumanPlay(seat) { } function blockingLessonBoardStep() { + if (lessonTableTaskIsDone()) return true; const step = activeLessonBoardStep(); return Boolean(step && step.gate !== "none"); } @@ -131,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")); @@ -154,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 []; } @@ -171,3 +416,27 @@ function lessonBoardHighlightCards(step) { } return []; } + + Object.assign(actions, { + activeLessonBoardStep, + acknowledgeLessonBoardStep, + blockingLessonBoardStep, + lessonBoardBlocksHumanBid, + lessonBoardBlocksHumanPlay, + lessonBoardHighlightCards, + lessonBoardHighlightTargets, + lessonBoardStepAcknowledged, + lessonBoardStepReady, + lessonBoardSteps, + lessonTableTaskIsDone, + maybeCompleteLessonTableTask, + validateLessonTableAction + }); + Object.assign(render, { + clearLessonBoardHighlights, + renderLessonBanner, + 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 new file mode 100644 index 0000000..a70ea90 --- /dev/null +++ b/scripts/learning/lesson-start.js @@ -0,0 +1,125 @@ +(function registerBridgeLessonStart(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerLessonStart = function registerLessonStart(runtime) { + const { actions, constants, helpers, render, state, transitions } = runtime; + const { seats } = constants; + const continuePlay = (...args) => actions.continuePlay(...args); + const leftOf = (...args) => helpers.leftOf(...args); + const partnerOf = (...args) => helpers.partnerOf(...args); + const renderAll = (...args) => render.renderAll(...args); + const setStatus = (...args) => actions.setStatus(...args); + const startPracticeHand = (...args) => actions.startPracticeHand(...args); + +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, lessonContext, skipFlow: startAtPlay }); + if (!startAtPlay) return scenario; + + const expected = scenario.expectedContract; + if (!expected) return scenario; + const contract = globalThis.PracticeHands.contractFromText(expected.contract); + Object.assign(state, transitions.finishAuctionTransition(state, { + contract, + declarer: expected.declarer, + dummy: partnerOf(expected.declarer), + leader: leftOf(expected.declarer), + seats + })); + state.phase = "playing"; + renderAll(); + setStatus("lead", { leader: state.leader, declarer: state.declarer, dummy: state.dummy }); + continuePlay(); + return scenario; +} + +function startLessonFromUrl() { + const params = new URLSearchParams(globalThis.location?.search || ""); + const lessonId = params.get("lesson"); + const handId = params.get("hand"); + if (!lessonId || !handId) return false; + + const lesson = globalThis.BridgeLessons?.findLesson?.(lessonId); + if (!lesson) return false; + + 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 + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); 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/card-animation.js b/scripts/render/card-animation.js index 8b51de0..93fba81 100644 --- a/scripts/render/card-animation.js +++ b/scripts/render/card-animation.js @@ -1,7 +1,14 @@ -(function initCardAnimation(root) { +(function registerBridgeCardAnimation(root) { "use strict"; + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; const CARD_PLAY_ANIMATION_MS = 280; + const TRICK_CLEAR_ANIMATION_MS = 360; + + modules.registerCardAnimation = function registerCardAnimation(runtime) { + const { constants, dom, helpers, render } = runtime; + const { rankLabel, suitSymbols } = constants; + const { seatEls } = dom; function captureCardPlayAnimationSource(seat, cardId) { if (prefersReducedCardMotion()) return null; @@ -23,17 +30,18 @@ function animateCardPlayToSlot({ source, targetEl, card }) { if (!source || !targetEl || prefersReducedCardMotion()) return false; - const targetRect = targetEl.getBoundingClientRect(); - if (!targetRect.width || !targetRect.height) return false; + targetEl.classList.add("card-animation-target", "played-card-settled"); + const initialTargetRect = targetEl.getBoundingClientRect(); + if (!initialTargetRect.width || !initialTargetRect.height) { + targetEl.classList.remove("card-animation-target", "played-card-settled"); + return false; + } - targetEl.classList.add("card-animation-target"); - const flyer = createCardEl(card, !source.startsFaceDown); + const flyer = render.createCardEl(card, !source.startsFaceDown); flyer.classList.add("card-play-flyer"); setFlyerBox(flyer, source.rect); document.body.appendChild(flyer); - const dx = targetRect.left - source.rect.left; - const dy = targetRect.top - source.rect.top; let cleanedUp = false; const cleanup = () => { if (cleanedUp) return; @@ -43,6 +51,13 @@ }; window.requestAnimationFrame(() => { + const targetRect = targetEl.getBoundingClientRect(); + if (!targetRect.width || !targetRect.height) { + cleanup(); + return; + } + const dx = targetRect.left - source.rect.left; + const dy = targetRect.top - source.rect.top; if (source.startsFaceDown) { flyer.classList.remove("back"); renderCardFace(flyer, card); @@ -57,6 +72,100 @@ return true; } + function animateCompletedTrickToWinner({ trickArea, slotEls, winner, onComplete }) { + if (!trickArea || !slotEls || !winner || prefersReducedCardMotion()) return false; + + const cards = Array.from(trickArea.querySelectorAll(".card.played")); + const winnerSlot = slotEls[winner]; + if (!cards.length || !winnerSlot) return false; + + const direction = trickClearDirection(trickArea, winnerSlot, winner); + if (!direction) return false; + + let completed = false; + let endedCount = 0; + const finish = () => { + if (completed) return; + completed = true; + cards.forEach((cardEl) => { + cardEl.classList.remove("trick-card-clearing"); + cardEl.style.removeProperty("--trick-clear-x"); + cardEl.style.removeProperty("--trick-clear-y"); + cardEl.style.removeProperty("--trick-clear-rotate"); + cardEl.style.removeProperty("--trick-clear-delay"); + }); + onComplete?.(); + }; + + cards.forEach((cardEl, index) => { + const spread = (index - (cards.length - 1) / 2) * 11; + const x = direction.x * direction.distance + direction.perpX * spread; + const y = direction.y * direction.distance + direction.perpY * spread; + cardEl.style.setProperty("--trick-clear-x", `${x}px`); + cardEl.style.setProperty("--trick-clear-y", `${y}px`); + cardEl.style.setProperty("--trick-clear-rotate", `${direction.rotate + spread * 0.18}deg`); + cardEl.style.setProperty("--trick-clear-delay", `${index * 18}ms`); + const onTransitionEnd = (event) => { + if (event.propertyName !== "transform") return; + cardEl.removeEventListener("transitionend", onTransitionEnd); + endedCount += 1; + if (endedCount >= cards.length) finish(); + }; + cardEl.addEventListener("transitionend", onTransitionEnd); + }); + + window.requestAnimationFrame(() => { + cards.forEach((cardEl) => cardEl.classList.add("trick-card-clearing")); + }); + window.setTimeout(finish, TRICK_CLEAR_ANIMATION_MS + cards.length * 18 + 140); + return true; + } + + function trickClearDirection(trickArea, winnerSlot, winner) { + const areaRect = trickArea.getBoundingClientRect(); + const slotRect = winnerSlot.getBoundingClientRect(); + if (!areaRect.width || !areaRect.height || !slotRect.width || !slotRect.height) return fallbackTrickClearDirection(winner, areaRect); + + const areaCenterX = areaRect.left + areaRect.width / 2; + const areaCenterY = areaRect.top + areaRect.height / 2; + const slotCenterX = slotRect.left + slotRect.width / 2; + const slotCenterY = slotRect.top + slotRect.height / 2; + const rawX = slotCenterX - areaCenterX; + const rawY = slotCenterY - areaCenterY; + const length = Math.hypot(rawX, rawY); + if (!length) return fallbackTrickClearDirection(winner, areaRect); + + const x = rawX / length; + const y = rawY / length; + const axisDistance = Math.abs(x) > Math.abs(y) ? areaRect.width / 2 : areaRect.height / 2; + return { + x, + y, + perpX: -y, + perpY: x, + distance: axisDistance + 150, + rotate: x * 9 + }; + } + + function fallbackTrickClearDirection(winner, areaRect = {}) { + const distance = Math.max(areaRect.width || 0, areaRect.height || 0, 300) / 2 + 150; + const directions = { + North: { x: 0, y: -1, rotate: -3 }, + East: { x: 1, y: 0, rotate: 8 }, + South: { x: 0, y: 1, rotate: 3 }, + West: { x: -1, y: 0, rotate: -8 } + }; + const direction = directions[winner]; + if (!direction) return null; + return { + ...direction, + perpX: -direction.y, + perpY: direction.x, + distance + }; + } + function findVisibleHandCard(handEl, cardId) { if (!cardId) return null; const selector = `.card[data-card-id="${escapeCss(String(cardId))}"]`; @@ -79,7 +188,7 @@
${suitSymbols[card.suit]}
${label}${suitSymbols[card.suit]}
`; - cardEl.setAttribute("aria-label", `${label} ${suitName(card.suit)}`); + cardEl.setAttribute("aria-label", `${label} ${helpers.suitName(card.suit)}`); } function rectSnapshot(rect) { @@ -99,6 +208,10 @@ return root.CSS?.escape ? root.CSS.escape(value) : value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); } - root.captureCardPlayAnimationSource = captureCardPlayAnimationSource; - root.animateCardPlayToSlot = animateCardPlayToSlot; + Object.assign(render, { + animateCardPlayToSlot, + animateCompletedTrickToWinner, + captureCardPlayAnimationSource + }); + }; })(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/render/contract-reveal.js b/scripts/render/contract-reveal.js index 5eeeb01..6e73eec 100644 --- a/scripts/render/contract-reveal.js +++ b/scripts/render/contract-reveal.js @@ -1,3 +1,12 @@ +(function registerBridgeContractRevealRenderer(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerContractRevealRenderer = function registerContractRevealRenderer(runtime) { + const { constants, els, helpers, render, state } = runtime; + const { separatorDot, suitSymbols } = constants; + function renderContractReveal() { const active = state.phase === "contract-reveal" && Boolean(state.contract && state.declarer && state.dummy && state.leader); els.tableArea?.classList.toggle("has-contract-reveal", active); @@ -9,15 +18,19 @@ function renderContractReveal() { const strainClass = `strain-${String(state.contract.strain || "").toLowerCase()}`; els.contractRevealBid.className = `contract-reveal-bid ${strainClass}`; els.contractRevealBid.innerHTML = ""; - appendBidContent(els.contractRevealBid, state.contract, "contract-reveal-strain"); - els.contractRevealBid.setAttribute("aria-label", formatBid(state.contract)); + render.appendBidContent(els.contractRevealBid, state.contract, "contract-reveal-strain"); + els.contractRevealBid.setAttribute("aria-label", helpers.formatBid(state.contract)); - const declarerTeam = teamOf(state.declarer); + const declarerTeam = helpers.teamOf(state.declarer); const roleText = declarerTeam === "NS" - ? `${seatName(state.declarer)} speelt. ${seatName(state.dummy)} wordt dummy.` - : `${seatName(state.declarer)} speelt. Jij verdedigt als Zuid.`; + ? `${helpers.seatName(state.declarer)} speelt. ${helpers.seatName(state.dummy)} wordt dummy.` + : `${helpers.seatName(state.declarer)} speelt. Jij verdedigt als Zuid.`; els.contractRevealMeta.textContent = roleText; - els.contractRevealLead.textContent = `${seatName(state.leader)} komt uit ${separatorDot} nodig: ${state.contract.level + 6} slagen`; + els.contractRevealLead.textContent = `${helpers.seatName(state.leader)} komt uit ${separatorDot} nodig: ${state.contract.level + 6} slagen`; els.contractReveal.dataset.strainSymbol = suitSymbols[state.contract.strain] || state.contract.strain; - els.contractReveal.setAttribute("aria-label", `${formatBid(state.contract)} door ${seatName(state.declarer)}. Klik of druk op Enter om te spelen.`); + els.contractReveal.setAttribute("aria-label", `${helpers.formatBid(state.contract)} door ${helpers.seatName(state.declarer)}. Klik of druk op Enter om te spelen.`); } + + Object.assign(render, { renderContractReveal }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/render/play-plan.js b/scripts/render/play-plan.js index 47b946b..cbda94f 100644 --- a/scripts/render/play-plan.js +++ b/scripts/render/play-plan.js @@ -1,3 +1,14 @@ +(function registerBridgePlayPlanRenderer(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerPlayPlanRenderer = function registerPlayPlanRenderer(runtime) { + const { actions, constants, els, helpers, render, rules, state } = runtime; + const { rankLabel } = constants; + const { formatBid, seatName, suitName, t, teamOf } = helpers; + const openingLeadHasBeenMade = () => actions.openingLeadHasBeenMade(); + function renderPlayPlan() { els.playPlan.hidden = true; els.playPlan.innerHTML = ""; @@ -46,7 +57,7 @@ function ensurePlayPlan() { if (!canCreatePlayPlan()) return state.playPlan || null; const key = playPlanStateKey(); if (state.playPlan && state.playPlanKey === key) return state.playPlan; - state.playPlan = bridgeRules.createPlayPlan({ + state.playPlan = rules.createPlayPlan({ declarerHand: state.hands[state.declarer], dummyHand: state.hands[state.dummy], contract: state.contract, @@ -339,3 +350,11 @@ function playPlanPriorityBriefText(priority) { if (priority.kind === "cashWinners" || priority.kind === "cashSureWinners") return `zekere slagen incasseren`; return "de zichtbare planregel"; } + + Object.assign(actions, { + ensurePlayPlan, + playPlanReferenceText + }); + Object.assign(render, { renderPlayPlan }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/render/render-app.js b/scripts/render/render-app.js new file mode 100644 index 0000000..768e2ae --- /dev/null +++ b/scripts/render/render-app.js @@ -0,0 +1,652 @@ +(function registerBridgeAppRenderer(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerAppRenderer = function registerAppRenderer(runtime) { + const { actions, constants, dom, els, helpers, media, render, reviewPlayback, state, timers } = runtime; + const { roleSeparator, seats, separatorDot, suitSymbols } = constants; + const { seatEls, slotEls } = dom; + + function jumpToTrickOverview() { + for (let attempt = 0; attempt < 12; attempt += 1) { + actions.startHand({ skipFlow: true }); + actions.autoCompleteAuction(); + if (state.phase !== "playing") continue; + actions.autoCompletePlay(); + break; + } + renderAll(); + window.setTimeout(scrollReviewToTricks, 0); + } + + function scrollReviewToTricks() { + if (!els.reviewTricks || els.reviewPanel.hidden) return; + els.reviewPanel.scrollTop = Math.max(0, els.reviewTricks.offsetTop - els.reviewPanel.offsetTop); + } + + function scrollBidExplanationIntoView(index) { + if (!state.developerMode || els.bidExplanations.hidden) return false; + const target = els.bidExplanations.querySelector(`[data-bid-explanation-index="${index}"]`); + if (!target) return false; + target.scrollIntoView({ block: "nearest" }); + return true; + } + + function selectReviewTrickNumber(trickNumber) { + if (state.phase !== "complete" || !state.trickHistory.length) return false; + const index = state.trickHistory.findIndex((trick) => trick.number === trickNumber); + if (index < 0) return false; + return activateReviewCursor({ trickIndex: index, playIndex: 0 }); + } + + function selectReviewPlayExplanation(trickNumber, seat, card) { + if (!state.developerMode || !state.trickHistory.length) return false; + const index = state.trickHistory.findIndex((trick) => trick.number === trickNumber); + if (index < 0) return false; + const cardId = typeof card === "string" ? card : card?.id; + const playIndex = state.trickHistory[index].cards.findIndex((play) => play.seat === seat && play.card?.id === cardId); + if (playIndex < 0) return false; + const key = actions.playExplanationKey(trickNumber, seat, card); + if (state.phase === "complete") activateReviewCursor({ trickIndex: index, playIndex }, { scroll: false }); + window.setTimeout(() => scrollReviewPlayExplanationIntoView(key), 0); + return true; + } + + function scrollReviewTrickExplanationIntoView(trickNumber) { + const panel = state.phase === "complete" ? els.reviewPanel : els.historyPanel; + if (!panel || panel.hidden) return; + const target = panel.querySelector(`[data-play-explanation-trick="${trickNumber}"]`); + target?.scrollIntoView({ block: "nearest" }); + } + + function scrollReviewPlayExplanationIntoView(key) { + const panel = state.phase === "complete" ? els.reviewPanel : els.historyPanel; + if (!panel || panel.hidden) return; + const target = Array.from(panel.querySelectorAll(".play-explanation[data-play-explanation-key]")) + .find((element) => element.dataset.playExplanationKey === key); + target?.scrollIntoView({ block: "nearest" }); + } + + function moveReviewTrickCursor(delta) { + if (state.phase !== "complete" || !state.trickHistory.length || els.reviewPanel.hidden) return false; + const nextCursor = reviewPlayback.moveReviewCursor(state.trickHistory, state.reviewCursor, delta); + if (!nextCursor) return false; + activateReviewCursor(nextCursor); + return true; + } + + function activateReviewCursor(cursor, { scroll = true } = {}) { + const nextCursor = reviewPlayback.normalizeCursor(state.trickHistory, cursor); + if (!nextCursor) return false; + state.reviewCursor = nextCursor; + state.reviewTrickCursor = nextCursor.trickIndex; + state.scoreOverviewDismissed = true; + renderAll(); + if (scroll) window.setTimeout(scrollSelectedReviewTrickIntoView, 0); + return true; + } + + function ensureReviewTrickCursor() { + const cursor = reviewPlayback.normalizeCursor(state.trickHistory, state.reviewCursor); + if (!cursor) { + state.reviewTrickCursor = null; + return null; + } + state.reviewCursor = cursor; + state.reviewTrickCursor = cursor.trickIndex; + return state.trickHistory[cursor.trickIndex]?.number || null; + } + + function reviewPlayIsSelected(trickNumber, seat, card) { + const cursor = reviewPlayback.normalizeCursor(state.trickHistory, state.reviewCursor); + if (!cursor) return false; + const trick = state.trickHistory[cursor.trickIndex]; + const selectedPlay = trick?.cards?.[cursor.playIndex]; + return trick?.number === trickNumber && selectedPlay?.seat === seat && selectedPlay.card?.id === card?.id; + } + + function currentReviewPlayback() { + if (state.phase !== "complete") return null; + return reviewPlayback.deriveReviewPlayback({ + originalHands: state.originalHands, + trickHistory: state.trickHistory, + cursor: state.reviewCursor, + seats + }); + } + + function scrollSelectedReviewTrickIntoView() { + if (!els.reviewPanel || els.reviewPanel.hidden) return; + const selectedRow = els.reviewPanel.querySelector(".review-trick-row.is-review-selected"); + selectedRow?.scrollIntoView({ block: "nearest" }); + } + + function renderAll() { + applyStaticText(); + renderResponsiveLayoutState(); + render.renderScoreTable(); + renderTurnFocus(); + if (!shouldSkipHandRenderForDealAnimation()) render.renderHands(); + renderReviewPlaybackTrickSlots(); + renderTrickSlotFocus(); + render.renderAuction(); + render.renderBidControls(); + render.renderPlayPlan(); + render.renderHistory(); + render.renderLessonPanel?.(); + render.renderPlayExplanations(); + render.renderReview(); + renderContract(); + renderGuidance(); + render.renderLessonBanner(); + render.renderContractReveal(); + render.renderFeedbackStatus(); + renderIllegalActionFeedback(); + renderReplayPanel(); + els.dealerBadge.textContent = `${helpers.t("board")} ${state.dealNumber} ${separatorDot} ${helpers.t("dealer")}: ${helpers.seatName(helpers.seatAt(state.dealerIndex))}`; + els.trickCount.textContent = `${state.tricks.NS + state.tricks.EW} ${helpers.t("tricks")}`; + els.scoreline.textContent = state.finalScore + ? `${helpers.t("bridgeScore")} ${state.finalScore.scoreText} ${separatorDot} ${helpers.t("vulnerability")}: ${helpers.vulnerabilityName()}` + : `${helpers.t("northSouth")} ${state.tricks.NS} ${separatorDot} ${helpers.t("eastWest")} ${state.tricks.EW} ${separatorDot} ${helpers.t("vulnerability")}: ${helpers.vulnerabilityName()}`; + renderHint(); + renderTrickAdvanceHint(); + render.renderSeedControls(); + renderStatus(); + } + + function renderResponsiveLayoutState() { + const isBidding = state.phase === "bidding"; + const isContractReveal = state.phase === "contract-reveal"; + const useTableBiddingLayout = isBidding; + const useMobileBiddingLayout = Boolean(useTableBiddingLayout && media.mobileBiddingLayoutQuery?.matches); + const useStableSidebarLayout = Boolean(media.stableSidebarLayoutQuery?.matches); + els.appShell?.classList.toggle("is-bidding", isBidding); + els.appShell?.classList.toggle("is-playing", state.phase === "playing"); + els.appShell?.classList.toggle("is-contract-reveal", isContractReveal); + els.appShell?.classList.toggle("is-table-bidding", useTableBiddingLayout); + els.appShell?.classList.toggle("is-mobile-bidding", useMobileBiddingLayout); + els.appShell?.classList.toggle("has-stable-sidebars", useStableSidebarLayout); + + if (!els.auctionPanel || !els.sidePanel || !els.mobileBiddingSlot) return; + + if (useTableBiddingLayout) { + if (els.bidControls.parentElement !== els.mobileBiddingSlot) { + els.mobileBiddingSlot.appendChild(els.bidControls); + } + els.mobileBiddingSlot.setAttribute("aria-hidden", "false"); + return; + } + + if (els.bidControls.parentElement === els.mobileBiddingSlot) { + els.bidControlsTitle.insertAdjacentElement("afterend", els.bidControls); + } + els.mobileBiddingSlot.setAttribute("aria-hidden", "true"); + } + + function isMobileLayout() { + return Boolean(media.mobileLayoutQuery?.matches); + } + + function usesStableSidebarLayout() { + return Boolean(media.stableSidebarLayoutQuery?.matches); + } + + function focusHandSuit(seat, suit) { + if (!isMobileLayout() || !seat || !suit) return false; + state.handSuitFocus = { seat, suit }; + render.renderHands(); + return true; + } + + function clearHandSuitFocus() { + if (!state.handSuitFocus) return false; + state.handSuitFocus = null; + render.renderHands(); + return true; + } + + function clearHandSuitFocusFromOutsideClick(target) { + if (!state.handSuitFocus || !isMobileLayout()) return false; + const focusedHand = seatEls[state.handSuitFocus.seat]; + if (focusedHand?.contains(target)) return false; + clearHandSuitFocus(); + return false; + } + + function shouldSkipHandRenderForDealAnimation() { + if (!state.animateDeal || !timers.dealAnimationHandsLocked || !timers.dealAnimationHandRenderSnapshot) return false; + const current = dealAnimationHandRenderState(); + const snapshot = timers.dealAnimationHandRenderSnapshot; + return ( + current.phase === snapshot.phase && + current.hands === snapshot.hands && + current.developerMode === snapshot.developerMode && + current.guidanceMode === snapshot.guidanceMode && + current.declarer === snapshot.declarer && + current.dummy === snapshot.dummy && + current.turnIndex === snapshot.turnIndex && + current.awaitingTrickAdvance === snapshot.awaitingTrickAdvance && + current.currentTrickLength === snapshot.currentTrickLength && + current.trickHistoryLength === snapshot.trickHistoryLength + ); + } + + function lockDealAnimationHands() { + if (!state.animateDeal) return; + timers.dealAnimationHandsLocked = true; + timers.dealAnimationHandRenderSnapshot = dealAnimationHandRenderState(); + } + + function dealAnimationHandRenderState() { + const playing = state.phase === "playing"; + return { + phase: state.phase, + hands: state.phase === "complete" ? state.originalHands : state.hands, + developerMode: state.developerMode, + guidanceMode: state.guidanceMode, + declarer: state.declarer, + dummy: state.dummy, + turnIndex: playing ? state.turnIndex : null, + awaitingTrickAdvance: playing ? state.awaitingTrickAdvance : false, + currentTrickLength: playing ? state.currentTrick.length : 0, + trickHistoryLength: playing ? state.trickHistory.length : 0 + }; + } + + function prefersReducedMotion() { + return Boolean(globalThis.matchMedia?.("(prefers-reduced-motion: reduce)")?.matches); + } + + function renderReplayPanel() { + const complete = state.phase === "complete" && state.finalScore && (state.contract || state.finalScore.passOut) && !state.scoreOverviewDismissed; + els.replayPanel.hidden = !complete; + if (!complete) return; + + els.replayContract.textContent = replayContractText(); + els.replayResult.textContent = replayResultText(); + els.replayPanel.dataset.strainSymbol = replayPanelSymbol(); + const playerScore = playerScoreValue(); + els.replayScore.textContent = replayScoreText(playerScore); + els.replayScore.parentElement?.classList.toggle("is-positive", playerScore > 0); + els.replayScore.parentElement?.classList.toggle("is-negative", playerScore < 0); + els.replayScore.parentElement?.classList.toggle("is-neutral", playerScore === 0); + els.replayScoreExplanation.textContent = replayScoreExplanationText(playerScore); + } + + function renderTurnFocus() { + const focusClasses = seats.map((seat) => `turn-focus-${seat.toLowerCase()}`); + els.tableArea.classList.remove(...focusClasses); + if (state.phase === "bidding" && helpers.seatAt(state.turnIndex) === "South") { + els.tableArea.classList.add("turn-focus-south"); + return; + } + if (state.phase !== "playing" || state.awaitingTrickAdvance) return; + els.tableArea.classList.add(`turn-focus-${helpers.seatAt(state.turnIndex).toLowerCase()}`); + } + + function renderTrickSlotFocus() { + Object.values(slotEls).forEach((slot) => { + slot.classList.remove("active-trick-slot", "pending-trick-winner"); + }); + const playback = currentReviewPlayback(); + if (playback) { + if (playback.winner) { + slotEls[playback.winner]?.classList.add("pending-trick-winner"); + return; + } + if (playback.activeSeat) slotEls[playback.activeSeat]?.classList.add("active-trick-slot"); + return; + } + if (state.phase === "playing" && state.awaitingTrickAdvance && state.pendingTrickWinner) { + slotEls[state.pendingTrickWinner]?.classList.add("pending-trick-winner"); + return; + } + if (state.phase !== "playing" || state.awaitingTrickAdvance) return; + slotEls[helpers.seatAt(state.turnIndex)]?.classList.add("active-trick-slot"); + } + + function renderReviewPlaybackTrickSlots() { + if (state.phase !== "complete") return; + render.clearTrickSlots(); + const playback = currentReviewPlayback(); + if (!playback) return; + playback.currentTrick.forEach((play) => { + render.renderPlayedCard(play.seat, play.card, { animate: false, reviewPlayback: true }); + }); + } + + function applyStaticText() { + document.title = helpers.t("title"); + els.title.textContent = helpers.t("title"); + els.heading.textContent = helpers.t("heading"); + if (els.playMode) els.playMode.textContent = helpers.t("playMode"); + render.applySettingsStaticText(); + els.openFeedback.textContent = helpers.t("openFeedback"); + els.openLessons.textContent = helpers.t("openLessons"); + els.lessonsEyebrow.textContent = helpers.t("lessonsEyebrow"); + els.lessonsTitle.textContent = helpers.t("lessonsTitle"); + els.lessonsIntro.textContent = helpers.t("lessonsIntro"); + els.closeLessons.setAttribute("aria-label", helpers.t("closeLessons")); + els.openGlossary.textContent = helpers.t("openGlossary"); + els.openScoreTable.textContent = helpers.t("openScoreTable"); + els.newHand.textContent = helpers.t("newHand"); + els.sameHand.textContent = helpers.t("sameHand"); + els.replayTitle.textContent = helpers.t("replayTitle"); + els.replayNewHand.textContent = helpers.t("newHand"); + els.replaySameHand.textContent = helpers.t("sameHand"); + els.quickReview.textContent = helpers.t("quickReview"); + els.developerOnlyElements.forEach((element) => { + element.hidden = !state.developerMode; + }); + els.seedLabel.textContent = helpers.t("seed"); + els.loadSeed.textContent = helpers.t("loadSeed"); + els.copySeed.textContent = helpers.t("copySeed"); + renderSeatLabel(els.northLabel, "North", helpers.t("partner")); + renderSeatLabel(els.eastLabel, "East"); + renderSeatLabel(els.southLabel, "South", helpers.t("you")); + renderSeatLabel(els.westLabel, "West"); + els.biddingTitle.textContent = helpers.t("bidding"); + els.historyTitle.textContent = helpers.t("history"); + els.reviewTitle.textContent = helpers.t("review"); + render.applyFeedbackStaticText(); + els.closeScoreTable.setAttribute("aria-label", helpers.t("closeScoreTable")); + els.closeGlossary.setAttribute("aria-label", helpers.t("closeGlossary")); + els.glossaryTitle.textContent = helpers.t("glossaryTitle"); + els.hintButton.setAttribute("aria-label", helpers.t("hint")); + } + + function renderGuidance() { + els.guidancePanel.hidden = true; + els.guidancePanel.innerHTML = ""; + if (!state.guidanceMode || state.awaitingTrickAdvance) return; + if (actions.blockingLessonBoardStep()) return; + + const guidance = currentGuidance(); + if (!guidance) return; + + const title = document.createElement("strong"); + title.textContent = `${guidance.label}: ${guidance.action}`; + const reason = document.createElement("span"); + reason.appendChild(BridgeGlossary.linkifyText(guidance.reason)); + els.guidancePanel.append(title, reason); + els.guidancePanel.hidden = false; + } + + function renderSeatLabel(label, seat, role = "") { + if (!label) return; + let name = label.querySelector(".seat-label-name"); + if (!name || name.parentElement !== label) { + name = document.createElement("span"); + name.className = "seat-label-name"; + label.replaceChildren(name); + } + + const nextName = helpers.seatName(seat); + if (name.textContent !== nextName) name.textContent = nextName; + name.classList.toggle("is-vulnerable-team", helpers.isSeatVulnerable(seat)); + + const roleText = role ? ` ${roleSeparator} ${role}` : ""; + const roleNode = Array.from(label.childNodes).find((node) => node.nodeType === Node.TEXT_NODE); + Array.from(label.childNodes).forEach((node) => { + if (node !== name && node !== roleNode) node.remove(); + }); + + if (!roleText) { + roleNode?.remove(); + return; + } + + if (roleNode) { + if (roleNode.textContent !== roleText) roleNode.textContent = roleText; + if (roleNode.previousSibling !== name) name.after(roleNode); + return; + } + + name.after(document.createTextNode(roleText)); + } + + function currentGuidance() { + if (state.phase === "bidding" && !state.animateDeal && helpers.seatAt(state.turnIndex) === "South") return biddingGuidance(); + if (state.phase === "playing" && actions.isHumanControlledSeat(helpers.seatAt(state.turnIndex))) return cardGuidance(); + return null; + } + + function biddingGuidance() { + const result = actions.chooseRecommendedBidResult("South"); + return { + label: helpers.t("recommendedBid"), + action: helpers.formatCall(result.bid), + reason: actions.recommendedBidReason(result, "South") + }; + } + + function cardGuidance() { + const seat = helpers.seatAt(state.turnIndex); + const result = actions.chooseCardPlayResult(seat); + if (!result?.card) return null; + return { + label: helpers.t("recommendedCard"), + action: helpers.cardText(result.card), + reason: `${actions.explainCardPlayResult(result)}${actions.playPlanReferenceText(result)}` + }; + } + + function formatCall(call) { + if (helpers.isPass(call)) return helpers.t("pass"); + if (helpers.isDouble(call)) return helpers.t("double"); + if (helpers.isRedouble(call)) return helpers.t("redouble"); + return helpers.formatBid(call); + } + + function renderHint() { + els.hintButton.dataset.hint = currentHint(); + } + + function renderIllegalActionFeedback() { + if (!els.tableFeedback) return; + els.tableFeedback.hidden = !state.illegalActionFeedback; + els.tableFeedback.textContent = state.illegalActionFeedback || ""; + } + + function renderTrickAdvanceHint() { + els.trickAdvanceHint.hidden = !state.awaitingTrickAdvance; + if (!state.awaitingTrickAdvance) { + els.trickAdvanceHint.textContent = ""; + return; + } + const winner = state.pendingTrickWinner; + const number = state.trickHistory.length + 1; + const winnerText = winner ? `${helpers.seatName(winner)} wint slag ${number}. ` : ""; + els.trickAdvanceHint.textContent = `${winnerText}Klik ergens of druk op Enter voor de volgende slag.`; + } + + function currentHint() { + if (state.phase === "idle") return "Deel een nieuwe hand om te starten."; + if (state.phase === "bidding") return biddingHint(); + if (state.phase === "contract-reveal") return "Het contract is bekend. Klik op de tafel of druk op Enter om het spel te starten."; + if (state.phase === "playing") return playingHint(); + if (state.phase === "complete") { + return state.developerMode + ? "Bekijk het scoreoverzicht. In Developermodus zie je ook bied- en speeluitleg." + : "Bekijk het scoreoverzicht om het biedverloop en de slagen terug te zien. Zet Developermodus aan voor extra uitleg."; + } + return "Zet Developermodus aan om meer uitleg over biedingen en speelkeuzes te zien."; + } + + function biddingHint() { + if (state.animateDeal) return "Wacht tot de kaarten gedeeld zijn; daarna begint het bieden."; + if (helpers.seatAt(state.turnIndex) === "South") { + if (helpers.highestBid()) return "Je mag alleen hoger bieden dan het huidige hoogste bod. Pas betekent dat je nu geen bod doet."; + return "Open alleen met genoeg kracht of een duidelijke verdeling. 1SA toont meestal een gebalanceerde hand."; + } + if (helpers.highestBid()?.strain === "NT") return "Na 1SA zoek je eerst een hoge-kleurfit: 2K Stayman met een vierkaart hoog, 2R/2H transfer met een vijfkaart hoog."; + return "Als partner jouw kleur steunt, hebben jullie waarschijnlijk een fit."; + } + + function playingHint() { + const seat = helpers.seatAt(state.turnIndex); + if (!actions.openingLeadHasBeenMade()) return "Dummy wordt pas zichtbaar na de uitkomst."; + if (actions.isHumanControlledSeat(seat)) { + if (state.currentTrick.length) { + const leadSuit = state.currentTrick[0].card.suit; + const canFollow = state.hands[seat].some((card) => card.suit === leadSuit); + if (canFollow) return `Je moet ${helpers.suitName(leadSuit)} bekennen als je kunt.`; + return "Je kunt niet bekennen; je mag afgooien of troeven."; + } + if (seat === state.declarer || seat === state.dummy) return "Als leider maak je eerst een plan: tel verliezers of vaste slagen voordat je speelt."; + return "Als partner de slag al wint, is laag spelen vaak verstandig."; + } + if (state.currentTrick.length) return "De hoogste kaart in de gevraagde kleur wint, tenzij iemand troeft."; + if (state.contract.strain !== "NT") return "Troef wint van elke andere kleur."; + return "In sans-atout wint de hoogste kaart in de gevraagde kleur."; + } + + function renderContract() { + if (state.finalScore?.passOut) { + els.contract.textContent = helpers.t("passedOut"); + return; + } + if (!state.contract) { + els.contract.textContent = state.phase === "bidding" ? helpers.t("auctionInProgress") : helpers.t("dealToStart"); + return; + } + els.contract.textContent = `${helpers.formatBid(state.contract)} ${helpers.t("by")} ${helpers.seatName(state.declarer)}`; + } + + function replayContractText() { + if (state.finalScore?.passOut) return helpers.t("passedOut"); + if (!state.contract) return helpers.t("none"); + return `${helpers.formatBid(state.contract)} ${helpers.t("by")} ${helpers.seatName(state.declarer)}`; + } + + function replayPanelSymbol() { + if (!state.contract?.strain) return "B"; + return suitSymbols[state.contract.strain] || state.contract.strain; + } + + function replayResultText() { + if (state.finalScore?.passOut) return helpers.t("passOutResult"); + if (!state.finalScore || !state.contract) return helpers.t("none"); + const made = state.finalScore.made ?? state.finalScore.tricksMade; + const needed = state.finalScore.needed ?? state.contract.level + 6; + const suffix = made < needed ? ` (${needed - made} down)` : ""; + return `${made} ${made === 1 ? "slag" : "slagen"} gemaakt${suffix}`; + } + + function replayScoreText(score = playerScoreValue()) { + if (!Number.isFinite(score)) return helpers.t("none"); + if (!score) return "0 NZ"; + return `${score > 0 ? "+" : "-"}${Math.abs(score)} NZ`; + } + + function playerScoreValue() { + const score = state.finalScore; + if (!score || score.passOut) return 0; + return score.declarerTeam === "NS" ? score.score : -score.score; + } + + function replayScoreExplanationText(playerScore = playerScoreValue()) { + const score = state.finalScore; + if (!score) return ""; + if (score.passOut || !state.contract) return "Waarom deze score? Rondpas: er is geen contract en Noord/Zuid scoort 0."; + + const contractText = helpers.formatBid(state.contract); + const made = score.made ?? score.tricksMade; + const needed = score.needed ?? state.contract.level + 6; + const result = made >= needed + ? `${made - needed ? `${made - needed} overslag${made - needed === 1 ? "" : "en"}` : "precies gemaakt"}` + : `${needed - made} down`; + const declaringTeam = score.declarerTeam === "NS" ? helpers.t("northSouth") : helpers.t("eastWest"); + + if (score.contractMade) { + return `Waarom deze score? ${contractText} vraagt ${needed} slagen. ${declaringTeam} haalde ${made}: ${result}. Contractpunten ${score.contractScore}, extra slagen ${score.overtrickScore} en bonus ${score.bonusScore} geven ${replayScoreText(playerScore)}.`; + } + + return `Waarom deze score? ${contractText} vraagt ${needed} slagen. ${declaringTeam} haalde ${made}: ${result}. De downscore is ${score.undertrickPenalty} punten, dus dit is ${replayScoreText(playerScore)}.`; + } + + function toggleReplayScoreExplanation(event) { + event?.stopPropagation(); + if (!els.replayScoreExplanation || els.replayPanel.hidden) return; + const open = els.replayScoreExplanation.hidden; + els.replayScoreExplanation.hidden = !open; + els.replayScoreHelp?.setAttribute("aria-expanded", String(open)); + } + + function dismissScoreOverview(event) { + event?.stopPropagation(); + state.scoreOverviewDismissed = true; + els.replayPanel.hidden = true; + } + + function setStatus(key, args = {}) { + state.status = { key, args }; + renderStatus(); + } + + function renderStatus() { + els.status.textContent = helpers.t(state.status.key, helpers.localizeArgs(state.status.args)); + renderDummyNotice(); + } + + function renderDummyNotice() { + els.dummyNotice.hidden = true; + els.dummyNotice.textContent = ""; + if ( + state.phase !== "playing" || + !state.declarer || + !state.dummy || + !actions.openingLeadHasBeenMade() || + state.trickHistory.length > 0 + ) { + return; + } + const key = helpers.teamOf(state.declarer) === "NS" ? "dummyNoticeDeclaring" : "dummyNoticeDefending"; + els.dummyNotice.textContent = helpers.t(key, { + declarer: helpers.seatName(state.declarer), + dummy: helpers.seatName(state.dummy) + }); + els.dummyNotice.hidden = false; + } + + Object.assign(actions, { + clearHandSuitFocus, + clearHandSuitFocusFromOutsideClick, + currentReviewPlayback, + dismissScoreOverview, + ensureReviewTrickCursor, + focusHandSuit, + isMobileLayout, + jumpToTrickOverview, + lockDealAnimationHands, + moveReviewCursor: moveReviewTrickCursor, + moveReviewTrickCursor, + prefersReducedMotion, + renderIllegalActionFeedback, + renderTrickAdvanceHint, + reviewPlayIsSelected, + scrollBidExplanationIntoView, + selectReviewPlayExplanation, + selectReviewTrickNumber, + setStatus, + toggleReplayScoreExplanation, + usesStableSidebarLayout + }); + Object.assign(helpers, { formatCall, playerScoreValue, replayScoreText }); + Object.assign(render, { + applyStaticText, + renderAll, + renderContract, + renderDummyNotice, + renderGuidance, + renderIllegalActionFeedback, + renderReplayPanel, + renderResponsiveLayoutState, + renderStatus, + renderTrickAdvanceHint, + renderTrickSlotFocus, + renderTurnFocus + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/render/render-auction.js b/scripts/render/render-auction.js index 0355b32..7e0ecff 100644 --- a/scripts/render/render-auction.js +++ b/scripts/render/render-auction.js @@ -1,3 +1,34 @@ +(function registerBridgeAuctionRenderer(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerAuctionRenderer = function registerAuctionRenderer(runtime) { + const { actions, constants, dom, els, helpers, render, rules, state } = runtime; + const { biddingBoxStrains, seats, separatorDot, suitSymbols } = constants; + const { seatAuctionEls } = dom; + const bridgeRules = rules; + const { + formatCall, + highestBid, + isBidHigher, + isContractBid, + isDouble, + isPass, + isRedouble, + sameCall, + seatAt, + seatName, + t + } = helpers; + const canDouble = (...args) => actions.canDouble(...args); + const canRedouble = (...args) => actions.canRedouble(...args); + const chooseRecommendedBidResult = (...args) => actions.chooseRecommendedBidResult(...args); + const explainBid = (...args) => actions.explainBid(...args); + const makeBid = (...args) => actions.makeBid(...args); + const saveSettings = (...args) => actions.saveSettings(...args); + const scrollBidExplanationIntoView = (...args) => actions.scrollBidExplanationIntoView(...args); + function renderAuction() { els.auctionLog.innerHTML = ""; renderSeatAuctionCalls(); @@ -30,8 +61,8 @@ function auctionHistoryTable() { const cellCount = Math.max(4, offset + state.auction.length + (isAuctionReady() ? 1 : 0)); const rowCount = Math.ceil(cellCount / 4); const callByPosition = new Map(); - state.auction.forEach((call) => { - callByPosition.set(offset + callByPosition.size, call); + state.auction.forEach((call, index) => { + callByPosition.set(offset + callByPosition.size, { call, index }); }); for (let rowIndex = 0; rowIndex < rowCount; rowIndex++) { @@ -39,8 +70,8 @@ function auctionHistoryTable() { for (let seatIndex = 0; seatIndex < headers.length; seatIndex++) { const position = rowIndex * 4 + seatIndex; const seat = headers[seatIndex]; - const call = callByPosition.get(position); - row.appendChild(auctionHistoryCell(call, position, position < offset, seat)); + const entry = callByPosition.get(position); + row.appendChild(auctionHistoryCell(entry, position, position < offset, seat)); } tbody.appendChild(row); } @@ -50,13 +81,13 @@ function auctionHistoryTable() { return wrapper; } -function auctionHistoryCell(call, position, isDealerOffset, seat) { +function auctionHistoryCell(entry, position, isDealerOffset, seat) { const cell = document.createElement("td"); cell.className = "auction-history-cell"; const nextCallPosition = state.dealerIndex + state.auction.length; cell.classList.toggle("auction-active-seat", isAuctionReady() && seatAt(state.turnIndex) === seat && position === nextCallPosition); - if (call) { - cell.appendChild(auctionCallContent(call)); + if (entry) { + cell.appendChild(auctionCallContent(entry.call, entry.index)); } else { cell.textContent = isDealerOffset ? separatorDot : "-"; } @@ -92,9 +123,21 @@ function nextAuctionCallPlaceholder(seat) { return placeholder; } -function auctionCallContent(call) { +function auctionCallContent(call, index = null) { const wrapper = document.createElement("span"); wrapper.className = "auction-call"; + if (Number.isInteger(index)) { + wrapper.dataset.bidIndex = String(index); + wrapper.setAttribute("role", "button"); + wrapper.tabIndex = 0; + wrapper.setAttribute("aria-label", `Ga naar bieduitleg voor bod ${index + 1}: ${seatName(call.seat)} ${formatCall(call.bid)}`); + wrapper.addEventListener("click", () => scrollBidExplanationIntoView(index)); + wrapper.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + scrollBidExplanationIntoView(index); + }); + } if (call.stop) wrapper.appendChild(auctionBadge(t("stop"), "stop")); const textEl = document.createElement("span"); textEl.className = `auction-call-token ${auctionCallStyleClasses(call.bid)}`; @@ -124,7 +167,8 @@ function auctionBadge(label, kind) { } function renderBidExplanations() { - els.bidExplanations.hidden = state.phase !== "bidding" || !state.developerMode || !state.auction.length; + const canShowBidExplanations = ["bidding", "contract-reveal", "playing", "complete"].includes(state.phase); + els.bidExplanations.hidden = !canShowBidExplanations || !state.developerMode || !state.auction.length; els.bidExplanations.innerHTML = ""; if (els.bidExplanations.hidden) return; @@ -137,6 +181,7 @@ function renderBidExplanations() { const index = state.auction.length - 1 - reversedIndex; const item = document.createElement("div"); item.className = "bid-explanation"; + item.dataset.bidExplanationIndex = String(index); const callText = formatCall(call.bid); const bidIndex = index + 1; const title = document.createElement("strong"); @@ -273,3 +318,11 @@ function appendBidContent(parent, bid, symbolClassName) { parent.append(document.createTextNode(" x")); } } + + Object.assign(render, { + appendBidContent, + renderAuction, + renderBidControls + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/render/render-hands.js b/scripts/render/render-hands.js index 6934134..413b64b 100644 --- a/scripts/render/render-hands.js +++ b/scripts/render/render-hands.js @@ -1,15 +1,27 @@ +(function registerBridgeHandsRenderer(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerHandsRenderer = function registerHandsRenderer(runtime) { + const { actions, constants, dom, helpers, render, state } = runtime; + const { handSuitOrder, rankLabel, rankOrder, seats, suitSymbols } = constants; + const { seatEls } = dom; + function renderHands() { - const recommended = state.guidanceMode ? currentRecommendedCard() : null; + const recommended = state.guidanceMode ? actions.currentRecommendedCard() : null; + const playback = actions.currentReviewPlayback(); for (const seat of seats) { seatEls[seat].innerHTML = ""; - const activePlayHand = state.phase === "playing" && !state.awaitingTrickAdvance && seatAt(state.turnIndex) === seat; - const humanControlled = isHumanControlledSeat(seat) && state.phase === "playing"; + const activePlayHand = state.phase === "playing" && !state.awaitingTrickAdvance && helpers.seatAt(state.turnIndex) === seat; + const activeReviewHand = playback?.activeSeat === seat; + const humanControlled = actions.isHumanControlledSeat(seat) && state.phase === "playing"; seatEls[seat].classList.toggle("playable", humanControlled); - seatEls[seat].classList.toggle("active-play-hand", activePlayHand); - seatEls[seat].classList.toggle("inactive-play-hand", state.phase === "playing" && !activePlayHand); + seatEls[seat].classList.toggle("active-play-hand", activePlayHand || activeReviewHand); + seatEls[seat].classList.toggle("inactive-play-hand", (state.phase === "playing" && !activePlayHand) || (Boolean(playback?.activeSeat) && !activeReviewHand)); const complete = state.phase === "complete"; - const visible = state.developerMode || complete || isSeatVisible(seat); - const sourceHand = complete ? state.originalHands[seat] : state.hands[seat]; + const visible = state.developerMode || complete || actions.isSeatVisible(seat); + const sourceHand = playback ? playback.hands[seat] : complete ? state.originalHands[seat] : state.hands[seat]; const cards = sortedHandCards(sourceHand || []); const suitFocus = visible ? activeHandSuitFocus(seat, cards) : null; seatEls[seat].classList.toggle("suit-focus-active", Boolean(suitFocus)); @@ -26,7 +38,7 @@ function renderHands() { }); } } - lockDealAnimationHands(); + actions.lockDealAnimationHands(); } function renderVisibleHandCards(seat, cards, recommended, suitFocus = null) { @@ -68,33 +80,33 @@ function createHandCardEl(seat, card, visible, index, recommended) { if (!state.awaitingTrickAdvance && state.currentTrick.length < 4 && recommended?.seat === seat && recommended.card.id === card.id) { cardEl.classList.add("recommended-card"); } - if (visible && isHumanControlledSeat(seat) && state.phase === "playing" && !state.awaitingTrickAdvance) { - const legal = isLegalCard(seat, card); - const lessonBlocked = lessonBoardBlocksHumanPlay(seat); + if (visible && actions.isHumanControlledSeat(seat) && state.phase === "playing" && !state.awaitingTrickAdvance) { + const legal = helpers.isLegalCard(seat, card); + const lessonBlocked = actions.lessonBoardBlocksHumanPlay(seat); cardEl.classList.add(legal ? "legal" : "illegal"); cardEl.classList.toggle("lesson-play-blocked", lessonBlocked); - if (seatAt(state.turnIndex) === seat) { + if (helpers.seatAt(state.turnIndex) === seat) { if (!lessonBlocked) cardEl.tabIndex = 0; cardEl.addEventListener("click", (event) => { event.stopPropagation(); if (lessonBlocked) return; - if (shouldFocusSuitBeforePlay(seat, card)) return focusHandSuit(seat, card.suit); - if (legal) playCard(seat, card.id); - else showIllegalCardFeedback(seat, card); + if (shouldFocusSuitBeforePlay(seat, card)) return actions.focusHandSuit(seat, card.suit); + if (legal) actions.playCard(seat, card.id); + else actions.showIllegalCardFeedback(seat, card); }); cardEl.addEventListener("keydown", (event) => { if (event.key !== "Enter" && event.key !== " ") return; event.preventDefault(); if (lessonBlocked) return; - if (shouldFocusSuitBeforePlay(seat, card)) return focusHandSuit(seat, card.suit); - if (legal) playCard(seat, card.id); - else showIllegalCardFeedback(seat, card); + if (shouldFocusSuitBeforePlay(seat, card)) return actions.focusHandSuit(seat, card.suit); + if (legal) actions.playCard(seat, card.id); + else actions.showIllegalCardFeedback(seat, card); }); } else { cardEl.addEventListener("click", (event) => { event.stopPropagation(); - if (shouldFocusSuitBeforePlay(seat, card)) return focusHandSuit(seat, card.suit); - showIllegalCardFeedback(seat, card); + if (shouldFocusSuitBeforePlay(seat, card)) return actions.focusHandSuit(seat, card.suit); + actions.showIllegalCardFeedback(seat, card); }); } } @@ -102,13 +114,13 @@ function createHandCardEl(seat, card, visible, index, recommended) { } function activeHandSuitFocus(seat, cards) { - if (!isMobileLayout() || state.handSuitFocus?.seat !== seat) return null; + if (!actions.isMobileLayout() || state.handSuitFocus?.seat !== seat) return null; const suit = state.handSuitFocus.suit; return cards.some((card) => card.suit === suit) ? suit : null; } function shouldFocusSuitBeforePlay(seat, card) { - if (!isMobileLayout() || state.phase !== "playing") return false; + if (!actions.isMobileLayout() || state.phase !== "playing") return false; if (state.handSuitFocus?.seat === seat && state.handSuitFocus.suit === card.suit) return false; return seat === "South" || seat === "North"; } @@ -146,7 +158,7 @@ function createEmptySuitEl(suit) { emptyEl.className = "suit-empty"; if (suit === "D" || suit === "H") emptyEl.classList.add("red"); emptyEl.textContent = suitSymbols[suit]; - emptyEl.setAttribute("aria-label", `geen ${suitName(suit)}`); + emptyEl.setAttribute("aria-label", `geen ${helpers.suitName(suit)}`); return emptyEl; } @@ -174,6 +186,13 @@ function createCardEl(card, visible = true) {
${suitSymbols[card.suit]}
${label}${suitSymbols[card.suit]}
`; - cardEl.setAttribute("aria-label", `${label} ${suitName(card.suit)}`); + cardEl.setAttribute("aria-label", `${label} ${helpers.suitName(card.suit)}`); return cardEl; } + + Object.assign(render, { + createCardEl, + renderHands + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/render/render-review.js b/scripts/render/render-review.js index a09bc48..b030bbb 100644 --- a/scripts/render/render-review.js +++ b/scripts/render/render-review.js @@ -1,6 +1,34 @@ +(function registerBridgeReviewRenderer(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerReviewRenderer = function registerReviewRenderer(runtime) { + const { actions, constants, els, helpers, render, state } = runtime; + const { seats, suitSymbols } = constants; + const { + cardText, + formatBid, + formatCall, + isContractBid, + seatName, + suitName, + t, + teamOf + } = helpers; + const ensureReviewTrickCursor = (...args) => actions.ensureReviewTrickCursor(...args); + const explainBid = (...args) => actions.explainBid(...args); + const renderFeedbackStatus = (...args) => render.renderFeedbackStatus(...args); + const reviewPlayIsSelected = (...args) => actions.reviewPlayIsSelected(...args); + const selectReviewPlayExplanation = (...args) => actions.selectReviewPlayExplanation(...args); + const selectReviewTrickNumber = (...args) => actions.selectReviewTrickNumber(...args); + const usesStableSidebarLayout = (...args) => actions.usesStableSidebarLayout(...args); + 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"); @@ -32,61 +60,36 @@ function renderPlayExplanations() { function playExplanationEl(explanation) { const item = document.createElement("div"); item.className = "play-explanation"; + item.dataset.playExplanationTrick = String(explanation.trick); + item.dataset.playExplanationKey = playExplanationKey(explanation.trick, explanation.seat, explanation.card); const title = document.createElement("strong"); title.textContent = `${t("trick")} ${explanation.trick}: ${seatName(explanation.seat)} ${cardText(explanation.card)}`; item.append(title, document.createElement("br"), BridgeGlossary.linkifyText(explanation.text)); return item; } +function playExplanationKey(trickNumber, seat, card) { + const cardId = typeof card === "string" ? card : card?.id; + return `${trickNumber}:${seat}:${cardId || ""}`; +} + function renderReview() { const complete = state.phase === "complete" && state.finalScore && (state.contract || state.finalScore.passOut); syncHistoryPanelState(complete); els.reviewPanel.hidden = !complete; if (!complete) return; - const passOut = Boolean(state.finalScore.passOut); - const openingPlay = state.trickHistory[0]?.cards[0] || null; - const contractText = passOut ? t("passedOut") : `${formatBid(state.contract)} ${t("by")} ${seatName(state.declarer)}`; - const resultText = passOut ? t("passOutResult") : t(state.status.args.resultKey, state.status.args.resultArgs || {}); + const resultText = state.finalScore.passOut ? t("passOutResult") : t(state.status.args.resultKey, state.status.args.resultArgs || {}); els.reviewResult.textContent = resultText; els.reviewSummary.innerHTML = ""; - els.reviewSummary.appendChild(reviewSectionTitle("Samenvatting")); - [ - [t("finalContract"), contractText], - [t("declarer"), passOut ? t("none") : seatName(state.declarer)], - [t("dummy"), passOut ? t("none") : seatName(state.dummy)], - [t("result"), resultText], - [t("bridgeScore"), state.finalScore.scoreText], - [t("vulnerability"), vulnerabilityName()], - [t("openingLead"), openingPlay ? `${seatName(openingPlay.seat)} ${cardText(openingPlay.card)}` : t("none")] - ].forEach(([label, value]) => els.reviewSummary.appendChild(reviewRow(label, value))); - appendScoreExplanation(passOut, resultText); - appendLessonFeedback(passOut, resultText); - appendLessonPoints(); - els.reviewSummary.appendChild(reviewRow(t("board"), state.dealNumber)); - if (state.developerMode) { - els.reviewSummary.appendChild(reviewRow(t("seed"), currentRepeatCode() || t("none"))); - } + els.reviewSummary.hidden = true; renderFeedbackStatus(); - if (state.playPlan) { - els.reviewSummary.appendChild(reviewSectionTitle(t("playPlan"))); - els.reviewSummary.appendChild(reviewRow(t("playPlanGoal"), playPlanGoalText(state.playPlan))); - playPlanMetricTexts(state.playPlan).forEach((metric) => { - els.reviewSummary.appendChild(reviewRow(t("playPlan"), metric)); - }); - if (state.playPlan.priorities?.length) { - els.reviewSummary.appendChild(playPlanList(t("playPlanPriorities"), state.playPlan.priorities.map(playPlanPriorityText))); - } - if (state.playPlan.warnings?.length) { - els.reviewSummary.appendChild(playPlanList(t("playPlanWarnings"), state.playPlan.warnings.map(playPlanWarningText), "warning")); - } - } els.reviewTricks.innerHTML = ""; els.reviewTricks.appendChild(reviewSectionTitle(t("trickOverview"))); const selectedTrickNumber = ensureReviewTrickCursor(); - if (state.developerMode && state.trickHistory.length) { + if (state.trickHistory.length) { const hint = document.createElement("p"); hint.className = "review-trick-keyboard-help"; hint.textContent = t("reviewTrickKeyboardHelp"); @@ -105,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() { @@ -345,12 +350,22 @@ function reviewTricksTable() { const numberCell = document.createElement("th"); numberCell.scope = "row"; numberCell.className = "review-trick-number"; - numberCell.textContent = trick.number; + if (state.phase === "complete") { + const indexButton = document.createElement("button"); + indexButton.type = "button"; + indexButton.className = "review-trick-index-button"; + indexButton.textContent = trick.number; + indexButton.setAttribute("aria-label", `Bekijk slag ${trick.number} vanaf de eerste kaart`); + indexButton.addEventListener("click", () => selectReviewTrickNumber(trick.number)); + numberCell.appendChild(indexButton); + } else { + numberCell.textContent = trick.number; + } row.appendChild(numberCell); const leader = trick.cards[0]?.seat; const playsBySeat = new Map(trick.cards.map((play) => [play.seat, play])); - seats.forEach((seat) => row.appendChild(reviewTrickCell(playsBySeat.get(seat), seat, leader, trick.winner))); + seats.forEach((seat) => row.appendChild(reviewTrickCell(playsBySeat.get(seat), seat, leader, trick.winner, trick.number))); tbody.appendChild(row); }); table.appendChild(tbody); @@ -359,7 +374,7 @@ function reviewTricksTable() { return wrapper; } -function reviewTrickCell(play, seat, leader, winner) { +function reviewTrickCell(play, seat, leader, winner, trickNumber) { const cell = document.createElement("td"); cell.className = "review-trick-cell"; if (seat === leader) cell.classList.add("is-leader"); @@ -368,6 +383,22 @@ function reviewTrickCell(play, seat, leader, winner) { } if (play) { + const key = playExplanationKey(trickNumber, seat, play.card); + if (reviewPlayIsSelected(trickNumber, seat, play.card)) { + cell.classList.add("is-review-play-selected"); + cell.setAttribute("aria-current", "step"); + } + if (state.developerMode && state.playExplanations.some((explanation) => playExplanationKey(explanation.trick, explanation.seat, explanation.card) === key)) { + cell.dataset.playExplanationKey = key; + cell.tabIndex = 0; + cell.setAttribute("role", "button"); + cell.addEventListener("click", () => selectReviewPlayExplanation(trickNumber, seat, play.card)); + cell.addEventListener("keydown", (event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + selectReviewPlayExplanation(trickNumber, seat, play.card); + }); + } const card = document.createElement("span"); card.className = "review-card-pill"; if (play.card.suit === "D" || play.card.suit === "H") card.classList.add("red"); @@ -404,174 +435,14 @@ function reviewTrickLegend() { return legend; } -async function copyFeedbackReport() { - try { - await copyText(buildFeedbackReport()); - state.feedbackStatus = t("feedbackCopied"); - } catch { - state.feedbackStatus = t("feedbackCopyFailed"); - } - renderFeedbackStatus(); -} - -async function submitFeedbackReport(event) { - event?.preventDefault(); - const endpoint = feedbackSubmitEndpoint(); - if (!endpoint) { - state.feedbackStatus = t("feedbackSubmitMissingEndpoint"); - renderFeedbackStatus(); - return; - } - - setFeedbackSubmitting(true); - state.feedbackStatus = t("feedbackSubmitSending"); - renderFeedbackStatus(); - - try { - await sendFeedbackReport(endpoint, buildFeedbackPayload()); - state.feedbackStatus = t("feedbackSubmitSent"); - } catch { - state.feedbackStatus = t("feedbackSubmitFailed"); - } finally { - setFeedbackSubmitting(false); - } - renderFeedbackStatus(); -} - -function feedbackSubmitEndpoint() { - return String(globalThis.BridgeFeedbackConfig?.endpoint || "").trim(); -} - -function setFeedbackSubmitting(isSubmitting) { - if (!els.mailFeedback) return; - els.mailFeedback.disabled = isSubmitting; - els.mailFeedback.textContent = isSubmitting ? t("feedbackSubmitting") : t("mailFeedback"); -} - -async function sendFeedbackReport(endpoint, payload) { - await fetch(endpoint, { - method: "POST", - mode: "no-cors", - body: JSON.stringify(payload) - }); -} - -function buildFeedbackPayload() { - const feedbackTypes = t("feedbackTypes"); - const type = els.feedbackType.value; - const typeLabel = feedbackTypes[type] || type; - const repeatCode = currentRepeatCode() || ""; - const answers = currentFeedbackAnswers(); - return { - source: "bridge-app", - type, - typeLabel, - message: answers.message, - feedbackQuestion: answers.primaryLabel, - feedbackAnswer: answers.primary, - feedbackDetailQuestion: answers.secondaryLabel, - feedbackDetail: answers.secondary, - report: buildFeedbackReport(), - repeatCode, - phase: state.phase, - phaseLabel: phaseName(state.phase), - dealNumber: state.dealNumber || "", - vulnerability: state.vulnerability || "", - vulnerabilityLabel: vulnerabilityName(), - contract: feedbackContractText(), - declarer: state.declarer || "", - turnSeat: currentFeedbackTurnSeat(), - dummyVisible: feedbackDummyVisibility(), - lessonId: state.practice?.lessonId || "", - practiceHandId: state.practice?.id || "", - startMode: currentFeedbackStartMode(), - pageUrl: globalThis.location?.href || "", - userAgent: globalThis.navigator?.userAgent || "", - language: globalThis.navigator?.language || "", - createdAt: new Date().toISOString() - }; -} - -function renderFeedbackStatus() { - els.feedbackState.textContent = state.feedbackStatus || ""; -} - -function buildFeedbackReport() { - const feedbackTypes = t("feedbackTypes"); - const type = feedbackTypes[els.feedbackType.value] || els.feedbackType.value; - const answers = currentFeedbackAnswers(); - const answerLines = [ - answers.primaryLabel, - answers.primary || t("feedbackNoMessage") - ]; - if (answers.secondaryLabel) { - answerLines.push("", answers.secondaryLabel, answers.secondary || t("feedbackAnswerMissing")); - } - return [ - "## Feedback", - "", - `Type: ${type}`, - ...answerLines, - "", - `${t("feedbackSituationSeed")}: ${currentRepeatCode() || t("none")}` - ].join("\n").trimEnd(); -} - -function currentFeedbackAnswers() { - const prompts = t("feedbackPrompts") || {}; - const prompt = prompts[els.feedbackType.value] || prompts.confusion || {}; - const primaryLabel = prompt.primaryLabel || t("feedbackMessageLabel"); - const secondaryLabel = prompt.secondaryLabel || ""; - const primary = els.feedbackMessage.value.trim(); - const secondary = secondaryLabel ? els.feedbackDetail.value.trim() : ""; - const message = secondaryLabel - ? [ - `${primaryLabel} ${primary || t("feedbackNoMessage")}`, - `${secondaryLabel} ${secondary || t("feedbackAnswerMissing")}` - ].join("\n") - : primary; - return { - primaryLabel, - primary, - secondaryLabel, - secondary, - message + Object.assign(actions, { + playExplanationKey + }); + Object.assign(render, { + renderHistory, + renderPlayExplanations, + renderReview + }); }; -} +})(typeof globalThis !== "undefined" ? globalThis : this); -function feedbackContractText() { - if (state.finalScore?.passOut) return t("passedOut"); - if (!state.contract) return ""; - return `${formatBid(state.contract)} ${t("by")} ${seatName(state.declarer)}`; -} - -function phaseName(phase) { - return { - idle: "Start", - bidding: "Bieden", - "contract-reveal": "Contract tonen", - playing: "Spelen", - complete: t("review") - }[phase] || phase; -} - -function currentFeedbackTurnSeat() { - if (!["bidding", "contract-reveal", "playing"].includes(state.phase)) return ""; - return seatAt(state.turnIndex); -} - -function feedbackDummyVisibility() { - if (!state.contract || !state.dummy) return "N.v.t."; - if (state.phase === "complete") return "Ja"; - if (state.phase === "contract-reveal") return "Nee"; - if (state.phase !== "playing") return "N.v.t."; - return openingLeadHasBeenMade() ? "Ja" : "Nee"; -} - -function currentFeedbackStartMode() { - if (state.practice?.lessonStartMode === "play") return "direct-play"; - if (state.practice?.lessonStartMode) return String(state.practice.lessonStartMode); - if (state.phase === "playing" && state.contract && !state.auction.length) return "direct-play"; - if (state.phase === "bidding" || state.phase === "contract-reveal") return "auction"; - return ""; -} diff --git a/scripts/render/score-table.js b/scripts/render/score-table.js index 37c5d87..dfe033f 100644 --- a/scripts/render/score-table.js +++ b/scripts/render/score-table.js @@ -1,6 +1,15 @@ +(function registerBridgeScoreTable(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + const scoreTableSuitSymbols = { C: "\u2663", D: "\u2666", H: "\u2665", S: "\u2660" }; + + modules.registerScoreTable = function registerScoreTable(runtime) { + const { render, rules } = runtime; + function renderScoreTable() { - if (!bridgeRules.getScoreTableData) return; - const data = bridgeRules.getScoreTableData(); + if (!rules.getScoreTableData) return; + const data = rules.getScoreTableData(); renderContractScoreTable(data.contractRows); renderOvertrickScoreTable(data.overtrickRows); renderUndertrickScoreTable(data.undertrickRows); @@ -124,4 +133,6 @@ function scoreValueCell(label, value) { return cell; } -const scoreTableSuitSymbols = { C: "\u2663", D: "\u2666", H: "\u2665", S: "\u2660" }; + Object.assign(render, { renderScoreTable }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/state/review-playback.js b/scripts/state/review-playback.js new file mode 100644 index 0000000..742a2e9 --- /dev/null +++ b/scripts/state/review-playback.js @@ -0,0 +1,120 @@ +(function initBridgeReviewPlayback(root, factory) { + const isCommonJs = typeof module === "object" && module.exports; + const playback = factory(); + if (isCommonJs) module.exports = playback; + root.BridgeReviewPlayback = playback; +})(typeof globalThis !== "undefined" ? globalThis : this, function createBridgeReviewPlayback() { + "use strict"; + + const defaultSeats = ["North", "East", "South", "West"]; + + function deriveReviewPlayback({ originalHands = {}, trickHistory = [], cursor = null, seats = defaultSeats } = {}) { + const normalized = normalizeCursor(trickHistory, cursor); + if (!normalized) return null; + + const trick = trickHistory[normalized.trickIndex]; + const visiblePlays = trick.cards.slice(0, normalized.playIndex + 1).map(copyPlay); + const playedCardIds = playedCardIdsThroughCursor(trickHistory, normalized); + const activeSeat = visiblePlays.length < trick.cards.length + ? trick.cards[visiblePlays.length]?.seat || nextSeatAfter(visiblePlays.at(-1)?.seat, seats) + : null; + const winner = visiblePlays.length >= trick.cards.length ? trick.winner : null; + + return { + cursor: normalized, + trickNumber: trick.number, + playNumber: normalized.playIndex + 1, + selectedPlay: copyPlay(trick.cards[normalized.playIndex]), + hands: remainingHands(originalHands, playedCardIds, seats), + currentTrick: visiblePlays, + activeSeat, + winner, + completeTrick: Boolean(winner) + }; + } + + function moveReviewCursor(trickHistory = [], cursor = null, delta = 0) { + const total = totalPlayCount(trickHistory); + if (!total || !Number.isFinite(delta) || delta === 0) return normalizeCursor(trickHistory, cursor); + + const normalized = normalizeCursor(trickHistory, cursor); + if (!normalized) return offsetToCursor(trickHistory, delta > 0 ? 0 : total - 1); + + const nextOffset = clamp(cursorToOffset(trickHistory, normalized) + delta, 0, total - 1); + return offsetToCursor(trickHistory, nextOffset); + } + + function normalizeCursor(trickHistory = [], cursor = null) { + if (!cursor || !Number.isInteger(cursor.trickIndex) || !Number.isInteger(cursor.playIndex)) return null; + if (cursor.trickIndex < 0 || cursor.trickIndex >= trickHistory.length) return null; + const trick = trickHistory[cursor.trickIndex]; + if (!trick?.cards?.length || cursor.playIndex < 0 || cursor.playIndex >= trick.cards.length) return null; + return { trickIndex: cursor.trickIndex, playIndex: cursor.playIndex }; + } + + function cursorToOffset(trickHistory, cursor) { + let offset = 0; + for (let trickIndex = 0; trickIndex < cursor.trickIndex; trickIndex += 1) { + offset += trickHistory[trickIndex]?.cards?.length || 0; + } + return offset + cursor.playIndex; + } + + function offsetToCursor(trickHistory, offset) { + let remaining = offset; + for (let trickIndex = 0; trickIndex < trickHistory.length; trickIndex += 1) { + const count = trickHistory[trickIndex]?.cards?.length || 0; + if (remaining < count) return { trickIndex, playIndex: remaining }; + remaining -= count; + } + return null; + } + + function totalPlayCount(trickHistory = []) { + return trickHistory.reduce((count, trick) => count + (trick.cards?.length || 0), 0); + } + + function playedCardIdsThroughCursor(trickHistory, cursor) { + const ids = new Set(); + for (let trickIndex = 0; trickIndex <= cursor.trickIndex; trickIndex += 1) { + const trick = trickHistory[trickIndex]; + const playCount = trickIndex === cursor.trickIndex ? cursor.playIndex + 1 : trick.cards.length; + trick.cards.slice(0, playCount).forEach((play) => { + if (play.card?.id) ids.add(play.card.id); + }); + } + return ids; + } + + function remainingHands(originalHands, playedCardIds, seats) { + return Object.fromEntries(seats.map((seat) => [ + seat, + (originalHands[seat] || []) + .filter((card) => !playedCardIds.has(card.id)) + .map(copyCard) + ])); + } + + function nextSeatAfter(seat, seats) { + const index = seats.indexOf(seat); + return index < 0 ? null : seats[(index + 1) % seats.length]; + } + + function copyPlay(play) { + return play ? { ...play, card: copyCard(play.card) } : null; + } + + function copyCard(card) { + return card ? { ...card } : card; + } + + function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); + } + + return { + deriveReviewPlayback, + moveReviewCursor, + normalizeCursor + }; +}); diff --git a/scripts/state/seed.js b/scripts/state/seed.js index d64daa0..c48d6f2 100644 --- a/scripts/state/seed.js +++ b/scripts/state/seed.js @@ -1,7 +1,58 @@ -const situationCodec = globalThis.BridgeSituationCodec; -if (!situationCodec) throw new Error("situation-codec.js must load before seed.js"); -const normalizeSeed = situationCodec.normalizeSeed; -const isSituationSeed = situationCodec.isSituationSeed; +(function registerBridgeSeedController(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerSeed = function registerSeed(runtime) { + const situationCodec = root.BridgeSituationCodec; + if (!situationCodec) throw new Error("situation-codec.js must load before seed.js"); + + const { actions, constants, els, helpers, render, rules, state, transitions } = runtime; + const { seats, separatorDot } = constants; + const bridgeRules = rules; + const BridgeStateTransitions = transitions; + const normalizeSeed = situationCodec.normalizeSeed; + const isSituationSeed = situationCodec.isSituationSeed; + const { + calculateBridgeScore, + currentWinningPlay, + dealHands, + dealerIndexForDeal, + formatBid, + highestBid, + isContractBid, + isDouble, + isLegalCard, + isPass, + isRedouble, + leftOf, + partnerOf, + sameCall, + seatAt, + t, + teamOf, + vulnerabilityForDeal + } = helpers; + const auctionComplete = (...args) => actions.auctionComplete(...args); + const chooseBidResult = (...args) => actions.chooseBidResult(...args); + const chooseCardPlayResult = (...args) => actions.chooseCardPlayResult(...args); + const clearTrickSlots = (...args) => render.clearTrickSlots(...args); + const ensurePlayPlan = (...args) => actions.ensurePlayPlan(...args); + const explainCardPlay = (...args) => actions.explainCardPlay(...args); + const finalScoreForCurrentContract = (...args) => actions.finalScoreForCurrentContract(...args); + const focusContractReveal = (...args) => actions.focusContractReveal(...args); + const isHumanControlledSeat = (...args) => actions.isHumanControlledSeat(...args); + const openingLeadHasBeenMade = (...args) => actions.openingLeadHasBeenMade(...args); + const practiceStateFromScenario = (...args) => actions.practiceStateFromScenario(...args); + const prepareContractFromAuction = (...args) => actions.prepareContractFromAuction(...args); + const renderAll = (...args) => render.renderAll(...args); + const renderPlayedCard = (...args) => render.renderPlayedCard(...args); + const renderTrickAdvanceHint = (...args) => render.renderTrickAdvanceHint(...args); + const renderTrickSlotFocus = (...args) => render.renderTrickSlotFocus(...args); + const setStatus = (...args) => actions.setStatus(...args); + const startHand = (...args) => actions.startHand(...args); + const startPracticeHand = (...args) => actions.startPracticeHand(...args); + const startPreparedHand = (...args) => actions.startPreparedHand(...args); function loadSeedFromInput() { const seed = normalizeSeed(els.seedInput.value); @@ -117,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({ @@ -155,8 +207,10 @@ function restoreSituationAuction(auction, sourcePhase = "") { }; if (bidResult && sameCall(bidResult.bid, bid)) call.bidResult = bidResult; if (bidResult && !sameCall(bidResult.bid, bid)) call.recommendedBidResult = bidResult; - state.auction.push(call); - state.turnIndex = (state.turnIndex + 1) % seats.length; + Object.assign(state, BridgeStateTransitions.applyBidTransition(state, { + ...call, + seatCount: seats.length + })); } if (!auctionComplete()) { @@ -166,15 +220,11 @@ function restoreSituationAuction(auction, sourcePhase = "") { const bid = highestBid(); if (!bid) { - state.phase = "complete"; - state.contract = null; - state.declarer = null; - state.dummy = null; - state.leader = null; - state.finalScore = calculateBridgeScore({ contract: null }); - state.finalScore.scoreText = `${t("northSouth")} 0 ${separatorDot} ${t("eastWest")} 0`; - state.finalScore.made = 0; - state.finalScore.defenders = 0; + const finalScore = calculateBridgeScore({ contract: null }); + finalScore.scoreText = `${t("northSouth")} 0 ${separatorDot} ${t("eastWest")} 0`; + finalScore.made = 0; + finalScore.defenders = 0; + Object.assign(state, BridgeStateTransitions.finishPassedOutAuctionTransition(state, { finalScore })); return; } @@ -254,9 +304,10 @@ function completeRestoredTrick() { } function finishRestoredHand() { - state.phase = "complete"; if (!state.contract) return; - recomputeFinalScore(); + const finalScore = finalScoreForCurrentContract(); + if (!finalScore) return; + Object.assign(state, BridgeStateTransitions.finishHandTransition(state, { finalScore })); const needed = state.contract.level + 6; const made = state.finalScore.made; const resultKey = made >= needed ? "made" : "down"; @@ -411,3 +462,20 @@ function renderSeedControls() { els.copySeed.disabled = !repeatCode; els.seedDescription.textContent = state.seedMessage || t("seedHelp"); } + + Object.assign(actions, { + copyCurrentSeed, + copyText, + createDealSeed, + createSituationSeed, + currentRepeatCode, + isSituationSeed, + loadSeedFromInput, + normalizeSeed, + startSituationSeed + }); + Object.assign(render, { + renderSeedControls + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/state/settings.js b/scripts/state/settings.js index 712cafd..6efaf56 100644 --- a/scripts/state/settings.js +++ b/scripts/state/settings.js @@ -1,24 +1,37 @@ -function loadSavedSettings() { - try { - const saved = JSON.parse(localStorage.getItem(settingsStorageKey) || "{}"); - if (typeof saved.developerMode === "boolean") state.developerMode = saved.developerMode; - if (typeof saved.guidanceMode === "boolean") state.guidanceMode = saved.guidanceMode; - if (typeof saved.showPlayHistory === "boolean") state.showPlayHistory = saved.showPlayHistory; - if (typeof saved.showAdvancedBidControls === "boolean") state.showAdvancedBidControls = saved.showAdvancedBidControls; - } catch { - // Ignore storage errors so the static app remains usable in private or restricted browsers. - } -} +(function registerBridgeSettings(root) { + "use strict"; -function saveSettings() { - try { - localStorage.setItem(settingsStorageKey, JSON.stringify({ - developerMode: state.developerMode, - guidanceMode: state.guidanceMode, - showPlayHistory: state.showPlayHistory, - showAdvancedBidControls: state.showAdvancedBidControls - })); - } catch { - // Settings persistence is a convenience, not required for play. - } -} + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + const settingsStorageKey = "bridge-app-settings"; + + modules.registerSettings = function registerSettings(runtime) { + const { actions, state } = runtime; + + function loadSavedSettings() { + try { + const saved = JSON.parse(localStorage.getItem(settingsStorageKey) || "{}"); + if (typeof saved.developerMode === "boolean") state.developerMode = saved.developerMode; + if (typeof saved.guidanceMode === "boolean") state.guidanceMode = saved.guidanceMode; + if (typeof saved.showPlayHistory === "boolean") state.showPlayHistory = saved.showPlayHistory; + if (typeof saved.showAdvancedBidControls === "boolean") state.showAdvancedBidControls = saved.showAdvancedBidControls; + } catch { + // Ignore storage errors so the static app remains usable in private or restricted browsers. + } + } + + function saveSettings() { + try { + localStorage.setItem(settingsStorageKey, JSON.stringify({ + developerMode: state.developerMode, + guidanceMode: state.guidanceMode, + showPlayHistory: state.showPlayHistory, + showAdvancedBidControls: state.showAdvancedBidControls + })); + } catch { + // Settings persistence is a convenience, not required for play. + } + } + + Object.assign(actions, { loadSavedSettings, saveSettings }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/state/state-transitions.js b/scripts/state/state-transitions.js index 6669232..468cad8 100644 --- a/scripts/state/state-transitions.js +++ b/scripts/state/state-transitions.js @@ -23,22 +23,67 @@ currentTrick: [], awaitingTrickAdvance: false, trickAdvanceArmed: false, + trickClearAnimating: false, pendingTrickWinner: null, tricks: { NS: 0, EW: 0 }, trickHistory: [], reviewTrickCursor: null, + reviewCursor: null, playExplanations: [], playPlan: null, playPlanKey: null, animateDeal: true, finalScore: null, + scoreOverviewDismissed: false, feedbackStatus: null, illegalActionFeedback: null, + lessonActionFeedback: null, pendingStop: false, pendingAlert: false }; } + function applyBidTransition(state, { seat, bid, stop = false, alert = false, bidResult = null, recommendedBidResult = null, seatCount = 4 }) { + const call = { seat, bid, stop, alert }; + if (bidResult) call.bidResult = bidResult; + if (recommendedBidResult) call.recommendedBidResult = recommendedBidResult; + return { + auction: [...state.auction, call], + pendingStop: false, + pendingAlert: false, + turnIndex: (state.turnIndex + 1) % seatCount + }; + } + + function finishAuctionTransition(state, { contract, declarer, dummy, leader, seats }) { + return { + contract, + declarer, + dummy, + leader, + turnIndex: seats.indexOf(leader), + currentTrick: [], + awaitingTrickAdvance: false, + trickAdvanceArmed: false, + trickClearAnimating: false, + pendingTrickWinner: null + }; + } + + function finishPassedOutAuctionTransition(state, { finalScore }) { + return { + phase: "complete", + scoreOverviewDismissed: false, + reviewTrickCursor: null, + reviewCursor: null, + contract: null, + declarer: null, + dummy: null, + leader: null, + finalScore + }; + } + function applyCardPlayTransition(state, { seat, card, cardId = card?.id, ruleResult = null, explanation = "" }) { const playedCardId = cardId || card?.id; const playExplanation = explanation @@ -60,7 +105,8 @@ }, currentTrick: [...state.currentTrick, { seat, card, ruleId: ruleResult?.ruleId || null }], playExplanations: [...state.playExplanations, ...playExplanation], - illegalActionFeedback: null + illegalActionFeedback: null, + lessonActionFeedback: null }; } @@ -68,6 +114,7 @@ return { awaitingTrickAdvance: false, trickAdvanceArmed: false, + trickClearAnimating: false, pendingTrickWinner: null, tricks: { ...state.tricks, @@ -111,12 +158,26 @@ return [...acknowledged, step.id]; } + function finishHandTransition(state, { finalScore }) { + return { + phase: "complete", + scoreOverviewDismissed: false, + reviewTrickCursor: null, + reviewCursor: null, + finalScore + }; + } + return { startPreparedHandTransition, + applyBidTransition, + finishAuctionTransition, + finishPassedOutAuctionTransition, applyCardPlayTransition, advanceCompletedTrickTransition, enterContractRevealTransition, startPlayFromContractRevealTransition, - acknowledgeLessonBoardStepTransition + acknowledgeLessonBoardStepTransition, + finishHandTransition }; }); diff --git a/scripts/ui/dialogs.js b/scripts/ui/dialogs.js new file mode 100644 index 0000000..8ddf04b --- /dev/null +++ b/scripts/ui/dialogs.js @@ -0,0 +1,31 @@ +(function registerBridgeDialogs(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerDialogs = function registerDialogs(runtime) { + const { actions, els } = runtime; + + function openScoreTableDialog() { + if (typeof els.scoreTableDialog.showModal === "function") { + els.scoreTableDialog.showModal(); + } else { + els.scoreTableDialog.setAttribute("open", ""); + } + els.closeScoreTable.focus(); + } + + function closeScoreTableDialog() { + if (typeof els.scoreTableDialog.close === "function") { + els.scoreTableDialog.close(); + } else { + els.scoreTableDialog.removeAttribute("open"); + } + } + + Object.assign(actions, { + closeScoreTableDialog, + openScoreTableDialog + }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); diff --git a/scripts/ui/menu.js b/scripts/ui/menu.js new file mode 100644 index 0000000..2295b90 --- /dev/null +++ b/scripts/ui/menu.js @@ -0,0 +1,101 @@ +(function registerBridgeMenu(root) { + "use strict"; + + const modules = root.BridgeAppModules = root.BridgeAppModules || {}; + + modules.registerMenu = function registerMenu(runtime) { + const { actions, els, helpers, render, state } = runtime; + + function initAppMenu() { + els.settingsSummary?.addEventListener("click", (event) => { + event.stopPropagation(); + toggleAppMenu(); + }); + els.appMenu?.querySelector(".menu-actions")?.addEventListener("click", (event) => { + if (event.target.closest("button, a")) closeAppMenu(); + }); + els.appMenu?.addEventListener("click", (event) => event.stopPropagation()); + document.addEventListener("click", () => closeAppMenu()); + document.addEventListener("keydown", (event) => { + if (event.key === "Escape") closeAppMenu(); + }); + } + + 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(); + }); + } + + function toggleAppMenu() { + const isOpen = !els.appMenu?.classList.contains("is-open"); + renderAppMenu(isOpen); + } + + function closeAppMenu() { + renderAppMenu(false); + } + + function renderAppMenu(isOpen) { + if (!els.appMenu || !els.settingsSummary) return; + els.appMenu.classList.toggle("is-open", isOpen); + els.settingsSummary.setAttribute("aria-expanded", String(isOpen)); + const panel = els.appMenu.querySelector(".app-menu-panel"); + if (panel) panel.hidden = !isOpen; + } + + function applySettingsStaticText() { + if (!els.settingsSummary) return; + els.settingsSummary.setAttribute("aria-label", "Menu"); + 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")); + } + + function setSettingInfo(element, tooltip) { + if (!element) return; + element.textContent = "i"; + element.dataset.tooltip = tooltip; + element.title = tooltip; + element.setAttribute("aria-label", tooltip); + } + + runtime.bootstrap.steps.push(initAppMenu, initSettingsControls); + Object.assign(actions, { closeAppMenu, toggleAppMenu }); + Object.assign(render, { applySettingsStaticText }); + }; +})(typeof globalThis !== "undefined" ? globalThis : this); 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/auction.css b/styles/auction.css index e499a4f..749ddb1 100644 --- a/styles/auction.css +++ b/styles/auction.css @@ -24,6 +24,13 @@ .auction-log { margin: 6px 0 12px; + min-width: 0; + max-width: 100%; +} + +.auction-log .review-trick-table-wrap { + max-width: 100%; + overflow-x: hidden; } .auction-log .review-trick-table { @@ -507,6 +514,16 @@ justify-content: center; } +.auction-call[role="button"] { + cursor: pointer; +} + +.auction-call[role="button"]:focus-visible { + outline: 2px solid rgba(118, 214, 255, 0.72); + outline-offset: 3px; + border-radius: 8px; +} + .auction-call-token { min-width: 38px; min-height: 26px; @@ -536,6 +553,8 @@ display: grid; gap: 6px; margin: 8px 0 0; + max-width: 100%; + min-width: 0; } .bid-explanation { @@ -546,6 +565,9 @@ color: var(--muted); font-size: 0.82rem; line-height: 1.35; + max-width: 100%; + min-width: 0; + overflow-wrap: anywhere; } .bid-explanation strong { @@ -556,6 +578,15 @@ display: inline-flex; gap: 6px; align-items: center; + max-width: 100%; + min-width: 0; + overflow-wrap: anywhere; + white-space: normal; +} + +.bid-explanation-title span:last-child { + min-width: 0; + overflow-wrap: anywhere; } .bid-explanation-index { diff --git a/styles/layout.css b/styles/layout.css index e1677ae..5d098fb 100644 --- a/styles/layout.css +++ b/styles/layout.css @@ -307,6 +307,10 @@ overscroll-behavior: contain; } +.auction-panel > * { + min-width: 0; +} + .history-panel { min-height: 0; display: grid; 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/review.css b/styles/review.css index c5da71c..f442c3e 100644 --- a/styles/review.css +++ b/styles/review.css @@ -2,6 +2,8 @@ display: grid; align-content: start; gap: 8px; + max-width: 100%; + min-width: 0; min-height: 0; padding-right: 2px; } @@ -14,6 +16,9 @@ color: var(--muted); font-size: 0.92rem; line-height: 1.42; + max-width: 100%; + min-width: 0; + overflow-wrap: anywhere; } .play-explanation strong { @@ -262,10 +267,36 @@ background: rgba(118, 214, 255, 0.14); } +.review-trick-cell[role="button"] { + cursor: pointer; +} + +.review-trick-cell[role="button"]:focus-visible { + outline: 2px solid rgba(118, 214, 255, 0.72); + outline-offset: -3px; +} + .review-trick-row.is-review-selected .review-trick-number { color: var(--ink); } +.review-trick-index-button { + width: 30px; + min-height: 30px; + border-color: rgba(224, 194, 95, 0.32); + background: rgba(224, 194, 95, 0.08); + color: var(--accent); + padding: 0; + font-weight: 950; + line-height: 1; +} + +.review-trick-index-button:hover, +.review-trick-index-button:focus-visible { + background: rgba(224, 194, 95, 0.16); + color: var(--ink); +} + .review-trick-table tbody tr:last-child th, .review-trick-table tbody tr:last-child td { border-bottom: 0; @@ -304,6 +335,21 @@ inset 5px 0 0 rgba(224, 194, 95, 0.95); } +.review-trick-cell.is-review-play-selected { + position: relative; + background: rgba(118, 214, 255, 0.2); + box-shadow: + inset 0 0 0 2px rgba(118, 214, 255, 0.8), + inset 0 0 18px rgba(118, 214, 255, 0.14); +} + +.review-trick-cell.is-review-play-selected.is-leader { + box-shadow: + inset 0 0 0 2px rgba(118, 214, 255, 0.86), + inset 5px 0 0 rgba(224, 194, 95, 0.95), + inset 0 0 18px rgba(118, 214, 255, 0.14); +} + .review-card-pill { display: inline-grid; place-items: center; diff --git a/styles/table.css b/styles/table.css index f6e6fcb..99eef8f 100644 --- a/styles/table.css +++ b/styles/table.css @@ -184,29 +184,242 @@ .replay-panel { position: absolute; - left: 50%; - top: 50%; - z-index: 4; - width: min(320px, calc(100% - 24px)); - transform: translate(-50%, -50%); - border: 1px solid rgba(224, 194, 95, 0.45); + inset: 0; + z-index: 10; + display: grid; + place-items: center; + padding: clamp(14px, 3vw, 28px); + border-radius: inherit; + background: + radial-gradient(circle at 50% 44%, rgba(224, 194, 95, 0.24), rgba(224, 194, 95, 0) 35%), + radial-gradient(circle at 18% 18%, rgba(96, 197, 185, 0.26), rgba(96, 197, 185, 0) 30%), + linear-gradient(145deg, rgba(8, 18, 16, 0.6), rgba(8, 18, 16, 0.84)); + backdrop-filter: blur(3px); + overflow: hidden; +} + +.replay-panel::before { + position: absolute; + right: max(12px, 8%); + top: max(10px, 8%); + color: rgba(247, 244, 232, 0.1); + content: attr(data-strain-symbol); + font-size: clamp(6rem, 22vw, 14rem); + font-weight: 950; + line-height: 1; + transform: rotate(8deg); + animation: contractRevealSuitFloat 1600ms cubic-bezier(0.2, 0.8, 0.22, 1) both; + pointer-events: none; +} + +.replay-panel::after { + content: ""; + position: absolute; + inset: 12%; + border: 1px solid rgba(224, 194, 95, 0.26); + border-radius: 999px; + opacity: 0.8; + animation: contractRevealRing 1300ms ease both; + pointer-events: none; +} + +.replay-reveal-panel { + position: relative; + z-index: 1; + width: min(520px, 100%); + border: 1px solid rgba(224, 194, 95, 0.52); border-radius: 8px; - background: rgba(22, 35, 28, 0.92); - box-shadow: 0 18px 40px rgba(0, 0, 0, 0.34); - padding: 13px; + background: + linear-gradient(160deg, rgba(37, 46, 38, 0.96), rgba(17, 37, 33, 0.96)), + rgba(24, 32, 28, 0.98); + box-shadow: + 0 28px 60px rgba(0, 0, 0, 0.44), + inset 0 1px 0 rgba(255, 255, 255, 0.08); + padding: clamp(16px, 3.5vw, 26px); display: grid; + justify-items: center; gap: 10px; text-align: center; + overflow: hidden; + animation: contractRevealPanelIn 620ms cubic-bezier(0.2, 0.84, 0.22, 1) both; } -.replay-panel p { +.replay-reveal-panel::before { + content: ""; + position: absolute; + inset: -1px; + border-radius: inherit; + background: linear-gradient(110deg, transparent 0%, rgba(255, 255, 255, 0.16) 44%, transparent 64%); + transform: translateX(-115%); + animation: contractRevealSweep 1200ms 180ms ease both; + pointer-events: none; +} + +.replay-reveal-panel > * { + position: relative; + z-index: 1; +} + +.replay-head { + width: 100%; + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; + gap: 8px; +} + +.replay-title { + grid-column: 2; margin: 0; + color: var(--accent-2); + font-size: 0.72rem; + font-weight: 950; + letter-spacing: 0; + text-transform: uppercase; +} + +.replay-close { + grid-column: 3; + justify-self: end; + width: 30px; + height: 30px; + min-height: 30px; + border-radius: 50%; + border-color: rgba(224, 194, 95, 0.42); + background: rgba(224, 194, 95, 0.1); color: var(--ink); - font-size: 1rem; + padding: 0; + display: grid; + place-items: center; + font-size: 0.78rem; + font-weight: 950; + line-height: 1; +} + +.replay-close:hover, +.replay-close:focus-visible { + border-color: rgba(224, 194, 95, 0.78); + background: rgba(224, 194, 95, 0.18); +} + +.replay-summary { + width: 100%; + display: grid; + grid-template-columns: 1.15fr 0.85fr; + gap: 8px; +} + +.replay-summary-item, +.replay-score-card { + width: 100%; + min-width: 0; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 8px; + background: rgba(255, 255, 255, 0.055); + color: var(--ink); + padding: 8px 10px; + display: grid; + gap: 4px; +} + +.replay-summary-item span, +.replay-score-card span { + color: rgba(247, 244, 232, 0.72); + font-size: 0.68rem; font-weight: 900; + text-transform: uppercase; +} + +.replay-summary-item strong { + color: var(--ink); + font-size: clamp(0.98rem, 2.3vw, 1.22rem); + font-weight: 780; + line-height: 1.16; + overflow-wrap: anywhere; +} + +.replay-contract-card strong { + color: #ffb6a8; + text-shadow: 0 0 18px rgba(223, 87, 72, 0.22); +} + +.replay-score-card { + border-color: rgba(224, 194, 95, 0.42); + background: rgba(224, 194, 95, 0.12); + padding: 10px 12px; +} + +.replay-score-card.is-positive { + border-color: rgba(85, 213, 126, 0.62); + background: rgba(85, 213, 126, 0.12); +} + +.replay-score-card.is-negative { + border-color: rgba(231, 97, 97, 0.62); + background: rgba(231, 97, 97, 0.12); +} + +.replay-score-head { + position: relative; + z-index: 2; + display: flex; + align-items: center; + justify-content: center; + gap: 7px; +} + +.replay-score-help { + width: 23px; + height: 23px; + min-height: 23px; + border-radius: 50%; + border-color: rgba(224, 194, 95, 0.42); + background: rgba(224, 194, 95, 0.12); + color: var(--ink); + padding: 0; + display: grid; + place-items: center; + font-size: 0.78rem; + font-weight: 640; + line-height: 1; +} + +.replay-score-help:hover, +.replay-score-help:focus-visible { + border-color: rgba(224, 194, 95, 0.78); + background: rgba(224, 194, 95, 0.18); +} + +.replay-score-card strong { + color: var(--ink); + font-size: clamp(2.6rem, 10vw, 4.8rem); + font-weight: 950; + line-height: 0.95; + text-shadow: 0 0 24px rgba(224, 194, 95, 0.22); + overflow-wrap: anywhere; +} + +.replay-score-card.is-positive strong { + color: #bdf4cb; + text-shadow: 0 0 24px rgba(85, 213, 126, 0.22); +} + +.replay-score-card.is-negative strong { + color: #ffb6a8; + text-shadow: 0 0 24px rgba(223, 87, 72, 0.28); +} + +.replay-score-explanation { + border-top: 1px solid rgba(247, 244, 232, 0.14); + color: rgba(247, 244, 232, 0.82); + font-size: 0.8rem; + line-height: 1.38; + padding-top: 8px; + text-align: left; } .replay-actions { + width: 100%; display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; @@ -219,6 +432,32 @@ font-weight: 900; } +@media (max-width: 760px) { + .replay-reveal-panel { + padding: 14px; + gap: 8px; + } + + .replay-summary { + grid-template-columns: 1fr; + } + + .replay-summary-item, + .replay-score-card { + padding: 7px 9px; + } + + .replay-score-card strong { + font-size: clamp(2.1rem, 12vw, 3.1rem); + } + + .replay-actions button { + padding: 0 6px; + font-size: 0.78rem; + line-height: 1.05; + } +} + .contract-reveal { position: absolute; inset: 0; @@ -393,6 +632,7 @@ .guidance-panel { width: 100%; + min-width: 0; border: 1px solid rgba(224, 194, 95, 0.34); border-radius: 8px; background: rgba(224, 194, 95, 0.1); @@ -402,6 +642,7 @@ gap: 3px; font-size: 0.84rem; line-height: 1.35; + overflow-wrap: anywhere; } .guidance-panel strong { @@ -499,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 { @@ -640,18 +1020,49 @@ } .trick-slot.pending-trick-winner::after { - border-color: rgba(224, 194, 95, 0.96); - opacity: 1; - box-shadow: - 0 0 0 2px rgba(19, 104, 63, 0.7), - 0 0 22px rgba(224, 194, 95, 0.42); + border-color: rgba(224, 194, 95, 0); + opacity: 0; + box-shadow: none; } .trick-slot.pending-trick-winner .card.played { + animation: winnerCardGlowBreathe 1500ms ease-in-out infinite; + overflow: hidden; transform: translateY(-8px); box-shadow: 0 18px 28px rgba(0, 0, 0, 0.34), - 0 0 0 3px rgba(224, 194, 95, 0.92); + 0 0 0 3px rgba(224, 194, 95, 0.92), + 0 0 20px rgba(224, 194, 95, 0.34); +} + +.trick-slot.pending-trick-winner .card.played::before { + content: ""; + position: absolute; + inset: 0; + z-index: 3; + border-radius: inherit; + background: + linear-gradient( + 112deg, + transparent 0%, + transparent 30%, + rgba(255, 242, 166, 0.16) 40%, + rgba(255, 221, 82, 0.86) 48%, + rgba(255, 255, 235, 0.72) 54%, + rgba(255, 228, 96, 0.24) 62%, + transparent 74%, + transparent 100% + ); + opacity: 0.88; + pointer-events: none; + transform: translateX(-130%); + animation: winnerCardShineSweep 1500ms ease-in-out infinite; +} + +@media (max-width: 760px) { + .trick-north.pending-trick-winner .card.played { + transform: none; + } } .trick-slot.active-trick-slot, @@ -834,12 +1245,43 @@ margin: 0; } +.card.played.played-card-settled { + animation: none; + opacity: 1; + transform: none; +} + .card.card-animation-target { animation: none; opacity: 0; transform: none; } +.card.played.played-card-settled.card-animation-target { + opacity: 0; +} + +.trick-slot.pending-trick-winner .card.played.trick-card-clearing, +.card.played.trick-card-clearing { + animation: none; + opacity: 0; + pointer-events: none; + transform: + translate(var(--trick-clear-x, 0), var(--trick-clear-y, 0)) + rotate(var(--trick-clear-rotate, 0deg)) + scale(0.88); + transition: + transform 360ms cubic-bezier(0.18, 0.82, 0.24, 1), + opacity 260ms ease; + transition-delay: var(--trick-clear-delay, 0ms); + z-index: 8; +} + +.trick-slot.pending-trick-winner .card.played.trick-card-clearing::before { + animation: none; + opacity: 0; +} + .card-play-flyer { animation: none; box-sizing: border-box; @@ -973,9 +1415,38 @@ } } +@keyframes winnerCardGlowBreathe { + 0%, 100% { + box-shadow: + 0 18px 28px rgba(0, 0, 0, 0.34), + 0 0 0 3px rgba(224, 194, 95, 0.94), + 0 0 22px rgba(224, 194, 95, 0.42), + 0 0 44px rgba(224, 194, 95, 0.16); + } + + 50% { + box-shadow: + 0 18px 28px rgba(0, 0, 0, 0.34), + 0 0 0 3px rgba(224, 194, 95, 1), + 0 0 34px rgba(224, 194, 95, 0.72), + 0 0 64px rgba(224, 194, 95, 0.28); + } +} + +@keyframes winnerCardShineSweep { + 0%, 24% { + transform: translateX(-130%); + } + + 58%, 100% { + transform: translateX(130%); + } +} + @media (prefers-reduced-motion: reduce) { .card, - .card.played { + .card.played, + .trick-slot.pending-trick-winner .card.played { animation: none; opacity: 1; transform: none; @@ -986,16 +1457,31 @@ transition: none; } + .card.played.trick-card-clearing { + transition: none; + } + + .trick-slot.pending-trick-winner .card.played::before { + animation: none; + opacity: 0; + } + .contract-reveal, .contract-reveal::before, .contract-reveal::after, .contract-reveal-panel, - .contract-reveal-panel::before { + .contract-reveal-panel::before, + .replay-panel, + .replay-panel::before, + .replay-panel::after, + .replay-reveal-panel, + .replay-reveal-panel::before { animation: none; transition: none; } - .contract-reveal-panel::before { + .contract-reveal-panel::before, + .replay-reveal-panel::before { display: none; } } @@ -1012,6 +1498,9 @@ color: var(--muted); font-size: 0.88rem; line-height: 1.4; + max-width: 100%; + min-width: 0; + overflow-wrap: anywhere; } .play-plan-title { diff --git a/tests/browser/smoke.spec.js b/tests/browser/smoke.spec.js index dfdd317..c67c7ff 100644 --- a/tests/browser/smoke.spec.js +++ b/tests/browser/smoke.spec.js @@ -683,6 +683,30 @@ test("uses a table-centered bidding layout on mobile", async ({ page }, testInfo expect(polish.activeBidBoxBoxShadow).toBe("none"); expect(polish.advancedToggleBackground).toBe("rgba(0, 0, 0, 0)"); expect(polish.advancedToggleBorder).toBe("rgba(0, 0, 0, 0)"); + + const overflow = await page.evaluate(() => { + const guidancePanel = document.querySelector("#guidance-panel"); + guidancePanel.hidden = false; + guidancePanel.replaceChildren(); + const title = document.createElement("strong"); + title.textContent = "AI-suggestie bod: 3NT"; + const reason = document.createElement("span"); + reason.textContent = "Kunstmatige afspraak: continuation.responderAfterFourthSuitChooseGame"; + guidancePanel.append(title, reason); + + const overflowBy = (element) => Math.ceil(element.scrollWidth - element.clientWidth); + return { + body: overflowBy(document.scrollingElement), + auctionPanel: overflowBy(document.querySelector(".auction-panel")), + auctionLog: overflowBy(document.querySelector("#auction-log")), + guidancePanel: overflowBy(guidancePanel) + }; + }); + + expect(overflow.body).toBeLessThanOrEqual(1); + expect(overflow.auctionPanel).toBeLessThanOrEqual(1); + expect(overflow.auctionLog).toBeLessThanOrEqual(1); + expect(overflow.guidancePanel).toBeLessThanOrEqual(1); }); test("keeps the mobile bidding box visible while waiting for another player", async ({ page }, testInfo) => { @@ -1402,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); @@ -1416,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"); @@ -1425,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); @@ -1442,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(); @@ -1454,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); @@ -1581,19 +1608,163 @@ test("opens lesson picker and starts a quiet challenge", async ({ page }) => { }); await expect(page.locator("#review-panel")).toBeVisible(); - await expect(page.locator("#review-summary")).toContainText("Lesfeedback"); - await expect(page.locator("#review-summary")).toContainText("verscheen Noord als dummy"); - await expect(page.locator("#review-summary")).toContainText("Lespunten"); - await expect(page.locator("#review-summary")).toContainText("Een bridgebord bestaat uit 13 slagen"); - await expect(page.locator("#review-summary")).not.toContainText("Herhaalcode"); + await expect(page.locator("#review-summary")).toBeHidden(); + 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); @@ -1735,8 +1906,54 @@ test("runs curated beginner practice hands through fixed UI checkpoints", async gameBonus: 500, tricksMade: 10 }); - await expect(page.locator("#review-summary")).toContainText("Waarom deze score?"); - await expect(page.locator("#review-summary")).toContainText("620"); + await expect(page.locator("#review-summary")).toBeHidden(); + await expect(page.locator("#review-summary")).toBeEmpty(); + await expect(page.locator("#replay-panel")).toContainText("Scoreoverzicht"); + await expect(page.locator("#replay-contract")).toContainText("4♥ door Zuid"); + await expect(page.locator("#replay-result")).toContainText("10 slagen gemaakt"); + await expect(page.locator("#replay-score")).toContainText("+620 NZ"); + await expect(page.locator(".replay-score-card")).toHaveClass(/is-positive/); + await expect(page.locator("#replay-score-explanation")).toBeHidden(); + await page.locator("#replay-score-help").click(); + await expect(page.locator("#replay-score-explanation")).toBeVisible(); + await expect(page.locator("#replay-score-explanation")).toContainText("Waarom deze score?"); + await expect(page.locator("#replay-score-explanation")).toContainText("+620 NZ"); + + await page.evaluate(() => { + const app = window.BridgeAppTestHooks; + const contract = app.rules.Bid(1, "S"); + app.startHand({ seed: "player-perspective-down-score", skipFlow: true }); + app.setState({ + phase: "complete", + contract, + declarer: "South", + dummy: "North", + leader: "West", + tricks: { NS: 6, EW: 7 }, + finalScore: { + ...app.rules.calculateBridgeScore({ + contract, + declarer: "South", + tricksMade: 6, + vulnerability: "NS" + }), + made: 6, + defenders: 7 + }, + status: { + key: "contractResult", + args: { + contract: "1♠", + declarer: "South", + resultKey: "down", + resultArgs: { under: 1 } + } + } + }); + app.renderAll(); + }); + await expect(page.locator("#replay-score")).toContainText("-100 NZ"); + await expect(page.locator(".replay-score-card")).toHaveClass(/is-negative/); }); test("stores South convention metadata without showing an AI suggestion", async ({ page }) => { @@ -1843,6 +2060,121 @@ test("developer bid explanations show the newest call first", async ({ page }) = expect(labels).toEqual(["4 West Pas", "3 Zuid 1♠", "2 Oost Pas", "1 Noord 1♥"]); }); +test("developer auction calls scroll to their bid explanation", async ({ page }) => { + await openFreshApp(page); + + await page.evaluate(() => { + const app = window.BridgeAppTestHooks; + app.startHand({ seed: "developer-bid-explanation-scroll", skipFlow: true }); + app.setState({ + developerMode: true, + phase: "bidding", + dealerIndex: 0, + turnIndex: 0, + auction: [ + { seat: "North", bid: app.rules.Bid(1, "H") }, + { seat: "East", bid: app.rules.Pass() }, + { seat: "South", bid: app.rules.Bid(2, "H") }, + { seat: "West", bid: app.rules.Pass() } + ], + animateDeal: false + }); + app.renderAll(); + window.__scrollIntoViewCalls = []; + Element.prototype.scrollIntoView = function scrollIntoView(options) { + window.__scrollIntoViewCalls.push({ + bidIndex: this.dataset.bidExplanationIndex || "", + className: this.className, + options + }); + }; + }); + + await page.locator("#auction-log [data-bid-index='2']").click(); + + await expect.poll(() => page.evaluate(() => window.__scrollIntoViewCalls.at(-1))).toMatchObject({ + bidIndex: "2", + className: expect.stringContaining("bid-explanation") + }); +}); + +test("developer bid explanations stay visible after play starts", async ({ page }) => { + await openFreshApp(page); + + await page.evaluate(() => { + const app = window.BridgeAppTestHooks; + app.startHand({ seed: "developer-bid-explanations-during-play", skipFlow: true }); + app.setState({ + developerMode: true, + phase: "bidding", + dealerIndex: 0, + turnIndex: 0, + auction: [ + { seat: "North", bid: app.rules.Bid(1, "H") }, + { seat: "East", bid: app.rules.Pass() }, + { seat: "South", bid: app.rules.Bid(2, "H") }, + { seat: "West", bid: app.rules.Pass() } + ], + animateDeal: false + }); + app.renderAll(); + }); + await expect(page.locator("#bid-explanations")).toBeVisible(); + await expect(page.locator("#bid-explanations")).toContainText("Zuid 2♥"); + + await page.evaluate(() => { + const app = window.BridgeAppTestHooks; + app.setState({ + phase: "playing", + contract: app.rules.Bid(2, "H"), + declarer: "North", + dummy: "South", + leader: "East", + turnIndex: 1 + }); + app.renderAll(); + }); + await expect(page.locator("#bid-explanations")).toBeVisible(); + await expect(page.locator("#bid-explanations")).toContainText("Zuid 2♥"); + + await page.evaluate(() => { + const app = window.BridgeAppTestHooks; + const contract = app.rules.Bid(2, "H"); + app.setState({ + phase: "complete", + contract, + declarer: "North", + dummy: "South", + leader: "East", + turnIndex: 1, + finalScore: { + ...app.rules.calculateBridgeScore({ + contract, + declarer: "North", + tricksMade: 8, + vulnerability: "none" + }), + made: 8, + defenders: 5 + }, + status: { + key: "contractResult", + args: { + contract: "2♥", + declarer: "North", + resultKey: "madeExactly", + resultArgs: {} + } + } + }); + app.renderAll(); + }); + await expect(page.locator("#review-panel")).toBeVisible(); + await expect(page.locator("#review-tricks")).toContainText("Slagenoverzicht"); + await expect(page.locator("#bid-explanations")).toBeVisible(); + await expect(page.locator("#bid-explanations")).toContainText("Zuid 2♥"); +}); + test("developer bid explanations flag South calls that differ from the heuristic", async ({ page }) => { await openFreshApp(page); @@ -2199,6 +2531,60 @@ test("pauses completed tricks without previewing the next AI card suggestion", a await expect(page.locator("#south-hand .recommended-card")).toHaveCount(0); await expect(page.locator(".trick-west")).toHaveClass(/pending-trick-winner/); await expect(page.locator("#trick-advance-hint")).toContainText("West wint slag 1."); + const winnerHighlight = await page.locator(".trick-west").evaluate((slot) => { + const slotMarker = getComputedStyle(slot, "::after"); + const card = slot.querySelector(".card.played"); + const cardStyle = card ? getComputedStyle(card) : null; + const shineStyle = card ? getComputedStyle(card, "::before") : null; + return { + slotMarkerOpacity: slotMarker.opacity, + slotMarkerBoxShadow: slotMarker.boxShadow, + cardBoxShadow: cardStyle?.boxShadow || "", + cardAnimationName: cardStyle?.animationName || "", + shineContent: shineStyle?.content || "", + shineAnimationName: shineStyle?.animationName || "", + shineOpacity: shineStyle?.opacity || "", + shineZIndex: shineStyle?.zIndex || "" + }; + }); + expect(winnerHighlight.slotMarkerOpacity).toBe("0"); + expect(winnerHighlight.slotMarkerBoxShadow).toBe("none"); + expect(winnerHighlight.cardBoxShadow).toContain("224, 194, 95"); + expect(winnerHighlight.cardAnimationName).toBe("winnerCardGlowBreathe"); + expect(winnerHighlight.shineContent).toBe('""'); + expect(winnerHighlight.shineAnimationName).toBe("winnerCardShineSweep"); + expect(Number(winnerHighlight.shineOpacity)).toBeGreaterThan(0.7); + expect(Number(winnerHighlight.shineZIndex)).toBeGreaterThan(1); + + await page.locator(".table-area").click({ position: { x: 20, y: 20 } }); + await page.waitForFunction(() => document.querySelectorAll("#trick-area .card.trick-card-clearing").length === 4); + const clearingMotion = await page.evaluate(() => { + const cards = Array.from(document.querySelectorAll("#trick-area .card.trick-card-clearing")); + return { + count: cards.length, + xValues: cards.map((card) => Number.parseFloat(card.style.getPropertyValue("--trick-clear-x"))), + yValues: cards.map((card) => Number.parseFloat(card.style.getPropertyValue("--trick-clear-y"))), + state: { + currentTrickLength: window.BridgeAppTestHooks.getState().currentTrick.length, + trickHistoryLength: window.BridgeAppTestHooks.getState().trickHistory.length + } + }; + }); + expect(clearingMotion.count).toBe(4); + expect(clearingMotion.xValues.every((value) => value < -100)).toBe(true); + expect(clearingMotion.yValues.every((value) => Math.abs(value) < 80)).toBe(true); + expect(clearingMotion.state).toEqual({ currentTrickLength: 4, trickHistoryLength: 0 }); + + await expect(page.locator("#trick-area .card.played")).toHaveCount(0); + const afterAdvance = await page.evaluate(() => { + const state = window.BridgeAppTestHooks.getState(); + return { + currentTrickLength: state.currentTrick.length, + trickHistoryLength: state.trickHistory.length, + lastWinner: state.trickHistory.at(-1)?.winner + }; + }); + expect(afterAdvance).toEqual({ currentTrickLength: 0, trickHistoryLength: 1, lastWinner: "West" }); }); test("keeps North's played card readable in the trick area", async ({ page }) => { @@ -2267,7 +2653,7 @@ test("keeps North's played card readable in the trick area", async ({ page }) => test("hides the target card while the play animation flyer moves", async ({ page }) => { await openFreshApp(page); - const duringAnimation = await page.evaluate(() => { + const duringAnimation = await page.evaluate(async () => { const app = window.BridgeAppTestHooks; const makeCard = app.makeCard; const hands = { @@ -2296,21 +2682,45 @@ test("hides the target card while the play animation flyer moves", async ({ page }); app.clearTrickSlots(); app.renderAll(); + const source = document.querySelector('#south-hand [data-card-id="5H"]'); + const sourceRect = source?.getBoundingClientRect(); app.playCard("South", "5H"); + await new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve))); const target = document.querySelector(".trick-south .card.played"); + const targetRect = target?.getBoundingClientRect(); + const flyer = document.querySelector(".card-play-flyer"); + const translateMatch = flyer?.style.transform.match(/translate\((-?[0-9.]+)px,\s*(-?[0-9.]+)px\)/); + const flyerLanding = sourceRect && translateMatch + ? { + left: sourceRect.left + Number(translateMatch[1]), + top: sourceRect.top + Number(translateMatch[2]) + } + : null; return { flyerCount: document.querySelectorAll(".card-play-flyer").length, targetIsAnimationTarget: target?.classList.contains("card-animation-target") || false, - targetOpacity: target ? getComputedStyle(target).opacity : null + targetIsSettled: target?.classList.contains("played-card-settled") || false, + targetOpacity: target ? getComputedStyle(target).opacity : null, + targetAnimationName: target ? getComputedStyle(target).animationName : null, + landingDelta: flyerLanding && targetRect + ? { + left: Math.abs(flyerLanding.left - targetRect.left), + top: Math.abs(flyerLanding.top - targetRect.top) + } + : null }; }); - expect(duringAnimation).toEqual({ + expect(duringAnimation).toMatchObject({ flyerCount: 1, targetIsAnimationTarget: true, - targetOpacity: "0" + targetIsSettled: true, + targetOpacity: "0", + targetAnimationName: "winnerCardGlowBreathe" }); + expect(duringAnimation.landingDelta?.left).toBeLessThanOrEqual(1); + expect(duringAnimation.landingDelta?.top).toBeLessThanOrEqual(1); await page.waitForTimeout(820); const afterAnimation = await page.evaluate(() => { @@ -2318,14 +2728,34 @@ test("hides the target card while the play animation flyer moves", async ({ page return { flyerCount: document.querySelectorAll(".card-play-flyer").length, targetIsAnimationTarget: target?.classList.contains("card-animation-target") || false, - targetOpacity: target ? getComputedStyle(target).opacity : null + targetIsSettled: target?.classList.contains("played-card-settled") || false, + targetOpacity: target ? getComputedStyle(target).opacity : null, + targetAnimationName: target ? getComputedStyle(target).animationName : null }; }); expect(afterAnimation).toEqual({ flyerCount: 0, targetIsAnimationTarget: false, - targetOpacity: "1" + targetIsSettled: true, + targetOpacity: "1", + targetAnimationName: "winnerCardGlowBreathe" + }); + + await page.waitForTimeout(420); + const afterSettling = await page.evaluate(() => { + const target = document.querySelector(".trick-south .card.played"); + return { + flyerCount: document.querySelectorAll(".card-play-flyer").length, + targetOpacity: target ? getComputedStyle(target).opacity : null, + targetAnimationName: target ? getComputedStyle(target).animationName : null + }; + }); + + expect(afterSettling).toEqual({ + flyerCount: 0, + targetOpacity: "1", + targetAnimationName: "winnerCardGlowBreathe" }); }); @@ -2471,24 +2901,77 @@ test("can finish a hand and copy or submit a feedback report from the review", a await clickMenuButton(page, "#quick-review"); await expect(page.locator("#review-panel")).toBeVisible(); - await expect(page.locator("#review-summary")).toContainText("Contract"); - await expect(page.locator("#review-summary")).toContainText("Waarom deze score?"); - await expect(page.locator("#review-summary")).toContainText("Nodig voor contract"); - await expect(page.locator("#review-summary")).toContainText("Herhaalcode"); - await expect(page.locator("#review-summary")).not.toContainText("Hand opnieuw spelen"); - await expect(page.locator("#review-summary")).toContainText("Eerste kaart"); - await expect(page.locator("#review-summary")).toContainText("Eindscore"); - await expect(page.locator("#review-summary")).not.toContainText("Handseed"); - await expect(page.locator("#review-summary")).not.toContainText("Contractdoel"); + await expect(page.locator("#review-summary")).toBeHidden(); + await expect(page.locator("#review-summary")).toBeEmpty(); await expect(page.locator("#review-tricks tbody tr")).toHaveCount(13); - await expect(page.locator("#review-tricks")).toContainText("gebruik \u2190 en \u2192"); + await expect(page.locator("#review-tricks")).toContainText("Klik op een slagnummer"); + await expect(page.locator("#replay-panel")).toBeVisible(); + await page.locator("#review-tricks tbody tr").first().locator(".review-trick-index-button").click(); + await expect(page.locator("#replay-panel")).toBeHidden(); await expect(page.locator("#review-tricks tbody tr").first()).toHaveClass(/is-review-selected/); + await expect(page.locator("#review-tricks tbody tr").first().locator(".review-trick-cell.is-review-play-selected")).toHaveCount(1); + await expect(page.locator("#trick-area .card.played")).toHaveCount(1); + await expect(page.locator(".trick-slot.active-trick-slot")).toHaveCount(1); + expect(await page.evaluate(() => { + const state = window.BridgeAppTestHooks.getState(); + return { + currentTrickLength: state.currentTrick.length, + trickHistoryLength: state.trickHistory.length, + reviewCursor: state.reviewCursor, + scoreOverviewDismissed: state.scoreOverviewDismissed + }; + })).toEqual({ + currentTrickLength: 0, + trickHistoryLength: 13, + reviewCursor: { trickIndex: 0, playIndex: 0 }, + scoreOverviewDismissed: true + }); + await page.keyboard.press("ArrowRight"); + await expect(page.locator("#trick-area .card.played")).toHaveCount(2); + await page.keyboard.press("ArrowRight"); + await expect(page.locator("#trick-area .card.played")).toHaveCount(3); + await page.keyboard.press("ArrowRight"); + await expect(page.locator("#trick-area .card.played")).toHaveCount(4); + await expect(page.locator(".trick-slot.pending-trick-winner")).toHaveCount(1); await page.keyboard.press("ArrowRight"); await expect(page.locator("#review-tricks tbody tr").nth(1)).toHaveClass(/is-review-selected/); + await expect(page.locator("#trick-area .card.played")).toHaveCount(1); await page.keyboard.press("ArrowLeft"); await expect(page.locator("#review-tricks tbody tr").first()).toHaveClass(/is-review-selected/); + await expect(page.locator("#trick-area .card.played")).toHaveCount(4); + expect(await page.evaluate(() => { + const state = window.BridgeAppTestHooks.getState(); + return { + currentTrickLength: state.currentTrick.length, + trickHistoryLength: state.trickHistory.length, + reviewCursor: state.reviewCursor + }; + })).toEqual({ + currentTrickLength: 0, + trickHistoryLength: 13, + reviewCursor: { trickIndex: 0, playIndex: 3 } + }); await expect(page.locator("#review-tricks .play-explanation").first()).toBeVisible(); await expect(page.locator("#review-tricks .play-explanation").first()).toContainText("Slag"); + await page.evaluate(() => { + window.__scrollIntoViewCalls = []; + Element.prototype.scrollIntoView = function scrollIntoView(options) { + window.__scrollIntoViewCalls.push({ + trick: this.dataset.playExplanationTrick || "", + key: this.dataset.playExplanationKey || "", + className: this.className, + options + }); + }; + }); + const clickedPlayKey = await page.locator("#review-tricks tbody tr").nth(1).locator(".review-trick-cell[role='button']").nth(1).getAttribute("data-play-explanation-key"); + await page.locator("#review-tricks tbody tr").nth(1).locator(".review-trick-cell[role='button']").nth(1).click(); + await expect(page.locator("#review-tricks tbody tr").nth(1)).toHaveClass(/is-review-selected/); + await expect.poll(() => page.evaluate(() => window.__scrollIntoViewCalls.at(-1))).toMatchObject({ + trick: "2", + key: clickedPlayKey, + className: expect.stringContaining("play-explanation") + }); await expect .poll(() => page.locator("#review-panel").evaluate((panel) => { @@ -2497,10 +2980,26 @@ test("can finish a hand and copy or submit a feedback report from the review", a }) ) .toBe(true); + await page.evaluate(() => { + window.BridgeAppTestHooks.setState({ scoreOverviewDismissed: false, reviewCursor: null, reviewTrickCursor: null }); + window.BridgeAppTestHooks.renderAll(); + }); await expect(page.locator("#replay-panel")).toBeVisible(); - await expect(page.locator("#replay-panel")).toContainText("Speel opnieuw"); + await expect(page.locator("#replay-panel")).toContainText("Scoreoverzicht"); + await expect(page.locator("#replay-panel")).toContainText("Contract"); + await expect(page.locator("#replay-panel")).toContainText("Resultaat"); + await expect(page.locator("#replay-panel")).toContainText("Eindscore"); await expect(page.locator("#replay-new-hand")).toBeVisible(); await expect(page.locator("#replay-same-hand")).toBeVisible(); + await page.locator("#replay-close").click(); + await expect(page.locator("#replay-panel")).toBeHidden(); + await page.evaluate(() => { + window.BridgeAppTestHooks.setState({ scoreOverviewDismissed: false }); + window.BridgeAppTestHooks.renderAll(); + }); + await expect(page.locator("#replay-panel")).toBeVisible(); + await page.locator("#replay-panel").click({ position: { x: 10, y: 10 } }); + await expect(page.locator("#replay-panel")).toBeHidden(); await expect(page.locator(".setup-controls > #open-feedback")).toBeVisible(); await page.locator(".setup-controls > #open-feedback").click(); @@ -2563,6 +3062,9 @@ test("can finish a hand and copy or submit a feedback report from the review", a }); await page.locator("#mail-feedback").click(); await expect(page.locator("#feedback-state")).toContainText("Feedback verstuurd"); + await expect(page.locator("#feedback-type")).toHaveValue("confusion"); + await expect(page.locator("#feedback-message")).toHaveValue(""); + await expect(page.locator("#feedback-detail")).toHaveValue(""); const requests = await page.evaluate(() => window.__feedbackRequests); expect(requests).toHaveLength(1); expect(requests[0].url).toContain("https://script.google.com/macros/s/test/exec"); diff --git a/tests/run-tests.js b/tests/run-tests.js index 8646c99..1519503 100644 --- a/tests/run-tests.js +++ b/tests/run-tests.js @@ -7,6 +7,7 @@ require("./unit/score-table.test.js"); require("./unit/auction.test.js"); require("./unit/script-order.test.js"); require("./unit/state-transitions.test.js"); +require("./unit/review-playback.test.js"); require("./unit/situation-codec.test.js"); require("./unit/bidding-dispatcher.test.js"); require("./unit/bridgespelen-concordance.test.js"); 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/); +}); diff --git a/tests/unit/review-playback.test.js b/tests/unit/review-playback.test.js new file mode 100644 index 0000000..b8e2881 --- /dev/null +++ b/tests/unit/review-playback.test.js @@ -0,0 +1,90 @@ +const { assert, test, card } = require("./harness.js"); +const playback = require("../../scripts/state/review-playback.js"); + +function play(seat, id) { + return { seat, card: card(id) }; +} + +function sampleReview() { + return { + originalHands: { + North: [card("AS"), card("2C")], + East: [card("KS"), card("3C")], + South: [card("QS"), card("4C")], + West: [card("JS"), card("5C")] + }, + trickHistory: [ + { + number: 1, + winner: "North", + cards: [play("West", "JS"), play("North", "AS"), play("East", "KS"), play("South", "QS")] + }, + { + number: 2, + winner: "West", + cards: [play("North", "2C"), play("East", "3C"), play("South", "4C"), play("West", "5C")] + } + ] + }; +} + +test("review playback removes the first selected card only", () => { + const review = sampleReview(); + const state = playback.deriveReviewPlayback({ + ...review, + cursor: { trickIndex: 0, playIndex: 0 } + }); + + assert.deepEqual(state.cursor, { trickIndex: 0, playIndex: 0 }); + assert.equal(state.currentTrick.length, 1); + assert.equal(state.currentTrick[0].card.id, "JS"); + assert.equal(state.activeSeat, "North"); + assert.equal(state.winner, null); + assert.deepEqual(state.hands.West.map((item) => item.id), ["5C"]); + assert.deepEqual(state.hands.North.map((item) => item.id), ["AS", "2C"]); +}); + +test("review playback shows partial tricks before the winner is known", () => { + const review = sampleReview(); + const state = playback.deriveReviewPlayback({ + ...review, + cursor: { trickIndex: 0, playIndex: 2 } + }); + + assert.deepEqual(state.currentTrick.map((item) => `${item.seat}:${item.card.id}`), ["West:JS", "North:AS", "East:KS"]); + assert.equal(state.activeSeat, "South"); + assert.equal(state.winner, null); + assert.deepEqual(state.hands.East.map((item) => item.id), ["3C"]); +}); + +test("review playback marks the winner after the fourth card", () => { + const review = sampleReview(); + const state = playback.deriveReviewPlayback({ + ...review, + cursor: { trickIndex: 0, playIndex: 3 } + }); + + assert.equal(state.currentTrick.length, 4); + assert.equal(state.activeSeat, null); + assert.equal(state.winner, "North"); + assert.equal(state.completeTrick, true); +}); + +test("review playback cursor moves across trick boundaries", () => { + const review = sampleReview(); + + assert.deepEqual(playback.moveReviewCursor(review.trickHistory, null, 1), { trickIndex: 0, playIndex: 0 }); + assert.deepEqual(playback.moveReviewCursor(review.trickHistory, null, -1), { trickIndex: 1, playIndex: 3 }); + assert.deepEqual(playback.moveReviewCursor(review.trickHistory, { trickIndex: 0, playIndex: 3 }, 1), { trickIndex: 1, playIndex: 0 }); + assert.deepEqual(playback.moveReviewCursor(review.trickHistory, { trickIndex: 1, playIndex: 0 }, -1), { trickIndex: 0, playIndex: 3 }); + assert.deepEqual(playback.moveReviewCursor(review.trickHistory, { trickIndex: 0, playIndex: 0 }, -1), { trickIndex: 0, playIndex: 0 }); +}); + +test("review playback ignores invalid cursors without mutating source hands", () => { + const review = sampleReview(); + const before = JSON.stringify(review.originalHands); + + assert.equal(playback.deriveReviewPlayback({ ...review, cursor: null }), null); + assert.equal(playback.deriveReviewPlayback({ ...review, cursor: { trickIndex: 9, playIndex: 0 } }), null); + assert.equal(JSON.stringify(review.originalHands), before); +}); diff --git a/tests/unit/script-order.test.js b/tests/unit/script-order.test.js index d1534e8..2064b64 100644 --- a/tests/unit/script-order.test.js +++ b/tests/unit/script-order.test.js @@ -53,11 +53,18 @@ const expectedScripts = [ "scripts/learning/bid-explanations.js", "scripts/learning/glossary.js", "scripts/learning/lessons.js", + "scripts/app/runtime.js", + "scripts/app/helpers.js", "scripts/state/settings.js", + "scripts/ui/menu.js", + "scripts/ui/dialogs.js", "scripts/state/situation-codec.js", "scripts/state/seed.js", "scripts/state/state-transitions.js", + "scripts/state/review-playback.js", "scripts/learning/lesson-board-coach.js", + "scripts/learning/lesson-start.js", + "scripts/feedback/controller.js", "scripts/render/score-table.js", "scripts/render/play-plan.js", "scripts/render/contract-reveal.js", @@ -65,9 +72,14 @@ const expectedScripts = [ "scripts/render/card-animation.js", "scripts/render/render-auction.js", "scripts/render/render-review.js", + "scripts/render/render-app.js", "scripts/flow/contract-reveal-flow.js", "scripts/flow/auction-flow.js", + "scripts/flow/hand-finish-flow.js", "scripts/flow/play-flow.js", + "scripts/app/hand-start.js", + "scripts/app/bootstrap.js", + "scripts/app/public-api.js", "scripts/app.js" ]; diff --git a/tests/unit/state-transitions.test.js b/tests/unit/state-transitions.test.js index e10e168..0680464 100644 --- a/tests/unit/state-transitions.test.js +++ b/tests/unit/state-transitions.test.js @@ -23,6 +23,8 @@ test("startPreparedHandTransition resets volatile game flow state", () => { assert.deepEqual(patch.tricks, { NS: 0, EW: 0 }); assert.equal(patch.practice.id, "demo"); assert.equal(patch.finalScore, null); + assert.equal(patch.scoreOverviewDismissed, false); + assert.equal(patch.reviewCursor, null); assert.equal(patch.pendingStop, false); }); @@ -53,6 +55,71 @@ test("applyCardPlayTransition removes the card and records the explanation immut assert.equal(patch.illegalActionFeedback, null); }); +test("applyBidTransition appends the call and clears pending call flags", () => { + const state = { + auction: [{ seat: "North", bid: { type: "pass" } }], + turnIndex: 2, + pendingStop: true, + pendingAlert: true + }; + const bid = { level: 1, strain: "S" }; + const bidResult = { bid, ruleId: "test.bid" }; + + const patch = transitions.applyBidTransition(state, { + seat: "South", + bid, + stop: true, + alert: true, + bidResult + }); + + assert.equal(patch.auction.length, 2); + assert.deepEqual(state.auction, [{ seat: "North", bid: { type: "pass" } }]); + assert.equal(patch.auction[1].seat, "South"); + assert.equal(patch.auction[1].stop, true); + assert.equal(patch.auction[1].alert, true); + assert.equal(patch.auction[1].bidResult.ruleId, "test.bid"); + assert.equal(patch.pendingStop, false); + assert.equal(patch.pendingAlert, false); + assert.equal(patch.turnIndex, 3); +}); + +test("finishAuctionTransition stores contract context and opening leader", () => { + const contract = { level: 3, strain: "NT" }; + const patch = transitions.finishAuctionTransition({}, { + contract, + declarer: "South", + dummy: "North", + leader: "West", + seats: ["North", "East", "South", "West"] + }); + + assert.equal(patch.contract, contract); + assert.equal(patch.declarer, "South"); + assert.equal(patch.dummy, "North"); + assert.equal(patch.leader, "West"); + assert.equal(patch.turnIndex, 3); + assert.deepEqual(patch.currentTrick, []); + assert.equal(patch.awaitingTrickAdvance, false); +}); + +test("finish hand and pass-out transitions mark the hand complete", () => { + const passOutScore = { passOut: true, score: 0 }; + const passOutPatch = transitions.finishPassedOutAuctionTransition({}, { finalScore: passOutScore }); + assert.equal(passOutPatch.phase, "complete"); + assert.equal(passOutPatch.contract, null); + assert.equal(passOutPatch.scoreOverviewDismissed, false); + assert.equal(passOutPatch.reviewCursor, null); + assert.equal(passOutPatch.finalScore, passOutScore); + + const finalScore = { score: 420, made: 10 }; + const handPatch = transitions.finishHandTransition({}, { finalScore }); + assert.equal(handPatch.phase, "complete"); + assert.equal(handPatch.scoreOverviewDismissed, false); + assert.equal(handPatch.reviewCursor, null); + assert.equal(handPatch.finalScore, finalScore); +}); + test("advanceCompletedTrickTransition scores the winner and makes them next leader", () => { const trickCard = card("H-2", "H", "2"); const state = {