diff --git a/.claude/skills/deckbuilder-shape/scripts/card-markup.tsx b/.claude/skills/deckbuilder-shape/scripts/card-markup.tsx
index 3e306c7..5252033 100644
--- a/.claude/skills/deckbuilder-shape/scripts/card-markup.tsx
+++ b/.claude/skills/deckbuilder-shape/scripts/card-markup.tsx
@@ -14,7 +14,7 @@
import * as React from "react";
import * as ReactDOMServer from "react-dom/server";
import { CardSvg, MAIN_VIEWPORT_SIZE } from "../../../../src/deckBuilder/CardSvg";
-import { CardData, DEFAULT_CARD, NUMBERS, ROTATIONS, YOLKS } from "../../../../src/deckBuilder/features";
+import { CardData, DEFAULT_CARD, NUMBERS, ROTATIONS, SPIRES, YOLKS } from "../../../../src/deckBuilder/features";
import { COLOR_SETS, ColorName, clampColorSet } from "../../../../src/deckBuilder/features/colors";
import { PATTERN_DEFS, PATTERN_NAMES, PatternName } from "../../../../src/deckBuilder/features/patterns";
import { SHAPE_NAMES, SHAPE_REGISTRY, ShapeName } from "../../../../src/deckBuilder/shapes";
@@ -83,9 +83,16 @@ const supportedYolks = (): (typeof YOLKS)[number][] => {
return [...supports.yolks];
};
+const supportedSpires = (): (typeof SPIRES)[number][] => {
+ if (supports.spires === false || supports.spires === undefined) return [1];
+ if (supports.spires === true) return [...SPIRES];
+ return [...supports.spires];
+};
+
const rotations = supportedRotations();
const patterns = supportedPatterns();
const yolks = supportedYolks();
+const spires = supportedSpires();
const numbers: (typeof NUMBERS)[number][] = [1, 2, 3];
interface Variant {
@@ -107,6 +114,7 @@ const buildVariants = (): Variant[] => {
rotations: rotations[0],
patterns: patterns[0],
yolks: yolks[0],
+ spires: spires[0],
};
const variants: Variant[] = [{ label: `${SAMPLE_COLORS[0]} · ${patterns[0]} · 1x`, card: base }];
@@ -125,6 +133,7 @@ const buildVariants = (): Variant[] => {
rotations.slice(1).forEach((rotation) => push(`rotate ${rotation}°`, { rotations: rotation }));
SAMPLE_COLORS.slice(1).forEach((color) => push(color, { colors: color }));
yolks.slice(1).forEach((count) => push(`yolks: ${count}`, { yolks: count }));
+ spires.slice(1).forEach((count) => push(`spires: ${count}`, { spires: count }));
// Then a few combinations, which is where clipping and overlap show up.
SAMPLE_COLORS.slice(1).forEach((color) =>
@@ -151,7 +160,7 @@ const renderBareSymbol = (): string => {
);
@@ -165,7 +174,7 @@ process.stdout.write(
colorCount,
symbolSize: SYMBOL_SIZE,
cardViewport: MAIN_VIEWPORT_SIZE,
- axes: { numbers, patterns, rotations, colors: SAMPLE_COLORS, yolks },
+ axes: { numbers, patterns, rotations, colors: SAMPLE_COLORS, yolks, spires },
bare: renderBareSymbol(),
variants: buildVariants().map((variant, index) => ({
label: variant.label,
diff --git a/src/daily/schedule.ts b/src/daily/schedule.ts
index 80d19a0..3456e55 100644
--- a/src/daily/schedule.ts
+++ b/src/daily/schedule.ts
@@ -72,6 +72,19 @@ export const DAILY_PUZZLE_SCHEDULE: DailyPuzzleSchedule = {
{ idPrefix: "daily-egg" }
),
},
+ "2026-08-01": {
+ name: "National Sand Castle Day",
+ createDeck: () =>
+ new GeometricDeckGenerator(
+ {
+ numbers: [1, 2, 3, 4],
+ spires: [1, 2, 3, 4],
+ colors: ["Beige", "Orange", "Brown", "Maroon"],
+ },
+ { shapes: "Sandcastle" },
+ { idPrefix: "daily-sandcastle" }
+ ),
+ },
};
/** The deck used whenever no scheduled puzzle matches the date. */
diff --git a/src/deckBuilder/CardSvg.tsx b/src/deckBuilder/CardSvg.tsx
index d2af6a5..52d4845 100644
--- a/src/deckBuilder/CardSvg.tsx
+++ b/src/deckBuilder/CardSvg.tsx
@@ -52,6 +52,15 @@ const resolveYolks = (
return supported.includes(yolks as 1 | 2 | 3) ? yolks : supported[0];
};
+const resolveSpires = (
+ supported: ShapeFeatureSupport["spires"],
+ spires: number
+): number => {
+ if (supported === false || supported === undefined) return 1;
+ if (supported === true) return spires;
+ return supported.includes(spires as 1 | 2 | 3 | 4) ? spires : supported[0];
+};
+
interface Props {
card: CardData;
/** Document-unique id, used to namespace this card's SVG defs. */
@@ -73,6 +82,7 @@ export const CardSvg = ({ card, cardId }: Props) => {
const filterName: FilterName = supports.filters === false ? "none" : card.filters;
const rotation = resolveRotation(supports.rotations, card.rotations);
const yolks = resolveYolks(supports.yolks, card.yolks);
+ const spires = resolveSpires(supports.spires, card.spires);
let patternName: PatternName = supports.patterns === false ? "solid" : card.patterns;
if (PATTERN_DEFS[patternName].colorsUsed > colorCount) {
patternName = "solid";
@@ -98,7 +108,12 @@ export const CardSvg = ({ card, cardId }: Props) => {
rotation ? `rotate(${rotation}, ${rotationCenter.x}, ${rotationCenter.y})` : undefined
}
>
-
+
);
diff --git a/src/deckBuilder/__tests__/cardSvg.test.tsx b/src/deckBuilder/__tests__/cardSvg.test.tsx
index 38965bb..9b7df5c 100644
--- a/src/deckBuilder/__tests__/cardSvg.test.tsx
+++ b/src/deckBuilder/__tests__/cardSvg.test.tsx
@@ -36,6 +36,7 @@ const CARD: CardData = {
filters: "shadow",
patterns: "striped",
yolks: 1,
+ spires: 1,
};
const renderCard = (card: CardData) =>
diff --git a/src/deckBuilder/__tests__/deckRules.test.ts b/src/deckBuilder/__tests__/deckRules.test.ts
index c979c6d..050966b 100644
--- a/src/deckBuilder/__tests__/deckRules.test.ts
+++ b/src/deckBuilder/__tests__/deckRules.test.ts
@@ -42,8 +42,8 @@ const DECK: GeneratedDeckMetaData = {
numbers: [9, 3, 4],
};
-test("yolks is the only shape-only feature today", () => {
- expect(SHAPE_ONLY_FEATURES).toEqual(["yolks"]);
+test("yolks and spires are the shape-only features today", () => {
+ expect(SHAPE_ONLY_FEATURES).toEqual(["yolks", "spires"]);
});
test("a shape owning no internal feature backs a single card", () => {
diff --git a/src/deckBuilder/__tests__/registry.test.ts b/src/deckBuilder/__tests__/registry.test.ts
index 5789407..9683d60 100644
--- a/src/deckBuilder/__tests__/registry.test.ts
+++ b/src/deckBuilder/__tests__/registry.test.ts
@@ -18,6 +18,7 @@ const LEGACY_SHAPE_NAMES = [
"Tracks - Wolf",
"Tracks - Frog",
"Fried Egg",
+ "Sandcastle",
];
test("shape registry keeps the legacy shape names", () => {
diff --git a/src/deckBuilder/features/index.ts b/src/deckBuilder/features/index.ts
index ff725b7..8cbe4e8 100644
--- a/src/deckBuilder/features/index.ts
+++ b/src/deckBuilder/features/index.ts
@@ -7,6 +7,7 @@ import { PATTERN_NAMES, PatternName } from "./patterns";
export const NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9] as const;
export const ROTATIONS: readonly Rotation[] = [0, 90, 180, 270];
export const YOLKS = [1, 2, 3] as const;
+export const SPIRES = [1, 2, 3, 4] as const;
/**
* The features a generated deck can vary, mapped to their option value type.
@@ -22,6 +23,7 @@ interface FeatureOptionMap {
filters: FilterName;
patterns: PatternName;
yolks: (typeof YOLKS)[number];
+ spires: (typeof SPIRES)[number];
}
export type FeatureName = keyof FeatureOptionMap;
@@ -57,6 +59,7 @@ export const FEATURES: { readonly [F in FeatureName]: FeatureConfig } = {
filters: { label: "Filter", options: FILTER_NAMES },
patterns: { label: "Pattern", options: PATTERN_NAMES },
yolks: { label: "Yolks", options: YOLKS, requiresShapeSupport: true },
+ spires: { label: "Spires", options: SPIRES, requiresShapeSupport: true },
};
export const FEATURE_NAMES = Object.keys(FEATURES) as FeatureName[];
@@ -77,6 +80,7 @@ export const DEFAULT_CARD: CardData = {
filters: "none",
patterns: "solid",
yolks: 1,
+ spires: 1,
};
export function getFeatureOptions(feature: F): readonly FeatureValue[] {
diff --git a/src/deckBuilder/shapes/Sandcastle.tsx b/src/deckBuilder/shapes/Sandcastle.tsx
new file mode 100644
index 0000000..88facfb
--- /dev/null
+++ b/src/deckBuilder/shapes/Sandcastle.tsx
@@ -0,0 +1,193 @@
+import * as React from "react";
+import { ShapeDefinition, ShapeProps } from "../types";
+
+/** An arched opening (door or window), wider at the base than the arch springs. */
+const arch = (cx: number, hw: number, top: number, bottom: number) =>
+ `M${cx - hw},${bottom} L${cx - hw},${top + hw} Q${cx},${top} ${cx + hw},${top + hw} L${cx + hw},${bottom} Z`;
+
+/** A triangular spire roof sitting on a tower of half-width `hw`. */
+const cone = (cx: number, hw: number, baseY: number, apexY: number) =>
+ `M${cx - hw},${baseY} L${cx},${apexY} L${cx + hw},${baseY} Z`;
+
+/** A pennant on a pole planted at `(cx, baseY)`, rising `poleLen` units. */
+const flag = (cx: number, baseY: number, poleLen: number, pennantW = 14, pennantH = 10) => {
+ const topY = baseY - poleLen;
+ return {
+ pole: { x1: cx, y1: baseY, x2: cx, y2: topY },
+ pennant: `M${cx},${topY} L${cx + pennantW},${topY + pennantH / 2} L${cx},${topY + pennantH} Z`,
+ };
+};
+
+interface RectSpec {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+/** One stage's full set of drawing instructions, in fill-then-outline order. */
+interface Stage {
+ fills: { d?: string; rect?: RectSpec; line?: never; color: string }[];
+ lines: { x1: number; y1: number; x2: number; y2: number }[];
+}
+
+// ---------------------------------------------------------------- Stage 1: a
+// lumpy hand-piled mound with a single notch and one flag. No towers at all.
+const MOUND_BODY =
+ "M40,58 L44,58 L44,42 L52,42 L52,58 L66,58 L66,42 L74,42 L74,58 L80,58 " +
+ "Q86,64 94,68 Q104,72 102,88 Q110,96 104,110 L16,110 " +
+ "Q10,96 18,88 Q16,72 26,68 Q34,64 40,58 Z";
+
+const stage1 = (colors: ShapeProps["colors"], fill: string): Stage => {
+ // The flag rises clear of the notch's teeth (apex y=42) before its
+ // pennant unfurls, so the pole reads as a pole and not a third tooth.
+ const mouthFlag = flag(59, 58, 38, 18, 12);
+ return {
+ fills: [
+ { d: MOUND_BODY, color: fill },
+ { d: arch(60, 12, 90, 110), color: colors.tertiary },
+ { d: mouthFlag.pennant, color: colors.secondary },
+ ],
+ lines: [mouthFlag.pole],
+ };
+};
+
+// ------------------------------------------------------ Stage 2: a modest
+// keep — a squat crenellated block flanked by two flat-topped towers, one
+// flag per tower. No spires yet.
+const stage2 = (colors: ShapeProps["colors"], fill: string): Stage => {
+ const leftFlag = flag(32, 50, 20);
+ const rightFlag = flag(88, 50, 20);
+ const wallRect: RectSpec = { x: 44, y: 64, width: 32, height: 46 };
+ const leftTower: RectSpec = { x: 20, y: 50, width: 24, height: 60 };
+ const rightTower: RectSpec = { x: 76, y: 50, width: 24, height: 60 };
+ const merlons: RectSpec[] = [
+ { x: 46, y: 54, width: 6, height: 10 },
+ { x: 57, y: 54, width: 6, height: 10 },
+ { x: 68, y: 54, width: 6, height: 10 },
+ // Tower teeth leave a wide gap (10 units) at the flag's center so the
+ // pole's 6-wide stroke never touches them.
+ { x: 22, y: 40, width: 5, height: 10 },
+ { x: 37, y: 40, width: 5, height: 10 },
+ { x: 78, y: 40, width: 5, height: 10 },
+ { x: 93, y: 40, width: 5, height: 10 },
+ ];
+ return {
+ fills: [
+ { rect: leftTower, color: fill },
+ { rect: rightTower, color: fill },
+ { rect: wallRect, color: fill },
+ ...merlons.map((rect) => ({ rect, color: fill })),
+ { d: arch(60, 10, 88, 110), color: colors.tertiary },
+ { d: arch(32, 5, 84, 100), color: colors.tertiary },
+ { d: arch(88, 5, 84, 100), color: colors.tertiary },
+ { d: arch(52, 5, 80, 96), color: colors.tertiary },
+ { d: arch(68, 5, 80, 96), color: colors.tertiary },
+ { d: leftFlag.pennant, color: colors.secondary },
+ { d: rightFlag.pennant, color: colors.secondary },
+ ],
+ lines: [leftFlag.pole, rightFlag.pole],
+ };
+};
+
+// -------------------------------------------------------- Stage 3: spires
+// arrive — a trapezoid wall with two shoulder towers and a taller center
+// tower, all three roofed with cones and flying flags.
+const stage3 = (colors: ShapeProps["colors"], fill: string): Stage => {
+ const left = flag(38, 32, 18);
+ const center = flag(60, 22, 14);
+ const right = flag(82, 32, 18);
+ const merlons: RectSpec[] = [
+ { x: 46, y: 70, width: 6, height: 8 },
+ { x: 68, y: 70, width: 6, height: 8 },
+ ];
+ return {
+ fills: [
+ { d: "M16,110 L28,78 L92,78 L104,110 Z", color: fill },
+ ...merlons.map((rect) => ({ rect, color: fill })),
+ { d: "M30,54 L46,54 L46,78 L30,78 Z", color: fill },
+ { d: "M74,54 L90,54 L90,78 L74,78 Z", color: fill },
+ { d: "M52,44 L68,44 L68,78 L52,78 Z", color: fill },
+ { d: cone(38, 8, 54, 32), color: colors.secondary },
+ { d: cone(82, 8, 54, 32), color: colors.secondary },
+ { d: cone(60, 8, 44, 22), color: colors.secondary },
+ { d: arch(60, 12, 92, 110), color: colors.tertiary },
+ { d: arch(40, 5, 86, 102), color: colors.tertiary },
+ { d: arch(80, 5, 86, 102), color: colors.tertiary },
+ { d: left.pennant, color: colors.secondary },
+ { d: center.pennant, color: colors.secondary },
+ { d: right.pennant, color: colors.secondary },
+ ],
+ lines: [left.pole, center.pole, right.pole],
+ };
+};
+
+// -------------------------------------------------------- Stage 4: a grand
+// castle — five spires stepping up to a tall center keep, a bannered gate,
+// and a hanging pennant over the door.
+const stage4 = (colors: ShapeProps["colors"], fill: string): Stage => {
+ const towers: { cx: number; top: number; roof: number; poleLen: number }[] = [
+ { cx: 28, top: 58, roof: 40, poleLen: 14 },
+ { cx: 44, top: 50, roof: 28, poleLen: 14 },
+ { cx: 60, top: 42, roof: 20, poleLen: 12 },
+ { cx: 76, top: 50, roof: 28, poleLen: 14 },
+ { cx: 92, top: 58, roof: 40, poleLen: 14 },
+ ];
+ const flags = towers.map((t) => flag(t.cx, t.roof, t.poleLen, 12));
+ return {
+ fills: [
+ { d: "M10,110 L22,74 L98,74 L110,110 Z", color: fill },
+ ...towers.map((t) => ({ d: `M${t.cx - 7},${t.top} L${t.cx + 7},${t.top} L${t.cx + 7},82 L${t.cx - 7},82 Z`, color: fill })),
+ ...towers.map((t) => ({ d: cone(t.cx, 7, t.top, t.roof), color: colors.secondary })),
+ { d: "M54,76 L66,76 L66,86 L60,90 L54,86 Z", color: colors.secondary },
+ { d: arch(60, 12, 84, 110), color: colors.tertiary },
+ { d: arch(44, 4, 62, 74), color: colors.tertiary },
+ { d: arch(76, 4, 62, 74), color: colors.tertiary },
+ ...flags.map((f) => ({ d: f.pennant, color: colors.secondary })),
+ ],
+ lines: flags.map((f) => f.pole),
+ };
+};
+
+const STAGES: Record Stage> = {
+ 1: stage1,
+ 2: stage2,
+ 3: stage3,
+ 4: stage4,
+};
+
+const Component = ({ colors, fill, spires = 1 }: ShapeProps) => {
+ const build = STAGES[spires] || STAGES[1];
+ const stage = build(colors, fill);
+
+ return (
+ <>
+
+ {stage.fills.map((part, i) =>
+ part.rect ? (
+
+ ) : (
+
+ )
+ )}
+
+
+ {stage.fills.map((part, i) =>
+ part.rect ? (
+
+ ) : (
+
+ )
+ )}
+ {stage.lines.map((line, i) => (
+
+ ))}
+
+ >
+ );
+};
+
+export const Sandcastle: ShapeDefinition = {
+ Component,
+ supports: { colors: 3, spires: [1, 2, 3, 4] },
+};
diff --git a/src/deckBuilder/shapes/index.ts b/src/deckBuilder/shapes/index.ts
index f27c6b2..f508e96 100644
--- a/src/deckBuilder/shapes/index.ts
+++ b/src/deckBuilder/shapes/index.ts
@@ -1,6 +1,7 @@
import { ShapeDefinition } from "../types";
import { CircleQuarter, CircleSemi, CircleThreeQuarter } from "./Circles";
import { FriedEgg } from "./FriedEgg";
+import { Sandcastle } from "./Sandcastle";
import { TetrisJBlock, TetrisLBlock, TetrisSBlock, TetrisTBlock } from "./Tetris";
import { TracksDeer, TracksFrog, TracksWolf } from "./Tracks";
import { Triangle } from "./Triangle";
@@ -28,6 +29,7 @@ export const SHAPE_REGISTRY = defineShapes({
"Tracks - Wolf": TracksWolf,
"Tracks - Frog": TracksFrog,
"Fried Egg": FriedEgg,
+ "Sandcastle": Sandcastle,
});
export type ShapeName = keyof typeof SHAPE_REGISTRY;
diff --git a/src/deckBuilder/types.ts b/src/deckBuilder/types.ts
index 7141253..a6ceb5d 100644
--- a/src/deckBuilder/types.ts
+++ b/src/deckBuilder/types.ts
@@ -30,6 +30,8 @@ export interface ShapeProps {
fill: string;
/** Yolk count for shapes that declare `supports.yolks`. */
yolks?: 1 | 2 | 3;
+ /** Spire count for shapes that declare `supports.spires`. */
+ spires?: 1 | 2 | 3 | 4;
}
/**
@@ -61,6 +63,13 @@ export interface ShapeFeatureSupport {
* neutral yolk count, so unsupported always falls back to 1.
*/
yolks?: boolean | readonly (1 | 2 | 3)[];
+ /**
+ * true = all spire counts, false/undefined = none (always 1), or the exact
+ * subset of counts the shape supports. Same fallback semantics as yolks:
+ * there's no universal neutral spire count, so unsupported always falls
+ * back to 1.
+ */
+ spires?: boolean | readonly (1 | 2 | 3 | 4)[];
}
/** A registered shape: a React component plus the features it supports. */