Skip to content
Open
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
15 changes: 12 additions & 3 deletions .claude/skills/deckbuilder-shape/scripts/card-markup.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand All @@ -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 }];
Expand All @@ -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) =>
Expand All @@ -151,7 +160,7 @@ const renderBareSymbol = (): string => {
<svg viewBox={viewBox} xmlns="http://www.w3.org/2000/svg" width="100%" height="100%">
{paint.defs && <defs>{paint.defs}</defs>}
<g id="bare-symbol">
<shape.Component colors={colors} fill={paint.fill} yolks={yolks[0]} />
<shape.Component colors={colors} fill={paint.fill} yolks={yolks[0]} spires={spires[0]} />
</g>
</svg>
);
Expand All @@ -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,
Expand Down
13 changes: 13 additions & 0 deletions src/daily/schedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
17 changes: 16 additions & 1 deletion src/deckBuilder/CardSvg.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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";
Expand All @@ -98,7 +108,12 @@ export const CardSvg = ({ card, cardId }: Props) => {
rotation ? `rotate(${rotation}, ${rotationCenter.x}, ${rotationCenter.y})` : undefined
}
>
<shape.Component colors={colors} fill={paint.fill} yolks={yolks as 1 | 2 | 3} />
<shape.Component
colors={colors}
fill={paint.fill}
yolks={yolks as 1 | 2 | 3}
spires={spires as 1 | 2 | 3 | 4}
/>
</g>
</svg>
);
Expand Down
1 change: 1 addition & 0 deletions src/deckBuilder/__tests__/cardSvg.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ const CARD: CardData = {
filters: "shadow",
patterns: "striped",
yolks: 1,
spires: 1,
};

const renderCard = (card: CardData) =>
Expand Down
4 changes: 2 additions & 2 deletions src/deckBuilder/__tests__/deckRules.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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", () => {
Expand Down
1 change: 1 addition & 0 deletions src/deckBuilder/__tests__/registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const LEGACY_SHAPE_NAMES = [
"Tracks - Wolf",
"Tracks - Frog",
"Fried Egg",
"Sandcastle",
];

test("shape registry keeps the legacy shape names", () => {
Expand Down
4 changes: 4 additions & 0 deletions src/deckBuilder/features/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -22,6 +23,7 @@ interface FeatureOptionMap {
filters: FilterName;
patterns: PatternName;
yolks: (typeof YOLKS)[number];
spires: (typeof SPIRES)[number];
}

export type FeatureName = keyof FeatureOptionMap;
Expand Down Expand Up @@ -57,6 +59,7 @@ export const FEATURES: { readonly [F in FeatureName]: FeatureConfig<F> } = {
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[];
Expand All @@ -77,6 +80,7 @@ export const DEFAULT_CARD: CardData = {
filters: "none",
patterns: "solid",
yolks: 1,
spires: 1,
};

export function getFeatureOptions<F extends FeatureName>(feature: F): readonly FeatureValue<F>[] {
Expand Down
193 changes: 193 additions & 0 deletions src/deckBuilder/shapes/Sandcastle.tsx
Original file line number Diff line number Diff line change
@@ -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<number, (colors: ShapeProps["colors"], fill: string) => 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 (
<>
<g stroke="none">
{stage.fills.map((part, i) =>
part.rect ? (
<rect key={i} x={part.rect.x} y={part.rect.y} width={part.rect.width} height={part.rect.height} fill={part.color} />
) : (
<path key={i} d={part.d} fill={part.color} />
)
)}
</g>
<g fill="none" stroke="#000000" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round">
{stage.fills.map((part, i) =>
part.rect ? (
<rect key={i} x={part.rect.x} y={part.rect.y} width={part.rect.width} height={part.rect.height} />
) : (
<path key={i} d={part.d} />
)
)}
{stage.lines.map((line, i) => (
<line key={i} x1={line.x1} y1={line.y1} x2={line.x2} y2={line.y2} />
))}
</g>
</>
);
};

export const Sandcastle: ShapeDefinition = {
Component,
supports: { colors: 3, spires: [1, 2, 3, 4] },
};
2 changes: 2 additions & 0 deletions src/deckBuilder/shapes/index.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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;
Expand Down
Loading
Loading