From 774051eb4011cf73cb2517ca1475486fff235325 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 2 Aug 2026 18:02:30 +0000 Subject: [PATCH] Add National Ice Cream Day (July 18) with 5 new ice cream shapes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds three new shape-only custom features (scoops 0-4, topping, sauce) following the existing yolks recipe, and five new shapes — waffle cone, sugar cone, sundae cup, sundae glass, banana split — that declare support for them, using primary color for the scoops and secondary for the container. Schedules a daily on 2026-07-18 that fixes the Sundae Glass shape and varies numbers x colors x scoops, mirroring National Egg Day's structure. --- src/daily/__tests__/schedule.test.ts | 12 + src/daily/schedule.ts | 13 + src/deckBuilder/CardSvg.tsx | 39 ++- src/deckBuilder/__tests__/cardSvg.test.tsx | 3 + src/deckBuilder/__tests__/deckRules.test.ts | 4 +- src/deckBuilder/__tests__/registry.test.ts | 5 + src/deckBuilder/features/index.ts | 18 ++ src/deckBuilder/shapes/IceCream.tsx | 303 ++++++++++++++++++++ src/deckBuilder/shapes/index.ts | 12 + src/deckBuilder/types.ts | 25 ++ 10 files changed, 431 insertions(+), 3 deletions(-) create mode 100644 src/deckBuilder/shapes/IceCream.tsx diff --git a/src/daily/__tests__/schedule.test.ts b/src/daily/__tests__/schedule.test.ts index 8d70e73..6c71726 100644 --- a/src/daily/__tests__/schedule.test.ts +++ b/src/daily/__tests__/schedule.test.ts @@ -74,3 +74,15 @@ test("National Egg Day builds a 27-card deck of Fried Egg cards", () => { expect(deck.features.length).toEqual(3); expect(Object.keys(deck.cards).length).toEqual(27); }); + +test("National Ice Cream Day is scheduled for July 18th and recurs annually", () => { + expect(getPuzzleForDate("2026-07-18").name).toEqual("National Ice Cream Day"); + expect(getPuzzleForDate("2027-07-18").name).toEqual("National Ice Cream Day"); + expect(getPuzzleForDate("2025-07-18").name).not.toEqual("National Ice Cream Day"); +}); + +test("National Ice Cream Day builds a 27-card deck of Sundae Glass cards", () => { + const deck = DAILY_PUZZLE_SCHEDULE["2026-07-18"].createDeck(); + expect(deck.features.length).toEqual(3); + expect(Object.keys(deck.cards).length).toEqual(27); +}); diff --git a/src/daily/schedule.ts b/src/daily/schedule.ts index 80d19a0..4f8a535 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-07-18": { + name: "National Ice Cream Day", + createDeck: () => + new GeometricDeckGenerator( + { + numbers: [1, 2, 3], + scoops: [1, 2, 3], + colors: ["Pink", "Brown", "Mint"], + }, + { shapes: "Ice Cream - Sundae Glass", topping: "Cherry", sauce: "Chocolate" }, + { idPrefix: "daily-icecream" } + ), + }, }; /** The deck used whenever no scheduled puzzle matches the date. */ diff --git a/src/deckBuilder/CardSvg.tsx b/src/deckBuilder/CardSvg.tsx index d2af6a5..5dfea37 100644 --- a/src/deckBuilder/CardSvg.tsx +++ b/src/deckBuilder/CardSvg.tsx @@ -52,6 +52,33 @@ const resolveYolks = ( return supported.includes(yolks as 1 | 2 | 3) ? yolks : supported[0]; }; +const resolveScoops = ( + supported: ShapeFeatureSupport["scoops"], + scoops: number +): number => { + if (supported === false || supported === undefined) return 1; + if (supported === true) return scoops; + return supported.includes(scoops as 0 | 1 | 2 | 3 | 4) ? scoops : supported[0]; +}; + +const resolveTopping = ( + supported: ShapeFeatureSupport["topping"], + topping: CardData["topping"] +): CardData["topping"] => { + if (supported === false || supported === undefined) return "None"; + if (supported === true) return topping; + return supported.includes(topping) ? topping : "None"; +}; + +const resolveSauce = ( + supported: ShapeFeatureSupport["sauce"], + sauce: CardData["sauce"] +): CardData["sauce"] => { + if (supported === false || supported === undefined) return "None"; + if (supported === true) return sauce; + return supported.includes(sauce) ? sauce : "None"; +}; + interface Props { card: CardData; /** Document-unique id, used to namespace this card's SVG defs. */ @@ -73,6 +100,9 @@ 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 scoops = resolveScoops(supports.scoops, card.scoops); + const topping = resolveTopping(supports.topping, card.topping); + const sauce = resolveSauce(supports.sauce, card.sauce); let patternName: PatternName = supports.patterns === false ? "solid" : card.patterns; if (PATTERN_DEFS[patternName].colorsUsed > colorCount) { patternName = "solid"; @@ -98,7 +128,14 @@ 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..26dfd55 100644 --- a/src/deckBuilder/__tests__/cardSvg.test.tsx +++ b/src/deckBuilder/__tests__/cardSvg.test.tsx @@ -36,6 +36,9 @@ const CARD: CardData = { filters: "shadow", patterns: "striped", yolks: 1, + scoops: 1, + topping: "None", + sauce: "None", }; const renderCard = (card: CardData) => diff --git a/src/deckBuilder/__tests__/deckRules.test.ts b/src/deckBuilder/__tests__/deckRules.test.ts index c979c6d..876fa90 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, scoops, topping, and sauce are the shape-only features today", () => { + expect(SHAPE_ONLY_FEATURES).toEqual(["yolks", "scoops", "topping", "sauce"]); }); 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..efea388 100644 --- a/src/deckBuilder/__tests__/registry.test.ts +++ b/src/deckBuilder/__tests__/registry.test.ts @@ -18,6 +18,11 @@ const LEGACY_SHAPE_NAMES = [ "Tracks - Wolf", "Tracks - Frog", "Fried Egg", + "Ice Cream - Waffle Cone", + "Ice Cream - Sugar Cone", + "Ice Cream - Sundae Cup", + "Ice Cream - Sundae Glass", + "Ice Cream - Banana Split", ]; test("shape registry keeps the legacy shape names", () => { diff --git a/src/deckBuilder/features/index.ts b/src/deckBuilder/features/index.ts index ff725b7..8367010 100644 --- a/src/deckBuilder/features/index.ts +++ b/src/deckBuilder/features/index.ts @@ -7,6 +7,15 @@ 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 SCOOPS = [0, 1, 2, 3, 4] as const; +export const TOPPINGS = [ + "Chocolate Chips", + "Cherry", + "Rainbow Sprinkles", + "Twisty Pretzel", + "None", +] as const; +export const SAUCES = ["Chocolate", "Strawberry", "Toffee", "Pistachio", "None"] as const; /** * The features a generated deck can vary, mapped to their option value type. @@ -22,6 +31,9 @@ interface FeatureOptionMap { filters: FilterName; patterns: PatternName; yolks: (typeof YOLKS)[number]; + scoops: (typeof SCOOPS)[number]; + topping: (typeof TOPPINGS)[number]; + sauce: (typeof SAUCES)[number]; } export type FeatureName = keyof FeatureOptionMap; @@ -57,6 +69,9 @@ 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 }, + scoops: { label: "Scoops", options: SCOOPS, requiresShapeSupport: true }, + topping: { label: "Topping", options: TOPPINGS, requiresShapeSupport: true }, + sauce: { label: "Sauce", options: SAUCES, requiresShapeSupport: true }, }; export const FEATURE_NAMES = Object.keys(FEATURES) as FeatureName[]; @@ -77,6 +92,9 @@ export const DEFAULT_CARD: CardData = { filters: "none", patterns: "solid", yolks: 1, + scoops: 2, + topping: "None", + sauce: "None", }; export function getFeatureOptions(feature: F): readonly FeatureValue[] { diff --git a/src/deckBuilder/shapes/IceCream.tsx b/src/deckBuilder/shapes/IceCream.tsx new file mode 100644 index 0000000..91cfe09 --- /dev/null +++ b/src/deckBuilder/shapes/IceCream.tsx @@ -0,0 +1,303 @@ +import * as React from "react"; +import { ShapeDefinition, ShapeFeatureSupport, ShapeProps } from "../types"; + +/** Shared support declaration for every ice cream shape. */ +const ICE_CREAM_SUPPORTS: ShapeFeatureSupport = { + colors: 2, + patterns: false, + rotations: false, + scoops: [0, 1, 2, 3, 4], + topping: ["Chocolate Chips", "Cherry", "Rainbow Sprinkles", "Twisty Pretzel", "None"], + sauce: ["Chocolate", "Strawberry", "Toffee", "Pistachio", "None"], +}; + +const OUTLINE = { + fill: "none", + stroke: "#000000", + strokeWidth: 6, + strokeLinecap: "round" as const, + strokeLinejoin: "round" as const, +}; + +interface Scoop { + cx: number; + cy: number; + r: number; +} + +/** + * `[dyFromMouth, radius]` per scoop, bottom-to-top. Fewer scoops are drawn + * larger; each scoop overlaps the one below it enough to read as a stack. + * Typed `number[][]` rather than tuple types — the pinned + * @typescript-eslint/parser crashes on tuple-array types and breaks the + * production build, which `yarn test` never sees. See commit d8701ac. + */ +const SCOOP_OFFSETS: Record = { + 0: [], + 1: [[-16, 24]], + 2: [[-14, 20], [-34, 17]], + 3: [[-13, 17], [-30, 15], [-46, 13]], + 4: [[-12, 15], [-27, 13], [-41, 12], [-54, 11]], +}; + +const scoopCircles = (scoops: number, mouthY: number): Scoop[] => + (SCOOP_OFFSETS[scoops] || []).map(([dy, r]) => ({ cx: 60, cy: mouthY + dy, r })); + +const renderScoopFills = (stack: Scoop[], fill: string) => ( + <> + {stack.map((s, i) => ( + + ))} + +); + +const renderScoopOutlines = (stack: Scoop[]) => ( + <> + {stack.map((s, i) => ( + + ))} + +); + +/** Sauce is intrinsic to the flavor, not the card's color feature — fixed literal colors. */ +const SAUCE_COLORS: Record = { + Chocolate: "#5C3317", + Strawberry: "#F4436C", + Toffee: "#A9702A", + Pistachio: "#8DA047", +}; + +/** A drizzle squiggle scaled to the top scoop's own radius. */ +const drizzlePath = (top: Scoop): string => { + const { cx, cy, r } = top; + const at = (dx: number, dy: number) => `${cx + dx * r},${cy + dy * r}`; + return `M${at(-0.65, -0.2)} Q${at(-0.25, -1.0)} ${at(0.05, -0.35)} Q${at(0.35, 0.15)} ${at( + 0.65, + -0.15 + )}`; +}; + +const renderSauce = (sauce: ShapeProps["sauce"], top: Scoop | undefined): JSX.Element | null => { + if (!top || !sauce || sauce === "None") return null; + const color = SAUCE_COLORS[sauce]; + if (!color) return null; + return ; +}; + +/** Toppings are intrinsic to the flavor too — fixed literal colors, not the card's colors. */ +const CHIP_COLOR = "#3B1F0E"; +const CHERRY_COLOR = "#C1121F"; +const CHERRY_STEM_COLOR = "#2E7D32"; +const SPRINKLE_COLORS = ["#e6194B", "#3cb44b", "#4363d8", "#f58231"]; +const PRETZEL_COLOR = "#B08461"; + +const CHIP_OFFSETS = [[0, -5], [5, -1], [-5, -1], [3, 4], [-3, 4]]; +const SPRINKLE_OFFSETS = [ + [-4, -4, -20], + [2, -6, 10], + [6, -2, 35], + [-6, 0, -35], + [0, 2, 0], + [4, 3, 20], +]; + +const renderTopping = (topping: ShapeProps["topping"], top: Scoop | undefined): JSX.Element | null => { + if (!top || !topping || topping === "None") return null; + const { cx, cy } = top; + + if (topping === "Chocolate Chips") { + return ( + + {CHIP_OFFSETS.map(([dx, dy], i) => ( + + ))} + + ); + } + if (topping === "Cherry") { + return ( + + + + + ); + } + if (topping === "Rainbow Sprinkles") { + return ( + + {SPRINKLE_OFFSETS.map(([dx, dy, rot], i) => ( + + ))} + + ); + } + // Twisty Pretzel: two small stroked loops, the same "colored accent stroke" + // treatment as the sauce drizzle — a closed filled loop collapses into a + // blob under the 6-unit outline stroke at this scale. + return ( + + + + + ); +}; + +interface ContainerGeometry { + mouthY: number; + fill: string; +} + +/** + * A container drawn as a single closed path, no interior texture: at 26 + * device pixels a 6-unit-wide outline stroke swallows any thin interior + * line (crosshatch, seam), so cones are told apart purely by proportion + * (wide/shallow vs narrow/steep) — silhouette-first, per iconography.md. + */ +const buildComponent = ({ mouthY, fill: containerFill }: ContainerGeometry) => { + const Component = ({ + colors, + fill, + scoops = 2, + topping = "None", + sauce = "None", + }: ShapeProps) => { + const stack = scoopCircles(scoops, mouthY); + const top = stack[stack.length - 1]; + return ( + <> + + + {renderScoopFills(stack, fill)} + + + + {renderScoopOutlines(stack)} + + {renderSauce(sauce, top)} + {renderTopping(topping, top)} + + ); + }; + return Component; +}; + +const WaffleConeComponent = buildComponent({ + mouthY: 74, + fill: "M18,74 L102,74 L60,112 Z", +}); + +const SugarConeComponent = buildComponent({ + mouthY: 74, + fill: "M46,74 L74,74 L60,114 Z", +}); + +const SundaeCupComponent = ({ colors, fill, scoops = 2, topping = "None", sauce = "None" }: ShapeProps) => { + const mouthY = 74; + const stack = scoopCircles(scoops, mouthY); + const top = stack[stack.length - 1]; + const cupPath = "M18,74 L102,74 L84,110 L36,110 Z"; + return ( + <> + + + {renderScoopFills(stack, fill)} + + + + + {renderScoopOutlines(stack)} + + {renderSauce(sauce, top)} + {renderTopping(topping, top)} + + ); +}; + +const SundaeGlassComponent = ({ colors, fill, scoops = 2, topping = "None", sauce = "None" }: ShapeProps) => { + const mouthY = 74; + const stack = scoopCircles(scoops, mouthY); + const top = stack[stack.length - 1]; + const bowlPath = "M18,74 Q24,100 50,100 L70,100 Q96,100 102,74 Z"; + return ( + <> + + + + + {renderScoopFills(stack, fill)} + + + + + + + {renderScoopOutlines(stack)} + + {renderSauce(sauce, top)} + {renderTopping(topping, top)} + + ); +}; + +const BananaSplitComponent = ({ colors, fill, scoops = 2, topping = "None", sauce = "None" }: ShapeProps) => { + const mouthY = 82; + const stack = scoopCircles(scoops, mouthY); + const top = stack[stack.length - 1]; + const leftBanana = "M8,100 Q16,64 48,82 Q30,90 8,100 Z"; + const rightBanana = "M112,100 Q104,64 72,82 Q90,90 112,100 Z"; + return ( + <> + + + + + {renderScoopFills(stack, fill)} + + + + + + {renderScoopOutlines(stack)} + + {renderSauce(sauce, top)} + {renderTopping(topping, top)} + + ); +}; + +export const IceCreamWaffleCone: ShapeDefinition = { + Component: WaffleConeComponent, + supports: ICE_CREAM_SUPPORTS, +}; + +export const IceCreamSugarCone: ShapeDefinition = { + Component: SugarConeComponent, + supports: ICE_CREAM_SUPPORTS, +}; + +export const IceCreamSundaeCup: ShapeDefinition = { + Component: SundaeCupComponent, + supports: ICE_CREAM_SUPPORTS, +}; + +export const IceCreamSundaeGlass: ShapeDefinition = { + Component: SundaeGlassComponent, + supports: ICE_CREAM_SUPPORTS, +}; + +export const IceCreamBananaSplit: ShapeDefinition = { + Component: BananaSplitComponent, + supports: ICE_CREAM_SUPPORTS, +}; diff --git a/src/deckBuilder/shapes/index.ts b/src/deckBuilder/shapes/index.ts index f27c6b2..bf2a4e3 100644 --- a/src/deckBuilder/shapes/index.ts +++ b/src/deckBuilder/shapes/index.ts @@ -1,6 +1,13 @@ import { ShapeDefinition } from "../types"; import { CircleQuarter, CircleSemi, CircleThreeQuarter } from "./Circles"; import { FriedEgg } from "./FriedEgg"; +import { + IceCreamBananaSplit, + IceCreamSugarCone, + IceCreamSundaeCup, + IceCreamSundaeGlass, + IceCreamWaffleCone, +} from "./IceCream"; import { TetrisJBlock, TetrisLBlock, TetrisSBlock, TetrisTBlock } from "./Tetris"; import { TracksDeer, TracksFrog, TracksWolf } from "./Tracks"; import { Triangle } from "./Triangle"; @@ -28,6 +35,11 @@ export const SHAPE_REGISTRY = defineShapes({ "Tracks - Wolf": TracksWolf, "Tracks - Frog": TracksFrog, "Fried Egg": FriedEgg, + "Ice Cream - Waffle Cone": IceCreamWaffleCone, + "Ice Cream - Sugar Cone": IceCreamSugarCone, + "Ice Cream - Sundae Cup": IceCreamSundaeCup, + "Ice Cream - Sundae Glass": IceCreamSundaeGlass, + "Ice Cream - Banana Split": IceCreamBananaSplit, }); export type ShapeName = keyof typeof SHAPE_REGISTRY; diff --git a/src/deckBuilder/types.ts b/src/deckBuilder/types.ts index 7141253..31943ed 100644 --- a/src/deckBuilder/types.ts +++ b/src/deckBuilder/types.ts @@ -30,6 +30,12 @@ export interface ShapeProps { fill: string; /** Yolk count for shapes that declare `supports.yolks`. */ yolks?: 1 | 2 | 3; + /** Scoop count for shapes that declare `supports.scoops`. */ + scoops?: 0 | 1 | 2 | 3 | 4; + /** Topping for shapes that declare `supports.topping`. */ + topping?: "Chocolate Chips" | "Cherry" | "Rainbow Sprinkles" | "Twisty Pretzel" | "None"; + /** Sauce for shapes that declare `supports.sauce`. */ + sauce?: "Chocolate" | "Strawberry" | "Toffee" | "Pistachio" | "None"; } /** @@ -61,6 +67,25 @@ export interface ShapeFeatureSupport { * neutral yolk count, so unsupported always falls back to 1. */ yolks?: boolean | readonly (1 | 2 | 3)[]; + /** + * true = all scoop counts, false/undefined = none (always 1), or the exact + * subset of counts the shape supports. No universal neutral scoop count + * (0 is a real, meaningful state, not a "don't care"), so unsupported + * always falls back to 1, same principle as yolks. + */ + scoops?: boolean | readonly (0 | 1 | 2 | 3 | 4)[]; + /** + * true = all toppings, false/undefined = none (always "None"), or the + * exact subset the shape supports. "None" is the universal neutral value, + * so unsupported falls back to "None". + */ + topping?: boolean | readonly ("Chocolate Chips" | "Cherry" | "Rainbow Sprinkles" | "Twisty Pretzel" | "None")[]; + /** + * true = all sauces, false/undefined = none (always "None"), or the exact + * subset the shape supports. "None" is the universal neutral value, so + * unsupported falls back to "None". + */ + sauce?: boolean | readonly ("Chocolate" | "Strawberry" | "Toffee" | "Pistachio" | "None")[]; } /** A registered shape: a React component plus the features it supports. */