diff --git a/.gitignore b/.gitignore index ab14989e..a39f86ba 100644 --- a/.gitignore +++ b/.gitignore @@ -90,6 +90,9 @@ apps/web/public/mockups/ # Design handoff reference (Claude Design exports) — local-only, never committed apps/web/reveals page redesign/ +# Security audit findings — sensitive, keep out of the public repo +docs/SECURITY_AUDIT.md + # Tool / plugin local state — managed by their installers, not tracked .agents/ .superpowers/ diff --git a/CLAUDE.md b/CLAUDE.md index 794ed597..649bdc4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,6 +2,41 @@ Lorcana synergy finder for Core format with archetype-based synergy detection. +## Active Epic: Deck Builder & Engine Score (milestone #3) + +The flagship multi-session initiative. **Read these before starting any deck-builder work:** +- **Plan (source of truth)**: [`docs/deck-builder/PLAN.md`](docs/deck-builder/PLAN.md) — full design + rationale (8 phases, E0–E7). +- **Progress**: milestone #3 → `gh issue list --milestone "Deck Builder & Engine Score" --state all`. Epics #450–#457; Phase-0/1 tasks #458–#473. +- **Session Handoff Log**: pinned issue **#474** — the running ledger. **Append an entry every session** (shipped / decisions / gotchas / in-progress / next); read the latest entry at session start. +- **Working location**: the MAIN checkout (`D:\johnn\Projects\inkweave`) on branch `claude/inkweave-deck-builder-af3e5b`. Never work from a `.claude/worktrees/` folder: this epic's commits are unmerged, and `.claude/settings.json` wires the hooks to the main checkout by absolute path, so a worktree silently runs without `branch-verification` or `engine-auto-rebuild`. + +**Golden rules for this epic:** +1. Deck-level synergy uses the **precomputed pairs JSON** (`pairs[id].aggregateScore`), NOT the live engine — lazy-import the engine only for hypothetical/preview cards. +2. The advisor is **pool-driven + archetype-parameterized** — detect roles from card text/keywords, **never hardcode card names** (Core rotates). +3. The Deck Quality Score is a **transparent weighted formula + case library**, never ML. +4. Meta/matchups are **deferred** until the user's Set-13 data exists (Phase 6). + +**Session ritual (a process to run every session, not a hope):** + +_Start:_ +1. **Audit the previous session's close-out first.** Is `git status` clean and on `claude/inkweave-deck-builder-af3e5b`? Does the latest #474 comment exist and end with a `Next:` line? Do the issues touched last time have a note? If anything is missing, surface it to the user before starting new work. +2. Read `docs/deck-builder/PLAN.md` and the latest #474 entry (its `Next:` line names your task). +3. `gh issue list --milestone "Deck Builder & Engine Score" --state open`, then pick the next unblocked task. Auth (#463) and migrations (#464) need the user's OAuth apps + live-DB authorization, so skip them when running headless. +4. `/implement-issue `. + +_End or any pause (the close-out contract, in order, all of it):_ +1. **Adversarial review before committing.** Self-review the diff, or spawn a code-reviewer agent, for correctness, lifecycle/async edge cases, and convention. Fix what it finds. A green suite is necessary, not sufficient (that is how the #465 unmount-flush + async-ink bugs slipped past a passing session). +2. **Commit** (WIP if incomplete). The pre-commit gate (lint + tests) must pass; never `--no-verify`. Nothing important stays only in the working tree. +3. **Append a #474 entry**: shipped (with SHAs) / decisions + why / gotchas / in-progress / **`Next:`**. Always end with an explicit `Next:` line, even on a demo or review entry, so the next session finds the thread immediately. +4. **Comment implementation notes** on each touched issue (decisions, deviations, carry-forward gotchas). +5. **Write a memory file** for any durable gotcha that generalizes beyond this task. +6. **Update `docs/deck-builder/PLAN.md`** if the design or scope changed. +7. **Verify clean**: `git status` clean, scratch files removed, then `/close-session`. + +**Epic-issue convention:** the milestone's task issues (#458 onward) are compact pointers; their authoritative spec is `docs/deck-builder/PLAN.md` plus the reference files each one names, not a standalone ops-runbook. Override `/implement-issue`'s defaults deliberately (and say so, do not expand the issue): stay on the epic branch (do NOT cut a `feature/-*` branch, since the work depends on this branch's unmerged commits), and treat its issue-quality rubric as not applicable (it scores production runbooks, not feature-code pointers). + +**Retire this whole section when milestone #3 closes.** It is the only always-loaded epic context in this file, and it should leave with the epic rather than becoming another stale block. It earns its place here only because the session ritual has no `paths:` trigger to hang a `.claude/rules/` file on: start-of-session process cannot be lazily loaded. + ## MVP Status - **Scope**: Core format only (sets 9+), community voting, deck builder diff --git a/apps/web/.storybook/preview.tsx b/apps/web/.storybook/preview.tsx index 1e0e071e..5a5f6ca3 100644 --- a/apps/web/.storybook/preview.tsx +++ b/apps/web/.storybook/preview.tsx @@ -1,4 +1,7 @@ import 'react-loading-skeleton/dist/skeleton.css'; +// Load the app's @font-face (Plus Jakarta Sans + Tinos, served from /public/fonts +// via staticDirs) so stories render in the real app fonts, not system fallbacks. +import '../src/index.css'; import type {Preview} from '@storybook/react-vite'; import {themes} from 'storybook/theming'; diff --git a/apps/web/e2e/E2E_TESTS.md b/apps/web/e2e/E2E_TESTS.md index 0e73b3c8..a6b0464b 100644 --- a/apps/web/e2e/E2E_TESTS.md +++ b/apps/web/e2e/E2E_TESTS.md @@ -205,6 +205,8 @@ Terminal-state guard for the in-depth vote page (`/vote/:a/:b`). Complements `pa ## `reveals-page.spec.ts` — 8 tests (7 desktop, 1 mobile) +**Seasonal — the whole suite auto-skips off-season.** `beforeEach` reads `/data/previewCards.json` and `test.skip`s when there are no revealed cards (`cards: []`, e.g. after a set graduates into `allCards.json`) or the release date has passed. Skipping happens before any 30s-timeout wait, so the suite is dormant (not failing) whenever there is nothing to reveal, and resumes automatically once the next set's reveal data is populated. + | Test | What it verifies | |---|---| | renders the tracker: hero, six ink trackers, and franchise cards | sr-only `h1`, the "Attack of the Vine!" logo, 6 `ink-tracker-tile`s, the "Ink board" section, and the 3 "View … cards" franchise buttons all render | diff --git a/apps/web/e2e/tests/reveals-page.spec.ts b/apps/web/e2e/tests/reveals-page.spec.ts index 44af548a..57b4dd0d 100644 --- a/apps/web/e2e/tests/reveals-page.spec.ts +++ b/apps/web/e2e/tests/reveals-page.spec.ts @@ -1,5 +1,29 @@ import {test, expect} from '../fixtures'; +/** + * Why the reveals suite is off-season-gated: the whole page only exists during a + * reveal season, so off-season the assertions cannot pass. Two off-season signals, + * either of which means "skip cleanly", checked BEFORE any 30s-timeout-prone wait: + * 1. No revealed cards at all — previewCards.json cards: [] (the primary signal; + * once a set graduates its cards move into allCards.json and this file empties). + * 2. Past the set's releaseDate — useRevealPhase returns 'released', RevealsGate + * redirects /reveals -> / and the nav entry / promo modal drop. + * Returns the skip reason, or null when a reveal season is genuinely active. + */ +function offSeasonSkipReason(data: unknown): string | null { + const d = (data ?? {}) as {cards?: unknown; sets?: Record}; + const revealCount = Array.isArray(d.cards) ? d.cards.length : 0; + if (revealCount === 0) return 'No active reveal season (previewCards.json has no revealed cards).'; + const dates = Object.values(d.sets ?? {}) + .map((s) => s?.releaseDate) + .filter((x): x is string => typeof x === 'string'); + const latestRelease = dates.length ? Math.max(...dates.map((x) => new Date(x).getTime())) : 0; + if (latestRelease > 0 && Date.now() >= latestRelease) { + return `Reveal season ended (latest releaseDate ${new Date(latestRelease).toISOString().slice(0, 10)}).`; + } + return null; +} + test.describe('Reveals page (flag on)', () => { test.beforeEach(async ({page}, testInfo) => { // Clear the daily-dismiss key so the promo modal reliably appears. @@ -12,27 +36,10 @@ test.describe('Reveals page (flag on)', () => { }); testInfo.annotations.push({type: 'requires', description: 'VITE_IS_REVEAL_SEASON=true'}); - // Skip when reveal season has ended (today is past the set's releaseDate). - // useRevealPhase returns 'released' once now >= releaseDate, RevealsGate - // redirects /reveals -> /, and the Reveals nav entry / promo modal no - // longer render — so these reveal-season-only assertions can't pass. - // After each set graduates and previewCards.json is refreshed with the - // NEXT set's dates, these tests pick back up automatically. + // Skip the whole suite off-season (see offSeasonSkipReason), before any wait. const resp = await page.request.get('/data/previewCards.json'); - if (resp.ok()) { - const data = await resp.json(); - const sets = (data?.sets ?? {}) as Record; - const dates = Object.values(sets) - .map((s) => s?.releaseDate) - .filter((d): d is string => typeof d === 'string'); - const latestRelease = dates.length ? Math.max(...dates.map((d) => new Date(d).getTime())) : 0; - if (latestRelease > 0 && Date.now() >= latestRelease) { - test.skip( - true, - `Reveal season ended (latest releaseDate ${new Date(latestRelease).toISOString().slice(0, 10)})`, - ); - } - } + const reason = resp.ok() ? offSeasonSkipReason(await resp.json()) : null; + test.skip(reason !== null, reason ?? ''); }); test('renders the tracker: hero, six ink trackers, and franchise cards', async ({page}, testInfo) => { diff --git a/apps/web/public/data/hosers.json b/apps/web/public/data/hosers.json new file mode 100644 index 00000000..387f821a --- /dev/null +++ b/apps/web/public/data/hosers.json @@ -0,0 +1,302 @@ +[ + { + "cardId": "1941", + "name": "Minnie Mouse - Sweetheart Princess", + "ink": "Amber", + "condition": { + "type": "high-cost", + "threshold": 5 + }, + "scope": "conditional", + "text": "ROYAL FAVOR Your characters named Mickey Mouse\ngain Support. (Whenever they quest, you may add their\n¤ to another chosen character's ¤ this turn.)\nBYE BYE, NOW Whenever this character quests,\nyou may banish chosen exerted character with 5 ¤\nor more." + }, + { + "cardId": "1966", + "name": "World's Greatest Criminal Mind", + "ink": "Amber", + "condition": { + "type": "high-cost", + "threshold": 5 + }, + "scope": "conditional", + "text": "(A character with cost 3 or more can ⟳ to sing this\nsong for free.)\nBanish chosen character with 5 ¤ or more." + }, + { + "cardId": "2009", + "name": "Prince Phillip - Vanquisher of Foes", + "ink": "Emerald", + "condition": { + "type": "damaged" + }, + "scope": "conditional", + "text": "Shift 6 ⬡ (You may pay 6 ⬡ to play this on top of one of\nyour characters named Prince Phillip.)\nEvasive (Only characters with Evasive can challenge this\ncharacter.)\nSWIFT AND SURE When you play this character, banish all\nopposing damaged characters." + }, + { + "cardId": "2034", + "name": "Make the Potion", + "ink": "Emerald", + "condition": { + "type": "damaged" + }, + "scope": "conditional", + "text": "Choose one:\n• Banish chosen item.\n• Deal 2 damage to chosen damaged character." + }, + { + "cardId": "2055", + "name": "Sisu - Daring Visitor", + "ink": "Ruby", + "condition": { + "type": "low-strength", + "threshold": 1 + }, + "scope": "conditional", + "text": "Evasive (Only characters with Evasive can challenge\nthis character.)\nBRING ON THE HEAT! When you play this character,\nbanish chosen opposing character with 1 ¤ or less." + }, + { + "cardId": "2124", + "name": "Tinker Bell - Giant Fairy", + "ink": "Steel", + "condition": { + "type": "mass" + }, + "scope": "mass", + "text": "Shift 4 ⬡ (You may pay 4 ⬡ to play this on top of one of\nyour characters named Tinker Bell.)\nROCK THE BOAT When you play this character, deal 1\ndamage to each opposing character.\nPUNY PIRATE! During your turn, whenever this character\nbanishes another character in a challenge, you may deal 2\ndamage to chosen opposing character." + }, + { + "cardId": "2138", + "name": "The Mob Song", + "ink": "Steel", + "condition": { + "type": "mass" + }, + "scope": "mass", + "text": "Sing Together 10 (Any number of your or your\nteammates' characters with total cost 10 or more may\n⟳ to sing this song for free.)\nDeal 3 damage to up to 3 chosen characters and/or\nlocations." + }, + { + "cardId": "2218", + "name": "The Horseman Strikes!", + "ink": "Amber", + "condition": { + "type": "evasive" + }, + "scope": "conditional", + "text": "Draw a card. You may banish chosen character\nwith Evasive." + }, + { + "cardId": "2263", + "name": "Finnick - Tiny Terror", + "ink": "Emerald", + "condition": { + "type": "low-strength", + "threshold": 2 + }, + "scope": "conditional", + "text": "YOU BETTER RUN When you play this character,\nyou may pay 2 ⬡ to return chosen opposing\ncharacter with 2 ¤ or less to their player's hand." + }, + { + "cardId": "2266", + "name": "Jetsam - Opportunistic Eel", + "ink": "Emerald", + "condition": { + "type": "damaged" + }, + "scope": "conditional", + "text": "AMBUSH FROM THE DEEP When you play this\ncharacter, deal 3 damage to chosen opposing\ndamaged character." + }, + { + "cardId": "2277", + "name": "Shere Khan - Fearsome Tiger", + "ink": "Emerald", + "condition": { + "type": "damaged" + }, + "scope": "conditional", + "text": "Evasive (Only characters with Evasive can\nchallenge this character.)\nON THE HUNT Whenever this character quests,\nbanish chosen opposing damaged character.\nThen, you may put 1 damage counter on another\nchosen character." + }, + { + "cardId": "2285", + "name": "Chomp!", + "ink": "Emerald", + "condition": { + "type": "damaged" + }, + "scope": "conditional", + "text": "Deal 2 damage to chosen damaged character." + }, + { + "cardId": "2314", + "name": "The Headless Horseman - Terror of Sleepy Hollow", + "ink": "Ruby", + "condition": { + "type": "low-strength", + "threshold": 2 + }, + "scope": "conditional", + "text": "LEAVES NO TRACE When you play this character,\nbanish chosen opposing character with 2 ¤ or less.\nGATHERING STRENGTH During your turn, whenever\nan opposing character is banished, each of your\ncharacters gets +1 ¤ this turn." + }, + { + "cardId": "2360", + "name": "Robin Hood - Ephemeral Archer", + "ink": "Steel", + "condition": { + "type": "mass" + }, + "scope": "mass", + "text": "Boost 1 ⬡ (Once during your turn, you may pay 1 ⬡\nto put the top card of your deck facedown under this\ncharacter.)\nEXPERT SHOT Whenever this character quests, if\nthere's a card under him, deal 1 damage to up to 2\nchosen characters." + }, + { + "cardId": "2491", + "name": "Raging Storm", + "ink": "Amber", + "condition": { + "type": "mass" + }, + "scope": "mass", + "text": "Banish all characters." + }, + { + "cardId": "2560", + "name": "Education or Elimination", + "ink": "Emerald", + "condition": { + "type": "damaged" + }, + "scope": "conditional", + "text": "(A character with cost 4 or more can ⟳ to sing this song\nfor free.)\nChoose one:\n• Draw a card. Chosen character of yours gets +1 ◊ and gains\nEvasive until the start of your next turn. (Only characters\nwith Evasive can challenge them.)\n• Banish chosen damaged character." + }, + { + "cardId": "2564", + "name": "Battering Ram", + "ink": "Emerald", + "condition": { + "type": "damaged" + }, + "scope": "conditional", + "text": "FULL FORCE ⟳ — Deal 1 damage to chosen damaged\ncharacter.\nBREAK THROUGH ⟳, Banish this item — Banish chosen\nlocation." + }, + { + "cardId": "2566", + "name": "Wreck-It Ralph - Raging Wrecker", + "ink": "Ruby", + "condition": { + "type": "mass" + }, + "scope": "mass", + "text": "Boost 1 ⬡ (Once during your turn, you may pay 1 ⬡ to put\nthe top card of your deck facedown under this character.)\nPOWERED UP This character gets +1 ¤ for each card\nunder him.\nWHO'S COMIN' WITH ME? When this character is banished,\nbanish all characters with ¤ equal to or less than the ¤\nhe had in play." + }, + { + "cardId": "2594", + "name": "Grab Your Bow", + "ink": "Ruby", + "condition": { + "type": "low-strength", + "threshold": 2 + }, + "scope": "conditional", + "text": "(A character with cost 5 or more can ⟳ to sing this\nsong for free.)\nBanish up to 2 chosen characters with 2 ¤ or less." + }, + { + "cardId": "2793", + "name": "Lord MacGuffin - Clever Swordsman", + "ink": "Emerald", + "condition": { + "type": "damaged" + }, + "scope": "conditional", + "text": "WAIT FOR IT... This character may enter play\nexerted to deal 3 damage to chosen damaged\ncharacter." + }, + { + "cardId": "2800", + "name": "Buzz Lightyear - On the Way", + "ink": "Emerald", + "condition": { + "type": "damaged" + }, + "scope": "conditional", + "text": "SECRET MISSION Whenever you pay 2 ⬡ or less\nto play a non-character, draw a card, then choose\nand discard a card.\nWORLD'S GREATEST TOY Whenever you pay 2 ⬡\nor less to play a character, deal 1 damage to\nchosen opposing damaged character." + }, + { + "cardId": "2807", + "name": "Cruella De Vil - Judgmental Traveler", + "ink": "Emerald", + "condition": { + "type": "damaged" + }, + "scope": "conditional", + "text": "YOU'RE OUT OF FASHION Whenever this character\nquests, if you played another character this turn,\nyou may banish chosen damaged character." + }, + { + "cardId": "2845", + "name": "Firefly Swarm", + "ink": "Ruby", + "condition": { + "type": "low-strength", + "threshold": 2 + }, + "scope": "conditional", + "text": "Choose one:\n• Banish chosen character with 2 ¤ or less.\n• If 2 or more other cards were put into your discard\nthis turn, banish chosen character." + }, + { + "cardId": "2909", + "name": "Fergus - Outpost Builder", + "ink": "Steel", + "condition": { + "type": "low-strength", + "threshold": 4 + }, + "scope": "conditional", + "text": "JUST THE SPOT During your turn, whenever this\ncharacter becomes exerted, you may play a location\nfrom your hand or discard with cost 4 or less for free.\nHOLD FAST While this character is at a location,\nwhenever a location is challenged and banished, you\nmay deal 4 damage to chosen character." + }, + { + "cardId": "2986", + "name": "Gaston - Superior Archer", + "ink": "Amber", + "condition": { + "type": "high-cost", + "threshold": 5 + }, + "scope": "conditional", + "text": "WATCH THIS! When you play this character, you\nmay banish chosen character with 5 ¤ or more." + }, + { + "cardId": "3072", + "name": "To Wither a Flower", + "ink": "Emerald", + "condition": { + "type": "damaged" + }, + "scope": "conditional", + "text": "(A character with cost 4 or more can ⟳ to sing this\nsong for free.)\nDeal 2 damage to each opposing damaged character." + }, + { + "cardId": "3106", + "name": "Red Alert", + "ink": "Ruby", + "condition": { + "type": "low-strength", + "threshold": 3 + }, + "scope": "conditional", + "text": "Banish chosen character with 3 ¤ or less. If you have\na Monster character in play, chosen opponent loses\n1 lore." + }, + { + "cardId": "3168", + "name": "The Vine - Towering Stalk", + "ink": "Steel", + "condition": { + "type": "mass" + }, + "scope": "mass", + "text": "Floodborn Shift 7 ⬡ (You may pay 7 ⬡ to play this on top\nof one of your Floodborn characters.)\nSATURATE Your other exerted Floodborn characters gain\nBodyguard.\nHOSTILE SWARM During an opponent's turn, whenever one\nof your Floodborn characters is banished, deal 1 damage to\neach opposing character." + }, + { + "cardId": "3174", + "name": "Windstorm", + "ink": "Steel", + "condition": { + "type": "evasive" + }, + "scope": "conditional", + "text": "Deal 1 damage to each opposing character and location.\nThen, deal 2 damage to each opposing character with\nEvasive and each opposing location with Evasive." + } +] diff --git a/apps/web/src/AppLayout.tsx b/apps/web/src/AppLayout.tsx index 2c6954f1..46d3a00b 100644 --- a/apps/web/src/AppLayout.tsx +++ b/apps/web/src/AppLayout.tsx @@ -12,6 +12,7 @@ import { } from './shared/components'; import type {SearchBottomSheetHandle} from './shared/components/SearchBottomSheet'; import {CardDataProvider} from './shared/contexts/CardDataContext'; +import {SessionProvider} from './shared/contexts/SessionContext'; import {CardModalProvider} from './shared/contexts/CardModalContext'; import {COLORS} from './shared/constants'; import {useCardDataContext} from './shared/contexts/CardDataContext'; @@ -115,11 +116,13 @@ function AppContent() { export function AppLayout() { return ( - - - - - + + + + + + + diff --git a/apps/web/src/features/deck/analysis/analyzeDeck.test.ts b/apps/web/src/features/deck/analysis/analyzeDeck.test.ts new file mode 100644 index 00000000..974f7ad9 --- /dev/null +++ b/apps/web/src/features/deck/analysis/analyzeDeck.test.ts @@ -0,0 +1,89 @@ +import {describe, it, expect} from 'vitest'; +import {analyzeDeck} from './analyzeDeck'; +import type {Deck, LorcanaCard} from '../types'; +import type {HoserEntry} from './vulnerabilities'; +import type {PairScore} from './deckSynergy'; +import {createCard} from '../../../shared/test-utils'; + +function resolver(cards: LorcanaCard[]) { + const byId = new Map(cards.map((c) => [c.id, c])); + return (id: string) => byId.get(id); +} + +function deckOf(cards: LorcanaCard[], qty = 4): Deck { + return { + id: 'd', + name: 'Test', + cards: cards.map((c) => ({cardId: c.id, quantity: qty})), + inks: [], + createdAt: 0, + updatedAt: 0, + schemaVersion: 1, + }; +} + +const lowStrengthHoser: HoserEntry[] = [ + { + cardId: 'hoser-1', + name: 'Low Sweeper', + ink: 'Sapphire', + condition: {type: 'low-strength', threshold: 2}, + scope: 'mass', + text: 'Banish all opposing characters with 2 strength or less.', + }, +]; + +const noSynergy: PairScore = () => 0; + +describe('analyzeDeck (advisor orchestrator)', () => { + it('composes stats, archetype, analyzers, synergy density and a quality score', () => { + const cards = Array.from({length: 15}, (_, i) => + createCard({ + id: `c${i}`, + fullName: `Card ${i}`, + ink: 'Amber', + cost: (i % 4) + 1, + strength: 3, + willpower: 3, + lore: 2, + type: 'Character', + inkwell: true, + }), + ); + const analysis = analyzeDeck(deckOf(cards), resolver(cards), {getPairScore: noSynergy, hosers: []}); + + expect(analysis.stats.totalCards).toBe(60); + // 10 composition analyzers + the synergy-density indicator + expect(analysis.health.analyzers).toHaveLength(11); + expect(analysis.health.analyzers.map((a) => a.id)).toContain('synergyDensity'); + expect(analysis.quality.score).toBeGreaterThanOrEqual(0); + expect(analysis.quality.score).toBeLessThanOrEqual(100); + // overall health is the quality score (single source of truth for the UI) + expect(analysis.health.overall).toBe(analysis.quality.score); + }); + + it('lets a declared gameplan override the auto-detected archetype', () => { + const cards = Array.from({length: 15}, (_, i) => + createCard({id: `g${i}`, fullName: `G ${i}`, ink: 'Amethyst', cost: 2, strength: 3, willpower: 3, type: 'Character', inkwell: true}), + ); + const analysis = analyzeDeck(deckOf(cards), resolver(cards), { + getPairScore: noSynergy, + hosers: [], + gameplan: 'control', + }); + + expect(analysis.health.archetype).toBe('control'); + expect(analysis.health.archetypeConfidence).toBe(1); + }); + + it('surfaces a low-strength vulnerability from the hoser catalog', () => { + const fragile = Array.from({length: 15}, (_, i) => + createCard({id: `f${i}`, fullName: `F ${i}`, ink: 'Amber', cost: 2, strength: 1, willpower: 2, type: 'Character', inkwell: true}), + ); + const analysis = analyzeDeck(deckOf(fragile), resolver(fragile), {getPairScore: noSynergy, hosers: lowStrengthHoser}); + + const lowStrength = analysis.health.vulnerabilities.find((v) => v.conditionType === 'low-strength'); + expect(lowStrength).toBeDefined(); + expect(lowStrength?.severity).toBe('high'); + }); +}); diff --git a/apps/web/src/features/deck/analysis/analyzeDeck.ts b/apps/web/src/features/deck/analysis/analyzeDeck.ts new file mode 100644 index 00000000..3de2817c --- /dev/null +++ b/apps/web/src/features/deck/analysis/analyzeDeck.ts @@ -0,0 +1,95 @@ +// Advisor orchestrator (integration of #469/#470/#471): composes the pure analysis +// modules into a single DeckHealth + Deck Quality Score. Pure — the caller injects +// the pair-score provider (from precomputed synergies) and the hoser catalog, so this +// stays testable and free of fetch/React. The builder UI calls this on every edit. + +import type { + Archetype, + Deck, + DeckHealth, + DeckStats, + HealthAnalyzer, + LorcanaCard, + QualityScore, +} from '../types'; +import {calculateDeckStats} from './deckStats'; +import {classifyArchetype} from './archetype'; +import {buildHealthAnalyzers} from './analyzers'; +import {analyzeVulnerabilities, type HoserEntry} from './vulnerabilities'; +import {aggregateDeckSynergy, type DeckSynergyResult, type PairScore} from './deckSynergy'; +import {scoreDeck} from './score'; + +export interface DeckAnalysis { + stats: DeckStats; + synergy: DeckSynergyResult; + health: DeckHealth; + quality: QualityScore; +} + +export interface AnalyzeDeckOptions { + /** Aggregate synergy score between two cardIds (0 if none), from precomputed pairs. */ + getPairScore: PairScore; + /** The pool-derived hoser catalog (`public/data/hosers.json`). */ + hosers: HoserEntry[]; + /** Optional user-declared gameplan; overrides the auto-detected archetype. */ + gameplan?: Archetype; +} + +/** Rough status cutoffs for the synergy-density indicator (calibrated later in Phase 3). */ +function synergyStatus(score: number): HealthAnalyzer['status'] { + if (score >= 50) return 'good'; + if (score >= 25) return 'warn'; + return 'bad'; +} + +/** + * Fold the aggregate synergy density into a HealthAnalyzer. Its id is `synergyDensity` + * to line up with the `scoring.json` weight key, so the composite score picks it up. + */ +function synergyDensityAnalyzer(synergy: DeckSynergyResult): HealthAnalyzer { + return { + id: 'synergyDensity', + label: 'Synergy density', + score: synergy.overallScore, + status: synergyStatus(synergy.overallScore), + message: + synergy.weakLinks.length > 0 + ? `${synergy.keyCards.length} key cards; ${synergy.weakLinks.length} weak links worth reconsidering` + : `${synergy.keyCards.length} key cards anchoring the deck`, + value: synergy.overallScore, + }; +} + +/** + * Run the full advisor pipeline over a deck. A declared `gameplan` overrides the + * auto-detected archetype (and reports full confidence). `health.overall` is set to + * the Deck Quality Score so the UI has a single "how good is this deck" number. + */ +export function analyzeDeck( + deck: Deck, + getCardById: (id: string) => LorcanaCard | undefined, + opts: AnalyzeDeckOptions, +): DeckAnalysis { + const stats = calculateDeckStats(deck, getCardById); + + const detected = classifyArchetype(stats, deck, getCardById); + const archetype = opts.gameplan ?? detected.archetype; + const archetypeConfidence = opts.gameplan ? 1 : detected.confidence; + + const analyzers = buildHealthAnalyzers(deck, stats, getCardById, archetype); + const synergy = aggregateDeckSynergy(deck, opts.getPairScore); + const vulnerabilities = analyzeVulnerabilities(deck, getCardById, opts.hosers); + + const health: DeckHealth = { + overall: 0, // replaced below with the scored result + archetype, + archetypeConfidence, + analyzers: [...analyzers, synergyDensityAnalyzer(synergy)], + vulnerabilities, + }; + + const quality = scoreDeck(health); + health.overall = quality.score; + + return {stats, synergy, health, quality}; +} diff --git a/apps/web/src/features/deck/analysis/analyzers.test.ts b/apps/web/src/features/deck/analysis/analyzers.test.ts new file mode 100644 index 00000000..f4c60bbc --- /dev/null +++ b/apps/web/src/features/deck/analysis/analyzers.test.ts @@ -0,0 +1,164 @@ +import {describe, it, expect} from 'vitest'; +import {buildHealthAnalyzers} from './analyzers'; +import {calculateDeckStats} from './deckStats'; +import type {Archetype, Deck, HealthAnalyzer, LorcanaCard} from '../types'; +import {createCard} from '../../../shared/test-utils'; + +/** Resolve deck cardIds from a fixed card list; unknown ids return undefined. */ +function makeResolver(cards: LorcanaCard[]) { + const byId = new Map(cards.map((c) => [c.id, c])); + return (id: string) => byId.get(id); +} + +/** Assemble a minimal Deck from [card, quantity] entries. */ +function makeDeck(entries: Array<[LorcanaCard, number]>): Deck { + return { + id: 'deck-1', + name: 'Test Deck', + cards: entries.map(([card, quantity]) => ({cardId: card.id, quantity})), + inks: [], + createdAt: 0, + updatedAt: 0, + schemaVersion: 1, + }; +} + +/** N distinct cards sharing the given overrides (unique id + fullName per index). */ +function genCards(n: number, prefix: string, over: Partial): LorcanaCard[] { + return Array.from({length: n}, (_, i) => + createCard({id: `${prefix}-${i}`, fullName: `${prefix} ${i}`, ...over}), + ); +} + +/** Build the analyzer list for a deck + archetype in one call. */ +function analyze(entries: Array<[LorcanaCard, number]>, archetype: Archetype): HealthAnalyzer[] { + const deck = makeDeck(entries); + const cards = entries.map(([c]) => c); + const resolver = makeResolver(cards); + return buildHealthAnalyzers(deck, calculateDeckStats(deck, resolver), resolver, archetype); +} + +/** Find a specific analyzer by id. */ +function byId(analyzers: HealthAnalyzer[], id: string): HealthAnalyzer { + const found = analyzers.find((a) => a.id === id); + if (!found) throw new Error(`analyzer '${id}' not found`); + return found; +} + +/** 60 characters, no removal / draw / shift — a body-only midrange shell. */ +function bodyOnlyDeck(): Array<[LorcanaCard, number]> { + return [ + ...genCards(4, 'body2', {type: 'Character', cost: 2, lore: 2}), + ...genCards(4, 'body3', {type: 'Character', cost: 3, lore: 2}), + ...genCards(4, 'body4', {type: 'Character', cost: 4, lore: 2}), + ...genCards(3, 'body5', {type: 'Character', cost: 5, lore: 3}), + ].map((c) => [c, 4] as [LorcanaCard, number]); +} + +describe('buildHealthAnalyzers', () => { + it('returns one analyzer per dimension, each with a 0..100 score and a valid status', () => { + const analyzers = analyze(bodyOnlyDeck(), 'midrange'); + + expect(analyzers.map((a) => a.id)).toEqual([ + 'curve', + 'inkable', + 'draw', + 'removal', + 'actionsCap', + 'typeMix', + 'ruleOfEight', + 'consistency', + 'lore', + 'shiftCoverage', + ]); + + for (const a of analyzers) { + expect(a.score).toBeGreaterThanOrEqual(0); + expect(a.score).toBeLessThanOrEqual(100); + expect(Number.isInteger(a.score)).toBe(true); + expect(['good', 'warn', 'bad']).toContain(a.status); + expect(a.message.length).toBeGreaterThan(0); + } + }); + + it('flags removal when a midrange deck runs no interaction', () => { + const removal = byId(analyze(bodyOnlyDeck(), 'midrange'), 'removal'); + expect(removal.value).toBe(0); + expect(removal.status).toBe('bad'); + expect(removal.message.toLowerCase()).toContain('removal'); + }); + + it('does NOT penalize a ramp deck on curve (validates the curve-jump instead)', () => { + const rampEntries: Array<[LorcanaCard, number]> = [ + ...genCards(3, 'ramp-src', { + type: 'Item', + cost: 2, + text: 'Put the top card of your deck into your inkwell.', + }), + ...genCards(2, 'ramp-src2', { + type: 'Item', + cost: 3, + text: 'Look at the top 2 cards of your deck. You may put one into your inkwell.', + }), + ...genCards(3, 'ramp-big', {type: 'Character', cost: 7, lore: 3, willpower: 7}), + ...genCards(2, 'ramp-big2', {type: 'Character', cost: 8, lore: 3, willpower: 8}), + ...genCards(3, 'ramp-mid', {type: 'Character', cost: 4, lore: 2}), + ...genCards(2, 'ramp-early', {type: 'Character', cost: 2, lore: 1}), + ].map((c) => [c, 4] as [LorcanaCard, number]); + + const rampCurve = byId(analyze(rampEntries, 'ramp'), 'curve'); + const midCurve = byId(analyze(rampEntries, 'midrange'), 'curve'); + + // Same top-heavy deck: ramp validates it (good), midrange penalizes the gap. + expect(rampCurve.status).toBe('good'); + expect(rampCurve.score).toBeGreaterThan(midCurve.score); + }); + + it('flags shift-coverage when a Shift card has no same-named base in the deck', () => { + const shifter = createCard({ + id: 'shift-elsa', + name: 'Elsa', + fullName: 'Elsa - Spirit of Winter', + type: 'Character', + cost: 6, + lore: 3, + keywords: ['Shift 4'], + }); + const filler = genCards(14, 'filler', {type: 'Character', cost: 3, lore: 2}); + const entries: Array<[LorcanaCard, number]> = [shifter, ...filler].map( + (c) => [c, 4] as [LorcanaCard, number], + ); + + const coverage = byId(analyze(entries, 'midrange'), 'shiftCoverage'); + expect(coverage.value).toBe(1); // one uncovered Shift card + expect(coverage.status).toBe('bad'); + }); + + it('passes shift-coverage once the same-named base body is present', () => { + const shifter = createCard({ + id: 'shift-elsa', + name: 'Elsa', + fullName: 'Elsa - Spirit of Winter', + type: 'Character', + cost: 6, + lore: 3, + keywords: ['Shift 4'], + }); + const base = createCard({ + id: 'base-elsa', + name: 'Elsa', + fullName: 'Elsa - Snow Queen', + type: 'Character', + cost: 4, + lore: 2, + }); + const filler = genCards(13, 'filler', {type: 'Character', cost: 3, lore: 2}); + const entries: Array<[LorcanaCard, number]> = [shifter, base, ...filler].map( + (c) => [c, 4] as [LorcanaCard, number], + ); + + const coverage = byId(analyze(entries, 'midrange'), 'shiftCoverage'); + expect(coverage.value).toBe(0); + expect(coverage.status).toBe('good'); + }); +}); diff --git a/apps/web/src/features/deck/analysis/analyzers.ts b/apps/web/src/features/deck/analysis/analyzers.ts new file mode 100644 index 00000000..dc7f77ab --- /dev/null +++ b/apps/web/src/features/deck/analysis/analyzers.ts @@ -0,0 +1,526 @@ +// Archetype-parameterized health analyzers (#469) — Tier 2 of the deck-health +// framework. Each analyzer scores one compositional dimension against the target +// profile for the deck's archetype, returning a normalized 0..100 `score`, a +// traffic-light `status`, and a human `message` that names the gap. +// +// The classifier (`archetype.ts`) runs first; its result (or a user-declared +// `deck.gameplan`, resolved by the caller) is passed in as `archetype`. Every +// per-dimension target lives in the `TARGETS` table below, derived from +// `docs/deck-builder/PLAN.md` Part A (midrange baseline + aggro/control/ramp +// variance). Universal dimensions (rule-of-eight, consistency, shift-coverage) +// use archetype-invariant constants. +// +// NOTE: there is deliberately NO `synergyDensity` analyzer here. That dimension +// is produced later by the `deckSynergy` module from the precomputed pairwise +// JSON and merged into the analyzer list by the controller. `scoring.json` +// reserves its weight (0.14); `score.ts` renormalizes over whatever weights are +// actually present, so omitting it now is safe. +// +// Analyzer ids match the `scoring.json` weight keys (curve, inkable, draw, +// removal, actionsCap, typeMix, ruleOfEight, consistency, lore, shiftCoverage) +// so the composite scorer can weight them — NOT the dashed shorthand. + +import type {Archetype, Deck, DeckStats, DeckStatus, HealthAnalyzer, LorcanaCard} from '../types'; +import { + getBaseName, + getCardMechanics, + getRampRoles, + getRemovalRoles, + getShiftBaseNames, + getShiftType, + hasAnyShift, + isCharacter, + isDeckRamp, + isItem, + isSong, +} from 'inkweave-synergy-engine'; + +// --------------------------------------------------------------------------- +// Target profiles (PLAN Part A — Tier 2) +// --------------------------------------------------------------------------- + +/** Per-archetype target ranges for the archetype-varying dimensions. */ +interface ArchetypeProfile { + /** Curve center-of-mass (avg cost) ideal band. */ + curve: {min: number; max: number}; + /** Inkable share of the deck (0..1): floor is the failure threshold, ideal earns full marks. */ + inkableFloor: number; + inkableIdeal: number; + /** Card-draw soft target (counts scaled to 60): `floor` = official pillar, `ideal` = comfortable. */ + draw: {floor: number; ideal: number}; + /** Removal soft target (counts scaled to 60): floor + ideal band. */ + removal: {floor: number; idealMin: number; idealMax: number}; + /** Actions+Songs share cap (0..1 of the deck). */ + actionShareCap: number; + /** Character count ideal band (scaled to 60). */ + characters: {min: number; max: number}; + /** Board-lore floor (Σ lore×qty scaled to 60) needed to race with this plan. */ + loreFloor: number; +} + +/** + * Archetype target table. Midrange is the sourced baseline (PLAN Part A Tier-2: + * inkable 44-48/60, curve peak 2-3 / center ~3.0-3.6, draw ≥4 floor 6-10 soft, + * removal ≥4 floor ~6-10 soft, actions ≤~25%, 22-26 characters); aggro / control + * / ramp shift the dials per the "Variance" column (aggro tolerates 18-20 + * uninkable + lower curve + light removal + more chars + high lore; control runs + * a higher/flatter curve + 8-12 removal + high draw + 18-20 chars + low own-lore; + * ramp wants a high inkable base and a top-heavy payoff — its curve is validated, + * not penalized, below). Tempo/combo interpolate between those poles. + */ +const TARGETS: Record = { + aggro: { + curve: {min: 2.2, max: 3.0}, + inkableFloor: 0.63, + inkableIdeal: 0.7, + draw: {floor: 4, ideal: 5}, + removal: {floor: 2, idealMin: 3, idealMax: 6}, + actionShareCap: 0.22, + characters: {min: 24, max: 28}, + loreFloor: 26, + }, + tempo: { + curve: {min: 2.6, max: 3.3}, + inkableFloor: 0.7, + inkableIdeal: 0.78, + draw: {floor: 4, ideal: 6}, + removal: {floor: 4, idealMin: 5, idealMax: 9}, + actionShareCap: 0.25, + characters: {min: 22, max: 26}, + loreFloor: 22, + }, + midrange: { + curve: {min: 3.0, max: 3.6}, + inkableFloor: 0.72, + inkableIdeal: 0.78, + draw: {floor: 4, ideal: 6}, + removal: {floor: 4, idealMin: 6, idealMax: 10}, + actionShareCap: 0.25, + characters: {min: 22, max: 26}, + loreFloor: 18, + }, + control: { + curve: {min: 3.4, max: 4.4}, + inkableFloor: 0.75, + inkableIdeal: 0.8, + draw: {floor: 6, ideal: 8}, + removal: {floor: 6, idealMin: 8, idealMax: 12}, + actionShareCap: 0.28, + characters: {min: 18, max: 22}, + loreFloor: 12, + }, + combo: { + curve: {min: 2.8, max: 3.8}, + inkableFloor: 0.72, + inkableIdeal: 0.78, + draw: {floor: 6, ideal: 9}, + removal: {floor: 2, idealMin: 3, idealMax: 8}, + actionShareCap: 0.3, + characters: {min: 16, max: 24}, + loreFloor: 14, + }, + ramp: { + curve: {min: 3.6, max: 4.8}, + inkableFloor: 0.75, + inkableIdeal: 0.82, + draw: {floor: 4, ideal: 6}, + removal: {floor: 4, idealMin: 5, idealMax: 9}, + actionShareCap: 0.25, + characters: {min: 20, max: 26}, + loreFloor: 16, + }, +}; + +/** Rule of Eight is universal: aim for ~8 interchangeable copies of the core plan. */ +const RULE_OF_EIGHT = {floor: 6, ideal: 8}; + +/** Ramp curve-jump target (counts scaled to 60): enough ramp sources into enough big payoff bodies. */ +const RAMP_CURVE_JUMP = {rampFloor: 4, payoffFloor: 6}; + +// --------------------------------------------------------------------------- +// Deck metrics (one resolve pass, then focused reducers) +// --------------------------------------------------------------------------- + +/** A resolved deck line: the card plus how many copies. */ +interface ResolvedEntry { + card: LorcanaCard; + quantity: number; +} + +/** Everything the analyzers measure off a deck, computed once. */ +interface DeckMetrics { + /** Resolved copy count (unresolved / rotated ids are excluded). */ + total: number; + /** Full deck size incl. unresolved ids (from `DeckStats`), for size-adherence. */ + deckSize: number; + centroid: number; + drawCount: number; + removalCount: number; + totalLore: number; + charCount: number; + actionCount: number; + songCount: number; + itemLocCount: number; + inkableCount: number; + /** Characters costing 5+ (the ramp payoff bodies). */ + highCostBodies: number; + /** Inkwell-ramp / deck-ramp enablers. */ + rampEnablers: number; + /** Deepest interchangeable package (max copies sharing a classification / keyword / role). */ + largestPackage: number; + uniqueLines: number; + singletonLines: number; + shiftTotal: number; + shiftUncovered: number; + /** Distinct card ids in the deck (from `DeckStats`), surfaced as the consistency value. */ + uniqueCards: number; +} + +/** Resolve every deck line to its card, dropping ids that no longer resolve. */ +function resolveEntries( + deck: Deck, + getCardById: (id: string) => LorcanaCard | undefined, +): ResolvedEntry[] { + const entries: ResolvedEntry[] = []; + for (const {cardId, quantity} of deck.cards) { + const card = getCardById(cardId); + if (card) entries.push({card, quantity}); + } + return entries; +} + +/** Sum copies of entries whose card matches `pred`. */ +function sumWhere(entries: ResolvedEntry[], pred: (card: LorcanaCard) => boolean): number { + return entries.reduce((sum, e) => (pred(e.card) ? sum + e.quantity : sum), 0); +} + +/** A card is an inkwell-ramp accelerant if it grows YOUR inkwell (role or deck-ramp shape). */ +function isRampEnabler(card: LorcanaCard): boolean { + return getRampRoles(card).includes('inkwell-ramp') || isDeckRamp(card); +} + +/** + * Deepest "interchangeable package": the largest number of copies that share a + * classification, a keyword, or a functional role (removal / draw). A proxy for + * the Rule-of-Eight core plan being redundant enough to draw reliably. + */ +function largestInterchangeable(entries: ResolvedEntry[]): number { + const groups = new Map(); + const bump = (key: string, qty: number) => groups.set(key, (groups.get(key) ?? 0) + qty); + + for (const {card, quantity} of entries) { + for (const cls of card.classifications ?? []) bump(`class:${cls.toLowerCase()}`, quantity); + for (const kw of card.keywords ?? []) { + const base = kw.replace(/\s*\d+\s*$/, '').trim().toLowerCase(); + bump(`kw:${base}`, quantity); + } + if (getRemovalRoles(card).length > 0) bump('role:removal', quantity); + if (getCardMechanics(card).includes('draw')) bump('role:draw', quantity); + } + + let max = 0; + for (const count of groups.values()) if (count > max) max = count; + return max; +} + +/** Whether a Shift card has a valid target already present in the deck. */ +function isShiftCovered(shift: LorcanaCard, others: LorcanaCard[]): boolean { + const type = getShiftType(shift); + if (!type) return false; + switch (type.kind) { + case 'standard': { + const targets = new Set(getShiftBaseNames(shift).map((n) => n.toLowerCase())); + // A real base body (not another Shift card) sharing one of the target names. + return others.some((c) => !hasAnyShift(c) && targets.has(getBaseName(c).toLowerCase())); + } + case 'classification': { + const wanted = type.classification.toLowerCase(); + return others.some((c) => (c.classifications ?? []).some((cl) => cl.toLowerCase() === wanted)); + } + case 'named-item': { + const wanted = type.itemName.toLowerCase(); + return others.some((c) => isItem(c) && getBaseName(c).toLowerCase() === wanted); + } + case 'universal': + return others.some((c) => isCharacter(c)); + } +} + +/** Count Shift cards and how many lack a same-named base target in the deck. */ +function countShiftGaps(entries: ResolvedEntry[]): {total: number; uncovered: number} { + const shifts = entries.filter((e) => hasAnyShift(e.card)); + let uncovered = 0; + for (const {card} of shifts) { + const others = entries.filter((e) => e.card.id !== card.id).map((e) => e.card); + if (!isShiftCovered(card, others)) uncovered += 1; + } + return {total: shifts.length, uncovered}; +} + +/** Measure everything the analyzers need in one resolve pass plus focused reducers. */ +function computeMetrics( + deck: Deck, + stats: DeckStats, + getCardById: (id: string) => LorcanaCard | undefined, +): DeckMetrics { + const entries = resolveEntries(deck, getCardById); + const total = sumWhere(entries, () => true); + const characters = entries.filter((e) => isCharacter(e.card)); + const shift = countShiftGaps(entries); + + return { + total, + deckSize: stats.totalCards, + centroid: total > 0 ? entries.reduce((s, e) => s + e.card.cost * e.quantity, 0) / total : 0, + drawCount: sumWhere(entries, (c) => getCardMechanics(c).includes('draw')), + removalCount: sumWhere(entries, (c) => getRemovalRoles(c).length > 0), + totalLore: characters.reduce((s, e) => s + (e.card.lore ?? 0) * e.quantity, 0), + charCount: characters.reduce((s, e) => s + e.quantity, 0), + actionCount: sumWhere(entries, (c) => c.type === 'Action'), + songCount: sumWhere(entries, isSong), + itemLocCount: sumWhere(entries, (c) => c.type === 'Item' || c.type === 'Location'), + inkableCount: sumWhere(entries, (c) => Boolean(c.inkwell)), + highCostBodies: sumWhere(entries, (c) => isCharacter(c) && c.cost >= 5), + rampEnablers: sumWhere(entries, isRampEnabler), + largestPackage: largestInterchangeable(entries), + uniqueLines: entries.length, + singletonLines: entries.filter((e) => e.quantity === 1).length, + shiftTotal: shift.total, + shiftUncovered: shift.uncovered, + uniqueCards: stats.uniqueCards, + }; +} + +// --------------------------------------------------------------------------- +// Scoring primitives +// --------------------------------------------------------------------------- + +function clamp(value: number, lo: number, hi: number): number { + return Math.min(hi, Math.max(lo, value)); +} + +/** Scale a raw count to its 60-card-deck equivalent. */ +function per60(count: number, total: number): number { + return total > 0 ? (count / total) * 60 : 0; +} + +/** + * "Meet the floor, reward the ideal" curve: 100 at/above `ideal`, 55 at `floor` + * (the just-acceptable line), linearly interpolated between, and dropping toward + * 0 as the value falls below the floor. + */ +function floorIdeal(value: number, floor: number, ideal: number): number { + if (ideal <= floor) return value >= ideal ? 100 : 0; + if (value >= ideal) return 100; + if (value >= floor) return 55 + (45 * (value - floor)) / (ideal - floor); + return floor > 0 ? (55 * value) / floor : 0; +} + +/** 100 inside `[min, max]`, linear falloff to 0 over `margin` beyond either edge. */ +function bandScore(value: number, min: number, max: number, margin: number): number { + if (value >= min && value <= max) return 100; + const d = value < min ? min - value : value - max; + return clamp(100 - (100 * d) / margin, 0, 100); +} + +/** 100 at/below `cap`, linear falloff to 0 over `margin` above it. */ +function capScore(value: number, cap: number, margin: number): number { + if (value <= cap) return 100; + return clamp(100 - (100 * (value - cap)) / margin, 0, 100); +} + +/** Removal band scoring: reward the ideal window, still credit the floor, mildly dock overload. */ +function removalScore(per: number, r: ArchetypeProfile['removal']): number { + if (per >= r.idealMin && per <= r.idealMax) return 100; + if (per > r.idealMax) return clamp(100 - 8 * (per - r.idealMax), 70, 100); + if (per >= r.floor) return 55 + (45 * (per - r.floor)) / (r.idealMin - r.floor); + return r.floor > 0 ? (55 * per) / r.floor : 0; +} + +/** Size-adherence penalty for consistency: competitive decks run exactly 60. */ +function sizeAdherencePenalty(deckSize: number): number { + if (deckSize === 60) return 0; + if (deckSize < 60) return Math.min(25, (60 - deckSize) * 1.5); + return Math.min(25, (deckSize - 60) * 3); +} + +/** Inputs for a HealthAnalyzer before its score is clamped, rounded, and graded. */ +interface AnalyzerSpec { + id: string; + label: string; + /** Raw (pre-clamp) 0..100 score; `mk` clamps and rounds it. */ + score: number; + message: string; + value: number; +} + +/** Assemble a HealthAnalyzer, clamping + rounding the score and deriving its status. */ +function mk(spec: AnalyzerSpec): HealthAnalyzer { + const score = Math.round(clamp(spec.score, 0, 100)); + const status: DeckStatus = score >= 75 ? 'good' : score >= 45 ? 'warn' : 'bad'; + return {id: spec.id, label: spec.label, score, status, message: spec.message, value: spec.value}; +} + +// --------------------------------------------------------------------------- +// Per-dimension analyzers +// --------------------------------------------------------------------------- + +/** + * Ramp special case: the curve analyzer STOPS penalizing a top-heavy gap and + * instead validates the curve-jump payoff — enough ramp sources feeding enough + * high-cost bodies to actually cash the acceleration in. + */ +function analyzeRampCurve(m: DeckMetrics): HealthAnalyzer { + const rampCov = clamp(per60(m.rampEnablers, m.total) / RAMP_CURVE_JUMP.rampFloor, 0, 1); + const payoffCov = clamp(per60(m.highCostBodies, m.total) / RAMP_CURVE_JUMP.payoffFloor, 0, 1); + const score = 100 * Math.min(rampCov, payoffCov); + const message = + score >= 75 + ? `Ramp plan holds up: ${m.rampEnablers} ramp source(s) into ${m.highCostBodies} high-cost payoff bodies.` + : `Curve-jump incomplete: ${m.rampEnablers} ramp source(s) and ${m.highCostBodies} big bodies, want more of each to justify the gap.`; + return mk({id: 'curve', label: 'Curve', score, message, value: m.highCostBodies}); +} + +function analyzeCurve(m: DeckMetrics, archetype: Archetype, t: ArchetypeProfile): HealthAnalyzer { + if (archetype === 'ramp') return analyzeRampCurve(m); + const score = bandScore(m.centroid, t.curve.min, t.curve.max, 1.2); + const centroid = Math.round(m.centroid * 10) / 10; + const message = + score >= 75 + ? `Curve centers at ${centroid} avg cost, on plan for ${archetype}.` + : `Curve centers at ${centroid} avg cost; ${archetype} wants ${t.curve.min} to ${t.curve.max}.`; + return mk({id: 'curve', label: 'Curve', score, message, value: centroid}); +} + +function analyzeInkable(m: DeckMetrics, archetype: Archetype, t: ArchetypeProfile): HealthAnalyzer { + const ratio = m.total > 0 ? m.inkableCount / m.total : 0; + const score = + ratio > 0.94 ? capScore(ratio, 0.94, 0.2) : floorIdeal(ratio, t.inkableFloor, t.inkableIdeal); + const pct = Math.round(ratio * 100); + const message = + score >= 75 + ? `${m.inkableCount} inkable (${pct}% of deck), a reliable inkwell.` + : `${m.inkableCount} inkable (${pct}%); ${archetype} wants about ${Math.round(t.inkableFloor * 100)}%+.`; + return mk({id: 'inkable', label: 'Inkable Ratio', score, message, value: m.inkableCount}); +} + +function analyzeDraw(m: DeckMetrics, archetype: Archetype, t: ArchetypeProfile): HealthAnalyzer { + const score = floorIdeal(per60(m.drawCount, m.total), t.draw.floor, t.draw.ideal); + const message = + score >= 75 + ? `${m.drawCount} card-draw source(s), enough to refuel a ${archetype} plan.` + : `Only ${m.drawCount} cards draw, aim for ${t.draw.ideal}+.`; + return mk({id: 'draw', label: 'Card Draw', score, message, value: m.drawCount}); +} + +function analyzeRemoval(m: DeckMetrics, archetype: Archetype, t: ArchetypeProfile): HealthAnalyzer { + const per = per60(m.removalCount, m.total); + const score = removalScore(per, t.removal); + let message: string; + if (score >= 75) { + message = `${m.removalCount} removal card(s), interaction on plan for ${archetype}.`; + } else if (per > t.removal.idealMax) { + message = `${m.removalCount} removal card(s), heavy for ${archetype} (target ${t.removal.idealMin} to ${t.removal.idealMax}).`; + } else { + message = `Only ${m.removalCount} removal card(s), ${archetype} wants ${t.removal.idealMin} to ${t.removal.idealMax}.`; + } + return mk({id: 'removal', label: 'Removal', score, message, value: m.removalCount}); +} + +function analyzeActions(m: DeckMetrics, archetype: Archetype, t: ArchetypeProfile): HealthAnalyzer { + const share = m.total > 0 ? m.actionCount / m.total : 0; + const score = capScore(share, t.actionShareCap, 0.2); + const pct = Math.round(share * 100); + const songs = m.songCount > 0 ? ` incl. ${m.songCount} song(s)` : ''; + const message = + score >= 75 + ? `${m.actionCount} actions/songs (${pct}% of deck)${songs}, within the ${archetype} budget.` + : `${m.actionCount} actions/songs (${pct}%)${songs}, over the ~${Math.round(t.actionShareCap * 100)}% cap.`; + return mk({id: 'actionsCap', label: 'Actions & Songs', score, message, value: m.actionCount}); +} + +function analyzeTypeMix(m: DeckMetrics, archetype: Archetype, t: ArchetypeProfile): HealthAnalyzer { + const score = bandScore(per60(m.charCount, m.total), t.characters.min, t.characters.max, 8); + const message = + score >= 75 + ? `${m.charCount} characters plus ${m.actionCount} actions and ${m.itemLocCount} items/locations, a healthy mix.` + : `${m.charCount} characters; ${archetype} wants ${t.characters.min} to ${t.characters.max} (${m.actionCount} actions, ${m.itemLocCount} items/locations).`; + return mk({id: 'typeMix', label: 'Card Types', score, message, value: m.charCount}); +} + +function analyzeRuleOfEight(m: DeckMetrics): HealthAnalyzer { + const score = floorIdeal(m.largestPackage, RULE_OF_EIGHT.floor, RULE_OF_EIGHT.ideal); + const message = + score >= 75 + ? `Deepest interchangeable package is ${m.largestPackage} cards, redundant enough to draw reliably.` + : `Deepest interchangeable package is only ${m.largestPackage} cards, aim for 8+ so you draw your plan.`; + return mk({id: 'ruleOfEight', label: 'Rule of Eight', score, message, value: m.largestPackage}); +} + +function analyzeConsistency(m: DeckMetrics): HealthAnalyzer { + const singletonShare = m.uniqueLines > 0 ? m.singletonLines / m.uniqueLines : 0; + const score = clamp(100 - 55 * singletonShare - sizeAdherencePenalty(m.deckSize), 0, 100); + const message = + score >= 75 + ? `${m.uniqueLines} distinct cards with few singletons, draws stay consistent.` + : `${m.singletonLines} singleton(s) across ${m.uniqueLines} lines, favor 4-ofs for consistency.`; + return mk({id: 'consistency', label: 'Consistency', score, message, value: m.uniqueCards}); +} + +function analyzeLore(m: DeckMetrics, archetype: Archetype, t: ArchetypeProfile): HealthAnalyzer { + const score = floorIdeal(per60(m.totalLore, m.total), t.loreFloor, t.loreFloor + 12); + const message = + score >= 75 + ? `${m.totalLore} board lore, enough to race as ${archetype}.` + : `${m.totalLore} board lore, low for ${archetype} (want about ${t.loreFloor}+ scaled to 60).`; + return mk({id: 'lore', label: 'Lore Output', score, message, value: m.totalLore}); +} + +function analyzeShiftCoverage(m: DeckMetrics): HealthAnalyzer { + if (m.shiftTotal === 0) { + return mk({id: 'shiftCoverage', label: 'Shift Coverage', score: 100, message: 'No Shift cards, nothing to cover.', value: 0}); + } + const covered = m.shiftTotal - m.shiftUncovered; + const score = (100 * covered) / m.shiftTotal; + const message = + m.shiftUncovered === 0 + ? `All ${m.shiftTotal} Shift card(s) have a same-named base in the deck.` + : `${m.shiftUncovered} of ${m.shiftTotal} Shift card(s) have no base target, add their base version.`; + return mk({id: 'shiftCoverage', label: 'Shift Coverage', score, message, value: m.shiftUncovered}); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Build one archetype-parameterized `HealthAnalyzer` per compositional dimension. + * + * `archetype` selects the target profile from `TARGETS` (the caller passes either + * the auto-detected archetype from `classifyArchetype` or the user's declared + * `deck.gameplan`). Ramp special-cases the `curve` analyzer to validate the + * curve-jump payoff instead of penalizing the gap. Pure and deterministic. + * + * The `synergyDensity` dimension is intentionally not built here (see file header). + */ +export function buildHealthAnalyzers( + deck: Deck, + stats: DeckStats, + getCardById: (id: string) => LorcanaCard | undefined, + archetype: Archetype, +): HealthAnalyzer[] { + const m = computeMetrics(deck, stats, getCardById); + const t = TARGETS[archetype]; + return [ + analyzeCurve(m, archetype, t), + analyzeInkable(m, archetype, t), + analyzeDraw(m, archetype, t), + analyzeRemoval(m, archetype, t), + analyzeActions(m, archetype, t), + analyzeTypeMix(m, archetype, t), + analyzeRuleOfEight(m), + analyzeConsistency(m), + analyzeLore(m, archetype, t), + analyzeShiftCoverage(m), + ]; +} diff --git a/apps/web/src/features/deck/analysis/archetype.test.ts b/apps/web/src/features/deck/analysis/archetype.test.ts new file mode 100644 index 00000000..fc45257a --- /dev/null +++ b/apps/web/src/features/deck/analysis/archetype.test.ts @@ -0,0 +1,108 @@ +import {describe, it, expect} from 'vitest'; +import {classifyArchetype} from './archetype'; +import {calculateDeckStats} from './deckStats'; +import type {Deck, LorcanaCard} from '../types'; +import {createCard} from '../../../shared/test-utils'; + +/** Resolve deck cardIds from a fixed card list; unknown ids return undefined. */ +function makeResolver(cards: LorcanaCard[]) { + const byId = new Map(cards.map((c) => [c.id, c])); + return (id: string) => byId.get(id); +} + +/** Assemble a minimal Deck from [card, quantity] entries. */ +function makeDeck(entries: Array<[LorcanaCard, number]>): Deck { + return { + id: 'deck-1', + name: 'Test Deck', + cards: entries.map(([card, quantity]) => ({cardId: card.id, quantity})), + inks: [], + createdAt: 0, + updatedAt: 0, + schemaVersion: 1, + }; +} + +/** N distinct cards sharing the given overrides (unique id + fullName per index). */ +function genCards(n: number, prefix: string, over: Partial): LorcanaCard[] { + return Array.from({length: n}, (_, i) => + createCard({id: `${prefix}-${i}`, fullName: `${prefix} ${i}`, ...over}), + ); +} + +/** Turn card groups into a 4-of deck (each distinct card contributes 4 copies). */ +function fourOfDeck(...groups: LorcanaCard[][]): {deck: Deck; cards: LorcanaCard[]} { + const cards = groups.flat(); + const deck = makeDeck(cards.map((c) => [c, 4] as [LorcanaCard, number])); + return {deck, cards}; +} + +function classify(deck: Deck, cards: LorcanaCard[]) { + const resolver = makeResolver(cards); + return classifyArchetype(calculateDeckStats(deck, resolver), deck, resolver); +} + +describe('classifyArchetype', () => { + it('detects aggro: low curve, high lore, light removal', () => { + const {deck, cards} = fourOfDeck( + genCards(5, 'aggro-1', {type: 'Character', cost: 1, lore: 1}), + genCards(6, 'aggro-2', {type: 'Character', cost: 2, lore: 2}), + genCards(3, 'aggro-3', {type: 'Character', cost: 3, lore: 2}), + genCards(1, 'aggro-rm', {type: 'Action', cost: 2, inkwell: false, text: 'Banish chosen character.'}), + ); + + const {archetype, confidence} = classify(deck, cards); + expect(archetype).toBe('aggro'); + expect(confidence).toBeGreaterThan(0.1); + expect(confidence).toBeLessThanOrEqual(1); + }); + + it('detects control: high curve, removal-and-draw heavy, low own-lore', () => { + const {deck, cards} = fourOfDeck( + genCards(3, 'ctrl-wall', {type: 'Character', cost: 5, lore: 1, willpower: 6}), + genCards(2, 'ctrl-finish', {type: 'Character', cost: 7, lore: 1, willpower: 7}), + genCards(5, 'ctrl-rm', {type: 'Action', cost: 3, inkwell: false, text: 'Banish chosen character.'}), + genCards(3, 'ctrl-draw', {type: 'Action', cost: 2, text: 'Draw 2 cards.'}), + genCards(2, 'ctrl-rm2', { + type: 'Action', + cost: 4, + inkwell: false, + text: 'Banish chosen character with cost 3 or less.', + }), + ); + + const {archetype, confidence} = classify(deck, cards); + expect(archetype).toBe('control'); + expect(confidence).toBeGreaterThan(0.1); + }); + + it('detects ramp: inkwell-ramp sources into a top-heavy payoff', () => { + const {deck, cards} = fourOfDeck( + genCards(3, 'ramp-src', { + type: 'Item', + cost: 2, + text: 'Put the top card of your deck into your inkwell.', + }), + genCards(2, 'ramp-src2', { + type: 'Item', + cost: 3, + text: 'Look at the top 2 cards of your deck. You may put one into your inkwell.', + }), + genCards(3, 'ramp-big', {type: 'Character', cost: 7, lore: 3, willpower: 7}), + genCards(2, 'ramp-big2', {type: 'Character', cost: 8, lore: 3, willpower: 8}), + genCards(3, 'ramp-mid', {type: 'Character', cost: 4, lore: 2}), + genCards(2, 'ramp-early', {type: 'Character', cost: 2, lore: 1}), + ); + + const {archetype, confidence} = classify(deck, cards); + expect(archetype).toBe('ramp'); + expect(confidence).toBeGreaterThan(0.1); + }); + + it('defaults to midrange with zero confidence on an empty deck', () => { + const deck = makeDeck([]); + const {archetype, confidence} = classify(deck, []); + expect(archetype).toBe('midrange'); + expect(confidence).toBe(0); + }); +}); diff --git a/apps/web/src/features/deck/analysis/archetype.ts b/apps/web/src/features/deck/analysis/archetype.ts new file mode 100644 index 00000000..11e64ac7 --- /dev/null +++ b/apps/web/src/features/deck/analysis/archetype.ts @@ -0,0 +1,207 @@ +// Archetype classifier (#469) — the FIRST step of the archetype-parameterized +// deck-health advisor. Given a deck's composition, it deterministically detects +// which of the six strategies (`aggro | tempo | midrange | control | combo | +// ramp`) the card choices actually describe, so the health analyzers can load +// the matching target profile. +// +// This is PURE AUTO-DETECT. A user-declared `deck.gameplan` overrides the result, +// but that override is applied by the CALLER (the advisor controller), not here. +// +// The scorecard reads four signal families off the resolved cards: +// - curve center-of-mass (average mana cost), +// - role densities (removal / draw / ramp counts, scaled to a 60-card deck), +// - lore output + character share (threat / racing density), +// - top-end body share (the ramp payoff signal). +// Each archetype scores as a weighted sum of how well those signals match its +// signature; the top score wins and `confidence` is the normalized margin over +// the runner-up. See `docs/deck-builder/PLAN.md` Part A (Tier 2). + +import type {Archetype, Deck, DeckStats, LorcanaCard} from '../types'; +import {getCardMechanics, getRampRoles, getRemovalRoles, isCharacter} from 'inkweave-synergy-engine'; + +/** The measured feature vector a deck presents to the scorecard. */ +interface Features { + /** Average mana cost over the resolved cards (curve center of mass). */ + centroid: number; + /** Opponent-removal cards, scaled to a 60-card deck. */ + removalPer60: number; + /** Card-draw sources, scaled to a 60-card deck. */ + drawPer60: number; + /** Inkwell-ramp / deck-ramp enablers, scaled to a 60-card deck. */ + rampPer60: number; + /** Total board lore (Σ lore×qty over characters), scaled to a 60-card deck. */ + lorePer60: number; + /** Character copies as a share of the deck, 0..1 (threat density). */ + charShare: number; + /** Cost-5+ character copies as a share of the deck, 0..1 (ramp payoff). */ + highCostShare: number; +} + +/** A linear response window: `lo`/`hi` bound where a signal starts and finishes scoring. */ +interface Ramp { + lo: number; + hi: number; +} + +/** A triangular response window: the `[lo, hi]` plateau plus a `margin` of linear falloff. */ +interface Band { + lo: number; + hi: number; + margin: number; +} + +/** 0 at/below `lo`, 1 at/above `hi`, linear in between (higher input scores higher). */ +function rampUp(x: number, r: Ramp): number { + if (x <= r.lo) return 0; + if (x >= r.hi) return 1; + return (x - r.lo) / (r.hi - r.lo); +} + +/** 1 at/below `lo`, 0 at/above `hi`, linear in between (lower input scores higher). */ +function rampDown(x: number, r: Ramp): number { + if (x <= r.lo) return 1; + if (x >= r.hi) return 0; + return (r.hi - x) / (r.hi - r.lo); +} + +/** Triangular peak: 1 inside `[lo, hi]`, linear falloff to 0 over `margin` beyond either edge. */ +function band(x: number, b: Band): number { + if (x >= b.lo && x <= b.hi) return 1; + const d = x < b.lo ? b.lo - x : x - b.hi; + return Math.max(0, 1 - d / b.margin); +} + +/** Running per-role tallies accumulated across a deck's resolved card copies. */ +interface FeatureTotals { + resolved: number; + cost: number; + removal: number; + draw: number; + ramp: number; + lore: number; + chars: number; + highCost: number; +} + +/** Fold one card's copies into the running totals (mutates `totals` in place). */ +function addCard(totals: FeatureTotals, card: LorcanaCard, quantity: number): void { + totals.resolved += quantity; + totals.cost += card.cost * quantity; + if (getRemovalRoles(card).length > 0) totals.removal += quantity; + if (getCardMechanics(card).includes('draw')) totals.draw += quantity; + if (getRampRoles(card).includes('inkwell-ramp')) totals.ramp += quantity; + if (isCharacter(card)) { + totals.chars += quantity; + totals.lore += (card.lore ?? 0) * quantity; + if (card.cost >= 5) totals.highCost += quantity; + } +} + +/** Fold the deck's resolved cards into the feature vector the scorecard reads. */ +function extractFeatures(deck: Deck, getCardById: (id: string) => LorcanaCard | undefined): Features { + const totals: FeatureTotals = { + resolved: 0, + cost: 0, + removal: 0, + draw: 0, + ramp: 0, + lore: 0, + chars: 0, + highCost: 0, + }; + + for (const {cardId, quantity} of deck.cards) { + const card = getCardById(cardId); + if (card) addCard(totals, card, quantity); + } + + const denom = totals.resolved || 1; + return { + centroid: totals.cost / denom, + removalPer60: (totals.removal / denom) * 60, + drawPer60: (totals.draw / denom) * 60, + rampPer60: (totals.ramp / denom) * 60, + lorePer60: (totals.lore / denom) * 60, + charShare: totals.chars / denom, + highCostShare: totals.highCost / denom, + }; +} + +/** + * Score each archetype from the feature vector. Every term is a weighted signal + * match (0..1); the weights encode how load-bearing that signal is for the + * archetype. `midrange` carries a small constant so a balanced, signal-less deck + * defaults to it rather than to a noisy runner-up. + */ +function scoreArchetypes(f: Features): Record { + return { + // Fast, front-loaded, lore-forward, light on removal. + aggro: + 1.4 * rampDown(f.centroid, {lo: 2.9, hi: 3.7}) + + 1.2 * rampUp(f.lorePer60, {lo: 22, hi: 40}) + + 0.9 * rampDown(f.removalPer60, {lo: 4, hi: 10}) + + 0.5 * rampUp(f.charShare, {lo: 0.5, hi: 0.62}), + // Low-mid curve with real interaction and efficient bodies. + tempo: + 1.2 * band(f.centroid, {lo: 2.7, hi: 3.3, margin: 0.8}) + + 0.9 * band(f.removalPer60, {lo: 5, hi: 9, margin: 4}) + + 0.8 * rampUp(f.lorePer60, {lo: 16, hi: 28}) + + 0.5 * rampUp(f.drawPer60, {lo: 3, hi: 8}), + // Balanced everything — the default fallback (baseline constant). + midrange: + 1.2 * band(f.centroid, {lo: 3.0, hi: 3.7, margin: 0.7}) + + 0.9 * band(f.removalPer60, {lo: 5, hi: 10, margin: 4}) + + 0.7 * band(f.charShare, {lo: 0.42, hi: 0.58, margin: 0.15}) + + 0.6, + // High curve, removal-and-draw heavy, low own-lore (wins late). + control: + 1.5 * rampUp(f.centroid, {lo: 3.4, hi: 4.4}) + + 1.4 * rampUp(f.removalPer60, {lo: 7, hi: 12}) + + 1.0 * rampUp(f.drawPer60, {lo: 6, hi: 11}) + + 0.8 * rampDown(f.lorePer60, {lo: 30, hi: 14}), + // Draw-forward, low interaction (a conservative signal — Inkweave has no + // generic tutor detector yet, so combo only edges out on clear draw engines). + combo: + 1.3 * rampUp(f.drawPer60, {lo: 7, hi: 12}) + + 0.8 * rampDown(f.removalPer60, {lo: 5, hi: 11}) + + 0.4 * rampUp(f.highCostShare, {lo: 0.05, hi: 0.15}), + // Mana acceleration into a top-heavy payoff — the ramp enabler term dominates. + ramp: + 1.9 * rampUp(f.rampPer60, {lo: 2, hi: 6}) + + 1.0 * rampUp(f.highCostShare, {lo: 0.08, hi: 0.2}) + + 0.8 * rampUp(f.centroid, {lo: 3.3, hi: 4.4}), + }; +} + +/** + * Auto-detect a deck's archetype from its composition. + * + * Deterministic and pure: the same `stats` + `deck` + resolver always yield the + * same result. Returns the top-scoring archetype plus a `confidence` in `0..1` + * (the normalized margin between the top score and the runner-up — a blowout + * near 1, a coin-flip near 0). An empty deck defaults to `midrange` at 0 + * confidence. `stats` provides the deck-size guard; the feature densities are + * measured over the resolved cards. + * + * A user-declared `deck.gameplan` is NOT consulted here — the caller applies + * that override on top of this auto-detection. + */ +export function classifyArchetype( + stats: DeckStats, + deck: Deck, + getCardById: (id: string) => LorcanaCard | undefined, +): {archetype: Archetype; confidence: number} { + if (stats.totalCards === 0) return {archetype: 'midrange', confidence: 0}; + + const features = extractFeatures(deck, getCardById); + const scores = scoreArchetypes(features); + const ranked = (Object.entries(scores) as Array<[Archetype, number]>).sort( + (a, b) => b[1] - a[1], + ); + + const [topArchetype, topScore] = ranked[0]; + const runnerUp = ranked[1][1]; + const confidence = topScore > 0 ? Math.min(1, Math.max(0, (topScore - runnerUp) / topScore)) : 0; + + return {archetype: topArchetype, confidence}; +} diff --git a/apps/web/src/features/deck/analysis/deckStats.test.ts b/apps/web/src/features/deck/analysis/deckStats.test.ts new file mode 100644 index 00000000..3d4a9468 --- /dev/null +++ b/apps/web/src/features/deck/analysis/deckStats.test.ts @@ -0,0 +1,152 @@ +import {describe, it, expect} from 'vitest'; +import {calculateDeckStats} from './deckStats'; +import type {Deck, LorcanaCard} from '../types'; +import {createCard} from '../../../shared/test-utils'; + +/** Resolve deck cardIds from a fixed card list; unknown ids return undefined. */ +function makeResolver(cards: LorcanaCard[]) { + const byId = new Map(cards.map((c) => [c.id, c])); + return (id: string) => byId.get(id); +} + +/** Assemble a minimal Deck from [card, quantity] entries. */ +function makeDeck(entries: Array<[LorcanaCard, number]>): Deck { + return { + id: 'deck-1', + name: 'Test Deck', + cards: entries.map(([card, quantity]) => ({cardId: card.id, quantity})), + inks: [], + createdAt: 0, + updatedAt: 0, + schemaVersion: 1, + }; +} + +/** N distinct cards sharing the given overrides (unique id + fullName per index). */ +function genCards(n: number, prefix: string, over: Partial): LorcanaCard[] { + return Array.from({length: n}, (_, i) => + createCard({id: `${prefix}-${i}`, fullName: `${prefix} ${i}`, ...over}), + ); +} + +describe('calculateDeckStats', () => { + it('counts a legal 60-card, two-ink deck (totals, inkable, legality)', () => { + // 8 inkable Amber + 7 uninkable Steel, 4 copies each = 60 cards, 15 unique. + const amber = genCards(8, 'amber', {ink: 'Amber', inkwell: true}); + const steel = genCards(7, 'steel', {ink: 'Steel', inkwell: false}); + const cards = [...amber, ...steel]; + const deck = makeDeck(cards.map((c) => [c, 4] as [LorcanaCard, number])); + + const stats = calculateDeckStats(deck, makeResolver(cards)); + + expect(stats.totalCards).toBe(60); + expect(stats.uniqueCards).toBe(15); + expect(stats.inkCount).toBe(2); + expect(stats.inkableCount).toBe(32); // only the 8 Amber cards x 4 + expect(stats.isLegal).toBe(true); + expect(stats.legalityErrors).toEqual([]); + }); + + it('buckets costs >= 7 under key 7 and sums the rest', () => { + const c2 = createCard({id: 'c2', fullName: 'Cost Two', cost: 2}); + const c7 = createCard({id: 'c7', fullName: 'Cost Seven', cost: 7}); + const c9 = createCard({id: 'c9', fullName: 'Cost Nine', cost: 9}); + const deck = makeDeck([ + [c2, 3], + [c7, 1], + [c9, 2], + ]); + + const stats = calculateDeckStats(deck, makeResolver([c2, c7, c9])); + + expect(stats.costCurve).toEqual({2: 3, 7: 3}); // 7 (x1) + 9 (x2) both bucket at 7 + }); + + it('builds ink and type distributions', () => { + const hero = createCard({id: 'h', fullName: 'Hero', ink: 'Amber', type: 'Character'}); + const spell = createCard({id: 's', fullName: 'Spell', ink: 'Amber', type: 'Action'}); + const deck = makeDeck([ + [hero, 4], + [spell, 2], + ]); + + const stats = calculateDeckStats(deck, makeResolver([hero, spell])); + + expect(stats.inkDistribution).toEqual({Amber: 6}); + expect(stats.typeDistribution).toEqual({Character: 4, Action: 2}); + }); + + it('counts a dual-ink card toward both inks while keeping the deck legal at 2 inks', () => { + const amber = genCards(8, 'amber', {ink: 'Amber'}); // 32 + const steel = genCards(6, 'steel', {ink: 'Steel'}); // 24 + const dual = createCard({id: 'dual', fullName: 'Dual One', ink: 'Amber', ink2: 'Steel'}); // 4 + const cards = [...amber, ...steel, dual]; + const deck = makeDeck(cards.map((c) => [c, 4] as [LorcanaCard, number])); // 60 total + + const stats = calculateDeckStats(deck, makeResolver(cards)); + + expect(stats.inkCount).toBe(2); // union stays {Amber, Steel} + expect(stats.isLegal).toBe(true); + // dual card's 4 copies land in BOTH inks, so the columns overcount vs totalCards. + expect(stats.inkDistribution.Amber).toBe(36); // 8*4 + dual 4 + expect(stats.inkDistribution.Steel).toBe(28); // 6*4 + dual 4 + }); + + it('flags a deck under 60 cards', () => { + const cards = genCards(15, 'x', {ink: 'Amber'}); + // 14 cards x4 (56) + 1 card x2 = 58; only the size rule fails. + const deck = makeDeck(cards.map((c, i) => [c, i < 14 ? 4 : 2] as [LorcanaCard, number])); + + const stats = calculateDeckStats(deck, makeResolver(cards)); + + expect(stats.totalCards).toBe(58); + expect(stats.isLegal).toBe(false); + expect(stats.legalityErrors).toEqual(['Deck has 58 cards (minimum 60)']); + }); + + it('flags a 5th copy of a single card', () => { + const cards = genCards(15, 'y', {ink: 'Amber'}); + // 13 x4 (52) + 1 x3 (55) + 1 x5 (60): total is legal, only the copy rule fails. + const quantities = [...Array(13).fill(4), 3, 5]; + const deck = makeDeck(cards.map((c, i) => [c, quantities[i]] as [LorcanaCard, number])); + + const stats = calculateDeckStats(deck, makeResolver(cards)); + + expect(stats.totalCards).toBe(60); + expect(stats.isLegal).toBe(false); + expect(stats.legalityErrors).toEqual(['5 copies of y 14 (max 4)']); + }); + + it('flags a 3rd ink with canonical ink ordering', () => { + const amber = genCards(5, 'a', {ink: 'Amber'}); + const ruby = genCards(5, 'r', {ink: 'Ruby'}); + const steel = genCards(5, 's', {ink: 'Steel'}); + const cards = [...amber, ...ruby, ...steel]; + const deck = makeDeck(cards.map((c) => [c, 4] as [LorcanaCard, number])); // 60, 3 inks + + const stats = calculateDeckStats(deck, makeResolver(cards)); + + expect(stats.inkCount).toBe(3); + expect(stats.isLegal).toBe(false); + expect(stats.legalityErrors).toEqual(['3 inks: Amber, Ruby, Steel (max 2)']); + }); + + it('skips unresolvable cardIds but keeps them in totals and notes them', () => { + const known = createCard({id: 'known', fullName: 'Known', ink: 'Amber'}); + const deck: Deck = { + ...makeDeck([[known, 4]]), + cards: [ + {cardId: 'known', quantity: 4}, + {cardId: 'ghost', quantity: 2}, // rotated out — resolver returns undefined + ], + }; + + const stats = calculateDeckStats(deck, makeResolver([known])); + + expect(stats.totalCards).toBe(6); // unresolved copies still count + expect(stats.uniqueCards).toBe(2); + expect(stats.inkDistribution).toEqual({Amber: 4}); // ghost contributes nothing + expect(stats.warnings).toContain('1 unresolved card skipped (rotated out of Core?)'); + expect(stats.legalityErrors).not.toContain('1 unresolved card skipped (rotated out of Core?)'); + }); +}); diff --git a/apps/web/src/features/deck/analysis/deckStats.ts b/apps/web/src/features/deck/analysis/deckStats.ts new file mode 100644 index 00000000..fa157103 --- /dev/null +++ b/apps/web/src/features/deck/analysis/deckStats.ts @@ -0,0 +1,136 @@ +// Pure compositional stats for a deck (#459), the Tier-1 layer under the deck +// builder. No React, no opinions: just counts + the three Core hard rules +// (size / copies / inks). Higher tiers (health, score) build on this. + +import type {CardType, Deck, DeckStats, Ink, LorcanaCard} from '../types'; +import {getInks} from 'inkweave-synergy-engine'; +import {ALL_INKS} from '../../../shared/constants'; + +/** Costs at or above this collapse into a single top bucket keyed by this value. */ +const COST_CURVE_CAP = 7; + +/** Core-format hard rules. */ +const MIN_DECK_SIZE = 60; +const MAX_COPIES = 4; +const MAX_INKS = 2; + +/** Mutable running totals folded over the deck's resolvable cards. */ +interface Tallies { + inkDistribution: Partial>; + costCurve: Record; + typeDistribution: Partial>; + inks: Set; + copiesByFullName: Map; + inkableCount: number; +} + +function emptyTallies(): Tallies { + return { + inkDistribution: {}, + costCurve: {}, + typeDistribution: {}, + inks: new Set(), + copiesByFullName: new Map(), + inkableCount: 0, + }; +} + +/** Fold one resolved card (with its copy count) into the running tallies. */ +function tallyCard(t: Tallies, card: LorcanaCard, quantity: number): void { + const bucket = Math.min(card.cost, COST_CURVE_CAP); + t.costCurve[bucket] = (t.costCurve[bucket] ?? 0) + quantity; + + // A dual-ink card contributes to BOTH of its inks (getInks returns 1 or 2). + for (const ink of getInks(card)) { + t.inkDistribution[ink] = (t.inkDistribution[ink] ?? 0) + quantity; + t.inks.add(ink); + } + + t.typeDistribution[card.type] = (t.typeDistribution[card.type] ?? 0) + quantity; + if (card.inkwell) t.inkableCount += quantity; + + const seen = t.copiesByFullName.get(card.fullName) ?? 0; + t.copiesByFullName.set(card.fullName, seen + quantity); +} + +/** Build the human-readable legality reasons (the copy/size/ink hard-rule failures only). */ +function buildLegalityErrors( + totalCards: number, + copiesByFullName: Map, + inks: Set, +): string[] { + const errors: string[] = []; + + if (totalCards < MIN_DECK_SIZE) { + errors.push(`Deck has ${totalCards} cards (minimum ${MIN_DECK_SIZE})`); + } + + for (const [fullName, copies] of copiesByFullName) { + if (copies > MAX_COPIES) { + errors.push(`${copies} copies of ${fullName} (max ${MAX_COPIES})`); + } + } + + if (inks.size > MAX_INKS) { + const named = ALL_INKS.filter((ink) => inks.has(ink)).join(', '); + errors.push(`${inks.size} inks: ${named} (max ${MAX_INKS})`); + } + + return errors; +} + +/** Non-blocking notes that don't bear on legality (e.g. cardIds rotated out of Core). */ +function buildWarnings(unresolvedCount: number): string[] { + if (unresolvedCount === 0) return []; + const noun = unresolvedCount === 1 ? 'card' : 'cards'; + return [`${unresolvedCount} unresolved ${noun} skipped (rotated out of Core?)`]; +} + +/** + * Compute pure compositional stats for a deck. `getCardById` resolves each + * `DeckCard.cardId` to its `LorcanaCard`; ids that don't resolve (e.g. a card + * rotated out of Core) still count toward `totalCards`/`uniqueCards` but are + * skipped for every card-derived stat and surfaced as a `warnings` note (they + * do not affect `isLegal`). + * + * `isLegal` is the three Core hard rules: >= 60 cards, <= 4 copies per unique + * `fullName`, and <= 2 inks (a dual-ink card counts toward both of its inks). + */ +export function calculateDeckStats( + deck: Deck, + getCardById: (id: string) => LorcanaCard | undefined, +): DeckStats { + const tallies = emptyTallies(); + const uniqueIds = new Set(); + const unresolvedIds = new Set(); + let totalCards = 0; + + for (const {cardId, quantity} of deck.cards) { + totalCards += quantity; + uniqueIds.add(cardId); + + const card = getCardById(cardId); + if (!card) { + unresolvedIds.add(cardId); + continue; + } + tallyCard(tallies, card, quantity); + } + + const inkCount = tallies.inks.size; + const withinCopyLimit = [...tallies.copiesByFullName.values()].every((c) => c <= MAX_COPIES); + const isLegal = totalCards >= MIN_DECK_SIZE && withinCopyLimit && inkCount <= MAX_INKS; + + return { + totalCards, + uniqueCards: uniqueIds.size, + inkDistribution: tallies.inkDistribution, + costCurve: tallies.costCurve, + typeDistribution: tallies.typeDistribution, + inkCount, + inkableCount: tallies.inkableCount, + isLegal, + legalityErrors: buildLegalityErrors(totalCards, tallies.copiesByFullName, tallies.inks), + warnings: buildWarnings(unresolvedIds.size), + }; +} diff --git a/apps/web/src/features/deck/analysis/deckSynergy.test.ts b/apps/web/src/features/deck/analysis/deckSynergy.test.ts new file mode 100644 index 00000000..72f46687 --- /dev/null +++ b/apps/web/src/features/deck/analysis/deckSynergy.test.ts @@ -0,0 +1,71 @@ +import {describe, it, expect} from 'vitest'; +import {aggregateDeckSynergy, type PairScore} from './deckSynergy'; +import type {Deck} from '../types'; + +/** Minimal deck from a list of card ids (deckSynergy only reads `cards[].cardId`). */ +function makeDeck(ids: string[]): Deck { + return { + id: 'd1', + name: 'Deck', + cards: ids.map((cardId) => ({cardId, quantity: 4})), + inks: [], + createdAt: 0, + updatedAt: 0, + schemaVersion: 1, + }; +} + +/** Symmetric pair-score lookup from an unordered-pair table (`'A|B'` keys, sorted). */ +function fixtureScores(table: Record): PairScore { + return (a, b) => table[[a, b].sort().join('|')] ?? 0; +} + +describe('aggregateDeckSynergy', () => { + it('counts connections and picks hubs / islands from a fixture', () => { + // A wires to B/C/D; B-C also connect; E is isolated. + const scores = fixtureScores({'A|B': 8, 'A|C': 6, 'A|D': 5, 'B|C': 7}); + const result = aggregateDeckSynergy(makeDeck(['A', 'B', 'C', 'D', 'E']), scores); + + expect(result.connectionCounts).toEqual({A: 3, B: 2, C: 2, D: 1, E: 0}); + // avg degree = 1.6 → A(3), B(2), C(2) are above-average hubs (ties by id). + expect(result.keyCards).toEqual(['A', 'B', 'C']); + // <= 1 connection, weakest first: E(0) then D(1). + expect(result.weakLinks).toEqual(['E', 'D']); + // total 26 over 10 pairs × max 10 → 26% density. + expect(result.overallScore).toBe(26); + }); + + it('normalizes a fully-connected max deck to 100 with no hubs or islands', () => { + const scores = fixtureScores({'A|B': 10, 'A|C': 10, 'B|C': 10}); + const result = aggregateDeckSynergy(makeDeck(['A', 'B', 'C']), scores); + + expect(result.overallScore).toBe(100); + expect(result.keyCards).toEqual([]); // every card sits exactly at the average + expect(result.weakLinks).toEqual([]); // every card has 2 connections + }); + + it('deduplicates repeated card ids into one distinct card', () => { + const deck: Deck = { + ...makeDeck(['A', 'B']), + cards: [ + {cardId: 'A', quantity: 2}, + {cardId: 'A', quantity: 2}, + {cardId: 'B', quantity: 4}, + ], + }; + + const result = aggregateDeckSynergy(deck, fixtureScores({'A|B': 6})); + + expect(result.connectionCounts).toEqual({A: 1, B: 1}); + expect(result.overallScore).toBe(60); // one pair scoring 6 of a possible 10 + }); + + it('handles a single-card deck without dividing by zero', () => { + const result = aggregateDeckSynergy(makeDeck(['solo']), () => 5); + + expect(result.overallScore).toBe(0); + expect(result.keyCards).toEqual([]); + expect(result.weakLinks).toEqual(['solo']); + expect(result.connectionCounts).toEqual({solo: 0}); + }); +}); diff --git a/apps/web/src/features/deck/analysis/deckSynergy.ts b/apps/web/src/features/deck/analysis/deckSynergy.ts new file mode 100644 index 00000000..9a214b92 --- /dev/null +++ b/apps/web/src/features/deck/analysis/deckSynergy.ts @@ -0,0 +1,100 @@ +// Deck-level synergy aggregation (#471, Part B "Live advisor engine"). +// +// Rolls the precomputed pairwise synergy scores up to the DECK level: how +// tightly the cards wire together (`overallScore`), which cards are the synergy +// hubs (`keyCards`), and which sit on their own island (`weakLinks`, the cut +// candidates). Pure and side-effect free. +// +// Kept engine-agnostic via an INJECTED pair-score provider. The controller wires +// the real fetch (`pairs[other].aggregateScore` from the precomputed JSON) later; +// tests pass a fixture. See `docs/deck-builder/PLAN.md` Part B. + +import type {Deck} from '../types'; + +/** + * Aggregate synergy score between two card ids, `0` when they don't synergize. + * Order-independent: `getPairScore(a, b) === getPairScore(b, a)`. + */ +export type PairScore = (a: string, b: string) => number; + +/** Deck-wide synergy rollup (see each field's use in the advisor). */ +export interface DeckSynergyResult { + /** Normalized 0..100 synergy density (mean pair score as a share of the max). */ + overallScore: number; + /** Above-average connection hubs, strongest first (top 5). */ + keyCards: string[]; + /** Cards with <= 1 connection — cut candidates, weakest first (top 5). */ + weakLinks: string[]; + /** Per-card connection count (distinct deck cards it synergizes with). */ + connectionCounts: Record; +} + +/** Top of the 1..10 synergy scale; the density denominator per pair. */ +const MAX_PAIR_SCORE = 10; +/** A card is a "weak link" (cut candidate) at or below this connection count. */ +const WEAK_LINK_MAX_CONNECTIONS = 1; +/** How many key cards / weak links to surface. */ +const TOP_N = 5; + +/** + * Aggregate a deck's pairwise synergies. + * + * Walks every unordered pair of DISTINCT deck card ids through `getPairScore`. + * A positive score is one "connection" for each endpoint; the scores also sum + * into a deck total that normalizes to `overallScore = mean pair score / 10 × 100` + * (every pair, connected or not, is in the denominator — a deck padded with + * non-synergistic cards scores lower). `keyCards` are the above-average hubs and + * `weakLinks` the <=1-connection islands, each capped at the top 5. + * + * Pure and deterministic; ties break by card id so the output is stable. + */ +export function aggregateDeckSynergy(deck: Deck, getPairScore: PairScore): DeckSynergyResult { + const ids = [...new Set(deck.cards.map((c) => c.cardId))]; + const connectionCounts: Record = Object.fromEntries(ids.map((id) => [id, 0])); + + let total = 0; + for (let i = 0; i < ids.length; i++) { + for (let j = i + 1; j < ids.length; j++) { + const score = getPairScore(ids[i], ids[j]); + if (score > 0) { + total += score; + connectionCounts[ids[i]] += 1; + connectionCounts[ids[j]] += 1; + } + } + } + + const pairCount = (ids.length * (ids.length - 1)) / 2; + const overallScore = + pairCount > 0 ? Math.round((total / (pairCount * MAX_PAIR_SCORE)) * 100) : 0; + + return { + overallScore, + keyCards: selectKeyCards(ids, connectionCounts), + weakLinks: selectWeakLinks(ids, connectionCounts), + connectionCounts, + }; +} + +/** Ids sorted by connection count descending, ties broken by id ascending. */ +function byConnectionsDesc(counts: Record) { + return (a: string, b: string) => counts[b] - counts[a] || a.localeCompare(b); +} + +/** Above-average connection hubs, strongest first, capped at the top N. */ +function selectKeyCards(ids: string[], counts: Record): string[] { + if (ids.length === 0) return []; + const average = ids.reduce((sum, id) => sum + counts[id], 0) / ids.length; + return ids + .filter((id) => counts[id] > average) + .sort(byConnectionsDesc(counts)) + .slice(0, TOP_N); +} + +/** <= 1-connection islands, weakest first, capped at the top N. */ +function selectWeakLinks(ids: string[], counts: Record): string[] { + return ids + .filter((id) => counts[id] <= WEAK_LINK_MAX_CONNECTIONS) + .sort((a, b) => counts[a] - counts[b] || a.localeCompare(b)) + .slice(0, TOP_N); +} diff --git a/apps/web/src/features/deck/analysis/index.ts b/apps/web/src/features/deck/analysis/index.ts new file mode 100644 index 00000000..b4e8abb2 --- /dev/null +++ b/apps/web/src/features/deck/analysis/index.ts @@ -0,0 +1,10 @@ +// Public surface of the deck-analysis module (the advisor "brain"). +// One import site for the builder UI + the eventual Engine Score package. +export * from './deckStats'; +export * from './score'; +export * from './archetype'; +export * from './analyzers'; +export * from './vulnerabilities'; +export * from './deckSynergy'; +export * from './suggestions'; +export * from './analyzeDeck'; diff --git a/apps/web/src/features/deck/analysis/score.test.ts b/apps/web/src/features/deck/analysis/score.test.ts new file mode 100644 index 00000000..081b6b4b --- /dev/null +++ b/apps/web/src/features/deck/analysis/score.test.ts @@ -0,0 +1,134 @@ +import {describe, expect, it} from 'vitest'; +import {scoreDeck, type ScoringConfig} from './score'; +import type { + DeckHealth, + HealthAnalyzer, + Vulnerability, + VulnerabilitySeverity, +} from '../types'; + +// The 11 weighted analyzer ids in scoring.json (weights sum to 1.0). +const WEIGHTED_IDS = [ + 'curve', + 'inkable', + 'draw', + 'removal', + 'actionsCap', + 'typeMix', + 'ruleOfEight', + 'consistency', + 'lore', + 'shiftCoverage', + 'synergyDensity', +]; + +function analyzer(id: string, score: number): HealthAnalyzer { + return {id, label: id, score, status: 'good', message: `${id}: ${score}`}; +} + +function vuln(severity: VulnerabilitySeverity, id: string = severity): Vulnerability { + return {id, label: id, conditionType: id, severity, message: `${id} exposure`}; +} + +/** A full DeckHealth with all weighted analyzers at the same score. */ +function fullHealth(score: number, vulnerabilities: Vulnerability[] = []): DeckHealth { + return { + overall: score, + archetype: 'midrange', + archetypeConfidence: 0.8, + analyzers: WEIGHTED_IDS.map((id) => analyzer(id, score)), + vulnerabilities, + }; +} + +const sumContributions = (breakdown: {contribution: number}[]): number => + breakdown.reduce((total, c) => total + c.contribution, 0); + +describe('scoreDeck', () => { + it('is deterministic and scores a uniform full health at its dimension level', () => { + // All 11 weights present (Σ = 1.0), every dimension at 80, no penalty → 80. + const result = scoreDeck(fullHealth(80)); + expect(result.score).toBe(80); + // Same input twice → identical output (pure). + expect(scoreDeck(fullHealth(80))).toEqual(result); + }); + + it('stamps the config version from scoring.json', () => { + expect(scoreDeck(fullHealth(50)).configVersion).toBe('score-v1'); + }); + + it('lets a custom config version flow through', () => { + const config: ScoringConfig = { + version: 'score-test', + weights: {curve: 1}, + vulnerabilityPenalty: {high: 8, medium: 4, low: 1}, + vulnerabilityPenaltyCap: 20, + }; + expect(scoreDeck(fullHealth(60), config).configVersion).toBe('score-test'); + }); + + it('breakdown contributions sum to the score (glass-box invariant)', () => { + const health = fullHealth(72, [vuln('medium'), vuln('low')]); + // Mix dimension scores so the sum is non-trivial. + health.analyzers[0].score = 40; + health.analyzers[3].score = 95; + health.analyzers[10].score = 88; + const result = scoreDeck(health); + expect(Math.abs(sumContributions(result.breakdown) - result.score)).toBeLessThanOrEqual(0.5); + }); + + it('produces one breakdown row per weighted analyzer plus the penalty row', () => { + const result = scoreDeck(fullHealth(80)); + expect(result.breakdown).toHaveLength(WEIGHTED_IDS.length + 1); + const last = result.breakdown.at(-1)!; + expect(last.dimension).toBe('vulnerability-penalty'); + expect(Math.abs(last.contribution)).toBe(0); // no vulnerabilities → 0 penalty + }); + + it('applies the vulnerability penalty', () => { + // Base 80, one high vuln (8 points) → 72. + const result = scoreDeck(fullHealth(80, [vuln('high')])); + expect(result.score).toBe(72); + expect(result.breakdown.at(-1)!.contribution).toBe(-8); + }); + + it('caps the vulnerability penalty at the configured ceiling', () => { + // 3 high vulns = 24 raw, capped at 20 → base 80 − 20 = 60. + const result = scoreDeck(fullHealth(80, [vuln('high', 'a'), vuln('high', 'b'), vuln('high', 'c')])); + expect(result.score).toBe(60); + expect(result.breakdown.at(-1)!.contribution).toBe(-20); + }); + + it('renormalizes a partial DeckHealth back onto 0..100', () => { + // Only 2 of the 11 analyzers present, both at 50 → renormalized base 50. + const partial: DeckHealth = { + overall: 50, + archetype: 'aggro', + archetypeConfidence: 0.5, + analyzers: [analyzer('curve', 50), analyzer('removal', 50)], + vulnerabilities: [], + }; + const result = scoreDeck(partial); + expect(result.score).toBe(50); + expect(result.score).toBeGreaterThanOrEqual(0); + expect(result.score).toBeLessThanOrEqual(100); + // 2 weighted rows + penalty row. + expect(result.breakdown).toHaveLength(3); + }); + + it('ignores analyzers with no configured weight (they contribute 0)', () => { + const withExtra = fullHealth(80); + withExtra.analyzers.push(analyzer('mysteryDimension', 5)); + const result = scoreDeck(withExtra); + // Unweighted analyzer neither scores nor appears in the breakdown. + expect(result.score).toBe(80); + expect(result.breakdown.some((c) => c.dimension === 'mysteryDimension')).toBe(false); + expect(result.breakdown).toHaveLength(WEIGHTED_IDS.length + 1); + }); + + it('clamps a catastrophic deck to 0 rather than going negative', () => { + // Very low base (all 5) with a maxed penalty would go negative → clamp to 0. + const result = scoreDeck(fullHealth(5, [vuln('high', 'a'), vuln('high', 'b'), vuln('high', 'c')])); + expect(result.score).toBe(0); + }); +}); diff --git a/apps/web/src/features/deck/analysis/score.ts b/apps/web/src/features/deck/analysis/score.ts new file mode 100644 index 00000000..4b444506 --- /dev/null +++ b/apps/web/src/features/deck/analysis/score.ts @@ -0,0 +1,135 @@ +// Deck Quality Score (#462) — the transparent, versioned composite scorer. +// +// This is a GLASS BOX: every point in the final `score` traces back to a +// weighted health dimension or the vulnerability penalty, and the returned +// `breakdown` carries that arithmetic verbatim (contribution + reason per term). +// No ML, no hidden fudge factors. Pure and deterministic: same `DeckHealth` + +// same `ScoringConfig` always yields the same `QualityScore`. +// +// See `docs/deck-builder/PLAN.md` Part A (Composite) + Part C (score/case model). + +import scoringConfig from './scoring.json'; +import type { + DeckHealth, + QualityScore, + ScoreContribution, + VulnerabilitySeverity, +} from '../types'; + +/** + * The versioned scoring config: per-dimension weights (keyed by `HealthAnalyzer.id`) + * plus the vulnerability-penalty schedule. Mirrors `scoring.json`. Weights are + * expected to sum to 1.0 for a full `DeckHealth`, but the scorer renormalizes by + * whatever weights are actually present so a partial health still scores on 0..100. + */ +export interface ScoringConfig { + /** Reproducibility stamp copied onto every `QualityScore.configVersion`. */ + version: string; + /** Dimension weight by analyzer id. Weights for a full deck sum to 1.0. */ + weights: Record; + /** Points subtracted per vulnerability, by severity. */ + vulnerabilityPenalty: Record; + /** Upper bound on the total vulnerability penalty (points). */ + vulnerabilityPenaltyCap: number; +} + +/** Stable dimension id for the single vulnerability-penalty breakdown row. */ +const VULNERABILITY_DIMENSION = 'vulnerability-penalty'; + +/** Default config imported from the versioned `scoring.json`. */ +const DEFAULT_CONFIG = scoringConfig as ScoringConfig; + +/** Clamp a number into the inclusive [min, max] range. */ +function clamp(value: number, min: number, max: number): number { + return Math.min(max, Math.max(min, value)); +} + +/** + * Summed, capped vulnerability penalty for a deck. + * `Σ penalty[severity]`, floored so a config entry can never be negative, + * then capped at `vulnerabilityPenaltyCap`. + */ +function computeVulnerabilityPenalty(health: DeckHealth, config: ScoringConfig): number { + const raw = health.vulnerabilities.reduce( + (sum, v) => sum + Math.max(0, config.vulnerabilityPenalty[v.severity] ?? 0), + 0, + ); + return Math.min(raw, config.vulnerabilityPenaltyCap); +} + +/** Human-readable summary of the applied vulnerability penalty. */ +function penaltyReason(health: DeckHealth, applied: number, config: ScoringConfig): string { + if (health.vulnerabilities.length === 0) return 'No vulnerabilities detected.'; + const counts = health.vulnerabilities.reduce>((acc, v) => { + acc[v.severity] = (acc[v.severity] ?? 0) + 1; + return acc; + }, {}); + const parts = (['high', 'medium', 'low'] as const) + .filter((sev) => counts[sev]) + .map((sev) => `${counts[sev]} ${sev}`); + const capped = applied >= config.vulnerabilityPenaltyCap ? ` (capped at ${config.vulnerabilityPenaltyCap})` : ''; + return `${health.vulnerabilities.length} vulnerabilit${health.vulnerabilities.length === 1 ? 'y' : 'ies'} (${parts.join(', ')}) subtract ${applied} point${applied === 1 ? '' : 's'}${capped}.`; +} + +/** + * Compute the transparent Deck Quality Score. + * + * Formula: + * base = ( Σ over weighted analyzers of weight × (score/100) ) / presentWeightSum × 100 + * penalty = min( Σ vulnerabilityPenalty[severity], cap ) + * scoreRaw = base − penalty + * score = round( clamp(scoreRaw, 0, 100) ) + * + * `base` is renormalized by `presentWeightSum` (the sum of the config weights for + * the analyzers actually supplied), so a partial `DeckHealth` still lands on 0..100. + * Analyzers with no configured weight contribute 0 and are excluded from both the + * numerator and `presentWeightSum` (they do not appear in the breakdown). + * + * The returned `breakdown` holds one `ScoreContribution` per weighted analyzer + * (its renormalized `contribution` in points) plus one row for the vulnerability + * penalty. Invariant: `Σ breakdown.contribution` equals `score` within rounding + * (exactly so whenever `scoreRaw` did not clamp). + * + * Pure and deterministic. + */ +export function scoreDeck(health: DeckHealth, config: ScoringConfig = DEFAULT_CONFIG): QualityScore { + const weighted = health.analyzers.filter((a) => typeof config.weights[a.id] === 'number'); + const presentWeightSum = weighted.reduce((sum, a) => sum + config.weights[a.id], 0); + + // Renormalization factor: maps the weighted sum back onto a 0..100 scale using + // only the weights present. Guarded against the no-weighted-analyzer degenerate. + const norm = presentWeightSum > 0 ? 100 / presentWeightSum : 0; + + const analyzerContributions: ScoreContribution[] = weighted.map((a) => { + const weight = config.weights[a.id]; + const dimensionScore = a.score / 100; + return { + dimension: a.id, + weight, + dimensionScore, + // Renormalized points this dimension puts into `base`. Σ of these == base. + contribution: weight * dimensionScore * norm, + reason: a.message, + }; + }); + + const base = analyzerContributions.reduce((sum, c) => sum + c.contribution, 0); + const penalty = computeVulnerabilityPenalty(health, config); + const scoreRaw = base - penalty; + const score = Math.round(clamp(scoreRaw, 0, 100)); + + const penaltyContribution: ScoreContribution = { + dimension: VULNERABILITY_DIMENSION, + // -1 weight: the penalty is a subtractive term. weight × dimensionScore × 100 == contribution. + weight: -1, + dimensionScore: penalty / 100, + contribution: -penalty, + reason: penaltyReason(health, penalty, config), + }; + + return { + score, + breakdown: [...analyzerContributions, penaltyContribution], + configVersion: config.version, + }; +} diff --git a/apps/web/src/features/deck/analysis/scoring.json b/apps/web/src/features/deck/analysis/scoring.json new file mode 100644 index 00000000..ba3f379d --- /dev/null +++ b/apps/web/src/features/deck/analysis/scoring.json @@ -0,0 +1,22 @@ +{ + "version": "score-v1", + "weights": { + "curve": 0.12, + "inkable": 0.1, + "draw": 0.1, + "removal": 0.12, + "actionsCap": 0.06, + "typeMix": 0.06, + "ruleOfEight": 0.08, + "consistency": 0.1, + "lore": 0.08, + "shiftCoverage": 0.04, + "synergyDensity": 0.14 + }, + "vulnerabilityPenalty": { + "high": 8, + "medium": 4, + "low": 1 + }, + "vulnerabilityPenaltyCap": 20 +} diff --git a/apps/web/src/features/deck/analysis/suggestions.test.ts b/apps/web/src/features/deck/analysis/suggestions.test.ts new file mode 100644 index 00000000..60be66fc --- /dev/null +++ b/apps/web/src/features/deck/analysis/suggestions.test.ts @@ -0,0 +1,153 @@ +import {describe, it, expect} from 'vitest'; +import {rankSuggestions} from './suggestions'; +import type {Deck, DeckHealth, DeckStatus, HealthAnalyzer, LorcanaCard} from '../types'; +import type {PairScore} from './deckSynergy'; +import {createCard} from '../../../shared/test-utils'; + +/** Deck from resolved cards, each tagged with its quantity / core flag. */ +function makeDeck(entries: Array<{card: LorcanaCard; quantity?: number; isCore?: boolean}>): Deck { + return { + id: 'd', + name: 'Deck', + cards: entries.map((e) => ({cardId: e.card.id, quantity: e.quantity ?? 4, isCore: e.isCore})), + inks: [], + createdAt: 0, + updatedAt: 0, + schemaVersion: 1, + }; +} + +/** Resolve card ids from a fixed list; unknown ids return undefined. */ +function resolver(cards: LorcanaCard[]) { + const byId = new Map(cards.map((c) => [c.id, c])); + return (id: string) => byId.get(id); +} + +function analyzer(id: string, status: DeckStatus): HealthAnalyzer { + return {id, label: id, score: 0, status, message: `${id} ${status}`}; +} + +function makeHealth(analyzers: HealthAnalyzer[]): DeckHealth { + return {overall: 50, archetype: 'midrange', archetypeConfidence: 1, analyzers, vulnerabilities: []}; +} + +const NO_GAPS = makeHealth([]); +const NO_SYNERGY: PairScore = () => 0; + +describe('rankSuggestions', () => { + it('boosts a removal candidate when the removal analyzer is failing', () => { + const deckCard = createCard({id: 'deck1', ink: 'Amber'}); + const removal = createCard({id: 'rem', ink: 'Amber', text: 'Banish chosen character.'}); + const vanilla = createCard({id: 'van', ink: 'Amber'}); // no removal, no synergy + + const result = rankSuggestions({ + deck: makeDeck([{card: deckCard}]), + candidateIds: ['rem', 'van'], + getPairScore: NO_SYNERGY, + health: makeHealth([analyzer('removal', 'bad')]), + getCardById: resolver([deckCard, removal, vanilla]), + }); + + // Only the removal card earns points; the vanilla candidate scores 0 and is dropped. + expect(result.map((s) => s.cardId)).toEqual(['rem']); + expect(result[0].gapScore).toBeGreaterThan(0); + expect(result[0].reasons).toContain('Fills removal gap'); + }); + + it('boosts an on-curve Song when the deck runs a Singer', () => { + const singer = createCard({id: 'singer', ink: 'Amber', keywords: ['Singer 5']}); + const onCurve = createCard({id: 'song4', ink: 'Amber', type: 'Action', classifications: ['Song'], cost: 4}); + const offCurve = createCard({id: 'song7', ink: 'Amber', type: 'Action', classifications: ['Song'], cost: 7}); + + const result = rankSuggestions({ + deck: makeDeck([{card: singer}]), + candidateIds: ['song4', 'song7'], + getPairScore: NO_SYNERGY, + health: NO_GAPS, + getCardById: resolver([singer, onCurve, offCurve]), + }); + + // Only the cost-4 Song is singable by Singer 5; the cost-7 Song earns nothing. + expect(result.map((s) => s.cardId)).toEqual(['song4']); + expect(result[0].reasons).toContain('On-curve Song for a Singer'); + expect(result[0].synergizesWith).toContain('singer'); + }); + + it('weights synergy with a core card at ×2', () => { + const core = createCard({id: 'core', ink: 'Amber'}); + const plain = createCard({id: 'plain', ink: 'Amber'}); + const candidate = createCard({id: 'cand', ink: 'Amber'}); + // Candidate pairs at 5 with both deck cards. + const scores: PairScore = (a, b) => (a === 'cand' || b === 'cand' ? 5 : 0); + + const result = rankSuggestions({ + deck: makeDeck([ + {card: core, isCore: true}, + {card: plain}, + ]), + candidateIds: ['cand'], + getPairScore: scores, + health: NO_GAPS, + getCardById: resolver([core, plain, candidate]), + }); + + // 5×2 (core) + 5×1 (plain) = 15, vs 10 without the core weighting. + expect(result[0].synergyScore).toBe(15); + expect([...result[0].synergizesWith].sort()).toEqual(['core', 'plain']); + }); + + it('excludes a candidate already at 4 copies', () => { + const maxed = createCard({id: 'x', ink: 'Amber', text: 'Banish chosen character.'}); + const result = rankSuggestions({ + deck: makeDeck([{card: maxed, quantity: 4}]), + candidateIds: ['x'], + getPairScore: NO_SYNERGY, + health: makeHealth([analyzer('removal', 'bad')]), + getCardById: resolver([maxed]), + }); + + expect(result).toEqual([]); + }); + + it('drops an off-ink candidate that cannot share the deck', () => { + const dual = createCard({id: 'dual', ink: 'Amber', ink2: 'Steel'}); + const ruby = createCard({id: 'ruby', ink: 'Ruby', text: 'Banish chosen character.'}); + + const result = rankSuggestions({ + deck: makeDeck([{card: dual}]), + candidateIds: ['ruby'], + getPairScore: NO_SYNERGY, + health: makeHealth([analyzer('removal', 'bad')]), + getCardById: resolver([dual, ruby]), + }); + + // Ruby can't live in an Amber/Steel deck, so it's filtered despite the removal gap. + expect(result).toEqual([]); + }); + + it('rewards a BECKON enabler when the deck runs a Merida anchor', () => { + const anchor = createCard({ + id: 'merida', + ink: 'Amethyst', + text: 'BECKON Whenever another character of yours enters play exerted, draw a card.', + }); + const enabler = createCard({ + id: 'enabler', + ink: 'Amethyst', + type: 'Character', + text: 'This character enters play exerted.', + }); + + const result = rankSuggestions({ + deck: makeDeck([{card: anchor}]), + candidateIds: ['enabler'], + getPairScore: NO_SYNERGY, + health: NO_GAPS, + getCardById: resolver([anchor, enabler]), + }); + + expect(result.map((s) => s.cardId)).toEqual(['enabler']); + expect(result[0].reasons).toContain('BECKON enabler for Merida'); + expect(result[0].synergizesWith).toContain('merida'); + }); +}); diff --git a/apps/web/src/features/deck/analysis/suggestions.ts b/apps/web/src/features/deck/analysis/suggestions.ts new file mode 100644 index 00000000..516ef63c --- /dev/null +++ b/apps/web/src/features/deck/analysis/suggestions.ts @@ -0,0 +1,271 @@ +// Live add-a-card suggestion ranking (#471, Part B "Live advisor engine"). +// +// Ranks candidate cards to add to a deck by two forces: +// 1. SYNERGY with the current deck (precomputed pair scores, core cards ×2), +// plus intrinsic direct-synergy boosts the pair data may not carry yet +// (Merida BECKON enablers, Shift targets, on-curve Songs for a Singer). +// 2. GAP-FILLING against the deck-health analyzers that are failing +// (removal / card-draw shortfalls, curve holes). +// +// `totalScore = synergyScore + GAP_WEIGHT × gapScore`, sorted descending. +// Pure and side-effect free; the pair-score provider is injected (the controller +// wires the real precomputed fetch later). See `docs/deck-builder/PLAN.md` Part B. + +import type {BeckonEnablerTier} from 'inkweave-synergy-engine'; +import { + canShareDeck, + getBeckonEnablerTier, + getCardMechanics, + getKeywordValue, + getRemovalRoles, + getShiftBaseNames, + hasAnyShift, + hasKeyword, + isBeckonAnchor, + isSong, +} from 'inkweave-synergy-engine'; +import type {Deck, DeckHealth, DeckStatus, LorcanaCard, Suggestion} from '../types'; +import type {PairScore} from './deckSynergy'; + +/** Core-format copy ceiling; a candidate already maxed is not a suggestion. */ +const MAX_COPIES = 4; +/** `deck.cards[].isCore` cards up-weight synergy this much (PLAN Part B). */ +const CORE_MULTIPLIER = 2; +/** Weight on the gap term relative to raw synergy points. */ +const GAP_WEIGHT = 3; +/** Curve slots we treat as fillable "holes" when the curve analyzer is failing. */ +const CURVE_SLOTS = [1, 2, 3, 4, 5, 6]; + +/** Intrinsic synergy bonus for a BECKON enabler when the deck runs a BECKON anchor. */ +const BECKON_TIER_BONUS: Record = {engine: 8, reanimator: 7, self: 5}; +/** Intrinsic synergy bonus for a Shift target of a Shift card in the deck. */ +const SHIFT_BONUS = 6; +/** Intrinsic synergy bonus for an on-curve Song when the deck runs a Singer. */ +const SINGER_BONUS = 6; + +/** Gap points a failing analyzer contributes when a candidate addresses it. */ +function statusPoints(status: DeckStatus): number { + if (status === 'bad') return 2; + if (status === 'warn') return 1; + return 0; +} + +/** One resolved deck card plus its core flag. */ +interface ResolvedDeckCard { + card: LorcanaCard; + isCore: boolean; +} + +/** A Shift card in the deck and the base names it can land on. */ +interface ShiftEntry { + id: string; + targets: Set; +} + +/** A Singer in the deck and its Singer value (cost ceiling it can sing for free). */ +interface SingerEntry { + id: string; + value: number; +} + +/** Deck-level facts precomputed once, then reused across every candidate. */ +interface DeckContext { + getPairScore: PairScore; + deckCards: ResolvedDeckCard[]; + quantityById: Map; + beckonAnchorIds: string[]; + shiftEntries: ShiftEntry[]; + singerEntries: SingerEntry[]; + curveHoles: Set; + failing: DeckHealth['analyzers']; +} + +/** Resolve the deck once into the reusable `DeckContext`. */ +function buildContext( + deck: Deck, + getPairScore: PairScore, + health: DeckHealth, + getCardById: (id: string) => LorcanaCard | undefined, +): DeckContext { + const deckCards: ResolvedDeckCard[] = []; + const quantityById = new Map(); + const costHistogram: Record = {}; + + for (const entry of deck.cards) { + quantityById.set(entry.cardId, (quantityById.get(entry.cardId) ?? 0) + entry.quantity); + const card = getCardById(entry.cardId); + if (!card) continue; + deckCards.push({card, isCore: entry.isCore === true}); + const bucket = Math.min(card.cost, CURVE_SLOTS.length + 1); + costHistogram[bucket] = (costHistogram[bucket] ?? 0) + entry.quantity; + } + + return { + getPairScore, + deckCards, + quantityById, + beckonAnchorIds: deckCards.filter((d) => isBeckonAnchor(d.card)).map((d) => d.card.id), + shiftEntries: collectShiftEntries(deckCards), + singerEntries: collectSingerEntries(deckCards), + curveHoles: new Set(CURVE_SLOTS.filter((slot) => (costHistogram[slot] ?? 0) === 0)), + failing: health.analyzers.filter((a) => a.status === 'bad' || a.status === 'warn'), + }; +} + +function collectShiftEntries(deckCards: ResolvedDeckCard[]): ShiftEntry[] { + return deckCards + .filter((d) => hasAnyShift(d.card)) + .map((d) => ({id: d.card.id, targets: new Set(getShiftBaseNames(d.card))})); +} + +function collectSingerEntries(deckCards: ResolvedDeckCard[]): SingerEntry[] { + const entries: SingerEntry[] = []; + for (const d of deckCards) { + if (!hasKeyword(d.card, 'Singer')) continue; + const value = getKeywordValue(d.card, 'Singer'); + if (value != null) entries.push({id: d.card.id, value}); + } + return entries; +} + +/** Accumulator threaded through the boost/synergy/gap passes for one candidate. */ +interface Scratch { + synergyScore: number; + gapScore: number; + synergizesWith: Set; + reasons: string[]; +} + +/** Sum the deck-synergy pair scores (core cards ×2), recording the partners. */ +function addDeckSynergy(candidate: LorcanaCard, ctx: DeckContext, s: Scratch): void { + for (const d of ctx.deckCards) { + if (d.card.id === candidate.id) continue; + const pair = ctx.getPairScore(candidate.id, d.card.id); + if (pair <= 0) continue; + s.synergyScore += pair * (d.isCore ? CORE_MULTIPLIER : 1); + s.synergizesWith.add(d.card.id); + } +} + +/** Merida BECKON: an anchor in the deck draws off any enter-play-exerted enabler. */ +function addBeckonBoost(candidate: LorcanaCard, ctx: DeckContext, s: Scratch): void { + if (ctx.beckonAnchorIds.length === 0) return; + const tier = getBeckonEnablerTier(candidate); + if (tier == null) return; + s.synergyScore += BECKON_TIER_BONUS[tier]; + for (const id of ctx.beckonAnchorIds) s.synergizesWith.add(id); + s.reasons.push('BECKON enabler for Merida'); +} + +/** Shift: a Shift card in the deck wants same-named base characters. */ +function addShiftBoost(candidate: LorcanaCard, ctx: DeckContext, s: Scratch): void { + const names = getShiftBaseNames(candidate); + const matched = ctx.shiftEntries.filter((e) => names.some((n) => e.targets.has(n))); + if (matched.length === 0) return; + s.synergyScore += SHIFT_BONUS; + for (const e of matched) s.synergizesWith.add(e.id); + s.reasons.push('Shift target for a card in the deck'); +} + +/** Singer: a Singer sings an on-curve Song (cost <= Singer value) for free. */ +function addSingerBoost(candidate: LorcanaCard, ctx: DeckContext, s: Scratch): void { + if (!isSong(candidate)) return; + const singable = ctx.singerEntries.filter((e) => candidate.cost <= e.value); + if (singable.length === 0) return; + s.synergyScore += SINGER_BONUS; + for (const e of singable) s.synergizesWith.add(e.id); + s.reasons.push('On-curve Song for a Singer'); +} + +/** Score how much a candidate fixes each FAILING analyzer, prepending the reasons. */ +function addGapFilling(candidate: LorcanaCard, ctx: DeckContext, s: Scratch): void { + for (const analyzer of ctx.failing) { + const points = statusPoints(analyzer.status); + if (analyzer.id === 'removal' && getRemovalRoles(candidate).length > 0) { + s.gapScore += points; + s.reasons.push('Fills removal gap'); + } else if (analyzer.id === 'draw' && getCardMechanics(candidate).includes('draw')) { + s.gapScore += points; + s.reasons.push('Fills card-draw gap'); + } else if (analyzer.id === 'curve' && ctx.curveHoles.has(candidate.cost)) { + s.gapScore += points; + s.reasons.push(`Fills curve hole at cost ${candidate.cost}`); + } + } +} + +/** A candidate is out if it's maxed or can't legally share the deck's inks. */ +function isEligible(candidate: LorcanaCard, ctx: DeckContext): boolean { + if ((ctx.quantityById.get(candidate.id) ?? 0) >= MAX_COPIES) return false; + return ctx.deckCards.every((d) => canShareDeck(candidate, d.card)); +} + +/** Score one eligible candidate, or null when it earns no points. */ +function scoreCandidate(candidate: LorcanaCard, ctx: DeckContext): Suggestion | null { + if (!isEligible(candidate, ctx)) return null; + + const s: Scratch = {synergyScore: 0, gapScore: 0, synergizesWith: new Set(), reasons: []}; + addGapFilling(candidate, ctx, s); + addDeckSynergy(candidate, ctx, s); + addBeckonBoost(candidate, ctx, s); + addShiftBoost(candidate, ctx, s); + addSingerBoost(candidate, ctx, s); + + const totalScore = s.synergyScore + GAP_WEIGHT * s.gapScore; + if (totalScore <= 0) return null; + + if (s.reasons.length === 0 && s.synergizesWith.size > 0) { + const n = s.synergizesWith.size; + s.reasons.push(`Synergizes with ${n} deck card${n === 1 ? '' : 's'}`); + } + + return { + cardId: candidate.id, + synergyScore: s.synergyScore, + gapScore: s.gapScore, + totalScore, + synergizesWith: [...s.synergizesWith], + reasons: s.reasons, + }; +} + +/** Inputs for {@link rankSuggestions}, bundled so the call reads by name. */ +export interface RankSuggestionsInput { + deck: Deck; + /** Candidate card ids to rank; assumed pre-filtered to the deck's inks. */ + candidateIds: string[]; + /** Precomputed pair-score provider (the controller wires the real fetch). */ + getPairScore: PairScore; + health: DeckHealth; + getCardById: (id: string) => LorcanaCard | undefined; +} + +/** + * Rank `candidateIds` as add-this-card suggestions for `deck`. + * + * Candidates are assumed pre-filtered to the deck's inks, but each is still + * `canShareDeck`-guarded against every deck card and dropped if already at 4 + * copies. Each survivor earns `synergyScore` (Σ deck pair scores, core cards ×2, + * plus BECKON/Shift/Singer intrinsic boosts) and `gapScore` (points per failing + * analyzer it addresses). `totalScore = synergyScore + GAP_WEIGHT × gapScore`; + * candidates that earn nothing are dropped. Sorted by `totalScore` descending, + * ties broken by card id for a stable order. + * + * Pure and deterministic given the same inputs (incl. `getPairScore`). + */ +export function rankSuggestions({ + deck, + candidateIds, + getPairScore, + health, + getCardById, +}: RankSuggestionsInput): Suggestion[] { + const ctx = buildContext(deck, getPairScore, health, getCardById); + + return [...new Set(candidateIds)] + .map((id) => getCardById(id)) + .filter((card): card is LorcanaCard => card != null) + .map((card) => scoreCandidate(card, ctx)) + .filter((sug): sug is Suggestion => sug != null) + .sort((a, b) => b.totalScore - a.totalScore || a.cardId.localeCompare(b.cardId)); +} diff --git a/apps/web/src/features/deck/analysis/vulnerabilities.test.ts b/apps/web/src/features/deck/analysis/vulnerabilities.test.ts new file mode 100644 index 00000000..5bdfdb2d --- /dev/null +++ b/apps/web/src/features/deck/analysis/vulnerabilities.test.ts @@ -0,0 +1,200 @@ +import {describe, it, expect} from 'vitest'; +import {analyzeVulnerabilities, type HoserEntry} from './vulnerabilities'; +import type {Deck, LorcanaCard} from '../types'; +import {createCard} from '../../../shared/test-utils'; + +/** Resolve deck cardIds from a fixed card list; unknown ids return undefined. */ +function makeResolver(cards: LorcanaCard[]) { + const byId = new Map(cards.map((c) => [c.id, c])); + return (id: string) => byId.get(id); +} + +/** Assemble a minimal Deck from [card, quantity] entries. */ +function makeDeck(entries: Array<[LorcanaCard, number]>): Deck { + return { + id: 'deck-1', + name: 'Test Deck', + cards: entries.map(([card, quantity]) => ({cardId: card.id, quantity})), + inks: [], + createdAt: 0, + updatedAt: 0, + schemaVersion: 1, + }; +} + +/** N distinct cards sharing the given overrides (unique id + fullName per index). */ +function genCards(n: number, prefix: string, over: Partial): LorcanaCard[] { + return Array.from({length: n}, (_, i) => + createCard({id: `${prefix}-${i}`, fullName: `${prefix} ${i}`, ...over}), + ); +} + +/** Shorthand for a catalog entry. */ +function hoser( + cardId: string, + name: string, + ink: string, + condition: HoserEntry['condition'], + scope: HoserEntry['scope'] = 'conditional', +): HoserEntry { + return {cardId, name, ink, condition, scope, text: name}; +} + +describe('analyzeVulnerabilities', () => { + it('flags a low-strength-heavy board as high severity with the modal threshold', () => { + // Catalog thresholds [1, 2, 2, 3] -> modal 2. Board: 8 str<=2 copies + 2 str-7 = 80%. + const catalog: HoserEntry[] = [ + hoser('2055', 'Sisu - Daring Visitor', 'Ruby', {type: 'low-strength', threshold: 1}), + hoser('2263', 'Finnick - Tiny Terror', 'Emerald', {type: 'low-strength', threshold: 2}), + hoser('2594', 'Grab Your Bow', 'Ruby', {type: 'low-strength', threshold: 2}), + hoser('3106', 'Red Alert', 'Ruby', {type: 'low-strength', threshold: 3}), + ]; + const weak = genCards(2, 'weak', {ink: 'Amber', strength: 2}); // 2 x4 = 8 copies + const tough = createCard({id: 'tough', fullName: 'Tough One', ink: 'Amber', strength: 7}); + const deck = makeDeck([ + [weak[0], 4], + [weak[1], 4], + [tough, 2], + ]); + + const vulns = analyzeVulnerabilities(deck, makeResolver([...weak, tough]), catalog); + + expect(vulns).toHaveLength(1); + expect(vulns[0].id).toBe('low-strength'); + expect(vulns[0].conditionType).toBe('low-strength'); + expect(vulns[0].severity).toBe('high'); + expect(vulns[0].exposurePct).toBe(80); // 8 / 10 + expect(vulns[0].message).toContain('2 strength or less'); + }); + + it('flags an evasive-heavy board', () => { + const catalog: HoserEntry[] = [ + hoser('2218', 'The Horseman Strikes!', 'Amber', {type: 'evasive'}), + hoser('3174', 'Windstorm', 'Steel', {type: 'evasive'}), + ]; + const flyers = genCards(2, 'fly', {ink: 'Amber', strength: 4, keywords: ['Evasive']}); // 8 + const grounded = createCard({id: 'ground', fullName: 'Grounded', ink: 'Amber', strength: 4}); + const deck = makeDeck([ + [flyers[0], 4], + [flyers[1], 4], + [grounded, 4], + ]); + + const vulns = analyzeVulnerabilities(deck, makeResolver([...flyers, grounded]), catalog); + + expect(vulns).toHaveLength(1); + expect(vulns[0].conditionType).toBe('evasive'); + expect(vulns[0].exposurePct).toBe(67); // 8 / 12 + expect(vulns[0].severity).toBe('high'); + }); + + it('returns [] for a deck exposed to none of the catalog conditions', () => { + const catalog: HoserEntry[] = [ + hoser('2263', 'Finnick - Tiny Terror', 'Emerald', {type: 'low-strength', threshold: 2}), + hoser('2986', 'Gaston - Superior Archer', 'Amber', {type: 'high-cost', threshold: 6}), + hoser('3174', 'Windstorm', 'Steel', {type: 'evasive'}), + ]; + // Beefy (str 6 > 2), cheap (cost 3 < 6), grounded (no Evasive). Nothing hits. + const cards = genCards(15, 'beef', {ink: 'Amber', cost: 3, strength: 6}); + const deck = makeDeck(cards.map((c) => [c, 4] as [LorcanaCard, number])); + + const vulns = analyzeVulnerabilities(deck, makeResolver(cards), catalog); + + expect(vulns).toEqual([]); + }); + + it('flags a go-wide deck as exposed to mass removal via board reliance', () => { + const catalog: HoserEntry[] = [ + hoser('2491', 'Raging Storm', 'Amber', {type: 'mass'}, 'mass'), + hoser('2138', 'The Mob Song', 'Steel', {type: 'mass'}, 'mass'), + ]; + const bodies = genCards(10, 'body', {ink: 'Amber', strength: 5}); // 40 char copies + const spells = genCards(2, 'spell', {ink: 'Amber', type: 'Action'}); // 8 non-char copies + const deck = makeDeck([...bodies, ...spells].map((c) => [c, 4] as [LorcanaCard, number])); + + const vulns = analyzeVulnerabilities(deck, makeResolver([...bodies, ...spells]), catalog); + + expect(vulns).toHaveLength(1); + expect(vulns[0].conditionType).toBe('mass'); + expect(vulns[0].exposurePct).toBe(83); // 40 / 48 + expect(vulns[0].severity).toBe('high'); + }); + + it('does not flag a spell-heavy control deck for mass removal (below the materiality floor)', () => { + const catalog: HoserEntry[] = [hoser('2491', 'Raging Storm', 'Amber', {type: 'mass'}, 'mass')]; + const bodies = genCards(3, 'body', {ink: 'Amber', strength: 5}); // 12 char copies + const spells = genCards(10, 'spell', {ink: 'Amber', type: 'Action'}); // 40 non-char copies + const deck = makeDeck([...bodies, ...spells].map((c) => [c, 4] as [LorcanaCard, number])); + + const vulns = analyzeVulnerabilities(deck, makeResolver([...bodies, ...spells]), catalog); + + expect(vulns).toEqual([]); // 12 / 52 = 23% < 25% floor + }); + + it('keeps damaged informational: capped at low severity even at full board reliance', () => { + const catalog: HoserEntry[] = [ + hoser('2285', 'Chomp!', 'Emerald', {type: 'damaged'}), + hoser('2807', 'Cruella De Vil - Judgmental Traveler', 'Emerald', {type: 'damaged'}), + ]; + const cards = genCards(15, 'char', {ink: 'Amber', strength: 5}); // all characters -> 100% + const deck = makeDeck(cards.map((c) => [c, 4] as [LorcanaCard, number])); + + const vulns = analyzeVulnerabilities(deck, makeResolver(cards), catalog); + + expect(vulns).toHaveLength(1); + expect(vulns[0].conditionType).toBe('damaged'); + expect(vulns[0].exposurePct).toBe(100); + expect(vulns[0].severity).toBe('low'); // never escalates despite 100% reliance + }); + + it('returns [] for a deck with no characters (nothing to remove)', () => { + const catalog: HoserEntry[] = [ + hoser('2263', 'Finnick - Tiny Terror', 'Emerald', {type: 'low-strength', threshold: 2}), + hoser('2491', 'Raging Storm', 'Amber', {type: 'mass'}, 'mass'), + ]; + const spells = genCards(15, 'spell', {ink: 'Amber', type: 'Action'}); + const deck = makeDeck(spells.map((c) => [c, 4] as [LorcanaCard, number])); + + const vulns = analyzeVulnerabilities(deck, makeResolver(spells), catalog); + + expect(vulns).toEqual([]); + }); + + it('orders example hoserCardIds by deck-ink relevance first', () => { + const catalog: HoserEntry[] = [ + hoser('offA', 'Off Ink', 'Emerald', {type: 'low-strength', threshold: 3}), + hoser('inR', 'In Ink', 'Ruby', {type: 'low-strength', threshold: 3}), + ]; + const cards = genCards(4, 'r', {ink: 'Ruby', strength: 2}); // Ruby deck, all str<=3 + const deck = makeDeck(cards.map((c) => [c, 4] as [LorcanaCard, number])); + + const vulns = analyzeVulnerabilities(deck, makeResolver(cards), catalog); + + expect(vulns).toHaveLength(1); + expect(vulns[0].hoserCardIds).toEqual(['inR', 'offA']); // deck-ink (Ruby) example first + expect(vulns[0].message).toContain('In Ink'); // named example is the deck-ink one + }); + + it('sorts multiple vulnerabilities sharpest-first', () => { + const catalog: HoserEntry[] = [ + hoser('2986', 'Gaston - Superior Archer', 'Amber', {type: 'high-cost', threshold: 5}), + hoser('3174', 'Windstorm', 'Steel', {type: 'evasive'}), + ]; + // 11 evasive copies (cost 3) + 4 high-cost copies (cost 6, not evasive) = 15 characters. + const flyers = genCards(3, 'fly', {ink: 'Amber', cost: 3, strength: 4, keywords: ['Evasive']}); // 4+4+3 = 11 evasive + const bigs = createCard({id: 'big', fullName: 'Big', ink: 'Amber', cost: 6, strength: 4}); // high-cost, no evasive + const deck = makeDeck([ + [flyers[0], 4], + [flyers[1], 4], + [flyers[2], 3], + [bigs, 4], + ]); + + const vulns = analyzeVulnerabilities(deck, makeResolver([...flyers, bigs]), catalog); + + // evasive: 11/15 = 73% high; high-cost: 4/15 = 27% low. High sorts first. + expect(vulns.map((v) => v.conditionType)).toEqual(['evasive', 'high-cost']); + expect(vulns[0].severity).toBe('high'); + expect(vulns[1].severity).toBe('low'); + }); +}); diff --git a/apps/web/src/features/deck/analysis/vulnerabilities.ts b/apps/web/src/features/deck/analysis/vulnerabilities.ts new file mode 100644 index 00000000..4790f7c5 --- /dev/null +++ b/apps/web/src/features/deck/analysis/vulnerabilities.ts @@ -0,0 +1,337 @@ +// "What to watch for" (#470) — the Tier-3 vulnerabilities analyzer. +// +// Pool-derived, meta-free. Given the auto-derived hoser catalog (produced by +// #461 into `public/data/hosers.json`), this measures how EXPOSED a deck is to +// each removal CONDITION the current Core pool can punish, and surfaces only the +// sharp ones. No hardcoded card names in the logic (survives rotation): the +// example cards named in each message come straight from the passed catalog. +// +// See `docs/deck-builder/PLAN.md` Part A Tier 3. + +import type {Deck, Ink, LorcanaCard, Vulnerability, VulnerabilitySeverity} from '../types'; +import {getInks, hasKeyword, isCharacter} from 'inkweave-synergy-engine'; + +/** + * One entry of the pool-derived hoser catalog (`public/data/hosers.json`). + * `condition.type` is one of the six Core removal conditions; `threshold` is + * present for the stat-gated ones (`low-strength <=N`, `high-cost >=N`). + */ +export interface HoserEntry { + cardId: string; + name: string; + ink: string; + condition: {type: string; threshold?: number}; + scope: 'mass' | 'conditional'; + text: string; +} + +// --------------------------------------------------------------------------- +// Exposure / severity cutoffs (documented, tunable) +// --------------------------------------------------------------------------- +// +// `exposurePct` is a 0..100 share. Two families of metric feed it: +// - stat / keyword conditions (low-strength, high-cost, evasive, bodyguard): +// share = matching character copies / total character copies. "How much of +// my board can this removal actually hit." +// - board-reliance conditions (mass, damaged): share = character copies / +// total (resolved) deck copies. A go-wide, character-dense deck is more +// exposed to a sweeper than a spell-heavy control deck. +// +// Severity ladder (applied to the rounded share): +// >= 60 -> high (the majority of your board folds to it) +// >= 35 -> medium +// >= 25 -> low (materiality floor; below this the deck isn't meaningfully exposed) +// < 25 -> dropped (not emitted) +// +// `damaged` is capped at `low` no matter the share: your board being damaged is +// dynamic (the opponent has to chip you first, healing/high willpower mitigate), +// so it's informational, not a structural weakness like a low-strength curve. +const MATERIAL_EXPOSURE = 25; +const MEDIUM_EXPOSURE = 35; +const HIGH_EXPOSURE = 60; + +/** Max example catalog cards attached to each vulnerability. */ +const MAX_EXAMPLES = 3; + +/** The six pool removal conditions, in a stable canonical order. */ +const TYPE_ORDER = [ + 'low-strength', + 'high-cost', + 'evasive', + 'bodyguard', + 'mass', + 'damaged', +] as const; +type ConditionType = (typeof TYPE_ORDER)[number]; + +const KNOWN_TYPES = new Set(TYPE_ORDER); + +/** Display label per condition type. */ +const LABELS: Record = { + 'low-strength': 'Low-strength board', + 'high-cost': 'High-cost board', + evasive: 'Evasive-heavy board', + bodyguard: 'Bodyguard-reliant board', + mass: 'Go-wide board', + damaged: 'Damage-vulnerable board', +}; + +/** Severity ordering for output sorting (sharpest first). */ +const SEVERITY_RANK: Record = {high: 3, medium: 2, low: 1}; + +// --------------------------------------------------------------------------- +// Deck context +// --------------------------------------------------------------------------- + +/** The deck reduced to what the exposure math needs: characters (with copies) + inks. */ +interface DeckContext { + characterEntries: Array<{card: LorcanaCard; quantity: number}>; + /** Total character COPIES (quantity-weighted). Denominator for stat/keyword shares. */ + characterCopies: number; + /** Total resolved COPIES of any type. Denominator for board-reliance shares. */ + totalResolvedCopies: number; + /** Inks the deck actually plays (dual-ink cards contribute both). */ + deckInks: Set; +} + +function buildDeckContext( + deck: Deck, + getCardById: (id: string) => LorcanaCard | undefined, +): DeckContext { + const characterEntries: Array<{card: LorcanaCard; quantity: number}> = []; + const deckInks = new Set(); + let characterCopies = 0; + let totalResolvedCopies = 0; + + for (const {cardId, quantity} of deck.cards) { + const card = getCardById(cardId); + if (!card) continue; // unresolved (rotated out of Core) — nothing to measure + + totalResolvedCopies += quantity; + for (const ink of getInks(card)) deckInks.add(ink); + + if (isCharacter(card)) { + characterEntries.push({card, quantity}); + characterCopies += quantity; + } + } + + return {characterEntries, characterCopies, totalResolvedCopies, deckInks}; +} + +// --------------------------------------------------------------------------- +// Exposure math +// --------------------------------------------------------------------------- + +/** Quantity-weighted share (0..100) of the deck's characters matching `predicate`. */ +function matchShare(ctx: DeckContext, predicate: (c: LorcanaCard) => boolean): number { + const matched = ctx.characterEntries.reduce( + (sum, e) => sum + (predicate(e.card) ? e.quantity : 0), + 0, + ); + return (matched / ctx.characterCopies) * 100; // characterCopies > 0 is guaranteed by the caller +} + +/** Share (0..100) of the whole deck that is characters — the "board reliance" of the deck. */ +function boardReliance(ctx: DeckContext): number { + return ctx.totalResolvedCopies > 0 ? (ctx.characterCopies / ctx.totalResolvedCopies) * 100 : 0; +} + +/** + * Per-condition exposure formula, keyed by condition type. Stat/keyword + * conditions measure the share of matching characters; board-reliance + * conditions (mass, damaged) measure how character-dense the deck is. + */ +const EXPOSURE_FNS: Record number> = { + 'low-strength': (ctx, threshold) => matchShare(ctx, (c) => c.strength != null && c.strength <= (threshold ?? 0)), + 'high-cost': (ctx, threshold) => matchShare(ctx, (c) => c.cost >= (threshold ?? Infinity)), + evasive: (ctx) => matchShare(ctx, (c) => hasKeyword(c, 'Evasive')), + bodyguard: (ctx) => matchShare(ctx, (c) => hasKeyword(c, 'Bodyguard')), + mass: (ctx) => boardReliance(ctx), + damaged: (ctx) => boardReliance(ctx), +}; + +/** Raw exposure share (0..100) of the deck to one removal condition. */ +function computeExposure(type: ConditionType, threshold: number | undefined, ctx: DeckContext): number { + return EXPOSURE_FNS[type](ctx, threshold); +} + +/** Map a rounded exposure share to a severity, or null when the deck isn't materially exposed. */ +function severityFor(type: ConditionType, exposurePct: number): VulnerabilitySeverity | null { + if (exposurePct < MATERIAL_EXPOSURE) return null; + if (type === 'damaged') return 'low'; // informational: damage is dynamic, never a structural high + if (exposurePct >= HIGH_EXPOSURE) return 'high'; + if (exposurePct >= MEDIUM_EXPOSURE) return 'medium'; + return 'low'; +} + +// --------------------------------------------------------------------------- +// Catalog helpers +// --------------------------------------------------------------------------- + +/** Group catalog entries by their (known) condition type, preserving catalog order. */ +function groupByType(hosers: HoserEntry[]): Map { + const byType = new Map(); + for (const entry of hosers) { + const type = entry.condition.type; + if (!KNOWN_TYPES.has(type)) continue; + const list = byType.get(type as ConditionType) ?? []; + list.push(entry); + byType.set(type as ConditionType, list); + } + return byType; +} + +/** Tally how often each stat threshold appears across a condition's catalog entries. */ +function tallyThresholds(entries: HoserEntry[]): Map { + const counts = new Map(); + for (const e of entries) { + const t = e.condition.threshold; + if (t != null) counts.set(t, (counts.get(t) ?? 0) + 1); + } + return counts; +} + +/** + * Does `threshold` (seen `count` times) beat the current best? More frequent + * wins; ties break toward the lower, more conservative threshold. + */ +function beatsBest( + threshold: number, + count: number, + best: number | undefined, + bestCount: number, +): boolean { + if (count > bestCount) return true; + if (count < bestCount) return false; + return threshold < (best ?? Infinity); +} + +/** + * The representative threshold for a stat-gated condition: the MOST COMMON one + * across the catalog's entries of that type (ties broken toward the lower, more + * conservative threshold). Undefined for keyword / board-reliance conditions. + */ +function modalThreshold(entries: HoserEntry[]): number | undefined { + const counts = tallyThresholds(entries); + + let best: number | undefined; + let bestCount = -1; + for (const [threshold, count] of counts) { + if (!beatsBest(threshold, count, best, bestCount)) continue; + best = threshold; + bestCount = count; + } + return best; +} + +/** Up to `MAX_EXAMPLES` catalog cards for a condition, deck-ink matches preferred. */ +function pickExamples(entries: HoserEntry[], deckInks: Set): HoserEntry[] { + const inInk = entries.filter((e) => deckInks.has(e.ink as Ink)); + const offInk = entries.filter((e) => !deckInks.has(e.ink as Ink)); + return [...inInk, ...offInk].slice(0, MAX_EXAMPLES); +} + +// --------------------------------------------------------------------------- +// Message +// --------------------------------------------------------------------------- + +/** Per-condition message formatter, keyed by condition type. `eg` is the pre-rendered example suffix. */ +const MESSAGE_BUILDERS: Record< + ConditionType, + (pct: number, threshold: number | undefined, eg: string) => string +> = { + 'low-strength': (pct, threshold, eg) => + `${pct}% of your characters have ${threshold ?? 0} strength or less; exposed to low-strength removal${eg}.`, + 'high-cost': (pct, threshold, eg) => + `${pct}% of your characters cost ${threshold ?? 0} or more; exposed to high-cost removal${eg}.`, + evasive: (pct, _threshold, eg) => + `${pct}% of your characters have Evasive; exposed to Evasive-hate removal${eg}.`, + bodyguard: (pct, _threshold, eg) => + `${pct}% of your characters have Bodyguard; exposed to Bodyguard-punishing removal${eg}.`, + mass: (pct, _threshold, eg) => + `${pct}% of your deck is characters; a go-wide board is exposed to board sweepers${eg}.`, + damaged: (pct, _threshold, eg) => + `${pct}% of your deck is characters; once they take chip damage, damage-based removal can pick them off${eg}.`, +}; + +function buildMessage( + type: ConditionType, + threshold: number | undefined, + pct: number, + exampleName: string | undefined, +): string { + const eg = exampleName ? ` (e.g. ${exampleName})` : ''; + return MESSAGE_BUILDERS[type](pct, threshold, eg); +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** + * Evaluate one removal condition against the deck, returning a `Vulnerability` + * only when the deck is materially exposed (>= 25%), else null. Pure given ctx. + */ +function evaluateCondition( + type: ConditionType, + entries: HoserEntry[] | undefined, + ctx: DeckContext, +): Vulnerability | null { + if (!entries || entries.length === 0) return null; + + const threshold = modalThreshold(entries); + const exposurePct = Math.round(computeExposure(type, threshold, ctx)); + const severity = severityFor(type, exposurePct); + if (!severity) return null; + + const examples = pickExamples(entries, ctx.deckInks); + return { + id: type, + label: LABELS[type], + conditionType: type, + severity, + exposurePct, + message: buildMessage(type, threshold, exposurePct, examples[0]?.name), + hoserCardIds: examples.map((e) => e.cardId), + }; +} + +/** Sort comparator: sharpest first (severity, then exposure share, then canonical type order). */ +function compareVulnerabilities(a: Vulnerability, b: Vulnerability): number { + return ( + SEVERITY_RANK[b.severity] - SEVERITY_RANK[a.severity] || + (b.exposurePct ?? 0) - (a.exposurePct ?? 0) || + TYPE_ORDER.indexOf(a.conditionType as ConditionType) - + TYPE_ORDER.indexOf(b.conditionType as ConditionType) + ); +} + +/** + * Compute the deck's "what to watch for" vulnerabilities from the pool-derived + * hoser catalog. For each removal CONDITION present in `hosers`, measure the + * deck's exposure over its characters (stat/keyword share) or over its board + * reliance (mass / damaged), and emit a `Vulnerability` only when that exposure + * is material (>= 25%). Results are sorted sharpest-first (severity, then share). + * + * A deck with no resolvable characters is never exposed to creature removal, so + * it returns `[]`. Pure and deterministic given the same deck + catalog. + */ +export function analyzeVulnerabilities( + deck: Deck, + getCardById: (id: string) => LorcanaCard | undefined, + hosers: HoserEntry[], +): Vulnerability[] { + const ctx = buildDeckContext(deck, getCardById); + if (ctx.characterCopies === 0) return []; + + const byType = groupByType(hosers); + const out: Vulnerability[] = []; + + for (const type of TYPE_ORDER) { + const vuln = evaluateCondition(type, byType.get(type), ctx); + if (vuln) out.push(vuln); + } + + return out.sort(compareVulnerabilities); +} diff --git a/apps/web/src/features/deck/components/CostGlyph.stories.tsx b/apps/web/src/features/deck/components/CostGlyph.stories.tsx new file mode 100644 index 00000000..a7bd61ba --- /dev/null +++ b/apps/web/src/features/deck/components/CostGlyph.stories.tsx @@ -0,0 +1,25 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {COLORS} from '../../../shared/constants'; +import {CostGlyph} from './CostGlyph'; + +const meta: Meta = { + title: 'Deck/CostGlyph', + component: CostGlyph, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], +}; +export default meta; +type Story = StoryObj; + +export const Inkable: Story = {args: {cost: 3, inkwell: true}}; + +export const Uninkable: Story = {args: {cost: 4, inkwell: false}}; + +// Cost >= 9 collapses to "9+" (real cards cap the inkwell number at 9). +export const HighCost: Story = {args: {cost: 9, inkwell: true}}; diff --git a/apps/web/src/features/deck/components/CostGlyph.test.tsx b/apps/web/src/features/deck/components/CostGlyph.test.tsx new file mode 100644 index 00000000..0f844041 --- /dev/null +++ b/apps/web/src/features/deck/components/CostGlyph.test.tsx @@ -0,0 +1,30 @@ +import {describe, it, expect} from 'vitest'; +import {render, screen} from '@testing-library/react'; +import {CostGlyph} from './CostGlyph'; + +describe('CostGlyph', () => { + it('renders the cost number for a normal cost', () => { + render(); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('renders "9+" for cost 9', () => { + render(); + expect(screen.getByText('9+')).toBeInTheDocument(); + }); + + it('renders "9+" for costs above 9', () => { + render(); + expect(screen.getByText('9+')).toBeInTheDocument(); + }); + + it('renders the Inkable symbol when inkwell is true', () => { + render(); + expect(screen.getByAltText('Inkable')).toBeInTheDocument(); + }); + + it('renders the Uninkable symbol when inkwell is false', () => { + render(); + expect(screen.getByAltText('Uninkable')).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/features/deck/components/CostGlyph.tsx b/apps/web/src/features/deck/components/CostGlyph.tsx new file mode 100644 index 00000000..337868ed --- /dev/null +++ b/apps/web/src/features/deck/components/CostGlyph.tsx @@ -0,0 +1,26 @@ +import {InkwellIcon} from '../../../shared/components/InkwellIcon'; + +interface CostGlyphProps { + /** Ink cost shown in the centre; >= 9 renders as "9+". */ + cost: number; + /** true → inkable symbol; false → uninkable symbol. */ + inkwell: boolean; + /** Square px size of the glyph (default 28). */ + size?: number; +} + +// One glyph per card: the inkable / uninkable filter symbol (InkwellIcon) with the +// cost number overlaid dead-centre — mirrors the real card (the cost sits inside the +// inkwell symbol) and folds cost + inkability into a single mark. +export function CostGlyph({cost, inkwell, size = 28}: CostGlyphProps) { + const label = cost >= 9 ? '9+' : String(cost); + const fontSize = cost >= 9 ? size * 0.3 : size * 0.36; + return ( + + + + {label} + + + ); +} diff --git a/apps/web/src/features/deck/components/DeckCardRow.stories.tsx b/apps/web/src/features/deck/components/DeckCardRow.stories.tsx new file mode 100644 index 00000000..f4849edd --- /dev/null +++ b/apps/web/src/features/deck/components/DeckCardRow.stories.tsx @@ -0,0 +1,37 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {fn} from 'storybook/test'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import {DeckCardRow} from './DeckCardRow'; +import {COLORS} from '../../../shared/constants'; + +const card = (over: Partial = {}): LorcanaCard => ({ + id: '1', + name: 'Mickey Mouse', + fullName: 'Mickey Mouse - Brave Little Tailor', + cost: 8, + ink: 'Amber', + inkwell: false, + type: 'Character', + ...over, +}); + +const meta: Meta = { + title: 'Deck/DeckCardRow', + component: DeckCardRow, + parameters: {backgrounds: {default: 'dark'}}, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + args: {onIncrement: fn(), onDecrement: fn(), onRemove: fn()}, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {args: {card: card(), quantity: 2}}; + +export const AtMaxCopies: Story = {args: {card: card(), quantity: 4}}; diff --git a/apps/web/src/features/deck/components/DeckCardRow.test.tsx b/apps/web/src/features/deck/components/DeckCardRow.test.tsx new file mode 100644 index 00000000..d5941703 --- /dev/null +++ b/apps/web/src/features/deck/components/DeckCardRow.test.tsx @@ -0,0 +1,40 @@ +import {describe, it, expect, vi} from 'vitest'; +import {render, screen, fireEvent} from '@testing-library/react'; +import {DeckCardRow} from './DeckCardRow'; +import {createCard} from '../../../shared/test-utils'; + +describe('DeckCardRow', () => { + const card = createCard({fullName: 'Elsa - Snow Queen'}); + + it('calls onIncrement when + is clicked', () => { + const onIncrement = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', {name: /add one copy/i})); + expect(onIncrement).toHaveBeenCalledOnce(); + }); + + it('calls onDecrement when − is clicked', () => { + const onDecrement = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', {name: /remove one copy/i})); + expect(onDecrement).toHaveBeenCalledOnce(); + }); + + it('disables + at the 4-copy limit (UI-side enforcement)', () => { + render(); + expect(screen.getByRole('button', {name: /maximum 4 copies/i})).toBeDisabled(); + }); + + it('calls onRemove when × is clicked', () => { + const onRemove = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', {name: /remove elsa - snow queen from deck/i})); + expect(onRemove).toHaveBeenCalledOnce(); + }); +}); diff --git a/apps/web/src/features/deck/components/DeckCardRow.tsx b/apps/web/src/features/deck/components/DeckCardRow.tsx new file mode 100644 index 00000000..a647d509 --- /dev/null +++ b/apps/web/src/features/deck/components/DeckCardRow.tsx @@ -0,0 +1,117 @@ +import {useState} from 'react'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import {COLORS, FONTS, FONT_SIZES, INK_COLORS, RADIUS, SPACING} from '../../../shared/constants'; +import {smallImageUrl} from '../../cards/loader'; +import {CostGlyph} from './CostGlyph'; +import {QuantityStepper} from './QuantityStepper'; + +/** Core copy limit — the + is disabled once a line reaches it. */ +const MAX_COPIES = 4; + +interface DeckCardRowProps { + card: LorcanaCard; + quantity: number; + onIncrement: () => void; + onDecrement: () => void; + onRemove: () => void; + /** Fired when the thumbnail/name is hovered; the panel shows a floating preview anchored to `anchor`. */ + onPreviewEnter?: (card: LorcanaCard, anchor: DOMRect) => void; + /** Fired when the pointer leaves the thumbnail/name; the panel hides the preview. */ + onPreviewLeave?: () => void; + /** Fired when the thumbnail/name is clicked; opens the card's synergy detail modal. */ + onOpenDetails?: (card: LorcanaCard) => void; +} + +function TrashIcon() { + return ( + + ); +} + +/** The row's display name: fullName, then name, then a stable placeholder. */ +function resolveCardName(card: LorcanaCard): string { + return card.fullName || card.name || 'Unknown card'; +} + +/** True for the keys that activate the card-identity button: Enter or Space. */ +function isActivationKey(key: string): boolean { + return key === 'Enter' || key === ' '; +} + +/** + * One line in the deck panel: cost-in-inkwell glyph, thumbnail + name (the hover + * preview target), an always-open [− qty +] stepper, and a trash remove. The + + * disables at MAX_COPIES; the row glows in the card's ink on hover. + */ +export function DeckCardRow({card, quantity, onIncrement, onDecrement, onRemove, onPreviewEnter, onPreviewLeave, onOpenDetails}: DeckCardRowProps) { + const [hovered, setHovered] = useState(false); + const ink = INK_COLORS[card.ink]; + const name = resolveCardName(card); + const atMax = quantity >= MAX_COPIES; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: SPACING.md, + padding: `${SPACING.sm}px ${SPACING.md}px`, + borderRadius: RADIUS.md, + // Hover glow uses the card's own ink colour (INK_COLORS[ink].border) with + // hex-alpha suffixes, matching the filter-button convention. + background: hovered ? `${ink.border}14` : 'transparent', + boxShadow: hovered ? `0 0 14px ${ink.border}40, inset 0 0 0 1px ${ink.border}66` : 'inset 0 0 0 1px transparent', + transition: 'background 0.15s ease, box-shadow 0.15s ease', + animation: 'inkweave-row-enter 0.28s ease-out', + }}> + + {/* Card identity: hovering it floats the preview, clicking it opens the detail + modal. Scoped to the thumbnail + name so the stepper and trash (siblings + below) neither preview nor open. */} + {/* Named for what it opens, not "View ... details": the pool tile's info + button already owns that label, and both can be on screen at once. */} +
onOpenDetails?.(card)} + onKeyDown={(e) => { + if (isActivationKey(e.key)) { + e.preventDefault(); + onOpenDetails?.(card); + } + }} + onMouseEnter={(e) => onPreviewEnter?.(card, e.currentTarget.getBoundingClientRect())} + onMouseLeave={onPreviewLeave} + style={{display: 'flex', alignItems: 'center', gap: SPACING.md, flex: 1, minWidth: 0, cursor: 'pointer'}}> +
+ +
+ + {name} + +
+ + +
+ ); +} diff --git a/apps/web/src/features/deck/components/DeckPanel.stories.tsx b/apps/web/src/features/deck/components/DeckPanel.stories.tsx new file mode 100644 index 00000000..7389eb3d --- /dev/null +++ b/apps/web/src/features/deck/components/DeckPanel.stories.tsx @@ -0,0 +1,88 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {fn} from 'storybook/test'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import type {DeckStats} from '../types'; +import {DeckPanel, type DeckRow} from './DeckPanel'; + +const RB = 'https://api.lorcana.ravensburger.com/images/en'; + +const card = (id: string, over: Partial = {}): LorcanaCard => ({ + id, + name: `Card ${id}`, + fullName: `Card ${id} - Hero`, + cost: 3, + ink: 'Amber', + inkwell: true, + type: 'Character', + ...over, +}); + +// A type-diverse deck so the panel's grouping (Characters / Actions / Songs / +// Items / Locations) and the mix of inks / inkable states are visible. +const rows: DeckRow[] = [ + {card: card('c1', {fullName: 'The Queen - Conceited Ruler', cost: 3, ink: 'Amber', inkwell: true, type: 'Character', imageUrl: `${RB}/set9/1_93b7a7794fa098c50d7f82e099a8db3928a78f9d.jpg`}), quantity: 4}, + {card: card('c2', {fullName: 'Kuzco - Temperamental Emperor', cost: 5, ink: 'Emerald', inkwell: true, type: 'Character', imageUrl: `${RB}/set9/69_a6126f19afa4cc53460116f2aaa51732adceaa05.jpg`}), quantity: 1}, + {card: card('c3', {fullName: 'Anna - True-Hearted', cost: 4, ink: 'Sapphire', inkwell: false, type: 'Character', imageUrl: `${RB}/set9/137_30b03ce9b58be1bc220632827d7d72051fe68876.jpg`}), quantity: 4}, + {card: card('a1', {fullName: "Bruno's Return", cost: 2, ink: 'Amber', inkwell: false, type: 'Action', imageUrl: `${RB}/set9/29_6c5411527a850fe20562f4f0cd4ea8e9f0a2a588.jpg`}), quantity: 2}, + {card: card('s1', {fullName: 'Heal What Has Been Hurt', cost: 3, ink: 'Amber', inkwell: true, type: 'Action', isSong: true, imageUrl: `${RB}/set9/27_9a6cbaef607009caebe172473534c1520b85b4b9.jpg`}), quantity: 3}, + {card: card('i1', {fullName: 'Lantern', cost: 2, ink: 'Amber', inkwell: false, type: 'Item', imageUrl: `${RB}/set9/32_c4b56c9ae7915c4401bd6946eaa828caa5478ee4.jpg`}), quantity: 2}, + {card: card('l1', {fullName: 'Atlantica - Concert Hall', cost: 1, ink: 'Amber', inkwell: true, type: 'Location', imageUrl: `${RB}/set9/34_ee0d239951bc0f6e7f54b327711424dcf0716a24.jpg`}), quantity: 1}, +]; + +const stats = (over: Partial = {}): DeckStats => ({ + totalCards: 17, + uniqueCards: 7, + inkDistribution: {Amber: 12, Emerald: 1, Sapphire: 4}, + costCurve: {1: 1, 2: 4, 3: 7, 4: 4, 5: 1}, + typeDistribution: {Character: 9, Action: 5, Item: 2, Location: 1}, + inkCount: 3, + inkableCount: 8, + isLegal: false, + legalityErrors: [], + ...over, +}); + +const meta: Meta = { + title: 'Deck/DeckPanel', + component: DeckPanel, + parameters: {layout: 'fullscreen', backgrounds: {default: 'dark'}}, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + args: {onRename: fn(), onIncrement: fn(), onDecrement: fn(), onRemove: fn(), onOpenDetails: fn()}, +}; +export default meta; +type Story = StoryObj; + +export const Empty: Story = { + args: { + name: 'Untitled deck', + rows: [], + stats: stats({ + totalCards: 0, + uniqueCards: 0, + inkDistribution: {}, + costCurve: {}, + typeDistribution: {}, + inkCount: 0, + inkableCount: 0, + }), + }, +}; + +export const Building: Story = {args: {name: 'Amber Midrange', rows, stats: stats()}}; + +export const Legal: Story = {args: {name: 'Amber Midrange', rows, stats: stats({totalCards: 60, inkCount: 2, isLegal: true})}}; + +export const Illegal: Story = { + args: { + name: 'Three Inks', + rows, + stats: stats({totalCards: 60, inkCount: 3, legalityErrors: ['3 inks: Amber, Emerald, Sapphire (max 2)']}), + }, +}; diff --git a/apps/web/src/features/deck/components/DeckPanel.test.tsx b/apps/web/src/features/deck/components/DeckPanel.test.tsx new file mode 100644 index 00000000..31ce0137 --- /dev/null +++ b/apps/web/src/features/deck/components/DeckPanel.test.tsx @@ -0,0 +1,128 @@ +import {describe, expect, it, vi} from 'vitest'; +import {fireEvent, render, screen} from '@testing-library/react'; +import type {DeckStats} from '../types'; +import {DeckPanel, type DeckRow} from './DeckPanel'; +import {createCard} from '../../../shared/test-utils'; + +const stats: DeckStats = { + totalCards: 3, + uniqueCards: 2, + inkDistribution: {Amber: 3}, + costCurve: {2: 3}, + typeDistribution: {Character: 2, Action: 1}, + inkCount: 1, + inkableCount: 3, + isLegal: false, + legalityErrors: [], +}; + +const rows: DeckRow[] = [ + {card: createCard({id: 'ch1', fullName: 'Elsa - Snow Queen', type: 'Character'}), quantity: 2}, + {card: createCard({id: 'ac1', fullName: 'Fire the Cannons - Blast', type: 'Action'}), quantity: 1}, +]; + +type Props = Parameters[0]; + +function renderPanel(over: Partial = {}) { + const props: Props = { + name: 'Test Deck', + onRename: vi.fn(), + rows, + stats, + onIncrement: vi.fn(), + onDecrement: vi.fn(), + onRemove: vi.fn(), + ...over, + }; + return {props, ...render()}; +} + +const trashElsa = () => screen.getByRole('button', {name: /remove elsa - snow queen from deck/i}); + +describe('DeckPanel', () => { + it('groups rows under type headers, hiding empty groups', () => { + renderPanel(); + // rows has a Character + an Action, so those two headers render. + expect(screen.getByText('Characters')).toBeInTheDocument(); + expect(screen.getByText('Actions')).toBeInTheDocument(); + // Songs / Items / Locations are empty → their headers are not rendered at all. + expect(screen.queryByText('Songs')).not.toBeInTheDocument(); + expect(screen.queryByText('Items')).not.toBeInTheDocument(); + expect(screen.queryByText('Locations')).not.toBeInTheDocument(); + expect(screen.queryByText('None yet')).not.toBeInTheDocument(); + }); + + it('defers removal until the collapse transition ends', () => { + const {props} = renderPanel(); + fireEvent.click(trashElsa()); + expect(props.onRemove).not.toHaveBeenCalled(); // deferred, not immediate + fireEvent.transitionEnd(trashElsa(), {propertyName: 'max-height'}); + expect(props.onRemove).toHaveBeenCalledOnce(); + expect(props.onRemove).toHaveBeenCalledWith('ch1'); + }); + + it('ignores an unrelated transition (e.g. opacity) — only max-height completes the removal', () => { + const {props} = renderPanel(); + fireEvent.click(trashElsa()); + fireEvent.transitionEnd(trashElsa(), {propertyName: 'opacity'}); + expect(props.onRemove).not.toHaveBeenCalled(); + }); + + it('flushes a pending removal when switching tabs (so the row cannot get stuck)', () => { + const {props} = renderPanel(); + fireEvent.click(trashElsa()); + expect(props.onRemove).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole('button', {name: 'Analysis'})); + expect(props.onRemove).toHaveBeenCalledOnce(); + expect(props.onRemove).toHaveBeenCalledWith('ch1'); + }); + + it('aborts the removal if the quantity changed during the collapse', () => { + const {props, rerender} = renderPanel(); + fireEvent.click(trashElsa()); + // The user re-incremented mid-collapse: the same card now has a new quantity. + const bumped: DeckRow[] = [{...rows[0], quantity: 3}, rows[1]]; + rerender(); + fireEvent.transitionEnd(trashElsa(), {propertyName: 'max-height'}); + expect(props.onRemove).not.toHaveBeenCalled(); + }); + + it('flushes a pending removal on unmount (transitionEnd never fires for an unmounted row)', () => { + const {props, unmount} = renderPanel(); + fireEvent.click(trashElsa()); + expect(props.onRemove).not.toHaveBeenCalled(); + unmount(); // e.g. the user navigates away mid-collapse + expect(props.onRemove).toHaveBeenCalledOnce(); + expect(props.onRemove).toHaveBeenCalledWith('ch1'); + }); + + it('aborts the removal on unmount too if the quantity changed during the collapse', () => { + const {props, rerender, unmount} = renderPanel(); + fireEvent.click(trashElsa()); + rerender(); + unmount(); + expect(props.onRemove).not.toHaveBeenCalled(); + }); + + it('opens details with the ids in rendered (type-grouped) order, not the given row order', () => { + const onOpenDetails = vi.fn(); + // Page order is cost-then-name, so the Action can precede the Character... + renderPanel({rows: [rows[1], rows[0]], onOpenDetails}); + fireEvent.click(screen.getByRole('button', {name: /view elsa - snow queen synergies/i})); + // ...but the modal's arrow-nav must walk Characters before Actions, as rendered. + expect(onOpenDetails).toHaveBeenCalledWith(rows[0].card, ['ch1', 'ac1']); + }); + + it('opens details from the keyboard', () => { + const onOpenDetails = vi.fn(); + renderPanel({onOpenDetails}); + fireEvent.keyDown(screen.getByRole('button', {name: /view elsa - snow queen synergies/i}), {key: 'Enter'}); + expect(onOpenDetails).toHaveBeenCalledOnce(); + }); + + it('shows the empty prompt (not five empty groups) when the deck has no cards', () => { + renderPanel({rows: [], stats: {...stats, totalCards: 0, uniqueCards: 0}}); + expect(screen.getByText(/no cards yet/i)).toBeInTheDocument(); + expect(screen.queryByText('None yet')).not.toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/features/deck/components/DeckPanel.tsx b/apps/web/src/features/deck/components/DeckPanel.tsx new file mode 100644 index 00000000..968f00dc --- /dev/null +++ b/apps/web/src/features/deck/components/DeckPanel.tsx @@ -0,0 +1,367 @@ +import {useEffect, useRef, useState, type CSSProperties} from 'react'; +import {createPortal} from 'react-dom'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import type {DeckStats} from '../types'; +import {DeckCardRow} from './DeckCardRow'; +import {previewGeometry} from './previewGeometry'; +import {COLORS, FONTS, FONT_SIZES, INK_COLORS, RADIUS, SPACING} from '../../../shared/constants'; + +/** Competitive Core deck size — the count badge + progress bar target. */ +const DECK_TARGET = 60; + +/** A resolved deck line: the card plus how many copies are in the deck. */ +export interface DeckRow { + card: LorcanaCard; + quantity: number; +} + +interface DeckPanelProps { + name: string; + onRename: (name: string) => void; + /** Rows pre-resolved + sorted by the page (cost, then name). */ + rows: DeckRow[]; + stats: DeckStats; + onIncrement: (cardId: string) => void; + onDecrement: (cardId: string) => void; + onRemove: (cardId: string) => void; + /** + * Clicking a row's thumbnail/name opens that card's detail modal. `siblingIds` is + * the deck in the panel's VISUAL (type-grouped) order, so the modal's arrow-nav + * walks the deck as rendered rather than the page's flat cost-then-name sort. + */ + onOpenDetails?: (card: LorcanaCard, siblingIds: string[]) => void; +} + +type PanelTab = 'cards' | 'analysis'; + +// Songs are pulled out of Actions into their own group. `card.isSong` is the +// reliable flag — the isSong() helper misses transformed cards (their 'Song' +// subtype is moved off `classifications` onto this boolean at load time). +type RowGroup = 'Character' | 'Action' | 'Song' | 'Item' | 'Location'; +const GROUP_ORDER: RowGroup[] = ['Character', 'Action', 'Song', 'Item', 'Location']; +const GROUP_LABEL: Record = { + Character: 'Characters', + Action: 'Actions', + Song: 'Songs', + Item: 'Items', + Location: 'Locations', +}; + +function rowGroup(card: LorcanaCard): RowGroup { + return card.isSong ? 'Song' : card.type; +} + +// Reserved zones for the advisor (issue #472) — its analyzers already exist under +// features/deck/analysis/; this tab is where they will surface. +const ANALYSIS_ZONES = [ + 'Deck quality score', + 'Cost curve', + 'Ink balance', + 'Synergies & key cards', + 'Vulnerabilities · what to watch for', + 'Suggestions', +]; + +function CountBadge({total, isLegal}: {total: number; isLegal: boolean}) { + const reached = total >= DECK_TARGET; + const color = isLegal ? COLORS.success : reached ? COLORS.error : COLORS.textMuted; + return ( + + {total}/{DECK_TARGET} + + ); +} + +function LegalitySummary({stats}: {stats: DeckStats}) { + const pct = Math.min(100, Math.round((stats.totalCards / DECK_TARGET) * 100)); + const barColor = stats.isLegal ? COLORS.success : COLORS.primary; + const status = stats.isLegal + ? 'Core legal' + : stats.legalityErrors.length === 0 + ? 'Keep building' + : `${stats.legalityErrors.length} to fix`; + + return ( +
+
+
+
+
+ {status} +
+ {stats.legalityErrors.map((err) => ( +
+ • {err} +
+ ))} +
+ ); +} + +function AnalysisTab() { + return ( +
+

+ Reserved for the advisor (#472). The analysis backend already exists; this tab is where it surfaces. +

+ {ANALYSIS_ZONES.map((zone) => ( +
+ {zone} +
+ ))} +
+ ); +} + +// The hover preview: a full card floated to the LEFT of the hovered row, portaled +// to so the panel's overflow can't clip it. pointer-events:none so it never +// steals the hover that spawned it. +function FloatingPreview({card, anchor}: {card: LorcanaCard; anchor: DOMRect}) { + const geometry = previewGeometry(anchor, window.innerHeight); + if (!geometry) return null; + const {width, left, top} = geometry; + return createPortal( + , + document.body, + ); +} + +const tabButton = (active: boolean): CSSProperties => ({ + border: 'none', + background: 'transparent', + cursor: 'pointer', + fontFamily: FONTS.body, + fontSize: `${FONT_SIZES.lg}px`, + fontWeight: 700, + color: active ? COLORS.text : COLORS.textMuted, + padding: '8px 12px', + borderBottom: `2px solid ${active ? COLORS.primary : 'transparent'}`, +}); + +const groupHeader: CSSProperties = { + display: 'flex', + alignItems: 'center', + gap: SPACING.sm, + padding: '12px 8px 4px', + fontFamily: FONTS.body, + fontSize: `${FONT_SIZES.md}px`, + fontWeight: 700, + color: COLORS.textMuted, + textTransform: 'uppercase', + letterSpacing: '0.05em', +}; + +/** + * The right-hand deck pane: an inline-editable name + count badge, Cards / Analysis + * tabs, the type-grouped list of {@link DeckCardRow}s (with a floating hover + * preview), and a persistent legality/progress footer. Purely prop-driven + * (resolved rows + {@link DeckStats}); all mutations route back through the page's + * useDeck actions. Removal is deferred so the row can collapse out first. + */ +export function DeckPanel({name, onRename, rows, stats, onIncrement, onDecrement, onRemove, onOpenDetails}: DeckPanelProps) { + const [tab, setTab] = useState('cards'); + // cardId -> the quantity snapshot when trash was clicked. The row collapses out, + // then the real removal completes on transitionEnd — but only if the quantity + // hasn't changed under us in the meantime (the user could re-increment via the + // pool tile mid-collapse), in which case the removal is aborted. + const [leaving, setLeaving] = useState>({}); + const [peek, setPeek] = useState<{card: LorcanaCard; anchor: DOMRect} | null>(null); + + const requestRemove = (cardId: string, snapshot: number) => { + setLeaving((l) => ({...l, [cardId]: snapshot})); + // Drop a preview still pinned to the card being removed. + setPeek((p) => (p?.card.id === cardId ? null : p)); + }; + const finishRemove = (cardId: string, currentQuantity: number) => { + const snapshot = leaving[cardId]; + setLeaving((l) => { + const next = {...l}; + delete next[cardId]; + return next; + }); + if (snapshot === currentQuantity) onRemove(cardId); + }; + + /** + * Commit every staged removal whose quantity hasn't changed under us. This is the + * same abort rule finishRemove applies, so a mid-collapse re-increment keeps the + * card whichever way the collapse ends. + */ + const flushLeaving = () => { + const currentQuantities = new Map(rows.map((r) => [r.card.id, r.quantity])); + Object.entries(leaving).forEach(([cardId, snapshot]) => { + if (currentQuantities.get(cardId) === snapshot) onRemove(cardId); + }); + }; + + // A removal only commits on transitionEnd, and transitionEnd never fires if the + // row unmounts first: navigate away mid-collapse and the row vanishes visually + // while the card stays in the (longer-lived) DeckProvider, reappearing later. + // Every exit needs a flush; switchTab has one, so unmount needs one too. + const flushOnUnmount = useRef<() => void>(() => {}); + useEffect(() => { + flushOnUnmount.current = flushLeaving; + }); + useEffect(() => () => flushOnUnmount.current(), []); + + /** The deck's card ids in the order the panel renders them (grouped by type). */ + const orderedCardIds = () => + GROUP_ORDER.flatMap((g) => rows.filter((r) => rowGroup(r.card) === g).map((r) => r.card.id)); + + const openDetails = (clicked: LorcanaCard) => { + setPeek(null); // the modal covers the panel; don't leave a preview floating behind it + onOpenDetails?.(clicked, orderedCardIds()); + }; + + const switchTab = (next: PanelTab) => { + // Switching tabs unmounts the rows, so an in-flight collapse would never fire + // its transitionEnd. Flush first so nothing gets stuck. + flushLeaving(); + setLeaving({}); + setTab(next); + setPeek(null); + }; + + const renderRow = ({card, quantity}: DeckRow) => { + const isLeaving = card.id in leaving; + return ( +
{ + if (isLeaving && e.propertyName === 'max-height') finishRemove(card.id, quantity); + }}> + onIncrement(card.id)} + onDecrement={() => onDecrement(card.id)} + onRemove={() => requestRemove(card.id, quantity)} + onPreviewEnter={(previewCard, anchor) => setPeek({card: previewCard, anchor})} + onPreviewLeave={() => setPeek(null)} + onOpenDetails={openDetails} + /> +
+ ); + }; + + return ( + + ); +} diff --git a/apps/web/src/features/deck/components/DeckPoolGrid.stories.tsx b/apps/web/src/features/deck/components/DeckPoolGrid.stories.tsx new file mode 100644 index 00000000..f71a1110 --- /dev/null +++ b/apps/web/src/features/deck/components/DeckPoolGrid.stories.tsx @@ -0,0 +1,47 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {fn} from 'storybook/test'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import {DeckPoolGrid} from './DeckPoolGrid'; + +const inks = ['Amber', 'Amethyst', 'Emerald', 'Ruby', 'Sapphire', 'Steel'] as const; +const cards: LorcanaCard[] = Array.from({length: 18}, (_, i) => ({ + id: `${i + 1}`, + name: `Card ${i + 1}`, + fullName: `Card ${i + 1} - Hero`, + cost: (i % 8) + 1, + ink: inks[i % inks.length], + inkwell: true, + type: 'Character', +})); + +const meta: Meta = { + title: 'Deck/DeckPoolGrid', + component: DeckPoolGrid, + parameters: {layout: 'fullscreen', backgrounds: {default: 'dark'}}, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + args: {onIncrement: fn(), onDecrement: fn(), onViewDetails: fn()}, +}; +export default meta; +type Story = StoryObj; + +export const EmptyDeck: Story = {args: {cards, quantities: new Map()}}; + +// card 1 is at the 4-copy max (its + disables); card 5 shows a count pip. +export const WithDeckState: Story = { + args: { + cards, + quantities: new Map([ + ['1', 4], + ['5', 2], + ]), + }, +}; + +export const NoResults: Story = {args: {cards: [], quantities: new Map()}}; diff --git a/apps/web/src/features/deck/components/DeckPoolGrid.tsx b/apps/web/src/features/deck/components/DeckPoolGrid.tsx new file mode 100644 index 00000000..5cc99910 --- /dev/null +++ b/apps/web/src/features/deck/components/DeckPoolGrid.tsx @@ -0,0 +1,89 @@ +import {forwardRef, type CSSProperties, type ReactNode} from 'react'; +import {VirtuosoGrid} from 'react-virtuoso'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import {PoolCardTile} from './PoolCardTile'; +import {COLORS, FONT_SIZES, LAYOUT, SPACING} from '../../../shared/constants'; +import {useResponsive} from '../../../shared/hooks'; + +// Larger than Browse's grid so the pool shows bigger, more readable card scans +// (fewer per row) — the deck panel is wide, so the pool doesn't need the density. +const POOL_CARD_MIN_WIDTH = 220; + +interface DeckPoolGridProps { + cards: LorcanaCard[]; + /** cardId → copies already in the deck (drives each tile's count + stepper). */ + quantities: Map; + onIncrement: (card: LorcanaCard) => void; + onDecrement: (card: LorcanaCard) => void; + onViewDetails: (card: LorcanaCard) => void; +} + +interface ListContainerProps { + style?: CSSProperties; + children?: ReactNode; +} + +// See BrowseCardGrid: VirtuosoGrid imperatively writes paddingTop/paddingBottom +// for virtual scroll positioning, so only horizontal padding is safe to set here. +// Row gap is a touch wider than the column gap to give the tiles' overhanging +// stepper (and its glow) breathing room between rows. +const createListContainer = (paddingX: number, minColWidth: number) => + forwardRef(function ListContainer({style, children}, ref) { + return ( +
+ {children} +
+ ); + }); + +/** + * Virtualized grid of {@link PoolCardTile}. Unlike BrowseCardGrid it renders the + * FULL pool (no 204-card cap) since a deck-builder must show the whole legal + * card base; react-virtuoso keeps it cheap by only mounting visible rows. + * Requires a height-bounded flex ancestor (the page provides `flex:1;minHeight:0`). + */ +export function DeckPoolGrid({cards, quantities, onIncrement, onDecrement, onViewDetails}: DeckPoolGridProps) { + const {isMobile} = useResponsive(); + const paddingX = isMobile ? SPACING.lg : SPACING.md; + const minColWidth = isMobile ? LAYOUT.cardGridMinWidthMobile : POOL_CARD_MIN_WIDTH; + const ListContainer = createListContainer(paddingX, minColWidth); + + if (cards.length === 0) { + return ( +
+ No cards match your filters. +
+ ); + } + + return ( + { + const card = cards[index]; + return ( + + ); + }} + style={{height: '100%'}} + /> + ); +} diff --git a/apps/web/src/features/deck/components/DeckRowConcepts.stories.tsx b/apps/web/src/features/deck/components/DeckRowConcepts.stories.tsx new file mode 100644 index 00000000..b03e55c0 --- /dev/null +++ b/apps/web/src/features/deck/components/DeckRowConcepts.stories.tsx @@ -0,0 +1,499 @@ +import {useState, type CSSProperties, type ReactNode} from 'react'; +import type {Meta, StoryObj} from '@storybook/react-vite'; +import type {Ink} from 'inkweave-synergy-engine'; +import {COLORS, FONTS, FONT_SIZES, INK_COLORS, RADIUS, SPACING} from '../../../shared/constants'; +import {InkwellIcon} from '../../../shared/components/InkwellIcon'; + +// Concept exploration for the deck-panel row (DeckCardRow). KEPT as the living +// design reference (Deck / Design Explorations). `Concepts` = the A/B/C compare; +// `RefinedB` = the chosen mini-art direction with hover glow + inkable/cost + qty/max. +// Real cards (one per ink); controls here are non-interactive mockups. + +const RB = 'https://api.lorcana.ravensburger.com/images/en'; +const GOLD = COLORS.primary; +const RED = COLORS.error; // decrement +const GREEN = COLORS.success; // increment + +type CardGroup = 'Character' | 'Action' | 'Song' | 'Item' | 'Location'; + +interface RowCard { + id: string; + fullName: string; + cost: number; + ink: Ink; + inkwell: boolean; + imageUrl: string; + qty: number; + type: CardGroup; +} + +const CARDS: RowCard[] = [ + {id: '1', type: 'Character', fullName: 'The Queen - Conceited Ruler', cost: 3, ink: 'Amber', inkwell: true, imageUrl: `${RB}/set9/1_93b7a7794fa098c50d7f82e099a8db3928a78f9d.jpg`, qty: 4}, + {id: '2', type: 'Character', fullName: 'Bruno Madrigal - Undetected Uncle', cost: 4, ink: 'Amethyst', inkwell: false, imageUrl: `${RB}/set9/0_056fe7b7709e9f6ab33e7d134bb084eca0ca74f6.jpg`, qty: 2}, + {id: '3', type: 'Character', fullName: 'Kuzco - Temperamental Emperor', cost: 5, ink: 'Emerald', inkwell: true, imageUrl: `${RB}/set9/69_a6126f19afa4cc53460116f2aaa51732adceaa05.jpg`, qty: 1}, + {id: '4', type: 'Character', fullName: 'LeFou - Instigator', cost: 2, ink: 'Ruby', inkwell: true, imageUrl: `${RB}/set9/103_819b37406bdbf9e3a2267a72ac7207892d5a6dfb.jpg`, qty: 3}, + {id: '5', type: 'Character', fullName: 'Anna - True-Hearted', cost: 4, ink: 'Sapphire', inkwell: false, imageUrl: `${RB}/set9/137_30b03ce9b58be1bc220632827d7d72051fe68876.jpg`, qty: 4}, + {id: '6', type: 'Character', fullName: 'Philoctetes - No-Nonsense Instructor', cost: 4, ink: 'Steel', inkwell: true, imageUrl: `${RB}/set9/171_3662018112dfd716842071ce702a08cfd0d428b8.jpg`, qty: 1}, +]; + +function MinusGlyph() { + return ( + + ); +} +function PlusGlyph() { + return ( + + ); +} +function TrashIcon() { + return ( + + ); +} + +const stepBtn = (disabled: boolean): CSSProperties => ({ + width: 22, + height: 22, + borderRadius: RADIUS.sm, + border: `1px solid ${COLORS.surfaceBorder}`, + background: COLORS.surfaceAlt, + color: disabled ? COLORS.textDim : COLORS.text, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + opacity: disabled ? 0.5 : 1, +}); + +function Stepper({qty}: {qty: number}) { + return ( +
+
+ +
+ + {qty} + +
= 4)}> + +
+
+ ); +} + +const rowBase: CSSProperties = {display: 'flex', alignItems: 'center', gap: SPACING.sm, padding: `5px ${SPACING.sm}px`, borderRadius: RADIUS.sm}; +const nameStyle: CSSProperties = {flex: 1, minWidth: 0, color: COLORS.text, fontFamily: FONTS.body, fontSize: FONT_SIZES.lg, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap'}; +const removeStyle: CSSProperties = {width: 20, textAlign: 'center', color: COLORS.textDim, fontSize: FONT_SIZES.lg, lineHeight: 1, flexShrink: 0, cursor: 'pointer'}; + +function RowA({c}: {c: RowCard}) { + const ink = INK_COLORS[c.ink]; + return ( +
+ + {c.cost} + + {c.fullName} + + × +
+ ); +} + +function RowB({c}: {c: RowCard}) { + const ink = INK_COLORS[c.ink]; + return ( +
+
+ +
+ {c.fullName} + + × +
+ ); +} + +function RowC({c}: {c: RowCard}) { + const ink = INK_COLORS[c.ink]; + return ( +
+
+ + {c.cost} + + {c.fullName} + + × +
+ ); +} + +function Panel({title, note, Row}: {title: string; note: string; Row: (p: {c: RowCard}) => ReactNode}) { + return ( +
+
{title}
+
{note}
+
+ {CARDS.map((c) => ( + + ))} +
+
+ ); +} + +// The chosen direction, enhanced: bigger spacing, gold glow-line on hover, the +// cost + inkable badge cluster, and qty / max. Hover a row (row 2 is pre-hovered +// so the glow shows in a static shot). +function RefinedBRow({c, forceHover}: {c: RowCard; forceHover?: boolean}) { + const [hovered, setHovered] = useState(false); + const on = hovered || forceHover; + const ink = INK_COLORS[c.ink]; + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + display: 'flex', + alignItems: 'center', + gap: SPACING.md, + padding: `${SPACING.sm}px ${SPACING.md}px`, + borderRadius: RADIUS.md, + background: on ? `${GOLD}0f` : 'transparent', + boxShadow: on ? `0 0 12px ${GOLD}30, inset 0 0 0 1px ${GOLD}55` : 'inset 0 0 0 1px transparent', + transition: 'background 0.15s ease, box-shadow 0.15s ease', + }}> +
+ +
+ {c.fullName} +
+ + {c.cost} + + +
+
+
+ +
+ + {c.qty} + / 4 + +
= 4)}> + +
+
+ × +
+ ); +} + +const meta: Meta = { + title: 'Deck/Design Explorations/DeckCardRow', + parameters: {layout: 'fullscreen', backgrounds: {default: 'dark'}}, +}; +export default meta; +type Story = StoryObj; + +export const Concepts: Story = { + render: () => ( +
+

Deck panel row · concepts

+
+ + + +
+
+ ), +}; + +export const RefinedB: Story = { + render: () => ( +
+

Deck row · B refined (mini art)

+

+ Bigger row spacing, a light gold glow-line on hover, cost + inkable badge, and qty / max. Hover a + row (row 2 is pre-hovered). +

+
+ {CARDS.map((c, i) => ( + + ))} +
+
+ ), +}; + +// ── Panel shell (tabs) + the four animations ──────────────────────────────── +const KEYFRAMES = ` +@keyframes ptRowEnter { from { opacity: 0; transform: translateY(-8px); } to { opacity: 1; transform: translateY(0); } } +@keyframes ptQtyPop { from { transform: scale(1.5); } to { transform: scale(1); } } +`; + +// One glyph per card: the inkable / uninkable filter symbol (InkwellIcon) with the +// cost number overlaid dead-centre — mirrors the real card (the cost sits inside the +// inkwell symbol) and folds cost + inkability into a single mark. +function CostGlyph({cost, inkwell, size}: {cost: number; inkwell: boolean; size: number}) { + const label = cost >= 9 ? '9+' : String(cost); + const fontSize = cost >= 9 ? size * 0.3 : size * 0.36; + return ( + + + + {label} + + + ); +} + +// Quantity stepper matching the pool grid's pill (PoolCardTile): a gold-bordered pill +// with a red − / green +, the − / + collapsing to width 0 at rest and growing on row +// hover. NOTE for the real build: extract this into a shared QuantityStepper used by +// both PoolCardTile and DeckCardRow rather than duplicating it. +const qtyCell: CSSProperties = {minWidth: 30, height: '100%', padding: '0 6px', border: 'none', background: 'transparent', fontFamily: FONTS.body, fontWeight: 800, lineHeight: 1, display: 'flex', alignItems: 'center', justifyContent: 'center'}; + +function qtySideBtn(shown: boolean, disabled: boolean, color: string): CSSProperties { + return {...qtyCell, minWidth: 0, width: shown ? 28 : 0, opacity: shown ? 1 : 0, padding: 0, color: disabled ? COLORS.textDim : color, cursor: disabled ? 'default' : 'pointer', overflow: 'hidden', transition: 'width 0.2s ease, opacity 0.18s ease, color 0.15s ease'}; +} + +interface LiveRow extends RowCard { + key: string; +} + +// A mock deck spanning all five groups (real cards + thumbnails). Mixed inks for the +// glow demo — NOT a legal 2-ink deck — and mixed inkable/uninkable for the glyph. +const DECK: RowCard[] = [ + {id: 'c1', type: 'Character', fullName: 'The Queen - Conceited Ruler', cost: 3, ink: 'Amber', inkwell: true, imageUrl: `${RB}/set9/1_93b7a7794fa098c50d7f82e099a8db3928a78f9d.jpg`, qty: 4}, + {id: 'c2', type: 'Character', fullName: 'Bruno Madrigal - Undetected Uncle', cost: 4, ink: 'Amethyst', inkwell: true, imageUrl: `${RB}/set9/0_056fe7b7709e9f6ab33e7d134bb084eca0ca74f6.jpg`, qty: 2}, + {id: 'c3', type: 'Character', fullName: 'Kuzco - Temperamental Emperor', cost: 5, ink: 'Emerald', inkwell: true, imageUrl: `${RB}/set9/69_a6126f19afa4cc53460116f2aaa51732adceaa05.jpg`, qty: 1}, + {id: 'c4', type: 'Character', fullName: 'LeFou - Instigator', cost: 2, ink: 'Ruby', inkwell: true, imageUrl: `${RB}/set9/103_819b37406bdbf9e3a2267a72ac7207892d5a6dfb.jpg`, qty: 3}, + {id: 'c5', type: 'Character', fullName: 'Anna - True-Hearted', cost: 4, ink: 'Sapphire', inkwell: false, imageUrl: `${RB}/set9/137_30b03ce9b58be1bc220632827d7d72051fe68876.jpg`, qty: 4}, + {id: 'a1', type: 'Action', fullName: "Bruno's Return", cost: 2, ink: 'Amber', inkwell: false, imageUrl: `${RB}/set9/29_6c5411527a850fe20562f4f0cd4ea8e9f0a2a588.jpg`, qty: 2}, + {id: 'a2', type: 'Action', fullName: 'Last-Ditch Effort', cost: 3, ink: 'Amethyst', inkwell: false, imageUrl: `${RB}/set9/62_1db1733133b8ce6abdba57238476b440f005d324.jpg`, qty: 2}, + {id: 's1', type: 'Song', fullName: 'Heal What Has Been Hurt', cost: 3, ink: 'Amber', inkwell: true, imageUrl: `${RB}/set9/27_9a6cbaef607009caebe172473534c1520b85b4b9.jpg`, qty: 3}, + {id: 's2', type: 'Song', fullName: 'Look at This Family', cost: 7, ink: 'Amber', inkwell: true, imageUrl: `${RB}/set9/25_9dfadcb6f520a6b4f669356ee3e708b90203c82f.jpg`, qty: 1}, + {id: 'i1', type: 'Item', fullName: 'Lantern', cost: 2, ink: 'Amber', inkwell: false, imageUrl: `${RB}/set9/32_c4b56c9ae7915c4401bd6946eaa828caa5478ee4.jpg`, qty: 2}, + {id: 'i2', type: 'Item', fullName: 'The Magic Feather', cost: 2, ink: 'Amethyst', inkwell: true, imageUrl: `${RB}/set9/64_e44f90862364d6a5242005e99ed15aed2caf995e.jpg`, qty: 2}, + {id: 'l1', type: 'Location', fullName: 'Atlantica - Concert Hall', cost: 1, ink: 'Amber', inkwell: true, imageUrl: `${RB}/set9/34_ee0d239951bc0f6e7f54b327711424dcf0716a24.jpg`, qty: 1}, + {id: 'l2', type: 'Location', fullName: 'Casa Madrigal - Casita', cost: 1, ink: 'Amethyst', inkwell: true, imageUrl: `${RB}/set9/68_e2318749d41cea23bafffccdfbbfa7f0cb7873d2.jpg`, qty: 1}, +]; + +// The "+ Add a card" button cycles these — spread across groups so adding shows +// cards landing in different sections. +const ADDABLE: RowCard[] = [ + {id: 'x1', type: 'Character', fullName: 'Pongo - Determined Father', cost: 3, ink: 'Amber', inkwell: true, imageUrl: `${RB}/set9/2_b3a460cfa62417b403cc1fc210a582cb81f908c0.jpg`, qty: 1}, + {id: 'x2', type: 'Item', fullName: "Ursula's Shell Necklace", cost: 3, ink: 'Amber', inkwell: false, imageUrl: `${RB}/set9/33_6092ccc36e9812be6a7e80cb3b98ae71503a9cd0.jpg`, qty: 1}, + {id: 'x3', type: 'Location', fullName: 'Hidden Cove - Tranquil Haven', cost: 1, ink: 'Emerald', inkwell: true, imageUrl: `${RB}/set9/102_5711c595508ef14e00f3fbc2c2fad56db30ef390.jpg`, qty: 1}, + {id: 'x4', type: 'Action', fullName: "I'm Stuck!", cost: 1, ink: 'Amethyst', inkwell: true, imageUrl: `${RB}/set9/63_6ba2958e18e030e8559f2eeff9db8439653af6d5.jpg`, qty: 1}, +]; + +const GROUP_ORDER: CardGroup[] = ['Character', 'Action', 'Song', 'Item', 'Location']; +const GROUP_LABEL: Record = {Character: 'Characters', Action: 'Actions', Song: 'Songs', Item: 'Items', Location: 'Locations'}; + +// The card preview shown by the docked (left) peek, and the floating (cursor) peek. +function CardPreview({card, width}: {card: RowCard; width: number}) { + return ( +
+ +
+ ); +} + +function AnimRow({row, hovered, onRowEnter, onRowLeave, onPeekEnter, onInc, onDec, onRemove}: { + row: LiveRow; + hovered: boolean; + onRowEnter: () => void; + onRowLeave: () => void; + onPeekEnter: () => void; + onInc: () => void; + onDec: () => void; + onRemove: () => void; +}) { + const ink = INK_COLORS[row.ink]; + return ( +
+ {/* Cost inside the inkable / uninkable symbol, leading the row. */} + + {/* Peek target: only the thumbnail + name fire the docked preview, so it never + reacts to the row's own controls. */} +
+
+ +
+ {row.fullName} +
+ {/* Always-open quantity stepper — the pool grid's pill (gold border, red − / green +). */} +
+ + + {row.qty} + + +
+ +
+ ); +} + +function AnalysisPlaceholder() { + const zones = ['Deck quality score', 'Cost curve', 'Ink balance', 'Synergies & key cards', 'Vulnerabilities · what to watch for', 'Suggestions']; + return ( +
+

+ Reserved for the advisor (#472). The analysis backend already exists; this tab is where it surfaces. +

+ {zones.map((z) => ( +
+ {z} +
+ ))} +
+ ); +} + +function PanelShellDemo() { + const [tab, setTab] = useState<'cards' | 'analysis'>('cards'); + const [rows, setRows] = useState(() => DECK.map((c, i) => ({...c, key: `init-${i}`}))); + const [leaving, setLeaving] = useState>({}); + const [hovered, setHovered] = useState(null); + const [peekCard, setPeekCard] = useState(null); + const [counter, setCounter] = useState(0); + const total = rows.reduce((n, r) => n + r.qty, 0); + + const addCard = () => { + const c = ADDABLE[counter % ADDABLE.length]; + setRows((r) => [...r, {...c, key: `add-${counter}`}]); + setCounter((x) => x + 1); + }; + const changeQty = (key: string, delta: number) => + setRows((r) => r.map((row) => (row.key === key ? {...row, qty: Math.max(1, Math.min(4, row.qty + delta))} : row))); + + const tabBtn = (t: 'cards' | 'analysis', label: string) => ( + + ); + + const renderRow = (row: LiveRow) => ( +
{ + if (leaving[row.key] && e.propertyName === 'max-height') { + setRows((r) => r.filter((x) => x.key !== row.key)); + setLeaving((l) => { + const next = {...l}; + delete next[row.key]; + return next; + }); + } + }}> + setHovered(row.key)} + onRowLeave={() => setHovered(null)} + onPeekEnter={() => setPeekCard(row)} + onInc={() => changeQty(row.key, 1)} + onDec={() => changeQty(row.key, -1)} + onRemove={() => setLeaving((l) => ({...l, [row.key]: true}))} + /> +
+ ); + + return ( +
setPeekCard(null)}> + +

Deck panel · shell (tabs)

+

+ Cards grouped by type — all five headers always shown. Hover a card's thumbnail or name to preview it in the docked pane on the left. The stepper stays open (± pops, bin removes), + Add a card slides in. Mixed inks (not a legal deck) — a layout mock. Analysis is reserved for #472. +

+
+
+ {peekCard ? ( + + ) : ( +
+ Hover a card's art or name to preview it here. +
+ )} +
+
+
+ New Deck + {total} / 60 +
+
+ {tabBtn('cards', 'Cards')} + {tabBtn('analysis', 'Analysis')} +
+ {tab === 'cards' ? ( +
+ + {GROUP_ORDER.map((g) => { + const groupRows = rows + .filter((r) => r.type === g) + .sort((a, b) => a.cost - b.cost || a.fullName.localeCompare(b.fullName)); + const count = groupRows.reduce((n, r) => n + r.qty, 0); + return ( +
+
+ {GROUP_LABEL[g]} + {count} +
+ {groupRows.length > 0 ? ( + groupRows.map(renderRow) + ) : ( +
None yet
+ )} +
+ ); + })} +
+ ) : ( + + )} +
+
+
+ ); +} + +export const PanelShell: Story = {render: () => }; diff --git a/apps/web/src/features/deck/components/PoolCardTile.stories.tsx b/apps/web/src/features/deck/components/PoolCardTile.stories.tsx new file mode 100644 index 00000000..d0fb1331 --- /dev/null +++ b/apps/web/src/features/deck/components/PoolCardTile.stories.tsx @@ -0,0 +1,45 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {fn} from 'storybook/test'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import {PoolCardTile} from './PoolCardTile'; + +// Real card thumbnails (direct Ravensburger .jpg) so the overhanging control is +// shown against actual art. smallImageUrl passes a non-.avif URL through, and an +// loads cross-origin fine (Storybook has no image proxy). Hover any in-deck +// tile to grow the red − / green + stepper out of the count pill. +const RB = 'https://api.lorcana.ravensburger.com/images/en'; + +const pocahontas = (over: Partial = {}): LorcanaCard => ({ + id: '2983', + name: 'Pocahontas', + fullName: 'Pocahontas - Guiding the Tribe', + cost: 2, + ink: 'Amber', + inkwell: true, + type: 'Character', + imageUrl: `${RB}/set13/12_25a881827d54e9214c236aa95d2475a3dfd8ebce.jpg`, + ...over, +}); + +const meta: Meta = { + title: 'Deck/PoolCardTile', + component: PoolCardTile, + parameters: {backgrounds: {default: 'dark'}}, + decorators: [ + (Story) => ( +
+ +
+ ), + ], + tags: ['autodocs'], + args: {onIncrement: fn(), onDecrement: fn(), onViewDetails: fn()}, +}; +export default meta; +type Story = StoryObj; + +export const Addable: Story = {args: {card: pocahontas(), inDeckCount: 0}}; + +export const InDeck: Story = {args: {card: pocahontas(), inDeckCount: 2}}; + +export const AtMaxCopies: Story = {args: {card: pocahontas(), inDeckCount: 4}}; diff --git a/apps/web/src/features/deck/components/PoolCardTile.test.tsx b/apps/web/src/features/deck/components/PoolCardTile.test.tsx new file mode 100644 index 00000000..ffc86564 --- /dev/null +++ b/apps/web/src/features/deck/components/PoolCardTile.test.tsx @@ -0,0 +1,68 @@ +import type {ComponentProps} from 'react'; +import {describe, it, expect, vi} from 'vitest'; +import {render, screen, fireEvent} from '@testing-library/react'; +import {PoolCardTile} from './PoolCardTile'; +import {createCard} from '../../../shared/test-utils'; + +const card = createCard({fullName: 'Pocahontas - Guiding the Tribe', ink: 'Amber'}); + +function renderTile(props: Partial> = {}) { + const handlers = {onIncrement: vi.fn(), onDecrement: vi.fn(), onViewDetails: vi.fn()}; + render(); + return handlers; +} + +describe('PoolCardTile', () => { + it('adds a copy when the card body is clicked', () => { + const {onIncrement} = renderTile({inDeckCount: 0}); + fireEvent.click(screen.getByTestId('card-tile')); + expect(onIncrement).toHaveBeenCalledWith(card); + }); + + it('adds another copy when an already-in-deck card is clicked', () => { + const {onIncrement} = renderTile({inDeckCount: 2}); + fireEvent.click(screen.getByTestId('card-tile')); + expect(onIncrement).toHaveBeenCalledWith(card); + }); + + it('opens details from the info button without adding', () => { + const {onViewDetails, onIncrement} = renderTile({inDeckCount: 0}); + fireEvent.click(screen.getByRole('button', {name: /view pocahontas - guiding the tribe details/i})); + expect(onViewDetails).toHaveBeenCalledWith(card); + expect(onIncrement).not.toHaveBeenCalled(); + }); + + it('steps quantity with the in-deck − / + controls', () => { + const {onIncrement, onDecrement} = renderTile({inDeckCount: 2}); + // The − / + are revealed (and exposed to AT) only on hover/focus; hover first. + fireEvent.mouseEnter(screen.getByTestId('card-tile')); + fireEvent.click(screen.getByRole('button', {name: /add one copy/i})); + fireEvent.click(screen.getByRole('button', {name: /remove one copy/i})); + expect(onIncrement).toHaveBeenCalledWith(card); + expect(onDecrement).toHaveBeenCalledWith(card); + }); + + it('does not add past the 4-copy limit', () => { + const {onIncrement} = renderTile({inDeckCount: 4}); + fireEvent.mouseEnter(screen.getByTestId('card-tile')); // reveal the stepper + expect(screen.getByRole('button', {name: /max 4 copies/i})).toBeDisabled(); + fireEvent.click(screen.getByTestId('card-tile')); + expect(onIncrement).not.toHaveBeenCalled(); + }); + + it('adds an off-ink card on click — the ink limit is a soft legality error, not a pool block', () => { + const ruby = createCard({fullName: 'Maleficent - Monstrous Dragon', ink: 'Ruby'}); + const onIncrement = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByTestId('card-tile')); + expect(onIncrement).toHaveBeenCalledWith(ruby); + }); +}); diff --git a/apps/web/src/features/deck/components/PoolCardTile.tsx b/apps/web/src/features/deck/components/PoolCardTile.tsx new file mode 100644 index 00000000..cb5b0ff6 --- /dev/null +++ b/apps/web/src/features/deck/components/PoolCardTile.tsx @@ -0,0 +1,115 @@ +import {useState, type CSSProperties} from 'react'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import {CardTile} from '../../cards/components/CardTile'; +import {QuantityStepper} from './QuantityStepper'; +import {getPoolTileState} from './poolTileState'; +import {COLORS, FONTS} from '../../../shared/constants'; + +const GOLD = COLORS.primary; +const GLOW = `0 0 10px ${GOLD}99, 0 4px 12px rgba(0,0,0,0.55)`; + +interface PoolCardTileProps { + card: LorcanaCard; + /** How many copies of this card are already in the deck. */ + inDeckCount: number; + onIncrement: (card: LorcanaCard) => void; + onDecrement: (card: LorcanaCard) => void; + onViewDetails: (card: LorcanaCard) => void; + priority?: boolean; +} + +const infoBtn: CSSProperties = { + position: 'absolute', + top: 6, + right: 6, + width: 22, + height: 22, + borderRadius: '50%', + border: `1px solid ${COLORS.surfaceBorder}`, + background: 'rgba(13, 13, 20, 0.72)', + color: COLORS.text, + fontFamily: FONTS.hero, + fontStyle: 'italic', + fontSize: 14, + fontWeight: 700, + lineHeight: 1, + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + boxShadow: '0 1px 4px rgba(0,0,0,0.5)', +}; + +/** + * A card in the builder's pool. Clicking the card body ADDS a copy (up to the + * 4-copy limit, after which the + goes inert via {@link getPoolTileState}). A + * small "i" opens the detail modal. Once in the deck, an overhanging count pill + * shows the quantity and grows a red "−" / green "+" stepper on hover/focus. Ink + * legality is not blocked here — going over two inks is flagged as a deck error. + */ +export function PoolCardTile({ + card, + inDeckCount, + onIncrement, + onDecrement, + onViewDetails, + priority, +}: PoolCardTileProps) { + // Hover and focus are tracked separately so a phantom blur (from a control + // unmounting) can't collapse the stepper while the mouse is still over the tile. + const [hovered, setHovered] = useState(false); + const [focused, setFocused] = useState(false); + // The pop is driven by the ± handlers (not a key remount) so it fires on a real + // change and stays silent when a tile scrolls back into view in the grid. + const [popping, setPopping] = useState(false); + + const {addDisabled, reason} = getPoolTileState(inDeckCount); + const name = card.fullName || card.name || 'card'; + const inDeck = inDeckCount > 0; + const showSides = inDeck && (hovered || focused); + + const add = () => { + if (addDisabled) return; + onIncrement(card); + setPopping(true); + }; + const remove = () => { + onDecrement(card); + setPopping(true); + }; + + return ( +
setHovered(true)} + onMouseLeave={() => setHovered(false)} + onFocus={() => setFocused(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget)) setFocused(false); + }}> + {/* Card body: a click adds a copy. */} + + + {/* Details affordance, distinct from the add-on-click body. */} + + + {inDeck && ( +
+ setPopping(false)} + /> +
+ )} +
+ ); +} diff --git a/apps/web/src/features/deck/components/QuantityStepper.stories.tsx b/apps/web/src/features/deck/components/QuantityStepper.stories.tsx new file mode 100644 index 00000000..e8d16db0 --- /dev/null +++ b/apps/web/src/features/deck/components/QuantityStepper.stories.tsx @@ -0,0 +1,28 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {fn} from 'storybook/test'; +import {COLORS} from '../../../shared/constants'; +import {QuantityStepper} from './QuantityStepper'; + +const meta: Meta = { + title: 'Deck/QuantityStepper', + component: QuantityStepper, + tags: ['autodocs'], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: {onIncrement: fn(), onDecrement: fn()}, +}; +export default meta; +type Story = StoryObj; + +export const Default: Story = {args: {value: 2, label: 'Pocahontas'}}; + +export const Small: Story = {args: {value: 2, size: 'sm', label: 'Pocahontas'}}; + +export const Collapsed: Story = {args: {value: 2, collapsed: true, label: 'Pocahontas'}}; + +export const AtMax: Story = {args: {value: 4, incrementDisabled: true, disabledReason: 'Maximum 4 copies', label: 'Pocahontas'}}; diff --git a/apps/web/src/features/deck/components/QuantityStepper.test.tsx b/apps/web/src/features/deck/components/QuantityStepper.test.tsx new file mode 100644 index 00000000..6612647b --- /dev/null +++ b/apps/web/src/features/deck/components/QuantityStepper.test.tsx @@ -0,0 +1,48 @@ +import type {ComponentProps} from 'react'; +import {describe, it, expect, vi} from 'vitest'; +import {render, screen, fireEvent} from '@testing-library/react'; +import {QuantityStepper} from './QuantityStepper'; + +function renderStepper(props: Partial> = {}) { + const handlers = {onIncrement: vi.fn(), onDecrement: vi.fn()}; + render(); + return handlers; +} + +describe('QuantityStepper', () => { + it('calls onIncrement when the + is clicked', () => { + const {onIncrement} = renderStepper(); + fireEvent.click(screen.getByRole('button', {name: /add one copy of pocahontas/i})); + expect(onIncrement).toHaveBeenCalledTimes(1); + }); + + it('calls onDecrement when the − is clicked', () => { + const {onDecrement} = renderStepper(); + fireEvent.click(screen.getByRole('button', {name: /remove one copy of pocahontas/i})); + expect(onDecrement).toHaveBeenCalledTimes(1); + }); + + it('disables the + and suppresses onIncrement when incrementDisabled', () => { + const {onIncrement} = renderStepper({incrementDisabled: true, disabledReason: 'Maximum 4 copies'}); + const plus = screen.getByRole('button', {name: /maximum 4 copies/i}); + expect(plus).toBeDisabled(); + fireEvent.click(plus); + expect(onIncrement).not.toHaveBeenCalled(); + }); + + it('exposes the disabledReason via the disabled + aria-label', () => { + renderStepper({incrementDisabled: true, disabledReason: 'Maximum 4 copies'}); + expect(screen.getByRole('button', {name: 'Maximum 4 copies'})).toBeInTheDocument(); + }); + + it('renders the current value', () => { + renderStepper({value: 3}); + expect(screen.getByText('3')).toBeInTheDocument(); + }); + + it('uses the label in both step aria-labels', () => { + renderStepper({label: 'Mickey Mouse'}); + expect(screen.getByRole('button', {name: 'Add one copy of Mickey Mouse'})).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Remove one copy of Mickey Mouse'})).toBeInTheDocument(); + }); +}); diff --git a/apps/web/src/features/deck/components/QuantityStepper.tsx b/apps/web/src/features/deck/components/QuantityStepper.tsx new file mode 100644 index 00000000..099ed6ae --- /dev/null +++ b/apps/web/src/features/deck/components/QuantityStepper.tsx @@ -0,0 +1,186 @@ +import {useState, type CSSProperties} from 'react'; +import {COLORS, FONTS, FONT_SIZES} from '../../../shared/constants'; + +interface QuantityStepperProps { + value: number; + onIncrement: () => void; + onDecrement: () => void; + /** Greys + disables the +, and suppresses onIncrement. */ + incrementDisabled?: boolean; + /** title + aria-label on a disabled +. */ + disabledReason?: string; + /** When true the − and + collapse to width 0 (count-only), transitioning open when false. */ + collapsed?: boolean; + /** 'md' = pool tile dims (default), 'sm' = deck row. */ + size?: 'md' | 'sm'; + /** Used in aria-labels: `Add one copy of ${label}` / `Remove one copy of ${label}`. */ + label?: string; + /** CONTROLLED pop: if provided (not undefined), the count pops while this is true. */ + popping?: boolean; + /** Called on the count's onAnimationEnd when controlled. */ + onPopEnd?: () => void; +} + +interface StepperDims { + height: number; + radius: number; + countMinWidth: number; + cellPadding: string; + sideWidth: number; + iconSize: number; +} + +// md reproduces the committed pool pill 1:1; sm is the tighter deck-row variant. +// Both flow from this one table so the two variants can never drift. +const DIMS: Record<'md' | 'sm', StepperDims> = { + md: {height: 32, radius: 16, countMinWidth: 40, cellPadding: '0 8px', sideWidth: 34, iconSize: 16}, + sm: {height: 30, radius: 15, countMinWidth: 30, cellPadding: '0 6px', sideWidth: 28, iconSize: 14}, +}; + +// SVG glyphs (not font characters) so "−"/"+" center exactly in the pill on any +// font — the font-metric baseline was rendering the text glyphs low. +function MinusIcon({size}: {size: number}) { + return ( + + ); +} +function PlusIcon({size}: {size: number}) { + return ( + + ); +} + +function cellStyle(dims: StepperDims): CSSProperties { + // Centered glyph/number cell. `line-height: 1` + flex-centering keeps the "−", + // "+" and digit optically centered in the pill regardless of their font metrics. + return { + minWidth: dims.countMinWidth, + height: '100%', + padding: dims.cellPadding, + border: 'none', + background: 'transparent', + fontFamily: FONTS.body, + fontWeight: 800, + lineHeight: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + }; +} + +// The − / + side buttons collapse to width 0 when `collapsed`; the width +// transition grows the count pill into a full stepper when opened. +function sideButtonStyle(dims: StepperDims, isShown: boolean, disabled: boolean, color: string): CSSProperties { + return { + ...cellStyle(dims), + minWidth: 0, + width: isShown ? dims.sideWidth : 0, + opacity: isShown ? 1 : 0, + padding: 0, + color: disabled ? COLORS.textDim : color, + cursor: disabled ? 'default' : 'pointer', + overflow: 'hidden', + transition: 'width 0.2s ease, opacity 0.18s ease, color 0.15s ease', + }; +} + +function incrementAriaLabel(disabled: boolean, disabledReason: string | undefined, name: string): string { + if (disabled) return disabledReason ?? `Cannot add more ${name}`; + return `Add one copy of ${name}`; +} + +// Owns the pop animation state so the component stays a thin view. Controlled +// (parent passes `popping`/`onPopEnd`) or uncontrolled (internal state); the pop +// is triggered from the click handlers only, never a render-time effect. +function usePopAnimation(popping: boolean | undefined, onPopEnd: (() => void) | undefined) { + const isControlled = popping !== undefined; + const [internalPop, setInternalPop] = useState(false); + const shouldPop = isControlled ? popping : internalPop; + const triggerPop = () => { + if (!isControlled) setInternalPop(true); + }; + const handlePopEnd = () => { + if (isControlled) onPopEnd?.(); + else setInternalPop(false); + }; + return {shouldPop, triggerPop, handlePopEnd}; +} + +/** + * The gold-pill `− count +` quantity stepper shared by the pool tile (variant + * `md`, collapse-on-hover) and the deck row (variant `sm`, always-open). + * + * The pop animation is driven from the click handlers only — never a useEffect + * or a set-state-during-render — so it fires on a real change and stays silent + * when a virtualized tile scrolls back into view. Pass `popping`/`onPopEnd` to + * own the pop from the parent (the pool tile also pops on card-body adds); + * omit them for the uncontrolled internal pop. + */ +export function QuantityStepper({ + value, + onIncrement, + onDecrement, + incrementDisabled = false, + disabledReason, + collapsed = false, + size = 'md', + label, + popping, + onPopEnd, +}: QuantityStepperProps) { + const dims = DIMS[size]; + const shown = !collapsed; + const name = label ?? 'card'; + + const {shouldPop, triggerPop, handlePopEnd} = usePopAnimation(popping, onPopEnd); + + const handleInc = () => { + if (incrementDisabled) return; + triggerPop(); + onIncrement(); + }; + const handleDec = () => { + triggerPop(); + onDecrement(); + }; + + const cell = cellStyle(dims); + + return ( +
+ + + {value} + + +
+ ); +} diff --git a/apps/web/src/features/deck/components/index.ts b/apps/web/src/features/deck/components/index.ts new file mode 100644 index 00000000..137854d8 --- /dev/null +++ b/apps/web/src/features/deck/components/index.ts @@ -0,0 +1,4 @@ +export {PoolCardTile} from './PoolCardTile'; +export {DeckPoolGrid} from './DeckPoolGrid'; +export {DeckCardRow} from './DeckCardRow'; +export {DeckPanel, type DeckRow} from './DeckPanel'; diff --git a/apps/web/src/features/deck/components/poolTileState.test.ts b/apps/web/src/features/deck/components/poolTileState.test.ts new file mode 100644 index 00000000..9d44d2dd --- /dev/null +++ b/apps/web/src/features/deck/components/poolTileState.test.ts @@ -0,0 +1,17 @@ +import {describe, it, expect} from 'vitest'; +import {getPoolTileState} from './poolTileState'; + +describe('getPoolTileState', () => { + it('allows adding below the 4-copy limit', () => { + expect(getPoolTileState(0)).toEqual({addDisabled: false, reason: undefined}); + expect(getPoolTileState(3)).toEqual({addDisabled: false, reason: undefined}); + }); + + it('disables + at the 4-copy limit with the copy message', () => { + expect(getPoolTileState(4)).toEqual({addDisabled: true, reason: 'Max 4 copies'}); + }); + + it('stays disabled beyond the limit', () => { + expect(getPoolTileState(5).addDisabled).toBe(true); + }); +}); diff --git a/apps/web/src/features/deck/components/poolTileState.ts b/apps/web/src/features/deck/components/poolTileState.ts new file mode 100644 index 00000000..702cf617 --- /dev/null +++ b/apps/web/src/features/deck/components/poolTileState.ts @@ -0,0 +1,29 @@ +// The UI guardrail for the deck-builder card pool. The deck STATE layer +// (`useDeck`) is deliberately PERMISSIVE — it will happily add a 5th copy — so +// this pure predicate enforces the one Core hard rule the pool blocks at the +// point of interaction: the 4-copy limit. +// +// The 2-ink limit is NOT blocked here — any ink is freely addable. Going over two +// inks surfaces as a deck legality error (see calculateDeckStats) rather than a +// disabled/dimmed pool tile, so the builder stays freeform + advisory. + +/** Core-format copy limit the pool UI enforces (the state layer does not). */ +const MAX_COPIES = 4; + +export interface PoolTileState { + /** Disable the + affordance: already at 4 copies. */ + addDisabled: boolean; + /** Short label for the disabled + (tooltip + aria), or undefined when addable. */ + reason?: string; +} + +/** + * Decide how a pool card should present given how many copies it already holds. + * (Ink legality is deliberately not enforced here — see the module comment.) + * + * @param inDeckCount how many copies are already in the deck + */ +export function getPoolTileState(inDeckCount: number): PoolTileState { + const addDisabled = inDeckCount >= MAX_COPIES; + return {addDisabled, reason: addDisabled ? 'Max 4 copies' : undefined}; +} diff --git a/apps/web/src/features/deck/components/previewGeometry.test.ts b/apps/web/src/features/deck/components/previewGeometry.test.ts new file mode 100644 index 00000000..22c1280b --- /dev/null +++ b/apps/web/src/features/deck/components/previewGeometry.test.ts @@ -0,0 +1,30 @@ +import {describe, expect, it} from 'vitest'; +import {previewGeometry} from './previewGeometry'; + +const anchor = (left: number) => ({left, top: 300, height: 56}); + +describe('previewGeometry', () => { + it('sizes the preview to the full card width when there is room', () => { + expect(previewGeometry(anchor(1600), 1200)?.width).toBe(340); + }); + + it('never overlaps the row it is anchored to, however narrow the gap', () => { + // A ~768px-wide window: the 500px-min deck pane leaves too little room for 340px. + const geo = previewGeometry(anchor(324), 900); + expect(geo).not.toBeNull(); + expect(geo!.left).toBeGreaterThanOrEqual(12); // clear of the viewport's left edge + expect(geo!.left + geo!.width).toBeLessThanOrEqual(324); // clear of the anchor + }); + + it('never overflows a short viewport', () => { + const viewportHeight = 400; // e.g. devtools docked along the bottom + const geo = previewGeometry(anchor(1600), viewportHeight); + expect(geo).not.toBeNull(); + expect(geo!.top).toBeGreaterThanOrEqual(12); + expect(geo!.top + geo!.height).toBeLessThanOrEqual(viewportHeight - 12); + }); + + it('renders nothing rather than an unreadable sliver when the gap is tiny', () => { + expect(previewGeometry(anchor(100), 900)).toBeNull(); + }); +}); diff --git a/apps/web/src/features/deck/components/previewGeometry.ts b/apps/web/src/features/deck/components/previewGeometry.ts new file mode 100644 index 00000000..06780c88 --- /dev/null +++ b/apps/web/src/features/deck/components/previewGeometry.ts @@ -0,0 +1,52 @@ +import {SPACING} from '../../../shared/constants'; + +/** A Lorcana card scan's width / height. */ +const CARD_ASPECT = 0.716; +/** Wide enough for the card's ability text to be readable at a glance. */ +const PREVIEW_MAX_WIDTH = 340; +/** Breathing room against the viewport edges and the anchoring row. */ +const PREVIEW_MARGIN = 12; +/** Below this the scan is unreadable, so showing nothing beats covering the row. */ +const PREVIEW_MIN_WIDTH = 120; + +export interface PreviewGeometry { + width: number; + height: number; + left: number; + top: number; +} + +/** The hovered row's position, as much of a DOMRect as the maths needs. */ +interface Anchor { + left: number; + top: number; + height: number; +} + +/** + * Where to float the deck panel's hover preview, given the row it points at. + * + * Fits the card into the space it actually has rather than clamping a fixed size + * into a box too small to hold it: `Math.max(floor, Math.min(v, ceiling))` quietly + * yields the floor when ceiling < floor, which is how a fixed 340px card ends up + * overlapping the row it describes (narrow window) or hanging off the bottom of a + * short viewport. Deriving the size from the available gap makes both violations + * unrepresentable. Returns null when there is no room worth rendering into. + */ +export function previewGeometry(anchor: Anchor, viewportHeight: number): PreviewGeometry | null { + const gapToTheLeft = anchor.left - SPACING.md - PREVIEW_MARGIN; + const widthThatFitsVertically = (viewportHeight - PREVIEW_MARGIN * 2) * CARD_ASPECT; + const width = Math.round(Math.min(PREVIEW_MAX_WIDTH, gapToTheLeft, widthThatFitsVertically)); + if (width < PREVIEW_MIN_WIDTH) return null; + + const height = Math.round(width / CARD_ASPECT); + const centered = anchor.top + anchor.height / 2 - height / 2; + return { + width, + height, + // width <= gapToTheLeft, so this never crosses the anchor nor the left edge. + left: anchor.left - SPACING.md - width, + // height <= viewportHeight - 2*margin, so the ceiling is never below the floor. + top: Math.max(PREVIEW_MARGIN, Math.min(centered, viewportHeight - height - PREVIEW_MARGIN)), + }; +} diff --git a/apps/web/src/features/deck/hooks/useDeckPoolFilters.test.ts b/apps/web/src/features/deck/hooks/useDeckPoolFilters.test.ts new file mode 100644 index 00000000..70ac167a --- /dev/null +++ b/apps/web/src/features/deck/hooks/useDeckPoolFilters.test.ts @@ -0,0 +1,111 @@ +import {describe, it, expect} from 'vitest'; +import {renderHook, act} from '@testing-library/react'; +import {useDeckPoolFilters, applyPoolFilters, type PoolFilterState} from './useDeckPoolFilters'; +import {createCard} from '../../../shared/test-utils'; + +describe('useDeckPoolFilters', () => { + it('toggles an ink on and off', () => { + const {result} = renderHook(() => useDeckPoolFilters()); + act(() => result.current.toggleInk('Amber')); + expect(result.current.inkFilters).toEqual(['Amber']); + act(() => result.current.toggleInk('Amber')); + expect(result.current.inkFilters).toEqual([]); + }); + + it('counts active facets across ink, type and cost', () => { + const {result} = renderHook(() => useDeckPoolFilters()); + act(() => { + result.current.toggleInk('Amber'); + result.current.toggleType('Action'); + result.current.toggleCost(3); + }); + expect(result.current.activeFilterCount).toBe(3); + }); + + it('clearAllFilters resets search, facets and count', () => { + const {result} = renderHook(() => useDeckPoolFilters()); + act(() => { + result.current.setSearchQuery('elsa'); + result.current.toggleInk('Amber'); + }); + act(() => result.current.clearAllFilters()); + expect(result.current.searchQuery).toBe(''); + expect(result.current.inkFilters).toEqual([]); + expect(result.current.activeFilterCount).toBe(0); + }); +}); + +describe('applyPoolFilters', () => { + const cards = [ + createCard({id: 'a', name: 'Elsa', fullName: 'Elsa - Snow Queen', ink: 'Sapphire', cost: 5}), + createCard({id: 'b', name: 'Stitch', fullName: 'Stitch - Rock Star', ink: 'Ruby', cost: 3}), + createCard({id: 'c', name: 'Anna', fullName: 'Anna - Heir to Arendelle', ink: 'Amber', cost: 2}), + ]; + const base: PoolFilterState = { + searchQuery: '', + inkFilters: [], + typeFilters: [], + costFilters: [], + filters: {}, + sortOrder: 'newest', + }; + + it('filters by ink', () => { + const out = applyPoolFilters(cards, {...base, inkFilters: ['Ruby']}); + expect(out.map((c) => c.id)).toEqual(['b']); + }); + + describe('dual-ink pool gate', () => { + const monoAmethyst = createCard({id: 'am', ink: 'Amethyst'}); + const monoRuby = createCard({id: 'ru', ink: 'Ruby'}); + const monoEmerald = createCard({id: 'em', ink: 'Emerald'}); + const amethystRuby = createCard({id: 'ar', ink: 'Amethyst', ink2: 'Ruby'}); + const amethystSapphire = createCard({id: 'as', ink: 'Amethyst', ink2: 'Sapphire'}); + const pool = [monoAmethyst, monoRuby, monoEmerald, amethystRuby, amethystSapphire]; + + it('includes a mono card of a selected ink when two inks are chosen', () => { + const out = applyPoolFilters(pool, {...base, inkFilters: ['Amethyst', 'Ruby']}); + expect(out.map((c) => c.id)).toContain('am'); + expect(out.map((c) => c.id)).toContain('ru'); + }); + + it('includes a dual card that fits entirely inside the two selected inks', () => { + const out = applyPoolFilters(pool, {...base, inkFilters: ['Amethyst', 'Ruby']}); + expect(out.map((c) => c.id)).toContain('ar'); + }); + + it('excludes a dual card whose second ink is outside the two selected inks', () => { + const out = applyPoolFilters(pool, {...base, inkFilters: ['Amethyst', 'Ruby']}); + expect(out.map((c) => c.id)).not.toContain('as'); + }); + + it('excludes a mono card of an unselected ink when two inks are chosen', () => { + const out = applyPoolFilters(pool, {...base, inkFilters: ['Amethyst', 'Ruby']}); + expect(out.map((c) => c.id)).not.toContain('em'); + }); + + it('includes a dual card containing the single selected ink (second slot still open)', () => { + const out = applyPoolFilters(pool, {...base, inkFilters: ['Amethyst']}); + expect(out.map((c) => c.id)).toContain('as'); + }); + + it('includes a mono card of the single selected ink', () => { + const out = applyPoolFilters(pool, {...base, inkFilters: ['Amethyst']}); + expect(out.map((c) => c.id)).toContain('am'); + }); + + it('excludes a mono card of another ink when a single ink is chosen', () => { + const out = applyPoolFilters(pool, {...base, inkFilters: ['Amethyst']}); + expect(out.map((c) => c.id)).not.toContain('ru'); + }); + }); + + it('searches by name', () => { + const out = applyPoolFilters(cards, {...base, searchQuery: 'elsa'}); + expect(out.map((c) => c.id)).toEqual(['a']); + }); + + it('returns every card when no filters are set', () => { + expect(applyPoolFilters(cards, base)).toHaveLength(3); + }); +}); diff --git a/apps/web/src/features/deck/hooks/useDeckPoolFilters.ts b/apps/web/src/features/deck/hooks/useDeckPoolFilters.ts new file mode 100644 index 00000000..77a076a8 --- /dev/null +++ b/apps/web/src/features/deck/hooks/useDeckPoolFilters.ts @@ -0,0 +1,124 @@ +// Local (non-URL) filter state for the deck-builder card pool, plus the pure +// filter/sort pipeline it feeds. It mirrors useFilterParams' return shape so +// BrowseToolbar and FilterDialog accept it verbatim — but the state lives in +// component state, never the URL: pool filtering is ephemeral UI and must not +// collide with the (shareable) deck URL. useFilterParams' clearAllFilters wipes +// the WHOLE query string, which is exactly why it can't be reused here. + +import {useState} from 'react'; +import {getInks} from 'inkweave-synergy-engine'; +import type {Ink, LorcanaCard} from 'inkweave-synergy-engine'; +import { + searchCardsByName, + filterCards, + applySortOrder, + type CardFilterOptions, +} from '../../cards/loader'; +import type {CardTypeFilter, BrowseSortOrder} from '../../../shared/constants'; +import type {UseFilterParamsReturn} from '../../../shared/hooks/useFilterParams'; + +/** Toggle an item in an array: remove if present, append if absent. */ +function toggleItem(items: T[], item: T): T[] { + return items.includes(item) ? items.filter((i) => i !== item) : [...items, item]; +} + +export function useDeckPoolFilters(): UseFilterParamsReturn { + const [searchQuery, setSearchQuery] = useState(''); + const [inkFilters, setInkFilters] = useState([]); + const [typeFilters, setTypeFilters] = useState([]); + const [costFilters, setCostFilters] = useState([]); + const [filters, setFilters] = useState({}); + const [sortOrder, setSortOrder] = useState('newest'); + + const activeFilterCount = + inkFilters.length + + typeFilters.length + + costFilters.length + + [filters.keywords?.length, filters.classifications?.length, filters.setCode, filters.inkwell].filter( + Boolean, + ).length; + + return { + searchQuery, + setSearchQuery, + inkFilters, + toggleInk: (ink) => setInkFilters((prev) => toggleItem(prev, ink)), + typeFilters, + toggleType: (type) => setTypeFilters((prev) => toggleItem(prev, type)), + costFilters, + toggleCost: (cost) => setCostFilters((prev) => toggleItem(prev, cost)), + clearCosts: () => setCostFilters([]), + filters, + setFilters, + replaceFilters: (inks, types, costs, opts) => { + setInkFilters(inks); + setTypeFilters(types); + setCostFilters(costs); + setFilters(opts); + }, + clearAllFilters: () => { + setSearchQuery(''); + setInkFilters([]); + setTypeFilters([]); + setCostFilters([]); + setFilters({}); + }, + activeFilterCount, + sortOrder, + setSortOrder, + }; +} + +/** Filter state shape consumed by {@link applyPoolFilters}. */ +export interface PoolFilterState { + searchQuery: string; + inkFilters: Ink[]; + typeFilters: CardTypeFilter[]; + costFilters: number[]; + filters: CardFilterOptions; + sortOrder: BrowseSortOrder; +} + +/** + * Merge the inline facets (type/cost) into the dialog-driven CardFilterOptions. + * Ink is deliberately NOT delegated here — the pool applies its own AND-style ink + * gate in {@link applyPoolFilters}, unlike Browse's shared either-ink matching. + */ +function buildCombinedFilters( + base: CardFilterOptions, + types: CardTypeFilter[], + costs: number[], +): CardFilterOptions { + const combined: CardFilterOptions = {...base}; + if (types.length > 0) combined.type = types; + if (costs.length > 0) combined.costs = costs; + return combined; +} + +/** + * Pure: apply the pool's search + filters + sort to the card list. Same pipeline + * BrowsePage uses, so pool results match Browse exactly. + */ +export function applyPoolFilters(cards: LorcanaCard[], state: PoolFilterState): LorcanaCard[] { + const combined = buildCombinedFilters(state.filters, state.typeFilters, state.costFilters); + let result = cards; + if (state.searchQuery.trim()) result = searchCardsByName(result, state.searchQuery); + if (Object.keys(combined).length > 0) result = filterCards(result, combined); + // Pool ink gate deviates from Browse's shared either-ink OR semantics: a legal + // deck holds exactly two inks, so once BOTH are chosen a dual-ink card must fit + // entirely inside them (an outside ink would be an illegal third). Browse's + // matchesInk can't express this, so gate ink locally rather than delegating it. + if (state.inkFilters.length > 0) { + const selected = state.inkFilters; + result = result.filter((card) => { + const inks = getInks(card); + // Two inks chosen = the deck's inks are decided, so a card must fit ENTIRELY + // inside them (a dual with an outside ink would be a third ink). + // One ink chosen = the second slot is still open, so a dual containing it is legal. + return selected.length >= 2 + ? inks.every((i) => selected.includes(i)) + : inks.some((i) => selected.includes(i)); + }); + } + return applySortOrder(result, state.sortOrder); +} diff --git a/apps/web/src/features/deck/state/DeckContext.test.tsx b/apps/web/src/features/deck/state/DeckContext.test.tsx new file mode 100644 index 00000000..39d1d594 --- /dev/null +++ b/apps/web/src/features/deck/state/DeckContext.test.tsx @@ -0,0 +1,184 @@ +import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest'; +import {renderHook, act} from '@testing-library/react'; +import type {ReactNode} from 'react'; +import {DeckProvider, useDeck} from './DeckContext'; +import {readDraft, writeDraft} from './deckStorage'; +import {createCard} from '../../../shared/test-utils'; +import type {Deck, LorcanaCard} from '../types'; + +// DeckProvider needs `getCardById` + `isLoading`; inject fixtures instead of loading the DB. +// `mockCtl.loading` lets a test simulate the card DB still fetching (getCardById -> undefined). +const fixtureCards = vi.hoisted(() => new Map()); +const mockCtl = vi.hoisted(() => ({loading: false})); +vi.mock('../../../shared/contexts/CardDataContext', () => ({ + useCardDataContext: () => ({ + getCardById: (id: string) => (mockCtl.loading ? undefined : fixtureCards.get(id)), + isLoading: mockCtl.loading, + }), +})); + +// DeckProvider reads useSession for the first-sign-in cloud migrator (#464). These +// tests cover the LOCAL draft, so stub a signed-out session: uid is null, the +// migrator effect returns early, and nothing touches Supabase. +vi.mock('../../../shared/contexts/SessionContext', () => ({ + useSession: () => ({ + user: null, + session: null, + loading: false, + enabled: false, + signIn: vi.fn(), + signOut: vi.fn(), + }), +})); + +function wrapper({children}: {children: ReactNode}) { + return {children}; +} + +const render = () => renderHook(() => useDeck(), {wrapper}); + +beforeEach(() => { + localStorage.clear(); + fixtureCards.clear(); + mockCtl.loading = false; + fixtureCards.set('amber', createCard({id: 'amber', fullName: 'Amber Card', ink: 'Amber'})); + fixtureCards.set('steel', createCard({id: 'steel', fullName: 'Steel Card', ink: 'Steel'})); + // Dual-ink is two typed fields (ink + ink2), not a hyphenated string — that's what getInks reads. + fixtureCards.set( + 'dual', + createCard({id: 'dual', fullName: 'Dual Card', ink: 'Amethyst', ink2: 'Sapphire'}), + ); +}); + +// Both persistence tests share one fake-timer arrange (render, add a card) and one +// assert (the add reached the stored draft). They differ only in what triggers the +// write, so each test passes in just that trigger. +function expectAddPersistsVia(trigger: (rendered: ReturnType) => void) { + vi.useFakeTimers(); + try { + const rendered = render(); + act(() => rendered.result.current.addCard('amber')); + act(() => trigger(rendered)); + expect(readDraft()?.cards).toEqual([{cardId: 'amber', quantity: 1}]); + } finally { + vi.useRealTimers(); + } +} + +describe('DeckContext', () => { + it('addCard creates a line then increments the existing line', () => { + const {result} = render(); + act(() => result.current.addCard('amber')); + act(() => result.current.addCard('amber')); + expect(result.current.deck.cards).toEqual([{cardId: 'amber', quantity: 2}]); + }); + + it('setQuantity sets an exact count and removes the line at 0', () => { + const {result} = render(); + act(() => result.current.setQuantity('amber', 3)); + expect(result.current.deck.cards).toEqual([{cardId: 'amber', quantity: 3}]); + act(() => result.current.setQuantity('amber', 0)); + expect(result.current.deck.cards).toEqual([]); + }); + + it('removeCard drops the line', () => { + const {result} = render(); + act(() => result.current.addCard('amber')); + act(() => result.current.removeCard('amber')); + expect(result.current.deck.cards).toEqual([]); + }); + + it('markCore toggles the isCore flag on a line', () => { + const {result} = render(); + act(() => result.current.addCard('amber')); + act(() => result.current.markCore('amber', true)); + expect(result.current.deck.cards[0].isCore).toBe(true); + }); + + it('setGameplan and renameDeck update deck metadata', () => { + const {result} = render(); + act(() => result.current.setGameplan('ramp')); + act(() => result.current.renameDeck('Ramp Pile')); + expect(result.current.deck.gameplan).toBe('ramp'); + expect(result.current.deck.name).toBe('Ramp Pile'); + }); + + it('clearDeck empties cards but keeps the deck id and name', () => { + const {result} = render(); + const id = result.current.deck.id; + act(() => result.current.renameDeck('Keep Me')); + act(() => result.current.addCard('amber')); + act(() => result.current.clearDeck()); + expect(result.current.deck.cards).toEqual([]); + expect(result.current.deck.id).toBe(id); + expect(result.current.deck.name).toBe('Keep Me'); + }); + + it('derives inks in canonical order, dual-ink counting both', () => { + const {result} = render(); + act(() => result.current.addCard('dual')); // Amethyst + Sapphire + act(() => result.current.addCard('amber')); // Amber + expect(result.current.deck.inks).toEqual(['Amber', 'Amethyst', 'Sapphire']); + }); + + it('persists the draft to localStorage after the debounce window', () => { + // 500ms clears the 400ms debounce, so the scheduled write fires. + expectAddPersistsVia(() => vi.advanceTimersByTime(500)); + }); + + it('restores a persisted draft on mount', () => { + const stored: Deck = { + id: 'saved-1', + name: 'Saved Deck', + cards: [{cardId: 'amber', quantity: 3}], + inks: ['Amber'], + createdAt: 1, + updatedAt: 2, + schemaVersion: 1, + }; + writeDraft(stored); + const {result} = render(); + expect(result.current.deck.id).toBe('saved-1'); + expect(result.current.deck.name).toBe('Saved Deck'); + expect(result.current.deck.cards).toEqual([{cardId: 'amber', quantity: 3}]); + }); + + it('flushes the draft on unmount even inside the debounce window', () => { + // Unmount BEFORE the 400ms debounce fires; the flush-on-unmount path still writes. + expectAddPersistsVia(({unmount}) => unmount()); + }); + + it('self-heals stale stored inks against the card DB on mount', () => { + // Stored inks are wrong for the actual cards; the self-heal effect corrects them. + const stale: Deck = { + id: 'stale-1', + name: 'Stale', + cards: [{cardId: 'dual', quantity: 1}], + inks: ['Amber'], + createdAt: 1, + updatedAt: 2, + schemaVersion: 1, + }; + writeDraft(stale); + const {result} = render(); + expect(result.current.deck.inks).toEqual(['Amethyst', 'Sapphire']); + }); + + it('does not wipe stored inks while the card DB is still loading', () => { + mockCtl.loading = true; // getCardById -> undefined for everything + const loading: Deck = { + id: 'loading-1', + name: 'Loading', + cards: [{cardId: 'amber', quantity: 1}], + inks: ['Amber'], + createdAt: 1, + updatedAt: 2, + schemaVersion: 1, + }; + writeDraft(loading); + const {result} = render(); + expect(result.current.deck.inks).toEqual(['Amber']); + }); +}); + +afterEach(() => vi.clearAllMocks()); diff --git a/apps/web/src/features/deck/state/DeckContext.tsx b/apps/web/src/features/deck/state/DeckContext.tsx new file mode 100644 index 00000000..1de8fe80 --- /dev/null +++ b/apps/web/src/features/deck/state/DeckContext.tsx @@ -0,0 +1,199 @@ +// Working-deck state for the builder (#465): a single active local draft with +// the mutation ops the UI drives, plus debounced localStorage persistence. +// +// Mounted UNDER `CardDataProvider` so it can resolve `cardId -> LorcanaCard` +// (needed to derive the deck's ink set, and later for live analysis). The state +// layer is deliberately PERMISSIVE: it never enforces the Core hard rules +// (<=4 copies / <=2 inks / >=60). `calculateDeckStats` is the single source of +// legality truth; the DeckPanel UI (#468) prevents illegal actions. Keeping the +// caps out of here avoids duplicating that logic in two places. + +import {createContext, useContext, useEffect, useRef, useState, type ReactNode} from 'react'; +import type {Archetype, Deck} from '../types'; +import {useCardDataContext} from '../../../shared/contexts/CardDataContext'; +import {useSession} from '../../../shared/contexts/SessionContext'; +import {hasMigratedDraft, markDraftMigrated, readDraft, writeDraft} from './deckStorage'; +import {upsertDeck} from './deckRepository'; +import { + addCardToDeck, + clearDeckCards, + deriveInks, + inksEqual, + markCardCore, + removeCardFromDeck, + renameDeckName, + setCardQuantity, + setDeckGameplan, +} from './deckMutations'; + +/** Debounce window for persisting the draft — coalesces rapid quantity/rename edits. */ +const DRAFT_WRITE_DEBOUNCE_MS = 400; + +/** A brand-new empty draft. */ +function createEmptyDraft(): Deck { + const now = Date.now(); + return { + id: newDeckId(), + name: 'New Deck', + cards: [], + inks: [], + createdAt: now, + updatedAt: now, + schemaVersion: 1, + }; +} + +/** Prefer the platform UUID; fall back to a timestamp-random id in exotic envs. */ +function newDeckId(): string { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { + return crypto.randomUUID(); + } + return `deck-${Date.now()}-${Math.floor(Math.random() * 1e9)}`; +} + +/** Restore the persisted draft, or start a fresh one. Runs once on mount. */ +function loadOrCreateDraft(): Deck { + return readDraft() ?? createEmptyDraft(); +} + +interface DeckContextValue { + /** The current working draft (with derived `inks`). */ + deck: Deck; + /** Add one copy of a card (new line, or +1 on the existing line). */ + addCard: (cardId: string) => void; + /** Remove a card's line entirely. */ + removeCard: (cardId: string) => void; + /** Set an exact copy count; `<= 0` removes the line. */ + setQuantity: (cardId: string, quantity: number) => void; + /** Flag/unflag a card as a "deck core" anchor for suggestions. */ + markCore: (cardId: string, isCore: boolean) => void; + /** Declare (or clear) the gameplan archetype; overrides auto-detection. */ + setGameplan: (gameplan: Archetype | undefined) => void; + /** Rename the deck. */ + renameDeck: (name: string) => void; + /** Empty the card list, keeping the deck's identity and name. */ + clearDeck: () => void; +} + +const DeckContext = createContext(null); + +/** + * First-sign-in draft->cloud migration (#464, #463 last mile). When a user signs + * in, promote their pre-sign-in anonymous draft into `decks` exactly ONCE so the + * work isn't lost. hasMigratedDraft/markDraftMigrated (localStorage, per-uid) make + * it idempotent across reloads and re-sign-ins; `migratedUid` claims the uid the + * moment it's handled so rapid edits during the in-flight upsert can't re-fire it. + * Only a non-empty pre-sign-in draft migrates: an authed user opening a fresh + * builder never litters `decks` with an empty ghost row. Reads the draft through + * `latestDeck` (synced each render) so the effect depends only on the sign-in + * moment, not every edit. Continuous sync + SaveDeckDialog + /decks list are + * deferred to #473. Fire-and-forget: on failure the local draft still persists. + */ +function useFirstSignInMigration(uid: string | null, isLoading: boolean, latestDeck: {current: Deck}) { + const claimedUid = useRef(null); + // Once a sign-in has been seen and then lost (sign-out), migration is blocked for + // the rest of this page instance: the draft still in memory belongs to the user + // who just left, and must not migrate into the NEXT account signed in on a shared + // browser without a reload. Refs only (set-state-in-effect is banned under the + // React Compiler). The rarer reload-then-different-user path is left to #473's + // saved-deck model, which will clear the draft on sign-out. + const blockedAfterSignOut = useRef(false); + useEffect(() => { + if (!uid) { + if (claimedUid.current !== null) blockedAfterSignOut.current = true; // a real sign-out + return; + } + if (isLoading || blockedAfterSignOut.current) return; // wait for the card DB; never after a sign-out + if (claimedUid.current === uid) return; // already handled this uid this session + claimedUid.current = uid; + if (hasMigratedDraft(uid)) return; // migrated in a prior session + const snapshot = latestDeck.current; + if (snapshot.cards.length === 0) return; // empty pre-sign-in draft: nothing to preserve + void upsertDeck({...snapshot, ownerId: uid}, uid).then(({error}) => { + if (!error) markDraftMigrated(uid); + }); + }, [uid, isLoading, latestDeck]); +} + +export function DeckProvider({children}: {children: ReactNode}) { + const {getCardById, isLoading} = useCardDataContext(); + const {user} = useSession(); + const [deck, setDeck] = useState(loadOrCreateDraft); + + // Inks are DERIVED in render, never stored as authoritative state — so they + // self-correct once the async card DB resolves (getCardById changes identity) and + // after a set-graduation id-renumber, with NO setState-in-effect. While the DB is + // still loading, keep the persisted inks (getCardById can't resolve anything yet) so + // a restored draft isn't transiently blanked. The React Compiler memoizes this, so + // `currentDeck` stays referentially stable across renders. + const inks = isLoading ? deck.inks : deriveInks(deck.cards, getCardById); + const currentDeck: Deck = inksEqual(inks, deck.inks) ? deck : {...deck, inks}; + + // Latest deck for the teardown flush. Synced in an effect — never mutate a ref in render. + const latestDeck = useRef(currentDeck); + useEffect(() => { + latestDeck.current = currentDeck; + }); + + // Persist (debounced). Recomputed from stable inputs so the deps stay referentially + // stable (no per-render churn) yet it re-fires on an edit or once the DB resolves. + useEffect(() => { + const resolved = isLoading ? deck.inks : deriveInks(deck.cards, getCardById); + const toPersist: Deck = inksEqual(resolved, deck.inks) ? deck : {...deck, inks: resolved}; + const handle = setTimeout(() => writeDraft(toPersist), DRAFT_WRITE_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [deck, isLoading, getCardById]); + + // Flush the pending draft on teardown so an edit made inside the debounce window is + // never lost: on unmount (leaving the builder) and on pagehide (tab close / mobile + // background). `writeDraft` is idempotent last-write-wins, so a redundant flush is cheap. + useEffect(() => { + const flush = () => writeDraft(latestDeck.current); + window.addEventListener('pagehide', flush); + return () => { + window.removeEventListener('pagehide', flush); + flush(); + }; + }, []); + + useFirstSignInMigration(user?.id ?? null, isLoading, latestDeck); + + const addCard = (cardId: string) => setDeck((d) => addCardToDeck(d, cardId, getCardById)); + + const removeCard = (cardId: string) => + setDeck((d) => removeCardFromDeck(d, cardId, getCardById)); + + const setQuantity = (cardId: string, quantity: number) => + setDeck((d) => setCardQuantity(d, cardId, quantity, getCardById)); + + const markCore = (cardId: string, isCore: boolean) => + setDeck((d) => markCardCore(d, cardId, isCore)); + + const setGameplan = (gameplan: Archetype | undefined) => + setDeck((d) => setDeckGameplan(d, gameplan)); + + const renameDeck = (name: string) => setDeck((d) => renameDeckName(d, name)); + + const clearDeck = () => setDeck((d) => clearDeckCards(d)); + + const value: DeckContextValue = { + deck: currentDeck, + addCard, + removeCard, + setQuantity, + markCore, + setGameplan, + renameDeck, + clearDeck, + }; + + return {children}; +} + +export function useDeck(): DeckContextValue { + const context = useContext(DeckContext); + if (!context) { + throw new Error('useDeck must be used within a DeckProvider'); + } + return context; +} diff --git a/apps/web/src/features/deck/state/deckMigration.test.tsx b/apps/web/src/features/deck/state/deckMigration.test.tsx new file mode 100644 index 00000000..b11559ab --- /dev/null +++ b/apps/web/src/features/deck/state/deckMigration.test.tsx @@ -0,0 +1,86 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import {render, act} from '@testing-library/react'; +import type {ReactNode} from 'react'; +import {DeckProvider} from './DeckContext'; +import {writeDraft} from './deckStorage'; +import {createCard} from '../../../shared/test-utils'; +import type {Deck, LorcanaCard} from '../types'; + +// Fixture card DB (resolved so inks derive), a mutable session, and a spied cloud +// upsert. The migrator (#464) lives in DeckProvider and reacts to the session uid. +const fixtureCards = vi.hoisted(() => new Map()); +vi.mock('../../../shared/contexts/CardDataContext', () => ({ + useCardDataContext: () => ({getCardById: (id: string) => fixtureCards.get(id), isLoading: false}), +})); + +const sessionCtl = vi.hoisted(() => ({uid: null as string | null})); +vi.mock('../../../shared/contexts/SessionContext', () => ({ + useSession: () => ({ + user: sessionCtl.uid ? {id: sessionCtl.uid} : null, + session: null, + loading: false, + enabled: true, + signIn: vi.fn(), + signOut: vi.fn(), + }), +})); + +const upsertDeck = vi.hoisted(() => vi.fn(() => Promise.resolve({data: null, error: null}))); +vi.mock('./deckRepository', () => ({upsertDeck})); + +function seedDraft() { + const draft: Deck = { + id: 'draft-1', + name: 'Draft', + cards: [{cardId: 'amber', quantity: 2}], + inks: ['Amber'], + createdAt: 1, + updatedAt: 1, + schemaVersion: 1, + }; + writeDraft(draft); +} + +beforeEach(() => { + localStorage.clear(); + fixtureCards.clear(); + fixtureCards.set('amber', createCard({id: 'amber', ink: 'Amber'})); + sessionCtl.uid = null; + upsertDeck.mockClear(); + seedDraft(); +}); + +const wrapper = ({children}: {children: ReactNode}) => {children}; + +describe('first-sign-in draft migration (#464)', () => { + it('migrates the pre-sign-in draft once when a user signs in', () => { + sessionCtl.uid = 'userA'; + render(
, {wrapper}); + expect(upsertDeck).toHaveBeenCalledTimes(1); + expect(upsertDeck).toHaveBeenCalledWith(expect.objectContaining({ownerId: 'userA'}), 'userA'); + }); + + it('does NOT migrate a signed-in user with an empty pre-sign-in draft', () => { + localStorage.clear(); // no draft + sessionCtl.uid = 'userA'; + render(
, {wrapper}); + expect(upsertDeck).not.toHaveBeenCalled(); + }); + + it('does NOT migrate the prior user draft into a second account after sign-out (shared browser)', () => { + sessionCtl.uid = 'userA'; + const {rerender} = render(
, {wrapper}); + expect(upsertDeck).toHaveBeenCalledTimes(1); // A's draft migrated + upsertDeck.mockClear(); + // A signs out, then B signs in on the SAME page instance (no reload). + act(() => { + sessionCtl.uid = null; + rerender(
); + }); + act(() => { + sessionCtl.uid = 'userB'; + rerender(
); + }); + expect(upsertDeck).not.toHaveBeenCalled(); // B must not inherit A's leftover draft + }); +}); diff --git a/apps/web/src/features/deck/state/deckMutations.test.ts b/apps/web/src/features/deck/state/deckMutations.test.ts new file mode 100644 index 00000000..2664b9ac --- /dev/null +++ b/apps/web/src/features/deck/state/deckMutations.test.ts @@ -0,0 +1,121 @@ +import {describe, it, expect} from 'vitest'; +import type {Deck, LorcanaCard} from '../types'; +import {createCard} from '../../../shared/test-utils'; +import { + addCardToDeck, + clearDeckCards, + deriveInks, + inksEqual, + markCardCore, + removeCardFromDeck, + renameDeckName, + setCardQuantity, + setDeckGameplan, +} from './deckMutations'; + +const cards: Record = { + amber: createCard({id: 'amber', ink: 'Amber'}), + steel: createCard({id: 'steel', ink: 'Steel'}), + dual: createCard({id: 'dual', ink: 'Sapphire', ink2: 'Amethyst'}), +}; +const resolve = (id: string): LorcanaCard | undefined => cards[id]; + +function deck(overrides: Partial = {}): Deck { + return { + id: 'd1', + name: 'Test', + cards: [], + inks: [], + createdAt: 1, + updatedAt: 1, + schemaVersion: 1, + ...overrides, + }; +} + +describe('deriveInks', () => { + it('returns a dual-ink card as BOTH inks, in canonical order', () => { + expect(deriveInks([{cardId: 'dual', quantity: 1}], resolve)).toEqual(['Amethyst', 'Sapphire']); + }); + + it('dedupes and orders across multiple cards', () => { + const list = [ + {cardId: 'steel', quantity: 1}, + {cardId: 'amber', quantity: 1}, + {cardId: 'amber', quantity: 1}, + ]; + expect(deriveInks(list, resolve)).toEqual(['Amber', 'Steel']); + }); + + it('skips ids that no longer resolve (rotated out of Core)', () => { + expect(deriveInks([{cardId: 'ghost', quantity: 4}], resolve)).toEqual([]); + }); +}); + +describe('inksEqual', () => { + it('is order-sensitive', () => { + expect(inksEqual(['Amber', 'Steel'], ['Amber', 'Steel'])).toBe(true); + expect(inksEqual(['Amber', 'Steel'], ['Steel', 'Amber'])).toBe(false); + expect(inksEqual(['Amber'], ['Amber', 'Steel'])).toBe(false); + }); +}); + +describe('addCardToDeck', () => { + it('creates a new line then increments the existing one', () => { + const once = addCardToDeck(deck(), 'amber', resolve); + expect(once.cards).toEqual([{cardId: 'amber', quantity: 1}]); + const twice = addCardToDeck(once, 'amber', resolve); + expect(twice.cards).toEqual([{cardId: 'amber', quantity: 2}]); + }); + + it('re-derives inks from the new card list', () => { + expect(addCardToDeck(deck(), 'dual', resolve).inks).toEqual(['Amethyst', 'Sapphire']); + }); +}); + +describe('setCardQuantity', () => { + it('sets an exact count, adding a line when absent', () => { + expect(setCardQuantity(deck(), 'amber', 3, resolve).cards).toEqual([{cardId: 'amber', quantity: 3}]); + }); + + it('removes the line when quantity <= 0', () => { + const d = deck({cards: [{cardId: 'amber', quantity: 2}], inks: ['Amber']}); + const cleared = setCardQuantity(d, 'amber', 0, resolve); + expect(cleared.cards).toEqual([]); + expect(cleared.inks).toEqual([]); + }); +}); + +describe('removeCardFromDeck', () => { + it('drops the line entirely and re-derives inks', () => { + const d = deck({cards: [{cardId: 'amber', quantity: 1}, {cardId: 'steel', quantity: 1}], inks: ['Amber', 'Steel']}); + const result = removeCardFromDeck(d, 'steel', resolve); + expect(result.cards).toEqual([{cardId: 'amber', quantity: 1}]); + expect(result.inks).toEqual(['Amber']); + }); +}); + +describe('markCardCore', () => { + it('flags the matching line without touching inks', () => { + const d = deck({cards: [{cardId: 'amber', quantity: 1}], inks: ['Amber']}); + expect(markCardCore(d, 'amber', true).cards).toEqual([{cardId: 'amber', quantity: 1, isCore: true}]); + }); +}); + +describe('setDeckGameplan / renameDeckName / clearDeckCards', () => { + it('sets the gameplan override', () => { + expect(setDeckGameplan(deck(), 'ramp').gameplan).toBe('ramp'); + }); + + it('renames the deck', () => { + expect(renameDeckName(deck(), 'Amber Aggro').name).toBe('Amber Aggro'); + }); + + it('empties the card list and inks, keeping identity', () => { + const d = deck({id: 'keep', cards: [{cardId: 'amber', quantity: 4}], inks: ['Amber']}); + const cleared = clearDeckCards(d); + expect(cleared.id).toBe('keep'); + expect(cleared.cards).toEqual([]); + expect(cleared.inks).toEqual([]); + }); +}); diff --git a/apps/web/src/features/deck/state/deckMutations.ts b/apps/web/src/features/deck/state/deckMutations.ts new file mode 100644 index 00000000..d4f7b354 --- /dev/null +++ b/apps/web/src/features/deck/state/deckMutations.ts @@ -0,0 +1,93 @@ +// Pure, reducer-style deck transforms for the builder (#465). Each returns a NEW +// Deck; none touches React or storage. Kept beside DeckContext (the poolTileState +// idiom) so the provider stays thin wiring and the branchy edit logic is unit- +// tested in isolation. `getCardById` resolves cardId -> LorcanaCard for ink re- +// derivation; ids that no longer resolve (rotated out of Core) are skipped. + +import {getInks} from 'inkweave-synergy-engine'; +import type {Archetype, Deck, DeckCard, Ink, LorcanaCard} from '../types'; +import {ALL_INKS} from '../../../shared/constants'; + +type CardResolver = (id: string) => LorcanaCard | undefined; + +/** + * Derive the deck's ink set from its cards. A dual-ink card contributes BOTH of + * its inks (the engine's `getInks` returns 1 or 2). Ids that no longer resolve + * (rotated out of Core) are skipped. The result must be DEDUPED and returned in + * canonical {@link ALL_INKS}, the same derivation `deckStats` uses, so the + * builder's ink chips agree with the stats bar. + */ +export function deriveInks(cards: DeckCard[], getCardById: CardResolver): Ink[] { + const inks = new Set(); + for (const {cardId} of cards) { + const card = getCardById(cardId); + if (!card) continue; // rotated out of Core, skip like deckStats + for (const ink of getInks(card)) inks.add(ink); + } + return ALL_INKS.filter((ink) => inks.has(ink)); +} + +/** Order-sensitive ink equality (both lists are already in canonical ALL_INKS order). */ +export function inksEqual(a: readonly Ink[], b: readonly Ink[]): boolean { + return a.length === b.length && a.every((ink, i) => ink === b[i]); +} + +/** Replace the deck's card list, re-deriving inks and stamping `updatedAt`. */ +function withCards(deck: Deck, cards: DeckCard[], getCardById: CardResolver): Deck { + return {...deck, cards, inks: deriveInks(cards, getCardById), updatedAt: Date.now()}; +} + +/** Add one copy of a card (new line, or +1 on the existing line). */ +export function addCardToDeck(deck: Deck, cardId: string, getCardById: CardResolver): Deck { + const existing = deck.cards.find((c) => c.cardId === cardId); + const cards = existing + ? deck.cards.map((c) => (c.cardId === cardId ? {...c, quantity: c.quantity + 1} : c)) + : [...deck.cards, {cardId, quantity: 1}]; + return withCards(deck, cards, getCardById); +} + +/** Remove a card's line entirely. */ +export function removeCardFromDeck(deck: Deck, cardId: string, getCardById: CardResolver): Deck { + return withCards(deck, deck.cards.filter((c) => c.cardId !== cardId), getCardById); +} + +/** Set an exact copy count; `<= 0` removes the line. */ +export function setCardQuantity( + deck: Deck, + cardId: string, + quantity: number, + getCardById: CardResolver, +): Deck { + if (quantity <= 0) { + return withCards(deck, deck.cards.filter((c) => c.cardId !== cardId), getCardById); + } + const has = deck.cards.some((c) => c.cardId === cardId); + const cards = has + ? deck.cards.map((c) => (c.cardId === cardId ? {...c, quantity} : c)) + : [...deck.cards, {cardId, quantity}]; + return withCards(deck, cards, getCardById); +} + +/** Flag/unflag a card as a "deck core" anchor for suggestions. */ +export function markCardCore(deck: Deck, cardId: string, isCore: boolean): Deck { + return { + ...deck, + cards: deck.cards.map((c) => (c.cardId === cardId ? {...c, isCore} : c)), + updatedAt: Date.now(), + }; +} + +/** Declare (or clear) the gameplan archetype; overrides auto-detection. */ +export function setDeckGameplan(deck: Deck, gameplan: Archetype | undefined): Deck { + return {...deck, gameplan, updatedAt: Date.now()}; +} + +/** Rename the deck. */ +export function renameDeckName(deck: Deck, name: string): Deck { + return {...deck, name, updatedAt: Date.now()}; +} + +/** Empty the card list, keeping the deck's identity and name. */ +export function clearDeckCards(deck: Deck): Deck { + return {...deck, cards: [], inks: [], updatedAt: Date.now()}; +} diff --git a/apps/web/src/features/deck/state/deckRepository.test.ts b/apps/web/src/features/deck/state/deckRepository.test.ts new file mode 100644 index 00000000..f2c71533 --- /dev/null +++ b/apps/web/src/features/deck/state/deckRepository.test.ts @@ -0,0 +1,197 @@ +import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest'; +import type {Deck} from '../types'; + +vi.mock('../../../shared/lib/supabase', () => ({getSupabase: vi.fn()})); +import {getSupabase} from '../../../shared/lib/supabase'; +import {listDecks, getDeck, createDeck, updateDeck, deleteDeck, upsertDeck} from './deckRepository'; + +const mockGetSupabase = vi.mocked(getSupabase); + +// Chainable Supabase query stand-in: every non-terminal returns the same object; +// terminals (.maybeSingle, or awaiting the builder for list/delete) resolve to +// `result`. Each vi.fn is inspectable so tests assert the exact calls made. +type QueryResult = {data: unknown; error: {code?: string; message: string} | null}; +function makeQuery(result: QueryResult) { + const q: Record> & {then?: unknown} = {}; + for (const method of ['select', 'insert', 'update', 'delete', 'upsert', 'eq', 'order']) { + q[method] = vi.fn(() => q); + } + q.maybeSingle = vi.fn(() => Promise.resolve(result)); + // Awaiting the builder itself (listDecks after .order, deleteDecks after .eq). + (q as {then: unknown}).then = (res: (v: QueryResult) => unknown) => Promise.resolve(result).then(res); + return q; +} +let currentQuery: ReturnType; +const from = vi.fn(() => currentQuery); +function prime(result: QueryResult) { + currentQuery = makeQuery(result); + return currentQuery; +} + +const ISO_A = '2026-01-01T00:00:00.000Z'; +const ISO_B = '2026-01-02T00:00:00.000Z'; +function makeRow(over: Record = {}) { + return { + id: 'deck-1', + owner_id: 'user-1', + name: 'My Deck', + gameplan: 'ramp', + inks: ['Amber', 'Steel'], + cards: [{cardId: 'c1', quantity: 4, isCore: true}], + is_public: false, + schema_version: 1, + created_at: ISO_A, + updated_at: ISO_B, + ...over, + }; +} +function makeDeck(over: Partial = {}): Deck { + return { + id: 'deck-1', + name: 'My Deck', + cards: [{cardId: 'c1', quantity: 4, isCore: true}], + gameplan: 'ramp', + inks: ['Amber', 'Steel'], + isPublic: false, + ownerId: 'user-1', + createdAt: Date.parse(ISO_A), + updatedAt: Date.parse(ISO_B), + schemaVersion: 1, + ...over, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + currentQuery = makeQuery({data: null, error: null}); + mockGetSupabase.mockReturnValue({from} as never); +}); +afterEach(() => vi.restoreAllMocks()); + +describe('getDeck (row -> domain mapping)', () => { + it('reads one row via eq(id) + maybeSingle and maps snake_case / jsonb / ISO timestamps', async () => { + const q = prime({data: makeRow(), error: null}); + const {data} = await getDeck('deck-1'); + expect(from).toHaveBeenCalledWith('decks'); + expect(q.eq).toHaveBeenCalledWith('id', 'deck-1'); + expect(q.maybeSingle).toHaveBeenCalled(); + expect(data).toEqual(makeDeck()); + }); + + it('maps a null gameplan to undefined and a non-array cards value to []', async () => { + prime({data: makeRow({gameplan: null, cards: null}), error: null}); + const {data} = await getDeck('deck-1'); + expect(data?.gameplan).toBeUndefined(); + expect(data?.cards).toEqual([]); + }); + + it('drops an unknown gameplan and filters invalid inks defensively', async () => { + prime({data: makeRow({gameplan: 'bogus', inks: ['Amber', 'Rainbow', 42]}), error: null}); + const {data} = await getDeck('deck-1'); + expect(data?.gameplan).toBeUndefined(); + expect(data?.inks).toEqual(['Amber']); + }); + + it('returns null data when the row is absent or RLS-hidden (maybeSingle -> null)', async () => { + prime({data: null, error: null}); + expect((await getDeck('missing')).data).toBeNull(); + }); +}); + +describe('listDecks', () => { + it('scopes to the owner, orders by updated_at desc, and maps every row', async () => { + const q = prime({data: [makeRow({id: 'a'}), makeRow({id: 'b'})], error: null}); + const {data} = await listDecks('user-1'); + expect(q.eq).toHaveBeenCalledWith('owner_id', 'user-1'); + expect(q.order).toHaveBeenCalledWith('updated_at', {ascending: false}); + expect(data?.map((d) => d.id)).toEqual(['a', 'b']); + }); + + it('returns null data and logs on a query error', async () => { + const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); + prime({data: null, error: {code: '42P01', message: 'no relation'}}); + const {data, error} = await listDecks('user-1'); + expect(data).toBeNull(); + expect(error).toBe('no relation'); + expect(spy).toHaveBeenCalled(); + }); +}); + +describe('createDeck (domain -> insert payload)', () => { + it('inserts the mapped columns with an explicit owner_id and reads the row back', async () => { + const q = prime({data: makeRow({id: 'new'}), error: null}); + const {data} = await createDeck(makeDeck({isPublic: false}), 'user-1'); + expect(q.insert).toHaveBeenCalledWith( + expect.objectContaining({ + owner_id: 'user-1', + name: 'My Deck', + gameplan: 'ramp', + inks: ['Amber', 'Steel'], + is_public: false, + }), + ); + expect(q.maybeSingle).toHaveBeenCalled(); + expect(data?.id).toBe('new'); + }); +}); + +describe('updateDeck (writable columns only)', () => { + it('updates the mapped patch, scopes by eq(id), reads back via maybeSingle', async () => { + const q = prime({data: makeRow({name: 'Renamed'}), error: null}); + const {data} = await updateDeck(makeDeck({name: 'Renamed'})); + const patch = q.update.mock.calls[0][0] as Record; + expect(patch.name).toBe('Renamed'); + expect(patch).not.toHaveProperty('id'); // id / owner_id / created_at are immutable + expect(patch).not.toHaveProperty('owner_id'); + expect(q.eq).toHaveBeenCalledWith('id', 'deck-1'); + expect(data?.name).toBe('Renamed'); + }); + + it('returns null data when RLS returns no row (not the owner)', async () => { + prime({data: null, error: null}); + expect((await updateDeck(makeDeck())).data).toBeNull(); + }); +}); + +describe('upsertDeck', () => { + it('upserts on the id conflict target with the owner_id set', async () => { + const q = prime({data: makeRow(), error: null}); + await upsertDeck(makeDeck(), 'user-1'); + expect(q.upsert).toHaveBeenCalledWith( + expect.objectContaining({id: 'deck-1', owner_id: 'user-1'}), + {onConflict: 'id'}, + ); + }); +}); + +describe('deleteDeck', () => { + it('issues delete().eq(id) and reports no error on success', async () => { + const q = prime({data: null, error: null}); + const {error} = await deleteDeck('deck-1'); + expect(q.delete).toHaveBeenCalled(); + expect(q.eq).toHaveBeenCalledWith('id', 'deck-1'); + expect(error).toBeNull(); + }); + + it('surfaces the error message and logs on failure', async () => { + vi.spyOn(console, 'error').mockImplementation(() => {}); + prime({data: null, error: {code: '42501', message: 'denied'}}); + expect((await deleteDeck('deck-1')).error).toBe('denied'); + }); +}); + +describe('env gate (Supabase not configured)', () => { + beforeEach(() => mockGetSupabase.mockReturnValue(null)); + + it('getDeck returns the not-configured result and never touches the client', async () => { + const {data, error} = await getDeck('deck-1'); + expect(data).toBeNull(); + expect(error).toBe('Supabase not configured'); + expect(from).not.toHaveBeenCalled(); + }); + + it('createDeck no-ops without the client', async () => { + expect((await createDeck(makeDeck(), 'user-1')).error).toBe('Supabase not configured'); + expect(from).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/features/deck/state/deckRepository.ts b/apps/web/src/features/deck/state/deckRepository.ts new file mode 100644 index 00000000..a5d4e64b --- /dev/null +++ b/apps/web/src/features/deck/state/deckRepository.ts @@ -0,0 +1,195 @@ +// Cloud persistence for saved decks (#464), the Supabase-backed sibling of +// deckStorage.ts's local drafts. Mirrors the voting layer's conventions in +// shared/lib/supabase.ts: every call is env-gated (getSupabase() may be null), +// wrapped in try/catch so a network failure degrades instead of throwing, and +// uses .maybeSingle() for optional single-row reads so a row hidden by RLS or one +// that does not exist yet returns {data:null} instead of the PGRST116 error the +// Sentry Supabase integration reports as unhandled (see #442, getPairScore). +// +// Owner scoping is enforced by RLS in the database, not here: reads return only +// the caller's own rows (plus is_public rows for getDeck), and writes are rejected +// unless owner_id = auth.uid(). This module still sets owner_id explicitly on +// writes so the WITH CHECK passes and the domain<->row mapping stays unit-testable. + +import type {Database, Json} from '../../../shared/lib/database.types'; +import {getSupabase} from '../../../shared/lib/supabase'; +import type {Archetype, Deck, DeckCard, Ink} from '../types'; + +// These aliases resolve only AFTER the decks migration lands and database.types.ts +// is regenerated (#464). Until then this import errors, the intended build-time +// coupling between the schema and this repository. +type DeckRow = Database['public']['Tables']['decks']['Row']; +type DeckInsert = Database['public']['Tables']['decks']['Insert']; +type DecksClient = NonNullable>; + +/** Uniform read/write result. `data` is null on any failure or empty read. */ +export interface RepoResult { + data: T | null; + error: string | null; +} + +const NOT_CONFIGURED = 'Supabase not configured'; +const SCHEMA_VERSION = 1 as const; + +const VALID_ARCHETYPES = new Set(['aggro', 'tempo', 'midrange', 'control', 'combo', 'ramp']); +const VALID_INKS = new Set(['Amber', 'Amethyst', 'Emerald', 'Ruby', 'Sapphire', 'Steel']); + +/** + * The shared shell every CRUD call runs inside: env-gate the client, await the + * caller's already-mapped query, and normalize errors to a {@link RepoResult}. + * `op` returns domain data (mapping happens inside it) so this stays generic and + * the six operations below carry no duplicated null-check / try-catch / logging. + */ +async function run( + op: (client: DecksClient) => PromiseLike<{data: T | null; error: {message: string} | null}>, +): Promise> { + const supabase = getSupabase(); + if (!supabase) return {data: null, error: NOT_CONFIGURED}; + try { + const {data, error} = await op(supabase); + if (error) { + console.error('[deckRepository] query failed:', error.message); + return {data: null, error: error.message}; + } + return {data, error: null}; + } catch (e) { + console.error('[deckRepository] network error:', e); + return {data: null, error: 'Network error'}; + } +} + +// ------------------------------------------------------------------- +// Row <-> domain mapping (defensive: never trusts jsonb / text[] blindly) +// ------------------------------------------------------------------- + +function parseInks(value: unknown): Ink[] { + if (!Array.isArray(value)) return []; + return value.filter((ink): ink is Ink => typeof ink === 'string' && VALID_INKS.has(ink as Ink)); +} + +function parseCards(value: Json): DeckCard[] { + if (!Array.isArray(value)) return []; + const cards: DeckCard[] = []; + for (const entry of value) { + if (typeof entry !== 'object' || entry === null) continue; + const c = entry as Record; + if (typeof c.cardId !== 'string' || typeof c.quantity !== 'number') continue; + const card: DeckCard = {cardId: c.cardId, quantity: c.quantity}; + if (typeof c.isCore === 'boolean') card.isCore = c.isCore; + cards.push(card); + } + return cards; +} + +/** Supabase row -> domain Deck. */ +function rowToDeck(row: DeckRow): Deck { + // gameplan is a loose text column; keep it only when it's a known archetype. + const gameplan = + row.gameplan !== null && VALID_ARCHETYPES.has(row.gameplan as Archetype) + ? (row.gameplan as Archetype) + : undefined; + // timestamptz strings -> epoch millis, matching the Deck domain's numeric clocks. + const created = Date.parse(row.created_at); + const updated = Date.parse(row.updated_at); + return { + id: row.id, + name: row.name, + cards: parseCards(row.cards), + gameplan, + inks: parseInks(row.inks), + isPublic: row.is_public, + ownerId: row.owner_id, + createdAt: Number.isNaN(created) ? Date.now() : created, + updatedAt: Number.isNaN(updated) ? Date.now() : updated, + schemaVersion: SCHEMA_VERSION, + }; +} + +/** The columns a client may freely rewrite on every save (no id / owner / clocks). */ +function writableColumns(deck: Deck) { + return { + name: deck.name, + gameplan: deck.gameplan ?? null, + inks: deck.inks, + cards: deck.cards as unknown as Json, + is_public: deck.isPublic ?? false, + }; +} + +/** + * Full column payload for insert/upsert, minus owner_id (the caller sets that from + * the authenticated id so the RLS WITH CHECK passes). created_at is carried from + * the draft so an aged local draft keeps its age; updated_at is left to the column + * default plus the touch trigger. + */ +function deckToInsert(deck: Deck): Omit { + return {id: deck.id, ...writableColumns(deck), created_at: new Date(deck.createdAt).toISOString()}; +} + +// ------------------------------------------------------------------- +// CRUD (each is a single mapped query inside the shared `run` shell) +// ------------------------------------------------------------------- + +/** All of the caller's own decks, newest-touched first. RLS scopes to owner. */ +export function listDecks(ownerId: string): Promise> { + return run(async (c) => { + const {data, error} = await c + .from('decks') + .select('*') + .eq('owner_id', ownerId) + .order('updated_at', {ascending: false}); + return {data: data ? data.map(rowToDeck) : null, error}; + }); +} + +/** One deck by id. RLS returns it when it is the caller's own or is_public. */ +export function getDeck(id: string): Promise> { + return run(async (c) => { + const {data, error} = await c.from('decks').select('*').eq('id', id).maybeSingle(); + return {data: data ? rowToDeck(data) : null, error}; + }); +} + +/** + * Run a single-row write (insert / update / upsert) that already appends + * `.select('*').maybeSingle()`, and map the returned row to a domain Deck. The + * three write ops differ only in `build`, so their env-gate + await + map shell + * lives here once. + */ +function writeOne( + build: (client: DecksClient) => PromiseLike<{data: DeckRow | null; error: {message: string} | null}>, +): Promise> { + return run(async (c) => { + const {data, error} = await build(c); + return {data: data ? rowToDeck(data) : null, error}; + }); +} + +export function createDeck(deck: Deck, ownerId: string): Promise> { + return writeOne((c) => c.from('decks').insert({...deckToInsert(deck), owner_id: ownerId}).select('*').maybeSingle()); +} + +/** Update the writable columns; id / owner_id / created_at stay immutable. */ +export function updateDeck(deck: Deck): Promise> { + return writeOne((c) => c.from('decks').update(writableColumns(deck)).eq('id', deck.id).select('*').maybeSingle()); +} + +export function deleteDeck(id: string): Promise> { + return run(async (c) => { + const {error} = await c.from('decks').delete().eq('id', id); + return {data: null, error}; + }); +} + +/** + * Insert-or-update by primary key. The workhorse for the first-sign-in draft + * migration: idempotent on `deck.id`, so replaying it never creates a duplicate. + * RLS still blocks writing a row owned by anyone else. PostgREST upsert compiles + * to INSERT ... ON CONFLICT DO UPDATE, so the decks table defines BOTH an + * owner-scoped INSERT policy (WITH CHECK) and an owner-scoped UPDATE policy. + */ +export function upsertDeck(deck: Deck, ownerId: string): Promise> { + return writeOne((c) => + c.from('decks').upsert({...deckToInsert(deck), owner_id: ownerId}, {onConflict: 'id'}).select('*').maybeSingle(), + ); +} diff --git a/apps/web/src/features/deck/state/deckStorage.test.ts b/apps/web/src/features/deck/state/deckStorage.test.ts new file mode 100644 index 00000000..9ad7d432 --- /dev/null +++ b/apps/web/src/features/deck/state/deckStorage.test.ts @@ -0,0 +1,65 @@ +import {describe, it, expect, beforeEach} from 'vitest'; +import { + DRAFT_KEY, + readDraft, + writeDraft, + clearDraft, + hasMigratedDraft, + markDraftMigrated, +} from './deckStorage'; +import type {Deck} from '../types'; + +function makeDeck(over: Partial = {}): Deck { + return { + id: 'deck-1', + name: 'My Deck', + cards: [{cardId: 'c1', quantity: 4}], + inks: ['Amber'], + createdAt: 1000, + updatedAt: 2000, + schemaVersion: 1, + ...over, + }; +} + +describe('deckStorage', () => { + beforeEach(() => localStorage.clear()); + + it('round-trips a draft under the namespaced key', () => { + const deck = makeDeck(); + writeDraft(deck); + expect(localStorage.getItem(DRAFT_KEY)).not.toBeNull(); + expect(readDraft()).toEqual(deck); + }); + + it('returns null when no draft is stored', () => { + expect(readDraft()).toBeNull(); + }); + + it('clears the key and returns null on corrupt JSON', () => { + localStorage.setItem(DRAFT_KEY, '{not valid json'); + expect(readDraft()).toBeNull(); + expect(localStorage.getItem(DRAFT_KEY)).toBeNull(); + }); + + it('clears the key and returns null on a schemaVersion mismatch', () => { + localStorage.setItem(DRAFT_KEY, JSON.stringify({...makeDeck(), schemaVersion: 99})); + expect(readDraft()).toBeNull(); + expect(localStorage.getItem(DRAFT_KEY)).toBeNull(); + }); + + it('clearDraft removes the key', () => { + writeDraft(makeDeck()); + clearDraft(); + expect(localStorage.getItem(DRAFT_KEY)).toBeNull(); + }); + + it('migration guard is idempotent per user and independent across users', () => { + expect(hasMigratedDraft('uid-a')).toBe(false); + markDraftMigrated('uid-a'); + expect(hasMigratedDraft('uid-a')).toBe(true); + markDraftMigrated('uid-a'); // idempotent — marking again stays true + expect(hasMigratedDraft('uid-a')).toBe(true); + expect(hasMigratedDraft('uid-b')).toBe(false); // other users unaffected + }); +}); diff --git a/apps/web/src/features/deck/state/deckStorage.ts b/apps/web/src/features/deck/state/deckStorage.ts new file mode 100644 index 00000000..a0c3ac21 --- /dev/null +++ b/apps/web/src/features/deck/state/deckStorage.ts @@ -0,0 +1,106 @@ +// localStorage persistence for the working deck draft (#465), cloned from the +// voting feature's `voteStorage.ts` pattern: every access is wrapped so a denied +// or corrupt store can never wedge the builder — a bad entry silently clears and +// the caller falls back to a fresh draft. +// +// SCOPE: local drafts only. The debounced Supabase *sync* and the actual +// draft->cloud *copy* (issue #465 parts b/c) wait on auth (#463) + the decks +// repository (#464). The migration GUARD below is pure localStorage, so it ships +// now — the first-sign-in flow that consumes it lands with the repo. + +import type {Deck} from '../types'; + +/** Single active local draft. The `*` in the issue's `inkweave:deck:*` namespace + * is realized as this draft key plus the per-user migration-guard keys below. */ +export const DRAFT_KEY = 'inkweave:deck:draft'; +const MIGRATED_PREFIX = 'inkweave:deck:migrated'; + +/** Persisted-shape version; a stored draft whose `schemaVersion` differs is + * discarded rather than trusted (no upgrade path defined yet). */ +const DRAFT_SCHEMA_VERSION = 1; + +function safeRead(key: string): string | null { + try { + return localStorage.getItem(key); + } catch (e) { + console.warn('[deckStorage] localStorage read denied:', e); + return null; + } +} + +function safeWrite(key: string, value: string): void { + try { + localStorage.setItem(key, value); + } catch (e) { + console.error('[deckStorage] localStorage write failed:', e); + } +} + +function safeRemove(key: string): void { + try { + localStorage.removeItem(key); + } catch (e) { + console.warn('[deckStorage] localStorage cleanup failed:', e); + } +} + +/** True when `value` looks like a current-schema {@link Deck}. Keeps the parse in + * {@link readDraft} honest without pulling in a schema library. */ +function isDraftShape(value: unknown): value is Deck { + if (typeof value !== 'object' || value === null) return false; + const d = value as Partial; + return ( + d.schemaVersion === DRAFT_SCHEMA_VERSION && + typeof d.id === 'string' && + typeof d.name === 'string' && + Array.isArray(d.cards) + ); +} + +/** + * Read the stored draft, or `null` if none. A corrupt entry (bad JSON, wrong + * shape, or a stale `schemaVersion`) is removed and treated as absent so the + * builder starts clean instead of throwing. + */ +export function readDraft(): Deck | null { + const raw = safeRead(DRAFT_KEY); + if (raw === null) return null; + try { + const parsed = JSON.parse(raw) as unknown; + if (isDraftShape(parsed)) return parsed; + console.error('[deckStorage] Unexpected draft shape, clearing:', parsed); + } catch (e) { + console.error('[deckStorage] Corrupted draft JSON, clearing key:', DRAFT_KEY, e); + } + safeRemove(DRAFT_KEY); + return null; +} + +/** Persist the working draft (last write wins). */ +export function writeDraft(deck: Deck): void { + safeWrite(DRAFT_KEY, JSON.stringify(deck)); +} + +/** Drop the working draft entirely (e.g. after it migrates to the cloud). */ +export function clearDraft(): void { + safeRemove(DRAFT_KEY); +} + +// ── First-sign-in draft->cloud migration guard ── +// The migration COPY (draft -> Supabase `decks`) is deferred with the repository +// (#464); these idempotency helpers are auth-independent and ship now so that +// flow is a one-line addition once the repo exists. + +function migratedKey(uid: string): string { + return `${MIGRATED_PREFIX}:${uid}`; +} + +/** Has this user's local draft already been migrated to the cloud? */ +export function hasMigratedDraft(uid: string): boolean { + return safeRead(migratedKey(uid)) === '1'; +} + +/** Mark this user's draft migrated so a repeat sign-in doesn't re-copy it. */ +export function markDraftMigrated(uid: string): void { + safeWrite(migratedKey(uid), '1'); +} diff --git a/apps/web/src/features/deck/state/index.ts b/apps/web/src/features/deck/state/index.ts new file mode 100644 index 00000000..d4c2962b --- /dev/null +++ b/apps/web/src/features/deck/state/index.ts @@ -0,0 +1,20 @@ +// Public surface of the deck-builder state layer (#465). +export {DeckProvider, useDeck} from './DeckContext'; +export { + readDraft, + writeDraft, + clearDraft, + hasMigratedDraft, + markDraftMigrated, + DRAFT_KEY, +} from './deckStorage'; +// Cloud persistence (#464). +export { + listDecks, + getDeck, + createDeck, + updateDeck, + deleteDeck, + upsertDeck, + type RepoResult, +} from './deckRepository'; diff --git a/apps/web/src/features/deck/types.ts b/apps/web/src/features/deck/types.ts new file mode 100644 index 00000000..6d732559 --- /dev/null +++ b/apps/web/src/features/deck/types.ts @@ -0,0 +1,238 @@ +// Type contract for the deck builder + live advisor (Phase 0 of the deck-builder plan). +// +// This file is TYPES-ONLY. Runtime logic lives elsewhere: +// - `calculateDeckStats` (#459) in `analysis/deckStats.ts` +// - the composite quality score (#462) in `analysis/score.ts` +// +// Cards are ALWAYS referenced by `cardId: string`; a `LorcanaCard` is never embedded. +// See `docs/deck-builder/PLAN.md` Part A (deck-health framework) + Part C (score/case model). + +import type {LorcanaCard, Ink, CardType, PlaystyleId} from 'inkweave-synergy-engine'; + +// Re-export the engine card-domain types so downstream deck-analysis modules +// (#459 deckStats, #462 score, and later analyzers) resolve cards / inks / +// playstyles from this single contract file instead of reaching into the engine. +export type {LorcanaCard, Ink, CardType, PlaystyleId}; + +// --------------------------------------------------------------------------- +// Enumerations +// --------------------------------------------------------------------------- + +/** + * Deck strategy archetype. Auto-detected by the classifier (curve centroid + + * role densities + avg lore/cost) and overridable via `Deck.gameplan`. + */ +export type Archetype = 'aggro' | 'tempo' | 'midrange' | 'control' | 'combo' | 'ramp'; + +/** Traffic-light health verdict used by analyzers and status badges. */ +export type DeckStatus = 'good' | 'warn' | 'bad'; + +/** How sharply a vulnerability can blow the deck out. */ +export type VulnerabilitySeverity = 'low' | 'medium' | 'high'; + +// --------------------------------------------------------------------------- +// Deck entity +// --------------------------------------------------------------------------- + +/** A single line in a deck list: which card, how many copies, and whether it anchors the plan. */ +export interface DeckCard { + /** Engine card id (`LorcanaCard.id`). */ + cardId: string; + /** Number of copies in the deck (Core rules cap this at 4 per unique `fullName`). */ + quantity: number; + /** Marks a "deck core" card that seeds and up-weights synergy suggestions. */ + isCore?: boolean; +} + +/** + * A saved (or draft) deck. `schemaVersion` pins the localStorage/JSONB shape so + * future migrations can detect and upgrade older drafts. + */ +export interface Deck { + id: string; + name: string; + cards: DeckCard[]; + /** Optional user-declared gameplan; when set it overrides the auto-detected archetype. */ + gameplan?: Archetype; + /** Derived from the deck's cards; Core format allows at most 2 (dual-ink counts as both). */ + inks: Ink[]; + /** Cloud decks default to private; only relevant once persisted to Supabase. */ + isPublic?: boolean; + /** Supabase `auth.users` id of the owner; null/undefined for anonymous local drafts. */ + ownerId?: string | null; + createdAt: number; + updatedAt: number; + /** Persisted-shape version. Bump when the `Deck`/`DeckCard` shape changes. */ + schemaVersion: 1; +} + +// --------------------------------------------------------------------------- +// Composition statistics (computed by #459 `calculateDeckStats`) +// --------------------------------------------------------------------------- + +/** Pure compositional stats derived from a deck's cards. No opinions, just counts. */ +export interface DeckStats { + /** Sum of every `DeckCard.quantity`. */ + totalCards: number; + /** Number of distinct `cardId`s in the deck. */ + uniqueCards: number; + /** Copies per ink; dual-ink cards contribute to both inks. */ + inkDistribution: Partial>; + /** Mana-cost histogram: cost -> copy count. Costs >= 7 are bucketed under key `7`. */ + costCurve: Record; + /** Copies per card type (Character / Action / Item / Location). */ + typeDistribution: Partial>; + /** Number of distinct inks in the deck (<= 2 when legal). */ + inkCount: number; + /** Copies that can be put into the inkwell (`inkwell === true`). */ + inkableCount: number; + /** True when the deck satisfies every Tier-1 hard rule (size / copies / inks). */ + isLegal: boolean; + /** Human-readable reasons the deck fails legality; empty when `isLegal`. */ + legalityErrors: string[]; + /** Non-blocking notes that don't affect legality (e.g. cardIds that no longer resolve — likely rotated out of Core). */ + warnings?: string[]; +} + +// --------------------------------------------------------------------------- +// Deck health (Tier-2 heuristics + Tier-3 vulnerabilities) +// --------------------------------------------------------------------------- + +/** One Tier-2 soft-heuristic dimension (curve, removal, draw, ...) with its verdict. */ +export interface HealthAnalyzer { + /** Stable analyzer id, e.g. `'curve'`, `'removal'`, `'inkable-ratio'`. */ + id: string; + /** Display label, e.g. `'Removal'`. */ + label: string; + /** Normalized health for this dimension, 0..100. */ + score: number; + status: DeckStatus; + /** One-line explanation of the verdict. */ + message: string; + /** Optional raw measured value (e.g. removal-card count) behind the score. */ + value?: number; +} + +/** + * A "what to watch for" weakness derived from the live Core pool (not a meta snapshot). + * `conditionType` mirrors the engine's future `RemovalCondition` id but is typed as a + * loose `string` to avoid a cross-package type cycle before `getRemovalRoles` lands (#460). + */ +export interface Vulnerability { + /** Stable vulnerability id, e.g. `'low-strength'`. */ + id: string; + /** Display label, e.g. `'Low-strength board'`. */ + label: string; + /** + * Removal-condition id this vulnerability keys on (e.g. `'low-strength'`, + * `'evasive'`, `'bodyguard'`, `'damaged'`, `'high-cost'`, `'mass'`). + */ + conditionType: string; + severity: VulnerabilitySeverity; + /** Share of your board exposed to this condition, 0..100. */ + exposurePct?: number; + /** One-line explanation, e.g. "68% of your characters have <= 2 strength...". */ + message: string; + /** Example answer cards from the pool that punish this condition. */ + hoserCardIds?: string[]; +} + +/** Aggregate deck-health report: overall grade, archetype, per-dimension analyzers, vulnerabilities. */ +export interface DeckHealth { + /** Composite health, 0..100. */ + overall: number; + archetype: Archetype; + /** Classifier confidence in `archetype`, 0..1. */ + archetypeConfidence: number; + analyzers: HealthAnalyzer[]; + vulnerabilities: Vulnerability[]; +} + +// --------------------------------------------------------------------------- +// Live suggestions +// --------------------------------------------------------------------------- + +/** A ranked add-this-card suggestion from the live advisor. */ +export interface Suggestion { + /** Engine card id of the suggested card. */ + cardId: string; + /** Aggregate synergy strength with the current deck (core cards weighted higher). */ + synergyScore: number; + /** How much this card fills a compositional/vulnerability gap. */ + gapScore: number; + /** Combined ranking score (`synergyScore` + weighted `gapScore`). */ + totalScore: number; + /** Card ids in the deck this card synergizes with (drives the explanation). */ + synergizesWith: string[]; + /** Human-readable reasons the card is suggested. */ + reasons: string[]; +} + +// --------------------------------------------------------------------------- +// Deck Quality Score (computed by #462 `score.ts`) +// --------------------------------------------------------------------------- + +/** One weighted term in the composite quality score, kept for a glass-box breakdown. */ +export interface ScoreContribution { + /** Scored dimension id (mirrors a `HealthAnalyzer.id` or coherence/penalty term). */ + dimension: string; + /** This dimension's weight from the versioned scoring config. */ + weight: number; + /** Normalized dimension performance, 0..1. */ + dimensionScore: number; + /** Points this dimension contributes to the final score (`weight * dimensionScore`). */ + contribution: number; + /** Why this dimension scored the way it did. */ + reason: string; +} + +/** The composite Deck Quality Score plus its full arithmetic breakdown and config stamp. */ +export interface QualityScore { + /** Final Deck Quality Score, 0..100. */ + score: number; + /** Per-dimension contributions that sum (with coherence/penalty) into `score`. */ + breakdown: ScoreContribution[]; + /** Version of the scoring config that produced this score (reproducibility stamp). */ + configVersion: string; +} + +// --------------------------------------------------------------------------- +// Human-in-the-loop calibration (Phase 3) +// --------------------------------------------------------------------------- + +/** Compact, comparable signature of a deck used for nearest-case retrieval. */ +export interface DeckFingerprint { + archetype: Archetype; + /** Bucketed dimension scores (dimension id -> coarse bucket), for distance calc. */ + bucketedScores: Record; + /** Dominant playstyles present in the deck (`_playstyles.json` intersected with the deck). */ + playstyles: PlaystyleId[]; + /** Rule ids of the top key-card synergies (the deck's "shape" signature). */ + synergySignature: string[]; + inks: Ink[]; + /** Mean weighted mana cost (curve center of mass). */ + curveCentroid: number; +} + +/** + * A stored, human-labeled analysis (the calibration "memory"). Captures the analyzer's + * output plus the creator's ground-truth score, per-dimension corrections, and notes. + */ +export interface AnalysisCase { + id: string; + ownerId?: string | null; + fingerprint: DeckFingerprint; + /** Analyzer's per-dimension scores at capture time (dimension id -> score). */ + dimensionScores: Record; + /** Analyzer's composite score at capture time. */ + analyzerScore: number; + /** Creator's ground-truth score, if entered. */ + humanScore?: number; + /** Per-dimension right/wrong verdicts with optional notes, keyed by dimension id. */ + corrections?: Record; + /** Free-form reviewer notes, replayed as advice on similar future decks. */ + notes?: string; + /** Scoring config version in effect when this case was captured. */ + configVersion: string; + createdAt: number; +} diff --git a/apps/web/src/index.css b/apps/web/src/index.css index 1ec490d5..981de72c 100644 --- a/apps/web/src/index.css +++ b/apps/web/src/index.css @@ -46,6 +46,28 @@ src: url('/fonts/plus-jakarta-sans-700.woff2') format('woff2'); } +/* Deck-builder pool tile: quantity-change pop (replays on count remount). */ +@keyframes inkweave-qty-pop { + from { + transform: scale(1.5); + } + to { + transform: scale(1); + } +} + +/* Deck-builder deck row: slide + fade in on mount (add / initial render). */ +@keyframes inkweave-row-enter { + from { + opacity: 0; + transform: translateY(-8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + *, *::before, *::after { diff --git a/apps/web/src/pages/AuthCallbackPage.tsx b/apps/web/src/pages/AuthCallbackPage.tsx new file mode 100644 index 00000000..7de674d4 --- /dev/null +++ b/apps/web/src/pages/AuthCallbackPage.tsx @@ -0,0 +1,66 @@ +import {useEffect, useState} from 'react'; +import {useNavigate} from 'react-router-dom'; +import {useSession} from '../shared/contexts/SessionContext'; +import {COLORS, FONTS, SPACING} from '../shared/constants'; + +/** + * `/auth/callback` (#463) — the OAuth provider redirects here after consent. supabase-js + * (detectSessionInUrl + PKCE) exchanges the code automatically; SessionContext then + * observes the new session and we route into the deck builder. A short grace period guards + * against a failed/invalid exchange so the user isn't stuck on a spinner. + */ +export function AuthCallbackPage() { + const {session, loading} = useSession(); + const navigate = useNavigate(); + const [failed, setFailed] = useState(false); + + useEffect(() => { + if (loading) return; + if (session) { + navigate('/decks', {replace: true}); + return; + } + const timer = setTimeout(() => setFailed(true), 4000); + return () => clearTimeout(timer); + }, [loading, session, navigate]); + + return ( +
+ {failed ? ( +
+

Sign-in didn't complete.

+

+ Please try again from the deck builder. +

+ +
+ ) : ( +

Finishing sign-in…

+ )} +
+ ); +} diff --git a/apps/web/src/pages/DeckBuilderPage.tsx b/apps/web/src/pages/DeckBuilderPage.tsx new file mode 100644 index 00000000..497fd2b4 --- /dev/null +++ b/apps/web/src/pages/DeckBuilderPage.tsx @@ -0,0 +1,172 @@ +import {useState, type CSSProperties, type ReactNode} from 'react'; +import type {LorcanaCard} from 'inkweave-synergy-engine'; +import {useDeck} from '../features/deck/state'; +import {DeckPanel, DeckPoolGrid, type DeckRow} from '../features/deck/components'; +import {useDeckPoolFilters, applyPoolFilters} from '../features/deck/hooks/useDeckPoolFilters'; +import {calculateDeckStats} from '../features/deck/analysis/deckStats'; +import {BrowseToolbar} from '../features/cards'; +import {CompactHeader, FilterDialog} from '../shared/components'; +import {useCardDataContext} from '../shared/contexts/CardDataContext'; +import {useCardModal} from '../shared/contexts/CardModalContext'; +import {useResponsive} from '../shared/hooks'; +import {COLORS, FONTS, FONT_SIZES, SPACING} from '../shared/constants'; + +// Desktop split: the deck panel takes ~40% (min 500px) so it has room for the +// synergy / suggestion / analysis content to come; the pool gets the rest, with +// larger, more readable card scans (see DeckPoolGrid's min column width). +const DECK_PANE_COLUMNS = 'minmax(0, 1fr) minmax(500px, 37%)'; + +// The visible page chrome is CompactHeader; keep one h1 in the a11y tree so the +// heading outline stays intact without a second visible title. +const SR_ONLY: CSSProperties = { + position: 'absolute', + width: 1, + height: 1, + padding: 0, + margin: -1, + overflow: 'hidden', + clip: 'rect(0, 0, 0, 0)', + whiteSpace: 'nowrap', + border: 0, +}; + +function CenteredNotice({children}: {children: ReactNode}) { + return ( +
+

+ {children} +

+
+ ); +} + +/** + * `/decks/new` and `/decks/:id/edit` — the builder shell (#467). Two panes on + * desktop: a filterable, virtualized card pool (left) and the live deck panel + * (right). Reuses BrowseToolbar + FilterDialog with a LOCAL (non-URL) filter + * store; the deck side reads/writes the shared DeckProvider draft. The advisor + * (#472) and full mobile building are later increments. + */ +export function DeckBuilderPage() { + const {isMobile} = useResponsive(); + const {deck, addCard, setQuantity, removeCard, renameDeck} = useDeck(); + const {cards, isLoading, getCardById, uniqueKeywords, uniqueClassifications, sets} = useCardDataContext(); + const {openCardModal} = useCardModal(); + const pool = useDeckPoolFilters(); + const [showFilters, setShowFilters] = useState(false); + + if (isMobile) { + return ( + The deck builder is desktop-only for now. Full mobile building is the next step. + ); + } + if (isLoading) { + return Loading cards…; + } + + const filtered = applyPoolFilters(cards, pool); + const quantities = new Map(deck.cards.map((c) => [c.cardId, c.quantity] as const)); + const rows: DeckRow[] = deck.cards + .map((dc) => ({card: getCardById(dc.cardId), quantity: dc.quantity})) + .filter((r): r is DeckRow => r.card !== undefined) + .sort( + (a, b) => + a.card.cost - b.card.cost || + (a.card.fullName || a.card.name).localeCompare(b.card.fullName || b.card.name), + ); + const stats = calculateDeckStats(deck, getCardById); + + const viewDetails = (card: LorcanaCard) => + openCardModal( + card.id, + filtered.map((c) => c.id), + ); + + // Opened from the DECK panel: the panel supplies its own rows (in rendered, + // type-grouped order) so the modal's arrow-nav walks the deck, not the pool. + const viewDeckDetails = (card: LorcanaCard, siblingIds: string[]) => openCardModal(card.id, siblingIds); + + const toolbarProps = { + onFiltersClick: () => setShowFilters(true), + activeFilterCount: pool.activeFilterCount, + inkFilters: pool.inkFilters, + typeFilters: pool.typeFilters, + costFilters: pool.costFilters, + filters: pool.filters, + onToggleInk: pool.toggleInk, + onToggleType: pool.toggleType, + onToggleCost: pool.toggleCost, + onClearCosts: pool.clearCosts, + onFiltersChange: pool.setFilters, + sortOrder: pool.sortOrder, + onSortChange: pool.setSortOrder, + isMobile: false, + searchQuery: pool.searchQuery, + onSearchChange: pool.setSearchQuery, + } as const; + + return ( +
+ +

Deck Builder

+ {/* The grid takes the remaining height under the sticky header; minHeight:0 + lets its panes own their own scroll instead of overflowing the 100vh shell. */} +
+
+ +
+ addCard(card.id)} + onDecrement={(card) => setQuantity(card.id, (quantities.get(card.id) ?? 0) - 1)} + onViewDetails={viewDetails} + /> +
+
+ + addCard(id)} + onDecrement={(id) => setQuantity(id, (quantities.get(id) ?? 0) - 1)} + onRemove={removeCard} + onOpenDetails={viewDeckDetails} + /> +
+ + setShowFilters(false)} + onApply={pool.replaceFilters} + inkFilters={pool.inkFilters} + typeFilters={pool.typeFilters} + costFilters={pool.costFilters} + filters={pool.filters} + uniqueKeywords={uniqueKeywords} + uniqueClassifications={uniqueClassifications} + sets={sets} + variant="modal" + /> +
+ ); +} diff --git a/apps/web/src/pages/DeckLayout.tsx b/apps/web/src/pages/DeckLayout.tsx new file mode 100644 index 00000000..035552af --- /dev/null +++ b/apps/web/src/pages/DeckLayout.tsx @@ -0,0 +1,14 @@ +// Route layout for `/decks/*` (#466). Mounts DeckProvider (under CardDataProvider, +// via AppLayout) so the whole deck subtree shares one working draft: the draft survives +// navigation between the list, builder, and view routes, and flushes to localStorage +// when the user leaves the section. +import {Outlet} from 'react-router-dom'; +import {DeckProvider} from '../features/deck/state'; + +export function DeckLayout() { + return ( + + + + ); +} diff --git a/apps/web/src/pages/DeckViewPage.tsx b/apps/web/src/pages/DeckViewPage.tsx new file mode 100644 index 00000000..d242bafd --- /dev/null +++ b/apps/web/src/pages/DeckViewPage.tsx @@ -0,0 +1,31 @@ +import {useParams} from 'react-router-dom'; +import {CompactHeader} from '../shared/components'; +import {useResponsive} from '../shared/hooks'; +import {COLORS, FONTS, LAYOUT, SPACING} from '../shared/constants'; + +/** + * `/decks/:id` — read-only deck view (public or owner). Fills in with sharing (#473) and + * the social phase; this is the scaffold shell (#466). + */ +export function DeckViewPage() { + const {id} = useParams(); + const {isMobile} = useResponsive(); + return ( + <> + +
+

Deck

+

+ Viewing deck {id}. The read-only view arrives with sharing (#473). +

+
+ + ); +} diff --git a/apps/web/src/pages/DecksPage.tsx b/apps/web/src/pages/DecksPage.tsx new file mode 100644 index 00000000..bb25edda --- /dev/null +++ b/apps/web/src/pages/DecksPage.tsx @@ -0,0 +1,95 @@ +import {useState} from 'react'; +import {Link} from 'react-router-dom'; +import {useSession} from '../shared/contexts/SessionContext'; +import {SignInDialog} from '../shared/components/SignInDialog'; +import {CompactHeader} from '../shared/components'; +import {useResponsive} from '../shared/hooks'; +import {COLORS, FONTS, LAYOUT, RADIUS, SPACING} from '../shared/constants'; + +/** + * `/decks` — the user's deck list. Local drafts and cloud decks fill in with #473/#464; + * this scaffold (#466) also carries the auth entry point (#463): sign in to save decks. + */ +export function DecksPage() { + const {user, enabled, loading, signOut} = useSession(); + const {isMobile} = useResponsive(); + const [signInOpen, setSignInOpen] = useState(false); + + return ( + <> + +
+
+

Your Decks

+ {enabled && !loading && user && ( +
+ + {user.email ?? 'Signed in'} + + +
+ )} + {enabled && !loading && !user && ( + + )} +
+ +

+ Build a Core-legal deck with live synergy guidance. +

+ + + New deck + + + setSignInOpen(false)} /> +
+ + ); +} diff --git a/apps/web/src/router.tsx b/apps/web/src/router.tsx index 4d3bec08..3c619b24 100644 --- a/apps/web/src/router.tsx +++ b/apps/web/src/router.tsx @@ -59,6 +59,11 @@ const AdminAnalyticsPage = lazyWithRetry( () => import('./pages/AdminAnalyticsPage'), 'AdminAnalyticsPage', ); +const DeckLayout = lazyWithRetry(() => import('./pages/DeckLayout'), 'DeckLayout'); +const DecksPage = lazyWithRetry(() => import('./pages/DecksPage'), 'DecksPage'); +const DeckBuilderPage = lazyWithRetry(() => import('./pages/DeckBuilderPage'), 'DeckBuilderPage'); +const DeckViewPage = lazyWithRetry(() => import('./pages/DeckViewPage'), 'DeckViewPage'); +const AuthCallbackPage = lazyWithRetry(() => import('./pages/AuthCallbackPage'), 'AuthCallbackPage'); const PrivacyPage = lazyWithRetry(() => import('./pages/PrivacyPage'), 'PrivacyPage'); const TermsPage = lazyWithRetry(() => import('./pages/TermsPage'), 'TermsPage'); const DisclaimerPage = lazyWithRetry(() => import('./pages/DisclaimerPage'), 'DisclaimerPage'); @@ -232,6 +237,58 @@ export const router = createBrowserRouter([ ), }, + { + // The deck builder subtree. The layout element mounts DeckProvider (under + // CardDataProvider) so one working draft is shared across list/builder/view. + path: 'decks', + element: ( + + + + ), + children: [ + { + index: true, + element: ( + + + + ), + }, + { + path: 'new', + element: ( + + + + ), + }, + { + path: ':id', + element: ( + + + + ), + }, + { + path: ':id/edit', + element: ( + + + + ), + }, + ], + }, + { + path: 'auth/callback', + element: ( + + + + ), + }, { path: 'privacy', element: ( diff --git a/apps/web/src/shared/components/MobileBottomNav.tsx b/apps/web/src/shared/components/MobileBottomNav.tsx index 7cc1e7bd..78677e0a 100644 --- a/apps/web/src/shared/components/MobileBottomNav.tsx +++ b/apps/web/src/shared/components/MobileBottomNav.tsx @@ -11,7 +11,7 @@ interface MobileBottomNavProps { phaseOverride?: RevealPhase; } -type TabKind = 'browse' | 'search' | 'reveals' | 'playstyles' | 'vote'; +type TabKind = 'browse' | 'search' | 'reveals' | 'playstyles' | 'vote' | 'decks'; interface TabDef { kind: TabKind; @@ -29,7 +29,7 @@ const TABS_REVEAL_SEASON: readonly TabDef[] = [ {kind: 'search', label: 'Search cards', action: 'search'}, {kind: 'reveals', label: 'Set 13 reveals', href: '/reveals', hasNewDot: true}, {kind: 'playstyles', label: 'Explore playstyles', href: '/playstyles'}, - {kind: 'vote', label: 'Rate synergies', href: '/vote'}, + {kind: 'decks', label: 'Build a deck', href: '/decks'}, ]; const TABS_OFF_SEASON: readonly TabDef[] = [ @@ -37,6 +37,7 @@ const TABS_OFF_SEASON: readonly TabDef[] = [ {kind: 'search', label: 'Search cards', action: 'search'}, {kind: 'playstyles', label: 'Explore playstyles', href: '/playstyles'}, {kind: 'vote', label: 'Rate synergies', href: '/vote'}, + {kind: 'decks', label: 'Build a deck', href: '/decks'}, ]; /** @@ -51,12 +52,7 @@ const POS_5 = { dashOffset: [-4, -24, -44, -64, -84], } as const; -const POS_4 = { - paddingTop: [29, 20, 20, 29], - dashOffset: [-6.5, -31.5, -56.5, -81.5], -} as const; - -type PositionTable = typeof POS_5 | typeof POS_4; +type PositionTable = typeof POS_5; // ===================================================================== // Domain types — aggregate related state so subcomponent signatures read @@ -105,7 +101,7 @@ function isRevealSeasonPhase({phase}: PhaseInput): boolean { function pickTabsAndPositions({isRevealSeason}: SeasonInput): TabsAndPositions { if (isRevealSeason) return {tabs: TABS_REVEAL_SEASON, positions: POS_5}; - return {tabs: TABS_OFF_SEASON, positions: POS_4}; + return {tabs: TABS_OFF_SEASON, positions: POS_5}; } /** @@ -177,6 +173,13 @@ function TabIcon({kind}: {kind: TabKind}) { ); + case 'decks': + return ( + + + + + ); case 'vote': return ( diff --git a/apps/web/src/shared/components/SignInDialog.stories.tsx b/apps/web/src/shared/components/SignInDialog.stories.tsx new file mode 100644 index 00000000..efed2b74 --- /dev/null +++ b/apps/web/src/shared/components/SignInDialog.stories.tsx @@ -0,0 +1,26 @@ +import type {Meta, StoryObj} from '@storybook/react-vite'; +import {SignInDialog} from './SignInDialog'; +import {SessionProvider} from '../contexts/SessionContext'; + +// Wrapped in the real SessionProvider. Storybook has no Supabase env, so auth reports +// `enabled: false` and the provider buttons render disabled with the "not configured" note +// — a faithful view of the dialog in an unconfigured environment. +const meta: Meta = { + title: 'Shared/SignInDialog', + component: SignInDialog, + parameters: {backgrounds: {default: 'dark'}}, + decorators: [ + (Story) => ( + + + + ), + ], + tags: ['autodocs'], +}; +export default meta; +type Story = StoryObj; + +export const Open: Story = {args: {isOpen: true, onClose: () => undefined}}; + +export const Closed: Story = {args: {isOpen: false, onClose: () => undefined}}; diff --git a/apps/web/src/shared/components/SignInDialog.tsx b/apps/web/src/shared/components/SignInDialog.tsx new file mode 100644 index 00000000..4b44dba5 --- /dev/null +++ b/apps/web/src/shared/components/SignInDialog.tsx @@ -0,0 +1,142 @@ +import {useEffect, useState} from 'react'; +import {useSession, type AuthProvider} from '../contexts/SessionContext'; +import {COLORS, FONTS, RADIUS, SPACING, Z_INDEX} from '../constants'; +import {signInProviderButtonState} from './signInProviderState'; + +interface SignInDialogProps { + isOpen: boolean; + onClose: () => void; +} + +const PROVIDERS: ReadonlyArray<{id: AuthProvider; label: string}> = [ + {id: 'google', label: 'Continue with Google'}, + {id: 'discord', label: 'Continue with Discord'}, +]; + +/** + * Sign-in modal (#463): kicks off a Google or Discord OAuth redirect via SessionContext. + * On success the browser navigates away to the provider, so there is no success state to + * render here; on failure it surfaces the error inline. Solid scrim (no backdrop-filter, + * which is a WebKit E2E repaint trap). + */ +export function SignInDialog({isOpen, onClose}: SignInDialogProps) { + const {signIn, enabled} = useSession(); + const [busy, setBusy] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + if (!isOpen) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + const start = async (provider: AuthProvider) => { + setBusy(provider); + setError(null); + try { + const {error: err} = await signIn(provider); + if (err) { + setError(err); + setBusy(null); + } + } catch { + setError('Sign-in failed. Please try again.'); + setBusy(null); + } + }; + + return ( +
+
+

+ Sign in to Inkweave +

+

+ Save your decks and open them from any device. +

+ +
+ {PROVIDERS.map((p) => { + const button = signInProviderButtonState(p, enabled, busy); + return ( + + ); + })} +
+ + {!enabled && ( +

+ Auth is not configured in this environment. +

+ )} + {error && ( +

+ {error} +

+ )} + + +
+
+ ); +} diff --git a/apps/web/src/shared/components/signInProviderState.test.ts b/apps/web/src/shared/components/signInProviderState.test.ts new file mode 100644 index 00000000..95a2cbb3 --- /dev/null +++ b/apps/web/src/shared/components/signInProviderState.test.ts @@ -0,0 +1,31 @@ +import {describe, it, expect} from 'vitest'; +import {signInProviderButtonState, type SignInProvider} from './signInProviderState'; + +const google: SignInProvider = {id: 'google', label: 'Continue with Google'}; + +describe('signInProviderButtonState', () => { + it('is active (enabled, full opacity, pointer) when auth is enabled and idle', () => { + expect(signInProviderButtonState(google, true, null)).toEqual({ + disabled: false, + label: 'Continue with Google', + cursor: 'pointer', + opacity: 1, + }); + }); + + it('disables and dims every button while a redirect is in flight', () => { + const state = signInProviderButtonState(google, true, 'discord'); + expect(state.disabled).toBe(true); + expect(state.cursor).toBe('default'); + expect(state.opacity).toBe(0.6); + }); + + it('shows the redirecting label only for the provider whose redirect is in flight', () => { + expect(signInProviderButtonState(google, true, 'google').label).toBe('Redirecting…'); + expect(signInProviderButtonState(google, true, 'discord').label).toBe('Continue with Google'); + }); + + it('is disabled when auth is not configured, even while idle', () => { + expect(signInProviderButtonState(google, false, null).disabled).toBe(true); + }); +}); diff --git a/apps/web/src/shared/components/signInProviderState.ts b/apps/web/src/shared/components/signInProviderState.ts new file mode 100644 index 00000000..283fdc0a --- /dev/null +++ b/apps/web/src/shared/components/signInProviderState.ts @@ -0,0 +1,34 @@ +import type {AuthProvider} from '../contexts/SessionContext'; + +export interface SignInProvider { + id: AuthProvider; + label: string; +} + +export interface SignInProviderButtonState { + disabled: boolean; + label: string; + cursor: 'pointer' | 'default'; + opacity: number; +} + +/** + * Pure view-state for a single provider button in SignInDialog. Extracted so the dialog + * component stays under the CodeScene cyclomatic-complexity threshold. `busy` holds the + * provider whose OAuth redirect is in flight (or null when idle). The button is active + * only when auth is enabled and no redirect is in flight; while a provider's redirect is + * in flight its label switches to a redirecting notice. + */ +export function signInProviderButtonState( + provider: SignInProvider, + enabled: boolean, + busy: AuthProvider | null, +): SignInProviderButtonState { + const active = enabled && busy === null; + return { + disabled: !active, + label: busy === provider.id ? 'Redirecting…' : provider.label, + cursor: active ? 'pointer' : 'default', + opacity: active ? 1 : 0.6, + }; +} diff --git a/apps/web/src/shared/contexts/SessionContext.test.tsx b/apps/web/src/shared/contexts/SessionContext.test.tsx new file mode 100644 index 00000000..4ec3e886 --- /dev/null +++ b/apps/web/src/shared/contexts/SessionContext.test.tsx @@ -0,0 +1,73 @@ +import {describe, it, expect, vi, beforeEach} from 'vitest'; +import {renderHook, act, waitFor} from '@testing-library/react'; +import type {ReactNode} from 'react'; +import {SessionProvider, useSession} from './SessionContext'; + +// A minimal Supabase auth double; `mockState.client` swaps between "configured" and +// "not configured" (getSupabase() === null). +const mockAuth = vi.hoisted(() => ({ + getSession: vi.fn(), + onAuthStateChange: vi.fn(), + signInWithOAuth: vi.fn(), + signOut: vi.fn(), +})); +const mockState = vi.hoisted(() => ({client: null as unknown})); + +vi.mock('../lib/supabase', () => ({ + getSupabase: () => mockState.client, +})); + +function wrapper({children}: {children: ReactNode}) { + return {children}; +} +const render = () => renderHook(() => useSession(), {wrapper}); + +beforeEach(() => { + vi.clearAllMocks(); + mockAuth.getSession.mockResolvedValue({data: {session: null}}); + mockAuth.onAuthStateChange.mockReturnValue({data: {subscription: {unsubscribe: vi.fn()}}}); + mockAuth.signInWithOAuth.mockResolvedValue({error: null}); + mockAuth.signOut.mockResolvedValue({error: null}); + mockState.client = {auth: mockAuth}; +}); + +describe('SessionContext', () => { + it('is enabled and resolves loading when Supabase is configured', async () => { + const {result} = render(); + await waitFor(() => expect(result.current.loading).toBe(false)); + expect(result.current.enabled).toBe(true); + expect(result.current.session).toBeNull(); + expect(result.current.user).toBeNull(); + }); + + it('signIn starts an OAuth redirect to /auth/callback for the provider', async () => { + const {result} = render(); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.signIn('discord'); + }); + expect(mockAuth.signInWithOAuth).toHaveBeenCalledWith({ + provider: 'discord', + options: {redirectTo: expect.stringContaining('/auth/callback')}, + }); + }); + + it('signOut calls Supabase signOut', async () => { + const {result} = render(); + await waitFor(() => expect(result.current.loading).toBe(false)); + await act(async () => { + await result.current.signOut(); + }); + expect(mockAuth.signOut).toHaveBeenCalled(); + }); + + it('is disabled and no-ops when Supabase is not configured', async () => { + mockState.client = null; + const {result} = render(); + expect(result.current.enabled).toBe(false); + expect(result.current.loading).toBe(false); + const res = await result.current.signIn('google'); + expect(res.error).toBeTruthy(); + expect(mockAuth.signInWithOAuth).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/src/shared/contexts/SessionContext.tsx b/apps/web/src/shared/contexts/SessionContext.tsx new file mode 100644 index 00000000..86aed4bf --- /dev/null +++ b/apps/web/src/shared/contexts/SessionContext.tsx @@ -0,0 +1,89 @@ +// Auth session state for the app (#463). Wraps Supabase Auth: seeds from the stored +// session, subscribes to auth changes, and exposes sign-in (Google/Discord OAuth) + +// sign-out. When Supabase env is unset (getSupabase() === null) auth is `enabled: false` +// and the actions are safe no-ops, so the app degrades gracefully, exactly like voting. +import {createContext, useContext, useEffect, useState, type ReactNode} from 'react'; +import type {Session, User} from '@supabase/supabase-js'; +import {getSupabase} from '../lib/supabase'; + +export type AuthProvider = 'google' | 'discord'; + +interface SessionContextValue { + session: Session | null; + user: User | null; + /** True until the initial session lookup resolves. */ + loading: boolean; + /** False when Supabase env is not configured (auth unavailable). */ + enabled: boolean; + /** Start an OAuth redirect. Returns `{error}` if it could not be initiated. */ + signIn: (provider: AuthProvider) => Promise<{error: string | null}>; + signOut: () => Promise; +} + +const SessionContext = createContext(null); + +export function SessionProvider({children}: {children: ReactNode}) { + const supabase = getSupabase(); + const [session, setSession] = useState(null); + // Only "loading" when auth is actually available; otherwise resolve immediately. + const [loading, setLoading] = useState(supabase !== null); + + useEffect(() => { + if (!supabase) return; + let active = true; + + supabase.auth + .getSession() + .then(({data}) => { + if (active) setSession(data.session); + }) + .catch(() => { + // Network or storage failure: swallow so the loading gate still resolves below. + }) + .finally(() => { + if (active) setLoading(false); + }); + + const {data} = supabase.auth.onAuthStateChange((_event, next) => { + setSession(next); + }); + + return () => { + active = false; + data.subscription.unsubscribe(); + }; + }, [supabase]); + + const signIn = async (provider: AuthProvider): Promise<{error: string | null}> => { + if (!supabase) return {error: 'Sign-in is unavailable (auth not configured).'}; + const {error} = await supabase.auth.signInWithOAuth({ + provider, + options: {redirectTo: `${window.location.origin}/auth/callback`}, + }); + return {error: error?.message ?? null}; + }; + + const signOut = async (): Promise => { + if (!supabase) return; + await supabase.auth.signOut(); + }; + + const value: SessionContextValue = { + session, + user: session?.user ?? null, + loading, + enabled: supabase !== null, + signIn, + signOut, + }; + + return {children}; +} + +export function useSession(): SessionContextValue { + const context = useContext(SessionContext); + if (!context) { + throw new Error('useSession must be used within a SessionProvider'); + } + return context; +} diff --git a/apps/web/src/shared/lib/database.types.ts b/apps/web/src/shared/lib/database.types.ts index 2b74b7a6..8b2d1a0d 100644 --- a/apps/web/src/shared/lib/database.types.ts +++ b/apps/web/src/shared/lib/database.types.ts @@ -14,6 +14,72 @@ export type Database = { } public: { Tables: { + decks: { + Row: { + cards: Json + created_at: string + gameplan: string | null + id: string + inks: string[] + is_public: boolean + name: string + owner_id: string + schema_version: number + updated_at: string + } + Insert: { + cards?: Json + created_at?: string + gameplan?: string | null + id?: string + inks?: string[] + is_public?: boolean + name: string + owner_id: string + schema_version?: number + updated_at?: string + } + Update: { + cards?: Json + created_at?: string + gameplan?: string | null + id?: string + inks?: string[] + is_public?: boolean + name?: string + owner_id?: string + schema_version?: number + updated_at?: string + } + Relationships: [] + } + profiles: { + Row: { + avatar_url: string | null + created_at: string + display_name: string | null + handle: string | null + id: string + updated_at: string + } + Insert: { + avatar_url?: string | null + created_at?: string + display_name?: string | null + handle?: string | null + id: string + updated_at?: string + } + Update: { + avatar_url?: string | null + created_at?: string + display_name?: string | null + handle?: string | null + id?: string + updated_at?: string + } + Relationships: [] + } votes: { Row: { accuracy: number | null diff --git a/apps/web/src/shared/lib/supabase.ts b/apps/web/src/shared/lib/supabase.ts index 9258d292..6f158193 100644 --- a/apps/web/src/shared/lib/supabase.ts +++ b/apps/web/src/shared/lib/supabase.ts @@ -26,7 +26,17 @@ export function getSupabase(): SupabaseClient | null { return null; } - client = createClient(url, key); + client = createClient(url, key, { + auth: { + // Persist the session so a signed-in user stays signed in across reloads, + // auto-refresh tokens, and complete the OAuth PKCE redirect on /auth/callback. + persistSession: true, + autoRefreshToken: true, + detectSessionInUrl: true, + flowType: 'pkce', + storageKey: 'inkweave:auth', + }, + }); return client; } diff --git a/apps/web/tsconfig.app.json b/apps/web/tsconfig.app.json index 71c131a0..c0ea2029 100644 --- a/apps/web/tsconfig.app.json +++ b/apps/web/tsconfig.app.json @@ -11,6 +11,7 @@ /* Bundler mode */ "moduleResolution": "bundler", "allowImportingTsExtensions": true, + "resolveJsonModule": true, "verbatimModuleSyntax": true, "moduleDetection": "force", "noEmit": true, diff --git a/docs/deck-builder/AUTH_SETUP.md b/docs/deck-builder/AUTH_SETUP.md new file mode 100644 index 00000000..7712ad36 --- /dev/null +++ b/docs/deck-builder/AUTH_SETUP.md @@ -0,0 +1,68 @@ +# Deck Builder Auth Setup: Google + Discord + +A one-time, ~20-minute task **for you** (Claude writes the code in #463; this is the part only you can do). The local builder works without it, so do this whenever you are ready to turn on cloud accounts. Nothing here needs code knowledge. + +**Your Supabase project:** `ttyidjyaxnycbpxwngqr` (eu-central-1). + +**The one URL you will paste into both providers (the "provider callback"):** + +``` +https://ttyidjyaxnycbpxwngqr.supabase.co/auth/v1/callback +``` + +Prerequisites: access to the Supabase dashboard for the project above, a Google account, and a Discord account. + +--- + +## Part A: Create the Google OAuth app (~8 min) + +1. Go to https://console.cloud.google.com/ and sign in. +2. Top bar: create a new project (or select an existing one). Name it e.g. `Inkweave`. +3. Left menu: **APIs & Services → OAuth consent screen**. + - User type: **External**. Create. + - App name: `Inkweave`. User support email: your email. Developer contact: your email. Save and continue through the scopes and test-users steps (defaults are fine). + - You can leave it in **Testing** mode for now (add your own Google account under "Test users"); Publish it later when you want anyone to sign in. +4. Left menu: **APIs & Services → Credentials → Create Credentials → OAuth client ID**. + - Application type: **Web application**. + - Name: `Inkweave Web`. + - **Authorized redirect URIs → Add URI**, paste exactly: + `https://ttyidjyaxnycbpxwngqr.supabase.co/auth/v1/callback` + - Create. +5. Copy the **Client ID** and **Client Secret** that pop up. Keep them for Part C. + +## Part B: Create the Discord OAuth app (~5 min) + +1. Go to https://discord.com/developers/applications and sign in. +2. **New Application**, name it `Inkweave`, Create. +3. Left menu: **OAuth2**. + - Under **Redirects → Add Redirect**, paste the same URL: + `https://ttyidjyaxnycbpxwngqr.supabase.co/auth/v1/callback` then **Save Changes**. + - Copy the **Client ID** (shown on the OAuth2 page). + - For the **Client Secret**: click **Reset Secret** (or Copy) and keep it. Save both for Part C. + +## Part C: Enable the providers in Supabase (~4 min) + +1. Open https://supabase.com/dashboard/project/ttyidjyaxnycbpxwngqr → **Authentication → Providers** (sometimes labeled "Sign In / Providers"). +2. **Google**: toggle **Enabled**, paste the Google Client ID + Client Secret from Part A, Save. +3. **Discord**: toggle **Enabled**, paste the Discord Client ID + Client Secret from Part B, Save. + +## Part D: URL configuration in Supabase (~2 min) + +1. **Authentication → URL Configuration**. +2. **Site URL**: your production URL (e.g. `https://inkweave.vercel.app` or your domain). If prod is not up yet, use `http://localhost:5173`. +3. **Redirect URLs**: add both of these (this is the *app's* callback route, which is different from the provider callback above): + - `http://localhost:5173/auth/callback` + - `https:///auth/callback` +4. Save. + +## Part E: Verify (after the #463 auth code is merged) + +- Run the app, click **Sign in**, choose Google or Discord, complete the consent, and you should land back on `/decks` signed in. +- If sign-in fails with a redirect error: the redirect URI in the provider (Parts A/B) must EXACTLY match `https://ttyidjyaxnycbpxwngqr.supabase.co/auth/v1/callback` (https, no trailing slash). + +## Notes and gotchas + +- **Two different callback URLs, both required.** The *provider* redirect is Supabase's `/auth/v1/callback` (Parts A/B/C). The *app* redirect is your site's `/auth/callback` (Part D). Do not mix them up. +- Google **Testing** mode restricts sign-in to your listed test users; Publish the consent screen for public sign-in. +- **Secrets** go only into the Supabase dashboard. Never commit them. The web client only ever ships the public anon key. +- Want zero setup to start? Supabase **anonymous sign-in** or **email magic-link** needs no OAuth apps and can be enabled in the same Providers page; we can layer Google/Discord on later. diff --git a/docs/deck-builder/PLAN.md b/docs/deck-builder/PLAN.md new file mode 100644 index 00000000..d2c8b58e --- /dev/null +++ b/docs/deck-builder/PLAN.md @@ -0,0 +1,200 @@ +# Inkweave Deck Builder + Live Synergy Advisor — Master Plan + +## Context + +Inkweave has, until now, been a *read-only* synergy explorer: pick a card, see what pairs with it. Every rule, playstyle, and score we built was groundwork for the feature this plan describes — **a deck builder that actively guides players toward good decks**, using our synergy engine plus a research-grounded, rotation-proof model of what makes a Lorcana deck strong — and a **Deck Quality Score** that a human progressively calibrates and that other sites can embed. + +The goal is not "another Dreamborn." It is: a builder where synergy detection (35 rules, 21 playstyles) drives live suggestions; a **deck-health advisor** that names what a deck lacks and what it's *vulnerable to*; a Dreamborn **collection** import that turns our engine into a migration tool (owned-card replacements); and an explainable **Inkweave Engine Score** the creator tunes via a human-in-the-loop loop and third parties can display. + +**Product decisions locked in with the user:** +- **v1 headline = builder + live advisor + a composite Deck Quality Score**, on **Supabase Auth + cloud decks**. Social later. +- **Auth**: Supabase Auth, **Google + Discord** (none today). +- **Build flow**: **freeform builder + always-on advisor**, with a "deck core" (mark key cards) seeding suggestions. +- **Rules**: Core pool (Sets 9-13) — 60 min, ≤4 per unique `fullName`, ≤2 inks. **Enforcement split (revised in build):** the ≤4-copy cap is a hard block at the pool tile (the + goes inert); the **≤2-ink limit is advisory** — any ink is freely addable and going over two surfaces as a legality error (`calculateDeckStats.legalityErrors`) in the panel footer, rather than dimming/disabling off-ink pool cards. The pool's ink *filter* still uses deck-legality semantics (with two inks selected, a card's inks must be a subset of them). +- **Archetype**: **auto-detected + optional user-declared gameplan** (sharpens validation, esp. Ramp). +- **Matchups / meta**: **deferred** — scraped meta is stale; user gathers Set-13 meta after competitive play. Near-term advice is **intrinsic** (archetype + composition + vulnerabilities). +- **Collection** (Phase 2): per-user Collection entity, Dreamborn import, own/don't-own indicators, synergy-driven replacement suggestions. +- **Deck Quality Score learning**: **transparent weighted scorer + case library** (glass-box calibration + precedent retrieval; **not** ML). +- **External Engine Score**: **standalone package + hosted endpoint + embeddable widget** (the project's first serverless). +- **Interop**: import + export deck formats (Dreamborn / Pixelborn / TTS) + shareable links. +- **Mobile**: **full build-on-mobile** — first-class ergonomics. + +**Guiding principle:** the advisor and score are **pool-driven, archetype-parameterized, and explainable — never hardcoded to a snapshot, never a black box.** Vulnerabilities derive from the live pool; targets from the archetype; matchups wait for real data; the score traces to arithmetic + named precedents. Correct across rotations, defensible to a competitive player. + +**Memory corrections (fix after plan mode):** `docs/deck-builder-resurrection-map.md` doesn't exist (old builder reconstructed from `e5ad4b4^`); "852 cards with synergies" is stale (~1024 Core / ~1278 synergy files); `CLAUDE.md:8` still lists "deck builder." + +--- + +## Part A — The Deck-Health Framework (research payoff) + +Computed from card fields (`cost`, `inkwell`, `type`, `classifications`, `strength`, `willpower`, `lore`, `keywords`, `text`) + precomputed synergy data. **Three tiers + a composite score.** + +### Tier 1 — Hard rules (enforced) +| Rule | Value | Compute | +|---|---|---| +| Deck size | ≥60 (competitive = exactly 60) | `sum(quantity)` | +| Copy limit | ≤4 per **unique `fullName`** (*Stitch - Rock Star* ≠ *Stitch - Carefree Surfer*) | group by `fullName` | +| Ink limit | ≤2 inks (dual-ink counts as **both**) | union of `getInks` / pairwise `canShareDeck` | + +### Tier 2 — Archetype-parameterized soft heuristics +**Classifier runs first.** Archetype ∈ `aggro | tempo | midrange | control | combo | ramp`, **auto-detected** (curve center + role densities + avg lore/cost), **overridable by `deck.gameplan`**. Each archetype supplies its own target profile. + +Baseline (midrange; archetypes shift the dials): +| Dimension | Target (sourced) | Compute | Variance | +|---|---|---|---| +| Inkable ratio | 44-48 (official 47-49 / 11-13 uninkable) | `inkwell===true` count | aggro tolerates 18-20 uninkable | +| Curve shape | peak 2-3, front-load 1-4 (1→6-10,2→10-14,3→8-14,4→6-10,5→4-8,6+→4-8) | `cost` histogram | **ramp inverts** (below); control flatter | +| Card draw (pillar) | **≥4 floor (official)**; 6-10 *(soft)* | `getCardMechanics`⊇`'draw'` | control high | +| Removal (pillar) | **≥4 floor (official)**; ~6-10 *(soft)* | **new `getRemovalRoles`** | control 8-12, aggro ≤4 | +| Actions+Songs cap | ≤~25% (~15); song deck 12-15 | `type==='Action'` count | control 14-16 | +| Card-type mix | 22-26 char / 12-15 action / 4-6 item+loc | count by `type` | aggro chars 24-28; control 18-20 | +| Rule of Eight | core plan ≥8 interchangeable (~65% opening-7) | group by role/keyword/classification | universal | +| Consistency | exactly 60, ~15-20 distinct, favor 4-ofs | playset vs singleton share | universal | +| Lore output | enough board lore to race | `sum(lore×qty)` characters | aggro high, control low | +| Shift-target coverage | Shift card needs same-named base | `getShiftBaseNames` presence | universal | +| Synergy density | high aggregate, few weak links | precomputed `pairs` | universal | + +**Ramp special validation:** when ramp is the plan, the curve analyzer **stops penalizing a gap** and validates the **curve-jump payoff** (ramp cost + amount → enough impactful bodies at the ramped-to ink). Each archetype can register plan-specific checks (aggro early pressure; combo payoff + Rule-of-Eight enablers). + +**Hypergeometric reference (verified):** opening-7 P(≥1): 4-of 39.9%, 3-of 31.5%, 2-of 22.1%, 1-of 11.7%. Rule of Eight: 8 copies 65.4% (~90% fully mulliganed). 46 inkable → P(≥3) 99.4%. + +### Tier 3 — Vulnerabilities / "What to watch for" (pool-derived, meta-free) +**Auto-derive a hoser catalog from the current Core pool** (scan conditional/mass removal via `getRemovalRoles`, extract each trigger condition: `low-strength ≤N`, `evasive`, `bodyguard`, `damaged`, `high-cost ≥N`, `mass`), then compute the deck's **exposure** and surface the sharp ones: *"68% of your characters have ≤2 strength — a low-strength wipe (Under the Sea-type) blows you out."* No hardcoded card names (survives rotation). Precomputed to `public/data/hosers.json` (hybrid: auto-derived + small curated override). + +### Composite: Deck Quality Score (v1 output; calibrated in Part C) +`DeckQualityScore ∈ 0-100 = Σ(weightᵢ × dimensionScoreᵢ)` over the Tier-2 dimensions (normalized 0-1) + archetype coherence − vulnerability penalty. **Every score returns a breakdown** (per-dimension contribution + reasons). Weights/thresholds live in a versioned, snapshot-guarded config (`scoring.json`, extending `tuning.json`). Ships with default weights in v1; Part C makes it learn. + +### Pros/cons + matchups +Pros/cons from analyzer statuses + archetype. **Matchups deferred** to Phase 6 (no meta-weighting until Set-13 data); near-term hints are intrinsic + framed as estimates. + +--- + +## Part B — Architecture (grounded in exploration) + +- **Stack**: static Vite SPA on Vercel, **no serverless yet** — browser → Supabase (anon key). CSP allows `connect-src https://*.supabase.co` + `img-src https:`. React Compiler on (avoid manual memo, #291). No store lib: **Context + URL params + localStorage**. +- **Engine client-safe** (zero-dep, ~88 KB, pure). Deck synergy uses **precomputed pairwise JSON** (`pairs[other].aggregateScore`, `_playstyles.json`, `_pairs_index.json`); lazy-import engine only for preview/hypothetical/replacement scoring. +- **Reuse (don't rebuild)**: `BrowseCardGrid`, `CardGrid`, `CardTile`, `BrowseToolbar`, `FilterDialog`/filter groups/`SortSelect`/`Chip`, `SearchBottomSheet`, `SynergyGroup`/`SynergyCard`/`StrengthBadge`, `CardModalContext`, theme tokens, `useResponsive`/`useFilterParams`/`useDialogFocus`. Inline styles (no Tailwind). **No `backdrop-filter: blur`** (WebKit E2E trap). + +### Supabase schema (JSONB card lists; timestamp-convention migrations) +- `profiles` (`id → auth.users`, `handle` unique-nullable, `display_name`, `avatar_url`), RLS public-read/owner-write + `handle_new_user()` trigger. +- `decks` (`id`, `owner_id`, `name`, `gameplan text?`, `inks text[]`≤2, `cards jsonb`=`[{cardId,quantity,isCore}]`, `is_public` default false, `slug` later), owner-scoped RLS + `updated_at` trigger. +- **`collections`** (Phase 2; `owner_id` PK, `cards jsonb`=`[{cardId,quantity}]`, `source`, `updated_at`), owner-only RLS. +- **`analysis_cases`** (Phase 3; `id`, `owner_id`, `deck_snapshot jsonb`, `fingerprint jsonb`, `dimension_scores jsonb`, `analyzer_score`, `human_score`, `corrections jsonb`, `notes`, `config_version`, `created_at`), owner-only RLS. +- `deck_favorites` (Phase 4) + `deck_stats` view. +- Regenerate `database.types.ts` after each migration. + +### Auth wiring +`supabase.ts`: add `auth:{persistSession, autoRefreshToken, detectSessionInUrl, flowType:'pkce', storageKey:'inkweave:auth'}`; keep env gate. `SessionContext`/`useSession` in `AppLayout`; `signInWithOAuth('google'|'discord',{redirectTo:${origin}/auth/callback})`. `/auth/callback` page → session → draft migrator → `/decks`. `SignInDialog` from `CompactHeader` + `/decks` header. **No CSP change** (top-level nav; token exchange + avatars already allowed). **Manual dashboard steps**: enable providers, set Site URL + redirect allowlist. + +### Routing & nav +Zero-prop lazy routes: `/decks`, `/decks/new`, `/decks/:id`, `/decks/:id/edit` (`DeckOwnerGate`), `/auth/callback`, `/collection` (P2), `/decks/import` (P5), `/u/:handle` (P4), `/admin/deck-lab` (P3, `AdminGate`). **Mobile nav**: replace "vote" primary tab with "Decks" (keeps `POS_4`; vote reachable elsewhere). + +### State +`DeckContext`/`useDeck` (under `CardDataProvider`): `addCard`/`removeCard`/`setQuantity`/`markCore`/`setGameplan`/`renameDeck`/`clearDeck`; debounced localStorage draft (`deckStorage.ts`, voteStorage pattern) + Supabase sync when authed. First-sign-in draft→cloud migration (idempotent). `CollectionContext`/`useCollection` (P2). `SessionContext` (P1). + +### Live advisor engine — `apps/web/src/features/deck/analysis/` +Pure, unit-testable. `analyzeDeck(deck, cards, {precomputedPairs, hosers, collection?, scoringConfig, cases?}) → {stats, health, vulnerabilities, suggestions, qualityScore}`. +- `archetype.ts` first → `{detected, confidence}`; `deck.gameplan` overrides. +- `deckSynergy.ts`: aggregate `pairs` → overall, `keyCards`, `weakLinks`; `_playstyles.json` density. +- Health analyzers (§A Tier 2), archetype-parameterized. +- `vulnerabilities.ts` (§A Tier 3) from `hosers.json`. +- `score.ts`: composite (§A) + breakdown; precedent anchoring when `cases` present (Part C). +- **NEW `getRemovalRoles`** in `packages/synergy-engine/src/utils/cardHelpers.ts` (fills the one engine gap): `banish | conditional-banish | damage | debuff | bounce`; excludes self-banish; reuses `getActionDamage`/`isMultiTargetDamageAction`/`getBounceRoles`; **emits the condition** feeding the hoser catalog. Own tests. +- `suggestions.ts`: `canShareDeck`-filtered candidates, `totalScore = synergyWithDeck (core ×2) + gapWeight × fillsAGap (incl. vulnerability mitigation)`. Merida-Wisp→`getBeckonEnablerTier`; Shift→`getShiftBaseNames`; Singer→on-curve `isSong`. + +### Collection & migration (Phase 2) — `features/deck/collection/` +`parseDreambornCollection(data)` → `[{cardId,quantity}]` (research the export format), resolve via name/set index, report unmatched → populate `collections`. Own/don't-own badges + "only cards I own" filter. **`replacements.ts`**: for an unowned deck card, rank **owned** candidates by (a) synergy-profile similarity vs the deck, (b) role/mechanic overlap (`getCardMechanics`/`getXRoles`), (c) cost+type+ink match. Engine lazy-imported. + +--- + +## Part C — Deck Quality Score & Human-in-the-Loop Calibration + +**Learning = a self-calibrating glass box + a memory of precedents. No ML.** + +**1. Transparent, versioned score** (§A composite). Weights/thresholds in `scoring.json` (snapshot-guarded, `/admin/tuning`-style). Each score stamps its `config_version` → reproducible ("Engine Score v3"). + +**2. Case capture (the labeled memory).** `analysis_cases` (Supabase, owner-only). You review an analysis (score + breakdown + reasons), enter your **ground-truth score** + per-dimension right/wrong + notes → a case is stored with the deck's **fingerprint**. + +**3. Deck fingerprint** (for similarity): `{ archetype, bucketed dimension scores, dominant playstyles (`_playstyles.json` ∩ deck), key-card synergy signature (top `keyCards`' rule ids), inks, curve centroid }`. Distance = weighted L2. + +**4. Two explainable learning channels:** +- **Global calibration:** across cases, compute per-dimension error (analyzer vs human); least-squares fit of weights **or** guided manual edits from an aggregate error report (*"removal overweighted 8% across 40 cases"*). Versioned + reversible (snapshot-guarded); each change bumps `config_version`. You approve every change. +- **Local precedents:** on a new analysis, kNN-retrieve nearest cases by fingerprint; **anchor** the raw score toward their human scores (confidence-weighted by neighbor count/similarity) and **replay their notes** as advice (*"decks like this you rated ~7; you flagged single-2-of-payoff reliance"*). This is the "recognizes patterns it has seen." + +**5. Calibration tool** — `/admin/deck-lab` (`AdminGate`): paste/import a deck → score + breakdown + nearest precedents → enter score + corrections + notes → save case. A "Calibrate" view shows the aggregate error report + proposes weight adjustments you approve/reject (versioned). + +**6. External "Inkweave Engine Score" (widget + hosted endpoint; the project's FIRST serverless):** +- **`inkweave-deck-score` package** (standalone, zero-dep like the engine): pure `scoreDeck(cards, config) → {score, breakdown, configVersion}`. +- **Hosted endpoint** (Supabase Edge Function **or** Vercel Function — decided at build): `POST /deck-score` → versioned `{score, breakdown, configVersion}`; needs the Core card DB + precomputed inputs bundled/fetchable at the edge; rate-limited like `submit_vote`. +- **Embeddable widget** (JS snippet / iframe) third parties drop in; requires `frame-ancestors`/CSP work. + +**Cold-start:** default-weight formula until cases exist; your calibration bootstraps it; precedents kick in as cases accumulate. + +--- + +## Part D — Phased roadmap (each bullet ≈ one PR-sized issue, in dependency order) + +### Phase 0 — Foundations +1. `features/deck/types.ts` (Deck+`gameplan`, DeckCard, DeckStats, DeckHealth, Vulnerability, Suggestion, Archetype+`'ramp'`, QualityScore, AnalysisCase, DeckFingerprint) + `calculateDeckStats` tests. +2. `deck-analysis` scaffold + `deckStats.ts` + tests. +3. **`getRemovalRoles`** engine detector (+ condition extraction) + tests. *(engine auto-rebuild/validator.)* +4. `scripts/precompute-hosers.mjs` → `hosers.json` + build wiring. +5. `scoring.json` default config + `score.ts` composite (transparent, versioned) + tests. + +### Phase 1 — v1: Builder + Live Advisor + Quality Score + Auth +6. `supabase.ts` auth. 7. `SessionContext`. 8. `/auth/callback` + migrator. 9. `SignInDialog` + entry points. 10. `profiles` migration+trigger. 11. `decks` migration+RLS. 12. `DeckContext`+`deckStorage.ts`. 13. `deckRepository`. 14. draft→cloud migration. 15. routes+pages. 16. mobile "Decks" tab. 17. `DeckBuilderPage` shell. 18. `DeckPanel`+`DeckCardRow` (enforcement). 19. `DeckStatsBar`. 20. `archetype.ts` + declared-gameplan control. 21. archetype-parameterized analyzers. 22. `vulnerabilities.ts`. 23. deck synergy aggregation. 24. `SuggestionList`+ranking. 25. **`DeckQualityScore` display + breakdown**. 26. `DeckAdvisorPanel`+`HealthMeter`+`VulnerabilityBox`+`ArchetypeBadge`+`ScoreGauge` (persistent; mobile sheet). 27. `SaveDeckDialog`. 28. `/decks` list. 29. native share link. + +**UX note — DeckPanel dead horizontal space (wide screens).** On wide screens the Cards-tab rows leave a large empty gap between the card name and the quantity stepper; Dreamborn fills that space with a cost curve + small charts, Duels.ink leaves it empty. **The data already exists** and is already passed into `DeckPanel` as `stats: DeckStats` (from `calculateDeckStats`, item 2): `costCurve` (cost→count, 7+ bucketed), `inkDistribution` (per ink; dual-ink counts both), `typeDistribution` (per card type), `inkableCount`/`inkCount`. So this is presentation-only — no new backend. Two candidate homes: **(a)** a compact summary strip above the grouped rows inside the Cards tab (mini cost-curve + ink/type pips) — a lightweight fill, decoupled from the full advisor; or **(b)** flesh out the reserved Analysis tab, whose `'Cost curve'` and `'Ink balance'` placeholder zones already earmark exactly these charts — folds into item 26. Relates to items 18/19 and 26. Not built; recorded only. + +### Phase 2 — Collection & Dreamborn Migration +30. `collections` migration+RLS. 31. `CollectionContext`+repo. 32. Dreamborn collection parser + `ImportCollectionDialog`. 33. `/collection` page. 34. own/don't-own badges + filter. 35. **replacement suggester** + UI. + +### Phase 3 — Deck Quality Score Calibration (human-in-the-loop) +36. `analysis_cases` migration+RLS. 37. fingerprint module + kNN retrieval. 38. `/admin/deck-lab` case-capture tool. 39. aggregate error report + versioned weight-calibration view. 40. precedent anchoring + note-replay wired into the live advisor. + +### Phase 4 — Social layer +41. `deck_favorites`+`deck_stats`. 42. `/decks/feed`. 43. favorites UI+counts. 44. copy-a-deck. 45. `/u/:handle` + handle uniqueness. 46. trending precompute. + +### Phase 5 — Interop depth +47. `ImportDeckDialog` (Dreamborn/Pixelborn/TTS **deck-list** parsers, shared name index). 48. export serializers. 49. `decks.slug` short links. 50. deep-link import. + +### Phase 6 — Advisor depth + meta (gated on Set-13 data) +51. full pros/cons. 52. `precompute-meta.mjs` against the **Set-13** pipeline. 53. meta-weighted `MatchupBox` (hedged, dated). 54. copy tuning via `/admin/tuning`. 55. opening-hand/mulligan simulator. 56. explain-suggestion/vulnerability drilldowns. + +### Phase 7 — External Inkweave Engine Score (first serverless) +57. `inkweave-deck-score` standalone package. 58. hosted scoring endpoint (Supabase Edge Function **or** Vercel Function — decide at build) + rate-limiting. 59. embeddable widget + `frame-ancestors`/CSP + docs. 60. versioned score contract + integration tests. + +--- + +## Part E — Decisions & defaults (recommendations; veto any at review) +1. **Mobile nav**: demote "vote" for "Decks" (vote reachable from card modals/playstyle pages). +2. **Cloud decks private by default**; **collections + analysis_cases owner-only** (no public sharing). +3. **Archetype**: auto-detect always on; declared `gameplan` optional, overrides when set. +4. **Score in v1** (default weights); **calibration is Phase 3** (before broad/public score exposure); **external Engine Score is Phase 7**, once the score is trusted — reorderable if you want it sooner. +5. **Handles**: derive from OAuth; require unique `@handle` only at profile publish (Phase 4). +6. **Anon drafts**: soft cap (~10 local decks) + sign-in nudge. +7. **Meta/matchups**: deferred to Phase 6 (Set-13 data); near-term advice intrinsic. +8. **Vulnerability catalog**: auto-derived from pool + small curated override; precomputed. +9. **Serverless choice** (Supabase Edge Function vs Vercel Function) for the Engine Score endpoint: decided at Phase 7 build (leaning Supabase Edge Function to keep everything in one backend). + +--- + +## Verification (per phase, end-to-end) +- **Unit (5-15 focused tests each, `.claude/rules/tests.md`)**: `deckStats` (60/≤2-ink/≤4-per-fullName), **`getRemovalRoles`** (hard/conditional banish, burn, self-banish NOT matching, bounce, condition extraction), `archetype` (aggro/control/**ramp** + declared override; ramp curve-jump not penalized), `vulnerabilities` (exposure from fixture `hosers.json`), `deckSynergy`, `suggestions` (Merida/Singer/vulnerability-mitigation), `replacements` (unowned→owned same-role ranking), `score` (deterministic given config + version stamp), `fingerprint`+kNN (nearest-case retrieval), `calibration` (fixture cases → expected weight shift + rollback), collection + deck-list parsers, `deckStorage`. +- **Stories**: `.stories.tsx` for every new visual component (`check:stories` gate) — deck states, `HealthMeter` statuses, `VulnerabilityBox`, `ArchetypeBadge`, `ScoreGauge` + breakdown, own/don't-own badges, replacement row, `/admin/deck-lab` panels. +- **E2E (chromium local / +webkit +mobile-chrome elsewhere)**: build→60→legality green; declare gameplan→advice changes; score+breakdown render; save (stub session); share opens fresh; import collection→badges→replacement; deck-lab: analyze→enter score→case saved→precedent surfaces next time. `data-testid`s; **no blur**; no hover-only affordances. +- **Supabase**: regenerate types + `pnpm test:supabase` + `get_advisors` after each migration; Phase 7 endpoint gets its own integration test + rate-limit test. +- **Engine change**: auto-rebuild hook + `engine-validator`; CodeScene `analyze_change_set` (base `origin/master`) before push. +- **Manual**: Google+Discord sign-in on a preview deploy; Dreamborn collection import vs a real export; Engine Score widget embedded on a scratch page. + +## Critical files +- `apps/web/src/shared/lib/supabase.ts` — auth options (linchpin). +- `apps/web/src/router.tsx` — new lazy routes + `DeckOwnerGate`. +- `apps/web/src/shared/components/MobileBottomNav.tsx` — "Decks" tab + arc-position tables. +- `packages/synergy-engine/src/utils/cardHelpers.ts` — `getRemovalRoles` (+ conditions); reuse `canShareDeck`, `getBeckonEnablerTier`, `getShiftBaseNames`, `getBounceRoles`, `getActionDamage`, `getRampRoles`/`isDeckRamp`. +- `apps/web/src/features/synergies/hooks/usePrecomputedSynergies.ts` — `fetchCardSynergies` reuse for aggregation + replacements. +- `supabase/migrations/` — `profiles` / `decks` / `collections` / `analysis_cases` / `deck_favorites`. +- `scripts/precompute-hosers.mjs` (new); later `scripts/precompute-meta.mjs` (P6). +- `packages/synergy-engine/src/data/tuning.json` + new `scoring.json` — versioned score config (extends the `/admin/tuning` pattern). +- New `packages/inkweave-deck-score/` (P7) + first serverless surface (Supabase Edge Function or Vercel `api/`). +- Pattern refs: `shared/contexts/CardDataContext.tsx`, `features/voting/lib/voteStorage.ts`, `features/admin-*` (for `/admin/deck-lab`), old builder via `git show e5ad4b4^:apps/web/src/features/deck/hooks/useDeckBuilder.ts`. diff --git a/package.json b/package.json index 13812f5a..c30ae6db 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build": "pnpm -r build", "build:engine": "pnpm --filter inkweave-synergy-engine build", "build:web": "pnpm --filter inkweave-web build", - "build:vercel": "node scripts/convert-preview-images.mjs && node scripts/download-card-images.mjs && pnpm build:engine && node scripts/precompute-synergies.mjs && node scripts/precompute-vote-analytics.mjs && node scripts/precompute-vercel-analytics.mjs && VITE_LOCAL_IMAGES=true pnpm build:web", + "build:vercel": "node scripts/convert-preview-images.mjs && node scripts/download-card-images.mjs && pnpm build:engine && node scripts/precompute-synergies.mjs && node scripts/precompute-hosers.mjs && node scripts/precompute-vote-analytics.mjs && node scripts/precompute-vercel-analytics.mjs && VITE_LOCAL_IMAGES=true pnpm build:web", "test": "pnpm -r test:run", "test:engine": "pnpm --filter inkweave-synergy-engine test:run", "test:web": "pnpm --filter inkweave-web test:run", @@ -25,6 +25,7 @@ "download-images": "node scripts/download-card-images.mjs", "convert-preview-images": "node scripts/convert-preview-images.mjs", "precompute-synergies": "node scripts/precompute-synergies.mjs", + "precompute-hosers": "node scripts/precompute-hosers.mjs", "precompute-vote-analytics": "node scripts/precompute-vote-analytics.mjs", "precompute-vercel-analytics": "node scripts/precompute-vercel-analytics.mjs", "test:scripts": "vitest run --config vitest.scripts.config.mjs", @@ -36,9 +37,9 @@ }, "size-limit": [ { - "name": "Total JS bundle", - "path": "apps/web/dist/assets/*.js", - "limit": "360 kB", + "name": "Initial JS (entry chunk)", + "path": "apps/web/dist/assets/index-*.js", + "limit": "200 kB", "gzip": true }, { diff --git a/packages/synergy-engine/src/__tests__/removal.test.ts b/packages/synergy-engine/src/__tests__/removal.test.ts new file mode 100644 index 00000000..dee145e8 --- /dev/null +++ b/packages/synergy-engine/src/__tests__/removal.test.ts @@ -0,0 +1,118 @@ +import {describe, it, expect} from 'vitest'; +import {getRemovalRoles, getRemovalCondition, isRemovalCard} from '../utils'; +import {createCard} from './fixtures.js'; + +// Real Core-pool card texts (ids noted) so the patterns are exercised against +// wording that actually ships, not synthetic strings. + +describe('getRemovalRoles', () => { + it('tags a hard, ungated banisher (Dragon Fire 2322)', () => { + const card = createCard({id: '2322', type: 'Action', text: 'Banish chosen character.'}); + expect(getRemovalRoles(card)).toEqual(['banish']); + }); + + it('tags a stat-gated banisher as conditional-banish (Red Alert 3106)', () => { + const card = createCard({ + id: '3106', + type: 'Action', + text: 'Banish chosen character with 3 ¤ or less. If you have a Monster character in play, chosen opponent loses 1 lore.', + }); + expect(getRemovalRoles(card)).toEqual(['conditional-banish']); + }); + + it('tags a damage action (Smash 2134)', () => { + const card = createCard({id: '2134', type: 'Action', text: 'Deal 3 damage to chosen character.'}); + expect(getRemovalRoles(card)).toEqual(['damage']); + }); + + it('tags a stat debuff (Maid Marian 2094)', () => { + const card = createCard({ + id: '2094', + text: 'HIGHBORN LADY When you play this character, chosen character gets -2 ¤ this turn.', + }); + expect(getRemovalRoles(card)).toEqual(['debuff']); + }); + + it('does NOT tag a self-banish (Sacrifice) card as removal (Time to Go! 2320)', () => { + const card = createCard({ + id: '2320', + type: 'Action', + text: 'Banish chosen character of yours to draw 2 cards. If that character had a card under them, draw 3 cards instead.', + }); + expect(getRemovalRoles(card)).toEqual([]); + expect(isRemovalCard(card)).toBe(false); + }); + + it('tags an opponent bounce via the Bounce detector, not as a banish (The Claw 2814)', () => { + const card = createCard({ + id: '2814', + type: 'Item', + text: "THE CLAW CHOOSES ⟳, 2 ⬡, Banish one of your characters — Return chosen opposing character to their player's hand.", + }); + // Self-banish clause is excluded; the opponent-return clause makes it removal via bounce. + expect(getRemovalRoles(card)).toEqual(['bounce']); + }); + + it('returns no roles for a vanilla non-removal card', () => { + const card = createCard({text: 'Bodyguard Support'}); + expect(getRemovalRoles(card)).toEqual([]); + }); +}); + +describe('getRemovalCondition', () => { + it('extracts low-strength with threshold from an "N or less" gate (Red Alert 3106)', () => { + const card = createCard({ + id: '3106', + type: 'Action', + text: 'Banish chosen character with 3 ¤ or less. If you have a Monster character in play, chosen opponent loses 1 lore.', + }); + expect(getRemovalCondition(card)).toEqual({type: 'low-strength', threshold: 3}); + }); + + it('reads the real "N or more" gate, not a Singer song reminder\'s cost (World\'s Greatest Criminal Mind 1966)', () => { + const card = createCard({ + id: '1966', + type: 'Action', + text: '(A character with cost 3 or more can ⟳ to sing this song for free.) Banish chosen character with 5 ¤ or more.', + }); + // The stripped reminder\'s "cost 3 or more" must not win over the real "5 ¤ or more". + expect(getRemovalCondition(card)).toEqual({type: 'high-cost', threshold: 5}); + }); + + it('keys on the damaged target restriction (Education or Elimination 2560)', () => { + const card = createCard({ + id: '2560', + type: 'Action', + text: '(A character with cost 4 or more can ⟳ to sing this song for free.) Choose one: • Draw a card. Chosen character of yours gets +1 ◊ and gains Evasive until the start of your next turn. (Only characters with Evasive can challenge them.) • Banish chosen damaged character.', + }); + expect(getRemovalRoles(card)).toEqual(['banish']); + expect(getRemovalCondition(card)).toEqual({type: 'damaged'}); + }); + + it('keys on an Evasive-gated target (The Horseman Strikes! 2218)', () => { + const card = createCard({ + id: '2218', + type: 'Action', + text: 'Draw a card. You may banish chosen character with Evasive.', + }); + expect(getRemovalCondition(card)).toEqual({type: 'evasive'}); + }); + + it('is not fooled by the Evasive keyword reminder into a false evasive gate (Sisu 2055)', () => { + const card = createCard({ + id: '2055', + text: 'Evasive (Only characters with Evasive can challenge this character.) BRING ON THE HEAT! When you play this character, banish chosen opposing character with 1 ¤ or less.', + }); + expect(getRemovalCondition(card)).toEqual({type: 'low-strength', threshold: 1}); + }); + + it('reports unconditional for ungated removal (Dragon Fire 2322)', () => { + const card = createCard({id: '2322', type: 'Action', text: 'Banish chosen character.'}); + expect(getRemovalCondition(card)).toEqual({type: 'unconditional'}); + }); + + it('returns null for a non-removal card', () => { + const card = createCard({text: 'Bodyguard Support'}); + expect(getRemovalCondition(card)).toBeNull(); + }); +}); diff --git a/packages/synergy-engine/src/index.ts b/packages/synergy-engine/src/index.ts index 7bd3b875..f3e4aff8 100644 --- a/packages/synergy-engine/src/index.ts +++ b/packages/synergy-engine/src/index.ts @@ -100,6 +100,9 @@ export { isExertConsumePayoff, getBounceRoles, isBounceCard, + getRemovalRoles, + getRemovalCondition, + isRemovalCard, getTribalRoles, isTribalCard, TRIBAL_SPECS, @@ -137,6 +140,9 @@ export type { HealRole, ExertRole, BounceRole, + RemovalRole, + RemovalConditionType, + RemovalCondition, BeckonEnablerTier, TribalRole, TribalSpec, diff --git a/packages/synergy-engine/src/utils/cardHelpers.ts b/packages/synergy-engine/src/utils/cardHelpers.ts index 3a9598af..13f18e0d 100644 --- a/packages/synergy-engine/src/utils/cardHelpers.ts +++ b/packages/synergy-engine/src/utils/cardHelpers.ts @@ -1915,3 +1915,159 @@ export function getBounceRoles(card: LorcanaCard): BounceRole[] { /** Check if a card participates in the bounce axis (any role). */ export const isBounceCard = (card: LorcanaCard): boolean => getBounceRoles(card).length > 0; + +// ============================================ +// REMOVAL DETECTION (generic opponent removal — the "removal pillar") +// ============================================ + +/** + * Generic opponent-facing removal, the piece the engine was missing: the existing + * detectors are either per-classification (`hasNegativeTargeting`) or your-OWN board + * (`getSacrificeRoles` self-banish), so nothing measured plain "kill the opponent's + * stuff" across the whole pool. This feeds the deck "removal pillar" count and the + * pool-derived vulnerability (hoser) catalog (#461). + * + * Five roles, one per interaction shape (a card can fill several — a banish that also + * debuffs carries both): + * - 'banish' — hard, ungated banish of an opposing/neutral body. + * - 'conditional-banish' — a banish GATED on a stat/cost threshold ("with N ¤ or less"). + * - 'damage' — deals fixed damage to a chosen/opposing character (any card type). + * - 'debuff' — shrinks a chosen character's combat stat ("gets -N ¤"). + * - 'bounce' — returns an opposing body to hand for tempo (delegated to Bounce). + */ +export type RemovalRole = 'banish' | 'conditional-banish' | 'damage' | 'debuff' | 'bounce'; + +/** + * The "hoser trigger" a removal effect keys on — what makes a card in the pool vulnerable + * to it (#461 scans the pool with this). Thresholds carry the numeric gate: + * - 'low-strength' — targets small bodies ("N ¤ or less" / "cost N or less"), threshold N. + * - 'high-cost' — targets big bodies ("N ¤ or more" / "cost N or more"), threshold N. + * - 'evasive' / 'bodyguard' / 'damaged' — keyed on a keyword/state the target must have. + * - 'mass' — board-wide (banish/damage ALL or EACH opposing, up-to-N). + * - 'unconditional' — removal with no recognized gate (the removal always applies). + * The threshold direction is the primary signal: "or less" ⇒ low-strength, "or more" ⇒ + * high-cost, regardless of whether the gate reads strength (¤) or cost. + */ +export type RemovalConditionType = + | 'low-strength' + | 'evasive' + | 'bodyguard' + | 'damaged' + | 'high-cost' + | 'mass' + | 'unconditional'; + +export interface RemovalCondition { + type: RemovalConditionType; + threshold?: number; +} + +/** + * Hard banish of an opposing/neutral body. The target qualifier (chosen/target/another + * chosen/each/all) plus a character/item/location noun keeps it off self-references + * ("banish this character/item") and off "is banished" trigger text. Self-banish + * ("... of yours", "your characters") is gated out separately via the Sacrifice pattern. + */ +const REMOVAL_BANISH_PATTERN = + /\bbanish (?:up to \d+ )?(?:chosen|target|another chosen|each|all)[^.]{0,30}?\b(?:characters?|items?|locations?)\b/i; + +/** A stat/cost threshold gate ("with N ¤ or less" / "with cost N or more") turns a banish conditional. */ +const REMOVAL_THRESHOLD_PATTERN = /with (?:cost )?\d+ ?[¤⛉]? or (?:less|more)/i; +/** Capturing variants of the threshold gate (direction fixes the condition type). */ +const REMOVAL_THRESHOLD_LESS = /with (?:cost )?(\d+) ?[¤⛉]? or less/i; +const REMOVAL_THRESHOLD_MORE = /with (?:cost )?(\d+) ?[¤⛉]? or more/i; + +/** + * Opponent-facing damage: "deal N damage to + * character". Works for any card type (Actions, Items, Characters, Locations all print + * this). Self-only damage ("... to chosen character of yours" / "to this character" / + * "to each of your ... character") never matches the target qualifiers here. + */ +const REMOVAL_DAMAGE_PATTERN = + /deals? \d+ damage to (?:up to \d+ )?(?:another |each )?(?:chosen|target|opposing)[^.]{0,25}?character/i; + +/** Combat-stat debuff: "chosen [opposing] character gets -N ¤/⛉". The minus sign gates out buffs; + * the strength/willpower glyph keeps it off lore (-N ◊) reduction, which is the Lore Denial axis. */ +const REMOVAL_DEBUFF_PATTERN = /chosen (?:opposing )?character[^.]{0,20}gets -\d+ ?[¤⛉]/i; + +/** Target restrictions that name the hoser trigger. Verb-anchored so a keyword REMINDER + * ("Only characters with Evasive can challenge this character") can't fake a match. */ +const REMOVAL_TARGET_DAMAGED = /(?:banish|damage to|return)[^.]{0,30}\bdamaged character/i; +const REMOVAL_TARGET_EVASIVE = /(?:banish|damage to|return)[^.]{0,30}character with evasive/i; +const REMOVAL_TARGET_BODYGUARD = /(?:banish|damage to|return)[^.]{0,30}character with bodyguard/i; +/** Board-wide removal: banish/damage ALL or EACH opposing characters, or hit up-to-N chosen. */ +const REMOVAL_MASS_PATTERN = /\b(?:all|each) (?:opposing )?(?:damaged )?characters?\b|up to \d+ chosen/i; + +/** + * Singer/Sing-Together reminders print "(A character with cost N or more can ⟳ to sing this + * song for free.)", whose "cost N or more" would otherwise be read as a removal threshold and + * poison both the conditional-banish split and the threshold extraction. Strip them first. + */ +const REMOVAL_SINGER_REMINDER = /\([^)]*sing this song[^)]*\)/gi; + +/** Fast pre-filter for the text-scanned roles (bounce is delegated and has its own pre-filter). */ +const HAS_REMOVAL_KEYWORD = /banish|damage|gets -/i; + +/** Card text with the Singer reminder stripped, so its "cost N or more" can't fake a threshold. */ +function removalText(card: LorcanaCard): string { + return normalizeCardText(card).replace(REMOVAL_SINGER_REMINDER, ' '); +} + +/** Hard/conditional banisher: banishes an opposing/neutral body and is NOT a self-banish (Sacrifice). */ +function isRemovalBanisher(text: string): boolean { + return REMOVAL_BANISH_PATTERN.test(text) && !SACRIFICE_SELF_BANISH_PATTERN.test(text); +} + +/** Opponent-facing damage that is not the self-only "deal N damage to chosen character of yours". */ +function isRemovalDamage(text: string): boolean { + return REMOVAL_DAMAGE_PATTERN.test(text) && !ACTION_DAMAGE_SELF_ONLY_PATTERN.test(text); +} + +/** + * Determine the removal role(s) a card fulfills. A card can be multi-role. Bounce is delegated + * to the Bounce detector (opponent-bounce only) rather than re-parsed, so the two stay in sync. + */ +export function getRemovalRoles(card: LorcanaCard): RemovalRole[] { + const roles: RemovalRole[] = []; + if (card.text) { + const text = removalText(card); + if (HAS_REMOVAL_KEYWORD.test(text)) { + if (isRemovalBanisher(text)) { + roles.push(REMOVAL_THRESHOLD_PATTERN.test(text) ? 'conditional-banish' : 'banish'); + } + if (isRemovalDamage(text)) roles.push('damage'); + if (REMOVAL_DEBUFF_PATTERN.test(text)) roles.push('debuff'); + } + } + if (getBounceRoles(card).includes('opponent-bounce')) roles.push('bounce'); + return roles; +} + +/** Check if a card is any kind of opponent-removal card. */ +export const isRemovalCard = (card: LorcanaCard): boolean => getRemovalRoles(card).length > 0; + +/** Extract the stat/cost threshold gate: "or less" ⇒ low-strength, "or more" ⇒ high-cost. */ +function removalThreshold(text: string): RemovalCondition | null { + const less = text.match(REMOVAL_THRESHOLD_LESS); + if (less) return {type: 'low-strength', threshold: parseInt(less[1], 10)}; + const more = text.match(REMOVAL_THRESHOLD_MORE); + if (more) return {type: 'high-cost', threshold: parseInt(more[1], 10)}; + return null; +} + +/** + * The hoser trigger a card's removal keys on, or null when the card is not removal. + * Priority runs most-specific first (a named target restriction / threshold beats the + * board-wide 'mass' flag), falling through to 'unconditional' for ungated removal. + */ +export function getRemovalCondition(card: LorcanaCard): RemovalCondition | null { + if (getRemovalRoles(card).length === 0) return null; + const text = removalText(card); + if (REMOVAL_TARGET_DAMAGED.test(text)) return {type: 'damaged'}; + if (REMOVAL_TARGET_EVASIVE.test(text)) return {type: 'evasive'}; + if (REMOVAL_TARGET_BODYGUARD.test(text)) return {type: 'bodyguard'}; + const threshold = removalThreshold(text); + if (threshold) return threshold; + if (REMOVAL_MASS_PATTERN.test(text)) return {type: 'mass'}; + return {type: 'unconditional'}; +} diff --git a/packages/synergy-engine/src/utils/index.ts b/packages/synergy-engine/src/utils/index.ts index 31c63275..517d0a09 100644 --- a/packages/synergy-engine/src/utils/index.ts +++ b/packages/synergy-engine/src/utils/index.ts @@ -56,6 +56,9 @@ export { isExertConsumePayoff, getBounceRoles, isBounceCard, + getRemovalRoles, + getRemovalCondition, + isRemovalCard, getTribalRoles, isTribalCard, TRIBAL_SPECS, @@ -89,6 +92,9 @@ export type { HealRole, ExertRole, BounceRole, + RemovalRole, + RemovalConditionType, + RemovalCondition, BeckonEnablerTier, TribalRole, TribalSpec, diff --git a/scripts/precompute-hosers.mjs b/scripts/precompute-hosers.mjs new file mode 100644 index 00000000..67e71c7e --- /dev/null +++ b/scripts/precompute-hosers.mjs @@ -0,0 +1,137 @@ +#!/usr/bin/env node +/** + * Pre-computes the "what to watch for" (hoser) catalog for the deck builder. + * + * A "hoser" is a Core-legal removal card that punishes a deck's board COMPOSITION + * rather than removing an arbitrary body — e.g. a banisher gated on low strength, + * a board wipe, an Evasive/Bodyguard hunter, etc. The deck builder reads this + * catalog to warn a player which opposing cards their board shape invites. + * + * It is pool-derived (no hardcoded card names) so it survives set rotation: every + * entry comes from scanning the current Core pool through the engine's + * `getRemovalCondition`, keeping only cards whose condition is NOT 'unconditional'. + * + * Output: + * apps/web/public/data/hosers.json + * [ { cardId, name, ink, condition: { type, threshold? }, scope, text }, ... ] + * sorted by cardId (numeric-aware) for clean diffs. + * + * Usage: + * node scripts/precompute-hosers.mjs + */ +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const ROOT = path.resolve(__dirname, '..'); +const MAIN_DATA_FILE = path.join(ROOT, 'apps/web/public/data/allCards.json'); +const PREVIEW_DATA_FILE = path.join(ROOT, 'apps/web/public/data/previewCards.json'); +const OUTPUT_FILE = path.join(ROOT, 'apps/web/public/data/hosers.json'); + +/** + * Curated overrides for hosers whose condition the removal regex can't parse + * (odd wording, templated gates, split-clause thresholds, etc.). Each entry is a + * full hoser record and is merged in by cardId: a curated entry OVERRIDES the + * auto-detected one for the same card, and a curated-only cardId is appended. + * Empty for now — populate as mis-classified hosers surface. + * + * Shape: { cardId, name, ink, condition: { type, threshold? }, scope, text } + */ +const CURATED_HOSERS = []; + +/** Load Core-legal cards (main + optional preview) exactly like precompute-synergies. */ +function loadCoreCards({transformCards, isCoreSet, MIN_CORE_SET}) { + const mainData = JSON.parse(fs.readFileSync(MAIN_DATA_FILE, 'utf-8')); + const mainIds = new Set(mainData.cards.map((c) => c.id)); + + let mergedRaw = mainData.cards; + let previewCount = 0; + if (fs.existsSync(PREVIEW_DATA_FILE)) { + const previewData = JSON.parse(fs.readFileSync(PREVIEW_DATA_FILE, 'utf-8')); + const previewFiltered = previewData.cards.filter((c) => !mainIds.has(c.id)); + previewCount = previewFiltered.length; + mergedRaw = [...mainData.cards, ...previewFiltered]; + } + + const preCoreCount = mergedRaw.length; + mergedRaw = mergedRaw.filter((c) => isCoreSet(c.setCode)); + if (mergedRaw.length < preCoreCount) { + console.log(` Core filter: dropped ${preCoreCount - mergedRaw.length} cards (sets < ${MIN_CORE_SET})`); + } + + const cards = transformCards(mergedRaw); + console.log( + ` ${cards.length}/${mergedRaw.length} cards loaded` + + (previewCount > 0 ? ` (${previewCount} from previewCards.json)` : ''), + ); + return cards; +} + +/** Build a hoser record from a card and its (already non-unconditional) removal condition. */ +function toHoser(card, condition) { + const cond = {type: condition.type}; + if (condition.threshold !== undefined) cond.threshold = condition.threshold; + return { + cardId: card.id, + name: card.fullName, + ink: card.ink, + condition: cond, + scope: condition.type === 'mass' ? 'mass' : 'conditional', + text: card.text ?? '', + }; +} + +async function main() { + console.log('⚙ Pre-computing hoser catalog...'); + const startTime = Date.now(); + + const enginePath = path.join(ROOT, 'packages/synergy-engine/dist/index.js'); + if (!fs.existsSync(enginePath)) { + console.error('ERROR: Engine not built. Run `pnpm build:engine` first.'); + process.exit(1); + } + const engineUrl = new URL(`file:///${enginePath.replace(/\\/g, '/')}`); + const {transformCards, isCoreSet, MIN_CORE_SET, getRemovalCondition} = await import(engineUrl.href); + + const cards = loadCoreCards({transformCards, isCoreSet, MIN_CORE_SET}); + + // Scan the pool: keep only conditional/mass removal (drop 'unconditional' and non-removal nulls). + const byId = new Map(); + for (const card of cards) { + const condition = getRemovalCondition(card); + if (!condition || condition.type === 'unconditional') continue; + byId.set(card.id, toHoser(card, condition)); + } + + // Merge curated overrides (override on cardId collision, append curated-only entries). + for (const override of CURATED_HOSERS) { + byId.set(override.cardId, override); + } + + // Stable ordering by cardId (numeric-aware) for clean diffs. + const hosers = [...byId.values()].sort((a, b) => + a.cardId.localeCompare(b.cardId, undefined, {numeric: true}), + ); + + fs.writeFileSync(OUTPUT_FILE, JSON.stringify(hosers, null, 2) + '\n'); + + // Summary: total + condition-type distribution. + const distribution = {}; + for (const h of hosers) { + distribution[h.condition.type] = (distribution[h.condition.type] ?? 0) + 1; + } + const elapsed = ((Date.now() - startTime) / 1000).toFixed(1); + console.log(`✓ Pre-computed hoser catalog in ${elapsed}s`); + console.log(` ${hosers.length} hosers (${CURATED_HOSERS.length} curated overrides)`); + console.log(' Condition distribution:'); + for (const [type, count] of Object.entries(distribution).sort((a, b) => b[1] - a[1])) { + console.log(` ${type}: ${count}`); + } + console.log(` Output: ${OUTPUT_FILE}`); +} + +main().catch((err) => { + console.error('Hoser pre-computation failed:', err); + process.exit(1); +}); diff --git a/supabase/migrations/20260710000000_profiles_table.sql b/supabase/migrations/20260710000000_profiles_table.sql new file mode 100644 index 00000000..a371dab3 --- /dev/null +++ b/supabase/migrations/20260710000000_profiles_table.sql @@ -0,0 +1,114 @@ +-- profiles: one row per auth user, holding PUBLIC IDENTITY ONLY (handle, +-- display_name, avatar_url). Never mirror auth.users PII (email, provider +-- tokens) here. Deck-builder Phase 1, PLAN Part B. +-- +-- Visibility is handle-gated: a profile becomes world-readable only once the +-- user publishes a @handle (a Phase 4 action). Until then only the owner sees +-- their own row, so the OAuth-derived display name is not exposed to anon +-- scrapers before opt-in. Nothing in Phase 1 reads other users' profiles. + +create table public.profiles ( + id uuid primary key references auth.users (id) on delete cascade, + handle text, + display_name text, + avatar_url text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + -- Handle is optional in v1 (set at profile publish, PLAN Part E.5). When set it + -- must be a 3-30 char slug; case-insensitive uniqueness is the index below. + constraint profiles_handle_format + check (handle is null or handle ~ '^[a-z0-9_]{3,30}$'), + constraint profiles_display_name_len + check (display_name is null or char_length(display_name) <= 60) +); + +-- Case-insensitive handle uniqueness (blocks Alice vs alice impersonation), but +-- only across rows that HAVE a handle, so unlimited rows keep a null handle (the +-- v1 default). This index is the ONLY authority on availability: the app must +-- treat a 23505 as handle-taken, never a read-then-insert TOCTOU pre-check. +create unique index profiles_handle_lower_key + on public.profiles (lower(handle)) + where handle is not null; + +alter table public.profiles enable row level security; + +-- Read: a published (handle set) profile is world-visible; owners always see own row. +create policy profiles_select_public_or_own on public.profiles + for select to anon, authenticated + using (handle is not null or (select auth.uid()) = id); + +create policy profiles_insert_own on public.profiles + for insert to authenticated + with check ((select auth.uid()) = id); + +create policy profiles_update_own on public.profiles + for update to authenticated + using ((select auth.uid()) = id) + with check ((select auth.uid()) = id); + +-- No DELETE policy: profile lifecycle rides the auth.users ON DELETE CASCADE. + +-- Generic updated_at stamper, reused by later owner-scoped tables (decks). +-- SECURITY INVOKER (a plain trigger, no cross-table access) but search_path is +-- still pinned to '' per the repo's function-search_path advisor discipline. +create function public.set_updated_at() +returns trigger +language plpgsql +set search_path = '' +as $$ +begin + new.updated_at := now(); + return new; +end; +$$; +revoke execute on function public.set_updated_at() from public, anon, authenticated; + +create trigger profiles_set_updated_at + before update on public.profiles + for each row execute function public.set_updated_at(); + +-- Auto-provision a profile whenever an auth user is created. SECURITY DEFINER so +-- it can write public.profiles from inside the auth signup transaction; empty +-- search_path with fully-qualified names to preempt function_search_path_mutable. +-- Handle is deliberately left null (deriving/uniquing it here could raise inside +-- signup and break auth entirely). display_name / avatar_url are copied from the +-- OAuth metadata (Google + Discord populate these under varying keys). nullif + +-- on-conflict-do-nothing make the insert incapable of aborting a signup. +create function public.handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + insert into public.profiles (id, display_name, avatar_url) + values ( + new.id, + coalesce( + nullif(new.raw_user_meta_data ->> 'name', ''), + nullif(new.raw_user_meta_data ->> 'full_name', '') + ), + coalesce( + nullif(new.raw_user_meta_data ->> 'avatar_url', ''), + nullif(new.raw_user_meta_data ->> 'picture', '') + ) + ) + on conflict (id) do nothing; + return new; +end; +$$; +-- EXECUTE revoked so this SECURITY DEFINER function is NOT anon-callable via +-- /rest/v1/rpc (which would trip advisor 0028/0029, as submit_vote does). A +-- trigger still fires after the revoke. +revoke execute on function public.handle_new_user() from public, anon, authenticated; + +create trigger on_auth_user_created + after insert on auth.users + for each row execute function public.handle_new_user(); + +-- Least privilege: Supabase default privileges already grant ALL to anon + +-- authenticated on new public tables, and RLS is the row gate. Strip the write +-- grants anon must never hold, and delete from authenticated (lifecycle is the +-- auth.users cascade, not an API delete). +revoke insert, update, delete, truncate, references on public.profiles from anon; +revoke delete, truncate, references on public.profiles from authenticated; diff --git a/supabase/migrations/20260710000001_decks_table.sql b/supabase/migrations/20260710000001_decks_table.sql new file mode 100644 index 00000000..e1c3028d --- /dev/null +++ b/supabase/migrations/20260710000001_decks_table.sql @@ -0,0 +1,65 @@ +-- decks: saved (and first-sign-in-migrated) decks, one row per deck. Deck-builder +-- Phase 1, PLAN Part B. Owner-scoped: a user reads/writes only their own rows; +-- anyone may read a row the owner explicitly marked is_public. cards is the +-- JSONB DeckCard[] from apps/web/src/features/deck/types.ts. + +create table public.decks ( + id uuid primary key default gen_random_uuid(), + owner_id uuid not null references auth.users (id) on delete cascade, + name text not null, + gameplan text, + inks text[] not null default '{}', + cards jsonb not null default '[]'::jsonb, + is_public boolean not null default false, + schema_version smallint not null default 1, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint decks_name_len check (char_length(name) between 1 and 120), + constraint decks_cards_array check (jsonb_typeof(cards) = 'array'), + -- Archetype union from apps/web/src/features/deck/types.ts (nullable = auto-detect). + constraint decks_gameplan_valid + check (gameplan is null or gameplan in + ('aggro', 'tempo', 'midrange', 'control', 'combo', 'ramp')), + -- Validate the ink VALUES (the 6 inks never rotate) but deliberately do NOT cap + -- the ink COUNT: the two-ink rule is advisory (PLAN line 13), so an over-ink + -- draft must still save and surface its legality error in the app. + constraint decks_inks_valid + check (inks <@ array['Amber', 'Amethyst', 'Emerald', 'Ruby', 'Sapphire', 'Steel']::text[]) +); + +create index decks_owner_id_idx on public.decks (owner_id); +-- Backs the public feed lookups (Phase 4); partial keeps it small. +create index decks_public_updated_idx + on public.decks (updated_at desc) where is_public; + +alter table public.decks enable row level security; + +-- Read: your own decks always; anyone (incl. anon) may read an is_public deck. +create policy decks_select_public_or_own on public.decks + for select to anon, authenticated + using (is_public or (select auth.uid()) = owner_id); + +-- Insert: you may only create decks you own. Without this WITH CHECK an authed +-- user could forge owner_id onto a victim (authorship spoofing). Mandatory. +create policy decks_insert_own on public.decks + for insert to authenticated + with check ((select auth.uid()) = owner_id); + +-- Update: only your rows (USING); cannot reassign to another owner (WITH CHECK). +-- Both are stated explicitly; a lone USING is not relied on to double as the check. +create policy decks_update_own on public.decks + for update to authenticated + using ((select auth.uid()) = owner_id) + with check ((select auth.uid()) = owner_id); + +create policy decks_delete_own on public.decks + for delete to authenticated + using ((select auth.uid()) = owner_id); + +create trigger decks_set_updated_at + before update on public.decks + for each row execute function public.set_updated_at(); + +-- Least privilege: anon may only SELECT (public decks); never write. +revoke insert, update, delete, truncate, references on public.decks from anon; +revoke truncate, references on public.decks from authenticated; diff --git a/supabase/migrations/20260710000002_truncate_profile_display_name.sql b/supabase/migrations/20260710000002_truncate_profile_display_name.sql new file mode 100644 index 00000000..e1689ec8 --- /dev/null +++ b/supabase/migrations/20260710000002_truncate_profile_display_name.sql @@ -0,0 +1,38 @@ +-- Fix: a long OAuth display name aborted signup. handle_new_user() inserted the +-- raw provider name into public.profiles.display_name, but the table has +-- `check (char_length(display_name) <= 60)`. A Google/Discord user whose name +-- exceeds 60 chars made the trigger's INSERT violate the check, and because the +-- trigger is SECURITY DEFINER inside the auth signup transaction, that aborted +-- the whole signup. Truncate to 60 so the insert can never violate the check. +-- (avatar_url has no length constraint, so it is left as-is.) +-- +-- CREATE OR REPLACE preserves the function's existing grants, but the REVOKE is +-- repeated defensively so this stays advisor-clean if replayed on a fresh DB. + +create or replace function public.handle_new_user() +returns trigger +language plpgsql +security definer +set search_path = '' +as $$ +begin + insert into public.profiles (id, display_name, avatar_url) + values ( + new.id, + left( + coalesce( + nullif(new.raw_user_meta_data ->> 'name', ''), + nullif(new.raw_user_meta_data ->> 'full_name', '') + ), + 60 + ), + coalesce( + nullif(new.raw_user_meta_data ->> 'avatar_url', ''), + nullif(new.raw_user_meta_data ->> 'picture', '') + ) + ) + on conflict (id) do nothing; + return new; +end; +$$; +revoke execute on function public.handle_new_user() from public, anon, authenticated; diff --git a/supabase/migrations/20260711000000_decks_updated_at_server_owned.sql b/supabase/migrations/20260711000000_decks_updated_at_server_owned.sql new file mode 100644 index 00000000..05720089 --- /dev/null +++ b/supabase/migrations/20260711000000_decks_updated_at_server_owned.sql @@ -0,0 +1,19 @@ +-- Harden decks.updated_at against client forgery on INSERT (CodeRabbit #480). +-- +-- The decks_set_updated_at trigger fired BEFORE UPDATE only, so on INSERT the +-- column fell back to its `default now()` — which an authenticated client can +-- override by POSTing an explicit updated_at (e.g. a far-future timestamp). +-- decks_public_updated_idx orders the Phase-4 public feed by `updated_at desc`, +-- so a forged value could pin a deck at the top of the feed. Widen the existing +-- stamper to BEFORE INSERT OR UPDATE so updated_at is server-owned on every path. +-- +-- created_at is deliberately left client-settable: deckRepository.deckToInsert +-- carries the local draft's created_at so an aged anonymous draft keeps its real +-- age when it migrates to the cloud (PLAN Phase 1). It is unindexed and low-impact, +-- so it stays under the app's control rather than being reset to migration time. + +drop trigger if exists decks_set_updated_at on public.decks; + +create trigger decks_set_updated_at + before insert or update on public.decks + for each row execute function public.set_updated_at(); diff --git a/vercel.json b/vercel.json index c7bc5c86..ba7dab3a 100644 --- a/vercel.json +++ b/vercel.json @@ -39,6 +39,15 @@ } ] }, + { + "source": "/data/hosers.json", + "headers": [ + { + "key": "Cache-Control", + "value": "public, max-age=3600, stale-while-revalidate=86400" + } + ] + }, { "source": "/data/vote-analytics.json", "headers": [