From 5eb98356d0d06d5b848d35a488abba1942e03d5b Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 30 Jul 2026 21:51:17 +0000 Subject: [PATCH] Let a symbol repeat when it owns an internal feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The deck builder made every card's symbol unique, which left one deck unbuildable: three fried eggs telling each other apart by yolk count. The yolks feature only unlocks once every card is an egg, and the uniqueness rule made that unreachable. A shape that owns a `requiresShapeSupport` feature now stands in for the symbol feature, so it can be worn by more than one card. Picking it a second time collapses the deck onto that one symbol — the symbol stops varying and its internal feature switches on with a value per card — which also keeps the generated deck from holding cards that render identically. Shapes owning no such feature are still one to a deck. The card count follows from the same data: a deck can hold no more cards than the smallest option pool it varies, so three yolk counts cap an egg deck at three cards. That also closes a latent hole where adding a card past a feature's option count wrote `undefined` into deck metadata. None of this names the fried egg — capacity, unlocking and the card cap all read the shape registry and the feature table, so the next shape with its own feature gets the same treatment. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01EA4U1xNbvJ3BwVKft8ECjh --- .claude/skills/deckbuilder-shape/SKILL.md | 7 +- .../references/architecture.md | 17 +- src/deckBuilder/README.md | 8 + src/deckBuilder/__tests__/deckRules.test.ts | 146 ++++++++++++++++++ src/deckBuilder/deckRules.ts | 143 +++++++++++++++++ src/deckBuilder/features/index.ts | 26 ++++ src/deckBuilder/shapes/index.ts | 16 +- src/views/gameEditor/GameEditor.tsx | 60 ++++--- .../gameEditor/__tests__/GameEditor.test.tsx | 121 +++++++++++++-- src/views/gameEditor/cardSelector.tsx | 3 +- src/views/gameEditor/featureSelect.tsx | 18 ++- 11 files changed, 508 insertions(+), 57 deletions(-) create mode 100644 src/deckBuilder/__tests__/deckRules.test.ts create mode 100644 src/deckBuilder/deckRules.ts diff --git a/.claude/skills/deckbuilder-shape/SKILL.md b/.claude/skills/deckbuilder-shape/SKILL.md index dc63edf..f120e95 100644 --- a/.claude/skills/deckbuilder-shape/SKILL.md +++ b/.claude/skills/deckbuilder-shape/SKILL.md @@ -241,9 +241,10 @@ order: ``` `requiresShapeSupport: true` marks this as a shape-only custom feature (no universal neutral value) — `GameEditor.tsx` reads this flag generically - to hide the feature's controls entirely in the editor until every card in - the deck uses a shape that declares support for it, via - `shapes/index.ts`'s `shapeSupportsFeature`. Omit it for features that + to hide the feature's controls entirely in the editor until the shapes in + the deck can give every card its own value of it, via `deckRules.ts`'s + `canShapesSupplyFeature`. The same flag lets a shape owning the feature be + repeated across cards (see `deckRules.ts`). Omit it for features that default to full support (like `rotations`/`filters`/`patterns`/`colors`). 3. **`CardSvg.tsx`** — resolve and pass it through, same shape as `resolveRotation`: diff --git a/.claude/skills/deckbuilder-shape/references/architecture.md b/.claude/skills/deckbuilder-shape/references/architecture.md index cf62032..ef4eb3f 100644 --- a/.claude/skills/deckbuilder-shape/references/architecture.md +++ b/.claude/skills/deckbuilder-shape/references/architecture.md @@ -32,13 +32,16 @@ while — this is a snapshot, not a guarantee. If the new feature is a shape-only custom feature (`requiresShapeSupport: true` in its `FEATURES` entry, e.g. `yolks`), `GameEditor.tsx` also hides that feature's `EnableFeature` switch and `FeatureSelect` dropdown entirely - (not just disables them) until every card in the deck resolves to a shape - whose `supports` declares it (via `shapes/index.ts`'s - `shapeSupportsFeature`), and auto-clears any per-card override the moment - that stops being true (e.g. a card's shape is edited away). This is generic - over the flag, not hardcoded to any one feature name — see - `views/gameEditor/__tests__/GameEditor.test.tsx` for the behavior this - guarantees. + (not just disables them) until the shapes in play can give every card its + own value of it (via `deckRules.ts`'s `canShapesSupplyFeature`), and + auto-clears any per-card override the moment that stops being true (e.g. a + card's shape is edited away). Owning such a feature is also what lets a + symbol repeat across cards: picking it twice collapses the deck onto that + one symbol and hands the varying job to its internal feature, which in turn + caps the card count (`deckRules.ts`). This is all generic over the flag, not + hardcoded to any one feature or shape name — see + `views/gameEditor/__tests__/GameEditor.test.tsx` and + `deckBuilder/__tests__/deckRules.test.ts` for the behavior this guarantees. - **Symbols render tiny.** `CardSvg` places each symbol in a `SYMBOL_SIZE = MAIN_VIEWPORT_SIZE / 3 - 5 = 35` unit box inside the 120-unit card viewport — a 0.29x downscale of the shape's own `0 0 120 120` space — diff --git a/src/deckBuilder/README.md b/src/deckBuilder/README.md index 6cda1ce..df6ee77 100644 --- a/src/deckBuilder/README.md +++ b/src/deckBuilder/README.md @@ -93,6 +93,14 @@ Features are data-driven from `features/index.ts`: add the option type to `FeatureOptionMap`, a row to `FEATURES`, a default to `DEFAULT_CARD`, and teach `CardSvg` how to apply it. The editor UI picks it up automatically. +A feature marked `requiresShapeSupport` belongs to the shapes that declare it +in `supports` rather than to every deck — the fried egg's yolk count is the +first. `deckRules.ts` derives the consequences generically: such a shape may be +repeated across cards, and picking it a second time collapses the deck onto +that one symbol so its own feature does the varying instead; the feature's +option count then caps how many cards the deck can hold. A shape declaring no +such feature can still only be used once per deck. + ## Colors and patterns The palette lives in `features/colors.ts`. Each named color is a `ColorSet` diff --git a/src/deckBuilder/__tests__/deckRules.test.ts b/src/deckBuilder/__tests__/deckRules.test.ts new file mode 100644 index 0000000..c979c6d --- /dev/null +++ b/src/deckBuilder/__tests__/deckRules.test.ts @@ -0,0 +1,146 @@ +import { ShapeDefinition } from "../types"; +import { SHAPE_NAMES, ShapeName } from "../shapes"; +import { YOLKS, GeneratedDeckMetaData, getFeatureOptions } from "../features"; +import { + MAX_CARDS, + SHAPE_ONLY_FEATURES, + canShapeFillDeck, + canShapesSupplyFeature, + collapseToShape, + getMaxCards, + getShapeCardCapacity, + getShapeFeature, + getShapeOnlyFeatures, + getSupportedFeatureValues, +} from "../deckRules"; +import { DEFAULT_CARD } from "../features"; + +// Two shapes that don't exist yet, standing in for whatever gets added later: +// one owning the full yolks range, one owning only part of it. Nothing here +// names the fried egg, so a future shape has to work by the same rules. +jest.mock("../shapes", () => { + const actual = jest.requireActual("../shapes"); + const Stub = () => null; + return { + ...actual, + SHAPE_REGISTRY: { + ...actual.SHAPE_REGISTRY, + "Stub - All Yolks": { Component: Stub, supports: { yolks: true } }, + "Stub - Two Yolks": { Component: Stub, supports: { yolks: [1, 2] } }, + "Stub - Plain": { Component: Stub, supports: { patterns: false } }, + } as Record, + }; +}); + +const ALL_YOLKS = "Stub - All Yolks" as ShapeName; +const TWO_YOLKS = "Stub - Two Yolks" as ShapeName; +const PLAIN = "Stub - Plain" as ShapeName; + +const DECK: GeneratedDeckMetaData = { + shapes: ["Circle - Three Quarter", "Circle - Quarter", "Circle - Semi"], + colors: ["Red", "Yellow", "Blue"], + numbers: [9, 3, 4], +}; + +test("yolks is the only shape-only feature today", () => { + expect(SHAPE_ONLY_FEATURES).toEqual(["yolks"]); +}); + +test("a shape owning no internal feature backs a single card", () => { + const plainShapes = SHAPE_NAMES.filter((name) => getShapeOnlyFeatures(name).length === 0); + expect(plainShapes).toContain("Triangle"); + plainShapes.forEach((name) => { + expect(getShapeFeature(name)).toBeUndefined(); + expect(getShapeCardCapacity(name)).toBe(1); + }); +}); + +test("the fried egg backs one card per yolk count", () => { + expect(getShapeOnlyFeatures("Fried Egg")).toEqual(["yolks"]); + expect(getShapeCardCapacity("Fried Egg")).toBe(YOLKS.length); +}); + +test("capacity comes from the shape's own declaration, not a known shape list", () => { + expect(getShapeCardCapacity(ALL_YOLKS)).toBe(getFeatureOptions("yolks").length); + expect(getShapeCardCapacity(TWO_YOLKS)).toBe(2); + expect(getShapeCardCapacity(PLAIN)).toBe(1); +}); + +test("a symbol may fill a deck only up to what its feature can tell apart", () => { + expect(canShapeFillDeck("Fried Egg", 2)).toBe(true); + expect(canShapeFillDeck("Fried Egg", 3)).toBe(true); + expect(canShapeFillDeck("Fried Egg", 4)).toBe(false); + + expect(canShapeFillDeck(TWO_YOLKS, 2)).toBe(true); + expect(canShapeFillDeck(TWO_YOLKS, 3)).toBe(false); +}); + +test("a symbol with no internal feature can never fill a deck", () => { + expect(canShapeFillDeck("Triangle", 2)).toBe(false); + expect(canShapeFillDeck(PLAIN, 2)).toBe(false); +}); + +test("deck size is capped by the smallest option pool in play", () => { + expect(getMaxCards(DECK)).toBe(MAX_CARDS); + expect(getMaxCards({})).toBe(MAX_CARDS); + expect(getMaxCards({ ...DECK, yolks: [1, 2, 3] })).toBe(YOLKS.length); +}); + +test("features that are off do not cap the deck", () => { + expect(getMaxCards({ colors: ["Red", "Blue"] })).toBe(MAX_CARDS); +}); + +test("collapsing onto a shape stops the symbol varying and starts its feature", () => { + const { deckData, deckDefaults } = collapseToShape( + { deckData: DECK, deckDefaults: DEFAULT_CARD }, + "Fried Egg", + 3 + ); + + expect(deckData.shapes).toBeUndefined(); + expect(deckDefaults.shapes).toBe("Fried Egg"); + expect(deckData.yolks).toHaveLength(3); + expect(new Set(deckData.yolks).size).toBe(3); + deckData.yolks?.forEach((yolk) => expect(YOLKS).toContain(yolk)); +}); + +test("collapsing leaves the deck's other features untouched", () => { + const { deckData } = collapseToShape({ deckData: DECK, deckDefaults: DEFAULT_CARD }, "Fried Egg", 3); + + expect(deckData.colors).toEqual(DECK.colors); + expect(deckData.numbers).toEqual(DECK.numbers); + expect(DECK.shapes).toBeDefined(); +}); + +test("collapsing onto a shape with no internal feature just fixes the symbol", () => { + const { deckData, deckDefaults } = collapseToShape( + { deckData: DECK, deckDefaults: DEFAULT_CARD }, + "Triangle", + 3 + ); + + expect(deckData.shapes).toBeUndefined(); + expect(deckDefaults.shapes).toBe("Triangle"); + expect(deckData.yolks).toBeUndefined(); +}); + +test("collapsing gives a partial-support shape only the values it declares", () => { + const { deckData } = collapseToShape({ deckData: DECK, deckDefaults: DEFAULT_CARD }, TWO_YOLKS, 2); + + expect(deckData.yolks).toEqual([1, 2]); +}); + +test("a feature offers only the values every shape in play can draw", () => { + expect(getSupportedFeatureValues(["Fried Egg"], "yolks")).toEqual([...YOLKS]); + expect(getSupportedFeatureValues([TWO_YOLKS], "yolks")).toEqual([1, 2]); + expect(getSupportedFeatureValues([ALL_YOLKS, TWO_YOLKS], "yolks")).toEqual([1, 2]); + expect(getSupportedFeatureValues(["Fried Egg", "Triangle"], "yolks")).toEqual([]); +}); + +test("a feature stays locked when it cannot give every card its own value", () => { + expect(canShapesSupplyFeature(["Fried Egg", "Fried Egg"], "yolks", 3)).toBe(true); + // Three yolk counts cannot tell four cards apart. + expect(canShapesSupplyFeature(["Fried Egg", "Fried Egg"], "yolks", 4)).toBe(false); + expect(canShapesSupplyFeature([TWO_YOLKS], "yolks", 3)).toBe(false); + expect(canShapesSupplyFeature(["Triangle"], "yolks", 2)).toBe(false); +}); diff --git a/src/deckBuilder/deckRules.ts b/src/deckBuilder/deckRules.ts new file mode 100644 index 0000000..94fc1f1 --- /dev/null +++ b/src/deckBuilder/deckRules.ts @@ -0,0 +1,143 @@ +import { SHAPE_REGISTRY, ShapeName } from "./shapes"; +import { ShapeFeatureSupport } from "./types"; +import { + CardData, + FEATURES, + FEATURE_NAMES, + FeatureName, + FeatureValue, + GeneratedDeckMetaData, + getEnabledOptions, + getFeatureOptions, + setFeatureOptions, +} from "./features"; + +/** + * The rules governing how many cards a deck can hold and when a symbol may + * repeat across them. Kept out of `shapes/index.ts` because these rules need + * the feature table, which already imports the shape registry. + */ + +/** Hard ceiling on cards, independent of which features vary. */ +export const MAX_CARDS = 4; + +/** + * Features that belong to a shape rather than to every deck — flagged + * `requiresShapeSupport` in FEATURES, and opted into per shape via + * `ShapeDefinition.supports`. The fried egg's yolk count is the first. + */ +export const SHAPE_ONLY_FEATURES: FeatureName[] = FEATURE_NAMES.filter( + (feature) => FEATURES[feature].requiresShapeSupport +); + +/** + * The values of `feature` that every one of `shapes` can render: the whole + * option list for a shape declaring `true`, the listed subset for one naming + * values, nothing for a shape that declares no support. Returned in the + * feature's own option order. + */ +export function getSupportedFeatureValues( + shapes: readonly ShapeName[], + feature: F +): FeatureValue[] { + const declared = (shape: ShapeName) => + SHAPE_REGISTRY[shape].supports?.[feature as keyof ShapeFeatureSupport]; + return getFeatureOptions(feature).filter((option) => + shapes.every((shape) => { + const supported = declared(shape); + return supported === true || (Array.isArray(supported) && supported.includes(option)); + }) + ); +} + +/** The shape-only features `shape` declares usable values for. */ +export const getShapeOnlyFeatures = (shape: ShapeName): FeatureName[] => + SHAPE_ONLY_FEATURES.filter((feature) => getSupportedFeatureValues([shape], feature).length > 0); + +/** + * The internal feature best able to tell apart cards that share this shape — + * the one offering the most values. Undefined for a shape that owns none. + */ +export const getShapeFeature = (shape: ShapeName): FeatureName | undefined => + getShapeOnlyFeatures(shape).reduce((widest, feature) => { + const count = getSupportedFeatureValues([shape], feature).length; + return widest === undefined || count > getSupportedFeatureValues([shape], widest).length + ? feature + : widest; + }, undefined); + +/** + * How many cards one shape can back on its own. A plain shape backs a single + * card — the symbol *is* what tells that card from the others — while a shape + * owning an internal feature can back one card per value of that feature. + */ +export const getShapeCardCapacity = (shape: ShapeName): number => { + const feature = getShapeFeature(shape); + return feature === undefined ? 1 : getSupportedFeatureValues([shape], feature).length; +}; + +/** + * Whether every card of an `numberOfCards`-card deck may share this shape. + * Repeating a symbol is all-or-nothing (see `collapseToShape`), so the shape's + * internal feature has to have a value to spare for each card. + */ +export const canShapeFillDeck = (shape: ShapeName, numberOfCards: number): boolean => { + const capacity = getShapeCardCapacity(shape); + return capacity > 1 && capacity >= numberOfCards; +}; + +/** + * Whether a shape-only feature can give each card its own value given the + * shapes in play — what unlocks the feature's controls in the editor. + */ +export const canShapesSupplyFeature = ( + shapes: readonly ShapeName[], + feature: FeatureName, + numberOfCards: number +): boolean => getSupportedFeatureValues(shapes, feature).length >= numberOfCards; + +/** + * The most cards a deck can hold: every varying feature needs a distinct value + * per card, so the smallest option pool in play sets the limit. + */ +export const getMaxCards = (metaData: GeneratedDeckMetaData): number => + FEATURE_NAMES.reduce( + (max, feature) => + getEnabledOptions(metaData, feature) ? Math.min(max, getFeatureOptions(feature).length) : max, + MAX_CARDS + ); + +/** The editor's deck state: the features that vary, plus the values shared by every card. */ +export interface DeckState { + deckData: GeneratedDeckMetaData; + deckDefaults: CardData; +} + +const enableFeature = ( + metaData: GeneratedDeckMetaData, + feature: F, + shape: ShapeName, + count: number +): void => + setFeatureOptions(metaData, feature, getSupportedFeatureValues([shape], feature).slice(0, count)); + +/** + * Hand the symbol feature's job over to a shape's own internal feature: every + * card becomes `shape`, so the symbol stops varying and moves to the shared + * defaults, and the shape's internal feature switches on with a distinct value + * per card. Without the handover a repeated symbol would leave the deck's + * cartesian product holding cards that render identically. + */ +export const collapseToShape = ( + state: DeckState, + shape: ShapeName, + numberOfCards: number +): DeckState => { + const deckData: GeneratedDeckMetaData = { ...state.deckData }; + delete deckData.shapes; + const feature = getShapeFeature(shape); + if (feature !== undefined && !getEnabledOptions(deckData, feature)) { + enableFeature(deckData, feature, shape, numberOfCards); + } + return { deckData, deckDefaults: { ...state.deckDefaults, shapes: shape } }; +}; diff --git a/src/deckBuilder/features/index.ts b/src/deckBuilder/features/index.ts index 46e5500..ff725b7 100644 --- a/src/deckBuilder/features/index.ts +++ b/src/deckBuilder/features/index.ts @@ -101,6 +101,32 @@ export function getAvailableValue( return available[Math.floor(Math.random() * available.length)]; } +/** + * `count` distinct options for a feature, one per card. `keep` pins a value to + * index `keepIndex` — the shared default of a feature being switched on — and + * is reserved up front so no later slot can collide with it (getAvailableValue + * only knows to avoid values it has already been told about). + */ +export function assignDistinctValues( + feature: F, + count: number, + keep?: FeatureValue, + keepIndex = 0 +): FeatureValue[] { + const assigned: FeatureValue[] = keep === undefined ? [] : [keep]; + const values: FeatureValue[] = []; + for (let i = 0; i < count; i++) { + if (keep !== undefined && i === keepIndex) { + values.push(keep); + } else { + const value = getAvailableValue(feature, assigned); + values.push(value); + assigned.push(value); + } + } + return values; +} + /** * Assign one feature's option list on deck metadata. TypeScript cannot check * writes through a generic key on an optional mapped type directly, so every diff --git a/src/deckBuilder/shapes/index.ts b/src/deckBuilder/shapes/index.ts index 008fc00..f27c6b2 100644 --- a/src/deckBuilder/shapes/index.ts +++ b/src/deckBuilder/shapes/index.ts @@ -1,4 +1,4 @@ -import { ShapeDefinition, ShapeFeatureSupport } from "../types"; +import { ShapeDefinition } from "../types"; import { CircleQuarter, CircleSemi, CircleThreeQuarter } from "./Circles"; import { FriedEgg } from "./FriedEgg"; import { TetrisJBlock, TetrisLBlock, TetrisSBlock, TetrisTBlock } from "./Tetris"; @@ -33,17 +33,3 @@ export const SHAPE_REGISTRY = defineShapes({ export type ShapeName = keyof typeof SHAPE_REGISTRY; export const SHAPE_NAMES = Object.keys(SHAPE_REGISTRY) as ShapeName[]; - -/** - * Whether `shape` explicitly declares support for `feature` (true, or a - * listed subset) — used to gate FeatureConfig.requiresShapeSupport features - * in the editor. Meaningless (and unused) for features that don't set that - * flag, since those default to full support when unspecified. - */ -export const shapeSupportsFeature = ( - shape: ShapeName, - feature: keyof ShapeFeatureSupport -): boolean => { - const supported = SHAPE_REGISTRY[shape].supports?.[feature]; - return supported === true || Array.isArray(supported); -}; diff --git a/src/views/gameEditor/GameEditor.tsx b/src/views/gameEditor/GameEditor.tsx index 67a442f..df03dd2 100644 --- a/src/views/gameEditor/GameEditor.tsx +++ b/src/views/gameEditor/GameEditor.tsx @@ -13,13 +13,18 @@ import { FeatureName, FeatureValue, GeneratedDeckMetaData, - getAvailableValue, + assignDistinctValues, getEnabledOptions, isGeneratedMetaData, setFeatureOptions, } from "deckBuilder/features"; -import { ShapeName, shapeSupportsFeature } from "deckBuilder/shapes"; -import { ShapeFeatureSupport } from "deckBuilder/types"; +import { + canShapeFillDeck, + canShapesSupplyFeature, + collapseToShape, + getSupportedFeatureValues, +} from "deckBuilder/deckRules"; +import { ShapeName } from "deckBuilder/shapes"; import { actions } from "views/actions"; import { FeatureSelect } from "./featureSelect"; import { CardSelector } from "./cardSelector"; @@ -50,9 +55,12 @@ export const GameEditor = () => { ? (shapeValues as ShapeName[]).slice(0, numberOfCards) : Array(numberOfCards).fill(deckDefaults.shapes as ShapeName); + // A shape-only feature is offered when the shapes in play can give every + // card its own value of it — a feature that runs out mid-deck could not tell + // the cards apart. const isFeatureLocked = (feature: FeatureName): boolean => !!FEATURES[feature].requiresShapeSupport && - !shapesInPlay.every((shape) => shapeSupportsFeature(shape, feature as keyof ShapeFeatureSupport)); + !canShapesSupplyFeature(shapesInPlay, feature, numberOfCards); useEffect(() => { const toClear = FEATURE_NAMES.filter((feature) => isFeatureLocked(feature) && deckData[feature]); @@ -72,6 +80,19 @@ export const GameEditor = () => { ) => { const values = getEnabledOptions(deckData, feature); if (values) { + if (feature === "shapes" && values[cardNumber] !== value && values.includes(value)) { + // Picking a symbol another card already wears only makes sense for a + // shape carrying its own feature, and that feature then takes over the + // symbol's job of telling the cards apart — for the whole deck. + const collapsed = collapseToShape( + { deckData, deckDefaults }, + (value as unknown) as ShapeName, + numberOfCards + ); + setDeckDefaults(collapsed.deckDefaults); + setDeckData(collapsed.deckData); + return; + } const newValues = [...values]; newValues[cardNumber] = value; const newDeckData: GeneratedDeckMetaData = { ...deckData }; @@ -97,21 +118,12 @@ export const GameEditor = () => { delete newDeckData[feature]; setDeckData(newDeckData); } else { - // The currently-selected card keeps the shared default; every other - // card gets a random still-unused option. The default is reserved - // up front so a later slot can never collide with it (getAvailableValue - // only knows to avoid values already in `assigned`). - const assigned: FeatureValue[] = [deckDefaults[feature]]; - const values: FeatureValue[] = []; - for (let i = 0; i < numberOfCards; i++) { - if (i === card) { - values.push(deckDefaults[feature]); - } else { - const value = getAvailableValue(feature, assigned); - values.push(value); - assigned.push(value); - } - } + // A shape-only feature can only hand out what its shape draws, so it + // takes its values in order. Everywhere else the currently-selected card + // keeps the shared default and every other card gets a still-unused option. + const values = FEATURES[feature].requiresShapeSupport + ? getSupportedFeatureValues(shapesInPlay, feature).slice(0, numberOfCards) + : assignDistinctValues(feature, numberOfCards, deckDefaults[feature], card); const newDeckData: GeneratedDeckMetaData = { ...deckData }; setFeatureOptions(newDeckData, feature, values); setDeckData(newDeckData); @@ -159,6 +171,16 @@ export const GameEditor = () => { value={values ? values[card] : deckDefaults[feature]} selection={values || [deckDefaults[feature]]} onChange={(value) => onDeckDataChange(card, feature, value)} + options={ + FEATURES[feature].requiresShapeSupport + ? getSupportedFeatureValues(shapesInPlay, feature) + : undefined + } + canRepeat={ + feature === "shapes" + ? (option) => canShapeFillDeck((option as unknown) as ShapeName, numberOfCards) + : undefined + } /> ); diff --git a/src/views/gameEditor/__tests__/GameEditor.test.tsx b/src/views/gameEditor/__tests__/GameEditor.test.tsx index 22ebf7e..4c72765 100644 --- a/src/views/gameEditor/__tests__/GameEditor.test.tsx +++ b/src/views/gameEditor/__tests__/GameEditor.test.tsx @@ -33,6 +33,99 @@ const setAllCardsToShape = (baseElement: HTMLElement, cardCount: number, shape: } }; +// `fireEvent.change` writes a select's value directly and ignores `disabled` +// on the target