Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions .claude/skills/deckbuilder-shape/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
17 changes: 10 additions & 7 deletions .claude/skills/deckbuilder-shape/references/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
8 changes: 8 additions & 0 deletions src/deckBuilder/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
146 changes: 146 additions & 0 deletions src/deckBuilder/__tests__/deckRules.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, ShapeDefinition>,
};
});

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);
});
143 changes: 143 additions & 0 deletions src/deckBuilder/deckRules.ts
Original file line number Diff line number Diff line change
@@ -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<F extends FeatureName>(
shapes: readonly ShapeName[],
feature: F
): FeatureValue<F>[] {
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<FeatureName | undefined>((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 = <F extends FeatureName>(
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 } };
};
26 changes: 26 additions & 0 deletions src/deckBuilder/features/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,32 @@ export function getAvailableValue<F extends FeatureName>(
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<F extends FeatureName>(
feature: F,
count: number,
keep?: FeatureValue<F>,
keepIndex = 0
): FeatureValue<F>[] {
const assigned: FeatureValue<F>[] = keep === undefined ? [] : [keep];
const values: FeatureValue<F>[] = [];
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
Expand Down
Loading
Loading