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 => { {paint.defs && {paint.defs}} - + ); @@ -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. */